markdown2article Article Editor
A full-featured Markdown-to-Astro article editor with live preview, TOC generation, and one-click export.
Purpose
Gives blog authors a visual editing environment for completing the following on a single page:
- Fill in the article's metadata (title, meta, activeNav, comment toggle, etc.)
- Edit sidebar-left (tagline, badge icon/text), sub-sidebar (tag list), and sidebar-right (promo)
- Write the Markdown body with a live rendered preview and automatic TOC generation
- Insert
HashedImage,XMindmind maps,SPZ3D Gaussian splat models, and terminal animation code snippets via dialogs - One-click export to a
.astrofile ready to use in your project - Load external Markdown files from the
?load=URL parameter
Feature Overview
| Feature | Location | Description |
|---|---|---|
| Page title / browser title | first row of editor-settings | Two separate inputs; the browser title automatically appends · NW' Blog |
| meta date, excerpt | second row of editor-settings | Optional excerpt, hidden when empty |
| activeNav selection | second row of editor-settings | Controls which nav item is highlighted |
| Comment section toggle | second row of editor-settings | When checked, the comment section is visible in the preview |
| Tag editing | sub-sidebar textarea | One name|link entry per line |
| Tagline editing | sidebar-left input | Controls the description text below the avatar |
| Badge editing | sidebar-left | Icon picker + text input → badge updates live |
| Icon picker | sidebar-left → pick icon | Pops up a fixed-position panel; search + click to select |
| Import MD file | toolbar → Import MD | Reads a local .md file into the editor |
| Insert HashedImage | toolbar → Insert HashedImage | Dialog: path, alt, figcaption, actual-size checkbox |
| Insert XMind | toolbar → Insert XMind | Dialog: JSON path, dark mode, layout, line style |
| Insert SPZ | toolbar → Insert SPZ | Dialog: .spz path, auto-load checkbox |
| Insert terminal code | toolbar → Insert Terminal | Dialog: title, code content; HTML is escaped automatically |
| MD editor | article-panel | Monospace textarea; the size counter shows a KB value |
| Live preview | article-panel | GFM Markdown → sanitized HTML |
| TOC | sidebar-right | Automatically extracts h1–h4; click for smooth scrolling; hierarchical indentation |
| Promo editing | sidebar-right | Editable title + content, synced with the preview area |
| Mode switch | fixed mode-bar | Edit/preview modes; preview mode hides all editing controls |
| Export button | fixed float-btn | Downloads the complete .astro file |
| URL loading | query parameter | ?load=/path/to/file.md automatically loads remote Markdown |
Page Layout
┌─ editor-mode-bar (fixed top:70, z-index:1000) ──────────┐
│ [Edit] [Preview] │
└──────────────────────────────────────────────────────────┘
┌─ main.main-layout[data-sidebar-mode="both"] ─────────────┐
│ ┌─ left-column ──┐ ┌─ article-panel ─────┐ ┌─ sidebar-right ┐ │
│ │ sidebar-left │ │ editor-settings │ │ TOC │ │
│ │ logo/avatar │ │ toolbar + dialogs │ │ promo editor │ │
│ │ tagline │ │ md-editor textarea │ │ promo display │ │
│ │ badge │ │ rendered-preview │ └────────────────┘ │
│ │ edit controls │ └──────────────────────┘ │
│ │ sub-sidebar │ CommentWidget │
│ │ tag list │ (4th grid child) │
│ └────────────────┘ │
└──────────────────────────────────────────────────────────────┘
┌─ editor-float-btns (fixed bottom:76 left:24) ───────────┐
│ [export] │
└──────────────────────────────────────────────────────────┘ The three column widths are defined by .main-layout in global.css — 280px minmax(0,1fr) 260px.
Core Workflows
Edit fields → live preview
All text/select inputs are bound to the input event → updatePreview():
- Updates the h1 (from pageTitle), meta, and excerpt
- Updates the tagline and the badge icon and text
- Parses the tags textarea → renders
<ul class="tag-list"> - Updates the promo title and body
- Assembles and displays the browser title
- Calls
renderMarkdown()to refresh the Markdown preview and TOC - Shows/hides the comment section based on the comment toggle
Markdown editing → rendered preview + TOC
updatePreview()fires after a 300ms input debouncerenderMarkdown()usesmarked.parse()+ the GFM extension → HTML- Filtered through
sanitizeHtml(): removes<script>,<style>,<iframe>(which are preserved),on*attributes,javascript:links, etc. generateTOC()scans h1–h4 in the preview area, generating an<li>withdata-levelfor each heading; clicking smooth-scrolls
Inserting the custom components
All four dialogs toggle via display:none/block. On insert, insertToEditor(tag) is called:
- Inserts the tag at the textarea's cursor position
- Automatically updates the character counter and the preview
- HashedImage:
<HashedImage src="..." alt="..." />with optionalclass="content-image-actual" - XMind: generates an
<iframe>pointing to/ToolBase/Xmind/XmindViewer_embed.html?dark=...&layout=...&data=... - SPZ: generates an
<iframe>pointing to/ToolBase/3DViewer/3DViewer.html?url=...; in non-auto-load mode it additionally renders a click-to-play placeholder - Terminal code: generates an HTML block with the
.terminal-codestructure (title bar + red/yellow/green control dots +<pre><code>), automatically HTML-escapes the code content, and preserves the user's original indentation
Edit/preview switching
Toggles editor-mode-preview-active via document.body.classList:
- In CSS,
:global(body.editor-mode-preview-active) .editor-settingsand the like are alldisplay: none !important - Preview mode hides all editing controls and the floating export button
- In preview mode, the comment section is shown/hidden by
updatePreview()according to the checkbox
Exporting the .astro file
Clicking the floating export button → runs the click handler of DOM.exportBtn:
- Collects the current value of every field
- Builds the complete Astro frontmatter + HTML template
- Splits the Markdown content into blocks (see the export logic in detail below)
- Generates a Blob → automatically downloads
{slug}.astro - The button icon temporarily switches to ✅ as feedback (reverts after 2 seconds)
Loading via URL parameter
When visiting ?load=/path/to/file.md:
- Automatically
fetches that path - On success, fills the MD editor and triggers a preview update
HTML Structure
<BaseLayout title="...">
<main class="main-layout" data-sidebar-mode="both">
<!-- column 1 -->
<div class="left-column">
<aside class="sidebar-left"> ← logo, avatar, tagline, badge, editor-field controls
<aside class="sub-sidebar"> ← h2 + editor-field(textarea) + dynamic tag-list
</div>
<!-- column 2 -->
<article class="article-panel"> ← editor-settings, toolbar, dialogs, md-editor, preview
<!-- column 3 -->
<aside class="sidebar-right"> ← TOC + promo-editor + promo-display
<!-- 4th child (still inside main) -->
<CommentWidget /> ← comment section
</main>
<!-- the following sit outside </main>, independently positioned -->
<div id="editor-mode-bar"> ← fixed top:70
<div class="editor-float-btns"> ← fixed bottom:76 left:24 (export button)
</BaseLayout>
Note: CommentWidget is placed inside
<main>as the 4th grid child, and the.comment-panel { grid-column: 2; }rule inglobal.csspositions it below the middle column.
CSS Architecture
Design system
| Token | Value |
|---|---|
| Base background colors | #09090f / #1a1c2a |
| Text color | #dcd7cc |
| Accent color | #ff6925 |
| Glassmorphism | backdrop-filter: blur(18px) |
| Borders | rgba(255,255,255,0.08~0.20) |
| Border radius | 8px / 12px / 14px |
Key classes
| Class | Purpose |
|---|---|
.editor-mode-bar | Fixed top bar with the edit/preview toggle buttons |
.editor-mode-btn--active | Active mode state (orange background) |
.editor-settings / .editor-settings-row | Page settings area, flex layout |
.editor-label | Field labels: orange left border + uppercase + hover highlight |
.editor-input | Generic input, dark theme style |
.editor-select | Custom select, appearance: none + SVG arrow + focus glow |
.editor-checkbox | Custom checkbox, appearance: none + orange check + SVG checkmark |
.editor-textarea | Generic multiline input, resizable height |
.editor-md-editor | Markdown editing area, monospace font, min-height: 323px |
.editor-icon-dropdown | Icon picker popup panel, position: fixed; z-index: 999999 |
.editor-icon-grid | Icon grid with 8 columns, with search filtering |
.editor-dialog | Insert dialogs, glassmorphism background |
.editor-toolbar / .editor-toolbar-btn | Toolbar button group |
.editor-float-btn | Floating button 44×44px, with -webkit-backdrop-filter fallback |
.editor-md-size | Markdown size counter (KB) |
Preview mode CSS
Uses the :global(body.editor-mode-preview-active) selector to hide all editing controls (display: none !important). Each control has an animation: editorFadeIn entrance animation.
Scrollbars
.editor-textarea, .editor-md-editor, .editor-icon-dropdown, and .editor-icon-grid are all customized as thin orange scrollbars.
Notes
backdrop-filtercreates a new stacking context → the icon dropdown works around it withposition: fixed+ JSappendChild(body)- The
-webkit-backdrop-filterprefix ensures Safari compatibility
JavaScript Modules
Core functions
| Function | Location | Responsibility |
|---|---|---|
sanitizeHtml(html) | utility | DOMParser filters XSS: removes script/style/iframe/on*/javascript: |
updateMdSize() | utility | Computes the MD byte size with TextEncoder, displayed in KB |
renderMarkdown() | rendering | marked.parse() → sanitizeHtml() → writes the preview + TOC |
generateTOC() | rendering | Scans h1–h4 → data-level list, id handling, smooth scrolling |
updatePreview() | core | Collects all fields → syncs them to the preview DOM + renders Markdown + toggles the comment section |
positionIconDropdown() | icons | Computes the button position, prevents overflow on the right, fixed positioning |
insertToEditor(tag) | insertion | Inserts text at the cursor, updates the counter + preview |
escapeAttr(s) | export | Encodes HTML attributes (& " < >) |
export logic | export | See the export logic in detail below |
DOM references
The DOM object centrally manages all getElementById references (~50 elements), defined together at the top.
Event bindings
- All editing fields:
input→updatePreview() - MD editor:
input→updateMdSize()+ 300ms debounce →updatePreview() - Icon search:
input→ filters the grid - Icon grid
click→ select + close + update preview - Export button
click→ build → download - Mode buttons
click→ toggle the body class + update preview - TOC click → highlight + smooth scroll
- Import button
click→ triggers the hidden<input type="file"> - Dialog buttons
click→ toggle visibility + insert
Initial load
On DOMContentLoaded:
- Calls
updatePreview()to populate the initial state - Checks the
?load=URL parameter → asynchronously fetches and fills the editor
Export Logic in Detail
The export button is the editor's core output feature. Here is the flow it executes:
1. Collect the fields
Reads the current values from all editing controls: title, meta, activeNav, excerpt, tagline, badge, tags, promo, comment toggle, MD content.
2. Extract the h1 from the Markdown
Uses the regex /^#\s+(.+)/m to extract the first level-1 heading from the MD content and use it as the <h1>, falling back to pageTitle if none exists.
3. Build the Astro template
Generates the complete Astro file string, including:
- the frontmatter
---block (BaseLayout, HashedImage, and conditional CommentWidget imports) - the
<BaseLayout>wrapper structure - the full sidebar-left / sub-sidebar / article-panel / sidebar-right skeleton
4. Markdown block splitting
The exporter analyzes the Markdown content line by line and splits it into the following types:
| Block type | Rule | Export result |
|---|---|---|
| Plain Markdown | Non-empty line + not a raw HTML tag | Wrapped in <div class="section-block"> |
| Raw HTML block (self-closing) | Starts with <HashedImage and ends with /> | Kept as-is, not wrapped in section-block |
Raw HTML block (multi-line <div>) | Starts with <div class="..."> and matches RAW_TAGS | Enters a multi-line accumulation state machine that tracks <div>/</div> nesting depth; once closed, the whole block is kept and not wrapped in section-block |
| Code block | ``` at the start of a line toggles the state | The whole code block is treated as one block |
| Empty line | Separator between consecutive blocks | Triggers flushBlock() |
Multi-line accumulation: when<div class="terminal-code",<div class="xmind-embed-wrapper", or<div class="splat-viewer-wrapper"is encountered, the exporter enters theinRawHtmlstate and accumulates lines until the matching</div>closes. It also cleans up the extra leading spaces inside<code>caused by the uniform indentation (only removing\n 6, preserving the user's original indentation).
Once splitting is done, all blocks are concatenated in order to form the article's complete body.
5. Conditional output
- If the comment toggle is on → import CommentWidget + render the comment section, then close
</main>and</BaseLayout> - If the comment toggle is off → omit the import and rendering
6. Download
Generates a slug from the title → creates a Blob → automatically triggers an <a> download of {slug}.astro.
Icon Picker
The FA_ICONS array contains ~180 Font Awesome 6.5 icons (fa- prefix, all in the fas style).
Interaction flow
- Click the button → JS
appendChilds the dropdown to the body (taking it out of the sidebar-left stacking context) positionIconDropdown()computes the button position, preventing overflow on the right- The search box filters live (
datase.icon.includes()) - Click an icon →
_selectedIconis updated + the menu closes + the preview refreshes - Click outside → the menu closes
- On scroll → repositioned
Technical details
position: fixed; z-index: 999999+ being removed from the original DOM tree ensures it is not covered by the background filter layers- Width 323px, max height 240px, 8-column grid, overflow-x: hidden
Dependencies
| Package | Purpose |
|---|---|
marked | Markdown → HTML parsing (GFM + breaks) |
| Astro built-ins | BaseLayout, HashedImage, CommentWidget components |
| Font Awesome 6.5 | Icon library (CDN, loaded by BaseLayout) |
No additional runtime dependencies.
Maintenance Notes
Common pitfalls
- backdrop-filter and stacking contexts — the
backdrop-filteron.sidebar-leftcreates a new stacking context, breaking innerz-indexvalues. The icon dropdown's workaround: JS moves the element to<body>+position: fixed. :global()scoping — Astro's scoped CSS cannot reachbody. That is why the preview-mode toggle uses the:global(body.editor-mode-preview-active)selector.- Comment section positioning — the
.comment-panelrendered byCommentWidgetis the 4th child of<main>and relies ongrid-column: 2fromglobal.cssto land below the middle column. Keep this in sync when changing themain-layoutgrid definition. data-sidebar-mode="both"— must be set on<main>so thatinitSidebarToggle()insite.jsactivates the state machine correctly.- Raw HTML preservation on export — self-closing tags (
<HashedImage ... />) are matched on a single line and flushed immediately;<div>block tags (terminal-code,xmind-embed-wrapper,splat-viewer-wrapper) enter the multi-line accumulation state machine, which tracks the<div>nesting depth. Inside<code>, the extra\n 6prefix indentation is additionally cleaned to protect code rendering. If new Astro component tags are added in the future, theRAW_TAGSarray must be updated accordingly. -webkit-backdrop-filter— Safari requires this prefix; both the floating button and the mode-bar must provide the standard property alongside it.- Comment toggle control — controlled by
DOM.commentPanel.style.displayinsideupdatePreview(); when switching preview modes,updatePreview()must also be called manually to keep everything in sync.
Extension guide
- Adding a new editing field: add the input/textarea/select in the HTML → add a reference to the DOM object → add the key to the LIVE_FIELDS array → add sync logic in
updatePreview()→ add collection in the export logic. - Adding a new insertable component type: add a button + dialog to the toolbar → call
insertToEditor(tag)when the dialog is confirmed → if the inserted content is an Astro component or a<div>block, add its prefix toRAW_TAGSso it is not wrapped in section-block on export. For<div>blocks, also make sure they carry the.terminal-codeclass to trigger the multi-line accumulation logic. - Adding a new FA icon: append the icon name to the
FA_ICONSarray; no extra configuration needed.