diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index f14859b..ddf1a4b 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -334,6 +334,7 @@ data URLs into an atomic `๐ผ image` chip โ display-only; the underlying text
| A **use-case page** | Add a `UseCaseContent` file under `marketing/content/use-cases/` and register it in that folder's `index.ts` (Arabic variant โ `USE_CASES_AR`). |
| A **config option** | Add to `PdfConfig` (types) + `DEFAULT_CONFIG` + a control in `ConfigPanel` + consume in `documentStyle.ts`/`document.css`/`pageStyles.ts`. |
| A **Markdown feature** | Add a remark/rehype plugin (or a custom one under `markdown/plugins/`) in `MarkdownRenderer.tsx`. |
+| A **diagram flavor** | Branch on the fence language in `MarkdownRenderer.tsx`'s `pre` override (see the `mermaid` / `ascii` branches) โ render a dedicated component under `markdown/components/`; styles in `document.css`; detection/measurement helpers live in `markdown/asciiDiagram.ts`; mirror the branch in `marketing/pages/TemplatePage.tsx` so template pages match. |
| An **export format** | Add to `io/exporters.ts` + a `MenuItem`/command. |
| A **command / dialog** | See the wiring recipe in [docs/P1_FEATURES.md](docs/P1_FEATURES.md) ยง0. |
diff --git a/README.md b/README.md
index 29a38c3..adb680b 100644
--- a/README.md
+++ b/README.md
@@ -64,6 +64,7 @@ Most "Markdown to PDF" tools render your document twice โ once for the screen,
- Callouts / admonitions (`:::tip โฆ :::`)
- Syntax-highlighted code (Prism, 4 themes, copy button)
- Math (KaTeX), Mermaid diagrams, HTML passthrough
+- ASCII / box-drawing diagrams (` ```ascii `, auto-detected in plain fences) โ crisp, auto-fitted figures with optional `title="โฆ"` captions
#### ๐ PDF export (Paged.js)
diff --git a/docs/ASCII_DIAGRAMS_PROMPT.md b/docs/ASCII_DIAGRAMS_PROMPT.md
new file mode 100644
index 0000000..f7d9151
--- /dev/null
+++ b/docs/ASCII_DIAGRAMS_PROMPT.md
@@ -0,0 +1,273 @@
+# Prompt โ First-class ASCII / box-drawing diagram rendering (preview + PDF + editor)
+
+Paste into a fresh Claude Code session in the **markdown-to-pdf** repo (Scripto). Meaty but
+self-contained feature โ keep the build green throughout
+(`npx tsc -b --force && npm run build && npm run lint && npm run test`). No `any`, no
+`console.log`, no new dependencies unless truly unavoidable. Minimal comments (one line only where
+a constraint isn't obvious).
+
+---
+
+## Why (what "looks weird" today)
+
+Users โ and especially AI assistants โ constantly produce Markdown containing ASCII architecture
+diagrams in fenced code blocks: Unicode box-drawing (`โโโ โ โผ โ โ`), classic ASCII (`+--+ | ->`),
+tree listings (`โโโ โโโ`), and shaded bars (`โโโโ`). Today Scripto renders these as ordinary code
+blocks, which ruins them:
+
+- `rehype-prism-plus` runs with `showLineNumbers: true` โ **line numbers** appear beside diagrams.
+- Code blocks inherit the document's line-height (`--doc-leading`, default 1.65) โ **vertical `โ`
+ segments don't connect** โ the diagram looks shredded. This is the single biggest "weird".
+- JetBrains Mono **ligatures** turn `->` `=>` `|>` into single arrow glyphs โ widths shift,
+ alignment breaks subtly.
+- Wide diagrams **wrap or overflow**; one wrapped line destroys the picture, and in the PDF a
+ too-wide diagram overflows the page.
+- A diagram **taller than one page** hits the known Paged.js pathology: `pre` carries
+ `break-inside: avoid`, and an unbreakable element taller than the page content box makes Paged.js
+ spill broken/blank pages (we fixed exactly this for Mermaid โ see `src/pdf/pageStyles.ts`, the
+ "Tall diagrams / images are scaled to fit" rule; that clamp covers `svg`/`img` only, **not**
+ `pre`). Read commit `3feab4d` for the failure signature.
+- In the **editor** itself, soft word-wrap is on by default โ long diagram lines wrap while
+ editing, so even authoring looks broken.
+
+Goal: a dedicated, beautiful diagram treatment. The preview must equal the PDF โ that's the
+product's core promise (ARCHITECTURE.md ยง5: one stylesheet, one rendered DOM, cloned for export).
+
+## What to build
+
+### 1. Detection โ which code blocks are diagrams
+
+A fenced block is a diagram when **either**:
+
+- **Explicit language**: ` ```ascii `, ` ```diagram `, or ` ```ascii-art ` (all three aliases), or
+- **Heuristic (untagged blocks only)**: no language AND the text scores as diagram-like.
+
+**Escape hatch:** ` ```text `, ` ```txt `, ` ```plain ` are explicit "never a diagram, never
+highlighted" โ the user's way to force plain rendering when the heuristic would fire.
+A tagged language (` ```js ` etc.) must NEVER be treated as a diagram, whatever its content.
+
+Heuristic โ implement as pure, unit-tested functions in a new `src/markdown/asciiDiagram.ts`:
+โฅ 2 lines, and characters from the diagram alphabet โ box drawing U+2500โU+257F, block elements
+U+2580โU+259F, shades `โโโ`, arrows `โ โ โ โ โ โฒ โผ โ โบ`, plus the classic set when structurally
+used (`+-|` corners, `->`, `=>`, `<->`) โ exceeding a threshold (suggested: โฅ 8 occurrences across
+โฅ 2 distinct lines). Tune against the fixtures below AND the negative cases (real code with `=>`
+arrows, YAML, diffs, markdown tables must NOT match). Keep it O(n), single pass โ it runs on every
+untagged block per (debounced) render.
+
+### 2. Rendering โ the `AsciiDiagram` component
+
+Wire it exactly where Mermaid is wired: `src/markdown/MarkdownRenderer.tsx`'s `pre` component
+override already extracts `language` + `raw` (see the `language === 'mermaid'` branch โ
+``, ~line 89โ93). Add the diagram branch there (explicit alias, or
+`!language && isAsciiDiagram(raw)`), rendering a new
+`src/markdown/components/AsciiDiagram.tsx` modeled on `Mermaid.tsx` / `CodeBlock.tsx`.
+
+**Critical:** feed the component the **`raw`** string (already available in the override) โ never
+the prism-processed children, which may contain `.code-line` / line-number spans. Render it as a
+single text node inside `
` (no HTML injection surface โ raw text only).
+
+Visual spec (styles in `src/styles/document.css` โ it styles BOTH preview and PDF; colors via the
+existing `--doc-*` variables so every skin and the forced-white print surface stay correct):
+
+- ``, treatment like `.mermaid-figure`: centered, vertical margins,
+ `break-inside: avoid; page-break-inside: avoid;`.
+- **No code-block chrome**: no header bar, no line numbers, no syntax colors. At most a whisper of
+ background โ decide by eye against fixtures in 2โ3 skins; scope everything under
+ `.ascii-diagram` so per-skin `pre` overrides (technical/terminal/blueprint style code heavily)
+ don't leak in, and skins can later theme it deliberately.
+- Typography โ the make-or-break details:
+ - `font-family: 'JetBrains Mono', ui-monospace, monospace` (full box-drawing coverage);
+ - **`line-height: 1.2` fixed** โ explicitly independent of `--doc-leading`/the user's line-height
+ setting (tune 1.15โ1.25 until a stacked column of `โ` glyphs visually connects; test literally
+ with that);
+ - `font-variant-ligatures: none; font-feature-settings: 'liga' 0, 'calt' 0;` (kills the arrow
+ ligatures) and `font-kerning: none; letter-spacing: 0;`;
+ - `white-space: pre; tab-size: 4;` โ never wrap, and no horizontal scrollbar in the PDF.
+- **Direction: always LTR**, even inside RTL/Arabic documents (`direction: ltr; text-align: start`
+ on the figure โ same approach as `.mermaid-figure`). Arabic/emoji labels *inside* a line are fine
+ (bidi handles the run) โ but see width handling in ยง3.
+- **Caption (fence meta)**: support ` ```ascii title="Server layers" ` โ `` under the
+ diagram, styled like a small muted caption. The meta string is available on the hast node
+ (`node.data?.meta` on the code child โ verify how react-markdown exposes it in this pipeline);
+ parse just `title="โฆ"` defensively, ignore anything else. Escape nothing manually โ render as a
+ React text child.
+- **Accessibility**: `role="img"` on the figure with `aria-label` = the caption title when present,
+ else a generic localized "ASCII diagram" (i18n key in both `EN_STRINGS` and the Arabic map,
+ `src/lib/i18n.ts`); `aria-hidden="true"` on the `
` so screen readers aren't read 500 box
+ characters. Selection/copy must still yield the raw diagram text.
+- Optional: reuse `CodeBlock`'s copy-button pattern (with `data-pdf-hide-interactive` so it's
+ hidden in export). Nice, not required.
+
+### 3. Auto-fit โ the "perfect way" requirement
+
+A diagram must always fit the content width, scaled down proportionally when too wide:
+
+- Compute `maxCols` = longest line's **visual** column count: expand tabs, and count wide
+ characters as 2 โ East Asian Wide/Fullwidth and emoji (a compact `codePointWidth()` helper in
+ `asciiDiagram.ts` is enough: ranges for CJK, Hangul, emoji presentation; unit-test it). Counting
+ code points alone breaks the math the moment an AI puts โ or Arabic in a box.
+- Preferred mechanism โ **container-query units**, so the same CSS is correct in the preview card
+ AND the PDF page box without DOM measurement: `container-type: inline-size` on the figure; the
+ component emits `--diagram-cols` inline; document.css does
+ `font-size: min(, calc(100cqw / (var(--diagram-cols) * 0.6)))`
+ (0.6 โ JetBrains Mono advance width in em).
+ **Verify `cqw` resolves inside Paged.js pages** (export a wide fixture and measure the rendered
+ width). If it doesn't: fall back to measuring in the component effect (`scrollWidth` vs
+ `clientWidth` โ inline `font-size`); the inline style survives the export clone by construction
+ (`buildExportContent.ts` clones the live DOM). Container-relative CSS is preferred because
+ preview and page widths differ โ chase it first.
+- **Interplay with preview zoom**: the preview applies CSS `zoom` (pinch/zoom control in
+ `Preview.tsx`). Confirm the fit math still holds at 50%/150% zoom (container queries should be
+ zoom-agnostic; just verify visually).
+- **Tall diagrams**: extend the tall-figure strategy in `src/pdf/pageStyles.ts` so a diagram taller
+ than the page content box **scales down** instead of triggering the blank-page spill. For text a
+ `max-height` alone won't reflow โ reduce font-size through the same custom-property mechanism
+ (emit `--diagram-rows` too; height โ rows ร 1.2 ร font-size, so a max-height in mm converts to a
+ font-size cap the same way), or `transform: scale()` with a measured wrapper height. Prove it
+ with fixture A: exported PDF shows it **intact on one page**, no leaked style text, no blanks.
+
+### 4. Editor experience (authoring shouldn't look broken either)
+
+- **Slash command**: add a "Diagram (ASCII)" item to `src/components/editor/slashCommands.ts`
+ inserting an ` ```ascii ` fence scaffold (mirror how existing insert items work); i18n label
+ en + ar.
+- **Optional stretch (skip if it grows big):** disable soft-wrap inside diagram fences in
+ CodeMirror via a line-decoration extension. โ ๏ธ If you attempt it, obey the invariant documented
+ at the top of `src/components/editor/MarkdownEditor.tsx`: extensions/props must stay
+ identity-stable or the โF search panel dies (reconfigure wipes runtime extensions). If in doubt,
+ don't touch the editor beyond the slash command โ the preview is the product here.
+
+### 5. Polish (small, do last)
+
+- **Cheat sheet**: add a row to the "Scripto extras" group in
+ `src/marketing/pages/CheatSheetPage.tsx` (` ```ascii ` โ crisp auto-fitted diagram figures,
+ `title="โฆ"` caption).
+- **FormattingHelpDialog** (`src/components/layout/FormattingHelpDialog.tsx`): short entry if it
+ has a code/diagrams section (i18n en + ar).
+- **Templates**: add 1โ2 `DocumentTemplate` entries in `src/data/templates.ts` (category
+ `'diagram'`), e.g. "ASCII Architecture" seeded with fixture A โ template pages on the marketing
+ site auto-generate from this data (free SEO). Include `nameKey`/`descKey` + translations, and
+ verify the diagram renders on its `/templates/` marketing page: that page has its OWN
+ ReactMarkdown `pre` override (`src/marketing/pages/TemplatePage.tsx`) โ give it the same diagram
+ branch or it will show the broken code-block treatment you're fixing.
+- **Other exports**: HTML and Word exports (`src/io/exporters.ts`) serialize the same DOM โ
+ spot-check the diagram survives as monospace text in the self-contained HTML export and doesn't
+ crash the `.doc` export (best-effort fidelity there is acceptable; no crash is mandatory).
+- **Docs**: one bullet in README's Rich Markdown feature list; one row in ARCHITECTURE.md ยง14
+ ("Extending") for adding diagram flavors.
+
+## Test fixtures (use verbatim)
+
+**A โ wide + tall Unicode box diagram (the real-world stress case):**
+
+````
+```ascii
+ Internet (your users & clients)
+ โ
+ โผ
+ โโโโโโโโโโโโโโโโโโโโ
+ โ Cloudflare โ DNS + proxy/CDN in front of the domain
+ โโโโโโโโโโฌโโโโโโโโโโ
+ โ
+ โโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ ONE ECS SERVER (ARM64) โ everything below is this box
+ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ โโโโโโโโโโโโโโโโโโโโ
+ โ Nginx โ Terminates HTTPS, forwards to:
+ โ (ports 80/443) โ
+ โโโโโโโโโโฌโโโโโโโโโโ
+ โ
+ โโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโ
+ โผ โผ โผ
+ :3000 sqcm-prod :3003 marketing :3002 hr-backend
+ โ
+ โโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ โ PM2 (process manager) โ
+ โ โโ marketing (Next.js web) โ
+ โ โโ marketing-worker (BullMQ jobs) โ
+ โโโโโโโโโโฌโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโ
+ โผ โผ
+ MongoDB (Docker) Redis (queues)
+```
+````
+
+**B โ classic ASCII style, untagged (the heuristic must catch it):**
+
+````
+```
+ +-------------+ +--------------+
+ users -->| Cloudflare | -----> | Nginx |
+ +-------------+ +------+-------+
+ |
+ +-----------+-----------+
+ v v
+ +-----------+ +-----------+
+ | Next.js | | Worker |
+ +-----------+ +-----------+
+```
+````
+
+**C โ tree listing with caption (tagged + meta):**
+
+````
+```ascii title="Project layout"
+src/
+โโโ markdown/
+โ โโโ MarkdownRenderer.tsx
+โ โโโ components/
+โ โโโ Mermaid.tsx
+โ โโโ AsciiDiagram.tsx
+โโโ styles/
+ โโโ document.css
+```
+````
+
+**D โ wide characters (emoji + Arabic label; width math must hold):**
+
+````
+```ascii
+โโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโ
+โ โ ุงููุดุฑ ุงูุชููุงุฆู โ โโโโบ โ ๐ฆ Object Storage โ
+โโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโ
+```
+````
+
+**Negative cases** (must stay normal code blocks): untagged JavaScript with `=>` arrows; untagged
+YAML; untagged diff with `+`/`-` prefixes; a GFM table pasted untagged; fixture B inside a tagged
+` ```js ` block; and ` ```text ` containing fixture A (escape hatch wins).
+
+## Verification (all of it โ look at pixels, don't just assert)
+
+1. `npx tsc -b --force && npm run build && npm run lint && npm run test` โ green, 0 lint errors;
+ unit tests for the detector (AโD positive, all negatives) and for `codePointWidth`/maxCols.
+2. **Preview pass** (headless Chrome via playwright-core โ repo-standard pattern; seeding recipe in
+ `docs/PRODUCT_DEMO_RECORDING_PROMPT.md`: preload `scripto:library:v1`, dismiss
+ `scripto:onboarding`, set `scripto:view-mode`). Screenshot fixtures AโD and INSPECT: verticals
+ connected (no gaps), nothing wrapped, no line numbers/chrome, caption on C, D's boxes aligned.
+ Repeat: dark theme; skins `technical` + `terminal` + `newsprint`; inside an RTL/Arabic document
+ (diagram stays LTR, page furniture mirrors); preview zoom 50% and 150%.
+3. **PDF pass**: Export PDF โ wait `.pagedjs_page` โ screenshot each fixture's page. A lands
+ **intact on one page**, auto-scaled; no blank pages; no leaked `#โฆ{` style text; captions print;
+ a normal ` ```js ` block in the same doc still shows header/copy/line numbers.
+4. **Regressions**: a Mermaid flowchart still renders; KaTeX untouched; marketing build still
+ prerenders all pages (~102+; count grows if you added templates โ the build logs exact counts);
+ HTML + Word exports don't crash.
+
+## Workflow
+
+- Branch `dev`; clear commit message; merge into `main` with `--no-ff`. **Do not push.**
+- `npm run dev` for the app at `/app`; stop the server when done.
+- Conventions: strict TS (no `any`), no `console.log` (use `lib/logger.ts`), i18n keys in en + ar,
+ logical CSS properties only (RTL), minimal comments.
+
+## Acceptance
+
+- Fixtures AโD render as crisp, connected, auto-fitted diagram figures in preview AND exported PDF
+ (A on a single page), light + dark, LTR + RTL, โฅ 3 skins, zoom-proof.
+- Untagged fixture B auto-detected; every negative case untouched; ` ```text ` escape hatch works;
+ tagged languages never hijacked.
+- Caption via ` title="โฆ" ` renders and prints; figure is accessible (role="img" + localized
+ label, pre aria-hidden).
+- `/diagram` slash command inserts a working scaffold.
+- Zero regressions (code blocks, Mermaid, KaTeX, marketing build, HTML/Word export).
+- All checks green: `tsc`, `build`, `lint` (0 errors), `test`.
diff --git a/src/components/editor/slashCommands.ts b/src/components/editor/slashCommands.ts
index 9967f34..95653c8 100644
--- a/src/components/editor/slashCommands.ts
+++ b/src/components/editor/slashCommands.ts
@@ -58,7 +58,7 @@ const S_AI: SlashSection = { titleKey: 'slash.section.ai', rank: 0 }
const S_TEXT: SlashSection = { titleKey: 'slash.section.text', rank: 1 }
const S_LISTS: SlashSection = { titleKey: 'slash.section.lists', rank: 2 }
const S_INSERT: SlashSection = { titleKey: 'slash.section.insert', rank: 3 }
-const S_DIAGRAM: SlashSection = { titleKey: 'slash.section.diagram', title: 'Diagrams (Mermaid)', rank: 3.5 }
+const S_DIAGRAM: SlashSection = { titleKey: 'slash.section.diagram', title: 'Diagrams', rank: 3.5 }
const S_CALLOUT: SlashSection = { titleKey: 'slash.section.callout', rank: 4 }
const S_TEMPLATE: SlashSection = { titleKey: 'slash.section.template', rank: 5 }
const S_HELP: SlashSection = { titleKey: 'slash.section.help', rank: 6 }
@@ -391,6 +391,28 @@ const ITEMS: SlashItem[] = [
insert:
'```mermaid\ntimeline\n title Product history\n 2021 : Founded\n 2023 : Public launch\n 2026 : 1M users\n```\n',
},
+ {
+ label: 'Diagram (ASCII)',
+ detail: 'Diagram',
+ info: 'A text-drawn box diagram, rendered as a crisp auto-fitted figure. Pasted ASCII art is detected automatically.',
+ labelKey: 'slash.asciiDiagram.label',
+ detailKey: 'slash.detail.diagram',
+ descKey: 'slash.asciiDiagram.desc',
+ section: S_DIAGRAM,
+ insert:
+ '```ascii title="Architecture"\nโโโโโโโโโโโโ โโโโโโโโโโโโ\nโ Client โ โโโโบ โ Server โ\nโโโโโโโโโโโโ โโโโโโโโโโโโ\n```\n',
+ },
+ {
+ label: 'Folder tree (ASCII)',
+ detail: 'Diagram',
+ info: 'A project or folder layout drawn with tree characters.',
+ labelKey: 'slash.asciiTree.label',
+ detailKey: 'slash.detail.diagram',
+ descKey: 'slash.asciiTree.desc',
+ section: S_DIAGRAM,
+ insert:
+ '```ascii title="Project layout"\nsrc/\nโโโ components/\nโ โโโ App.tsx\nโ โโโ Button.tsx\nโโโ index.ts\n```\n',
+ },
// ---- Highlight boxes (callouts) ----
{
diff --git a/src/components/layout/FormattingHelpDialog.tsx b/src/components/layout/FormattingHelpDialog.tsx
index b507367..e29e8d1 100644
--- a/src/components/layout/FormattingHelpDialog.tsx
+++ b/src/components/layout/FormattingHelpDialog.tsx
@@ -121,6 +121,18 @@ const ROWS: FormatRow[] = [
),
},
+ {
+ labelKey: 'formatHelp.row.asciiDiagram',
+ syntax: '```ascii\nโโโโโ โโโโโ\nโ A โโโโบโ B โ\nโโโโโ โโโโโ\n```',
+ sample: (
+
+ {'โโโโโ โโโโโ\nโ A โโโโบโ B โ\nโโโโโ โโโโโ'}
+
+ ),
+ },
]
/** Beginner cheat-sheet showing "you type" (Markdown) โ "you get" (a live preview). */
diff --git a/src/data/templates.ts b/src/data/templates.ts
index eef8b63..1f7d4e5 100644
--- a/src/data/templates.ts
+++ b/src/data/templates.ts
@@ -1583,6 +1583,75 @@ timeline
2023 : Public launch
2026 : 1M users
\`\`\`
+`,
+ },
+ {
+ id: 'diagram-ascii-architecture',
+ name: 'ASCII Architecture',
+ description: 'A text-drawn infrastructure diagram that exports crisp and auto-fitted.',
+ nameKey: 'template.diagram-ascii-architecture.name',
+ descKey: 'template.diagram-ascii-architecture.desc',
+ category: 'diagram',
+ emoji: '๐ฅ๏ธ',
+ content: `# Server Architecture
+
+Everything below runs on a single server behind Cloudflare.
+
+\`\`\`ascii title="Production topology"
+ Internet (your users & clients)
+ โ
+ โผ
+ โโโโโโโโโโโโโโโโโโโโ
+ โ Cloudflare โ DNS + proxy/CDN in front of the domain
+ โโโโโโโโโโฌโโโโโโโโโโ
+ โ
+ โโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ ONE ECS SERVER (ARM64) โ everything below is this box
+ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ โโโโโโโโโโโโโโโโโโโโ
+ โ Nginx โ Terminates HTTPS, forwards to:
+ โ (ports 80/443) โ
+ โโโโโโโโโโฌโโโโโโโโโโ
+ โ
+ โโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโ
+ โผ โผ โผ
+ :3000 sqcm-prod :3003 marketing :3002 hr-backend
+ โ
+ โโโโโโโโโโดโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
+ โ PM2 (process manager) โ
+ โ โโ marketing (Next.js web) โ
+ โ โโ marketing-worker (BullMQ jobs) โ
+ โโโโโโโโโโฌโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโ
+ โผ โผ
+ MongoDB (Docker) Redis (queues)
+\`\`\`
+
+Untagged ASCII art in a plain fence is detected automatically; tag a fence
+as \`text\` to force plain rendering instead.
+`,
+ },
+ {
+ id: 'diagram-ascii-tree',
+ name: 'ASCII Folder Tree',
+ description: 'A project layout drawn with tree characters, with a caption.',
+ nameKey: 'template.diagram-ascii-tree.name',
+ descKey: 'template.diagram-ascii-tree.desc',
+ category: 'diagram',
+ emoji: '๐ณ',
+ content: `# Project Layout
+
+The folders that matter, at a glance.
+
+\`\`\`ascii title="Source layout"
+src/
+โโโ markdown/
+โ โโโ MarkdownRenderer.tsx
+โ โโโ components/
+โ โโโ Mermaid.tsx
+โ โโโ AsciiDiagram.tsx
+โโโ styles/
+ โโโ document.css
+\`\`\`
`,
},
{
diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts
index 0c1fcd7..e9f88e7 100644
--- a/src/lib/i18n.ts
+++ b/src/lib/i18n.ts
@@ -475,6 +475,11 @@ export const EN_STRINGS = {
'template.diagram-mindmap.desc': 'A branching map of ideas around a theme.',
'template.diagram-timeline.name': 'Timeline โ Milestones',
'template.diagram-timeline.desc': 'A chronological list of milestones.',
+ 'template.diagram-ascii-architecture.name': 'ASCII Architecture',
+ 'template.diagram-ascii-architecture.desc':
+ 'A text-drawn infrastructure diagram that exports crisp and auto-fitted.',
+ 'template.diagram-ascii-tree.name': 'ASCII Folder Tree',
+ 'template.diagram-ascii-tree.desc': 'A project layout drawn with tree characters, with a caption.',
'slash.section.ai': 'AI help',
'slash.section.text': 'Text',
'slash.section.lists': 'Lists',
@@ -542,7 +547,7 @@ export const EN_STRINGS = {
'slash.stepGuide.desc': 'A numbered how-to structure with a title.',
'slash.help.label': 'Formatting help',
'slash.help.desc': 'Open the cheatsheet of formatting shortcuts and tips.',
- 'slash.section.diagram': 'Diagrams (Mermaid)',
+ 'slash.section.diagram': 'Diagrams',
'slash.detail.diagram': 'Diagram',
'slash.flowchart.label': 'Flowchart',
'slash.flowchart.desc': 'A process or decision flow with boxes and arrows (Mermaid).',
@@ -566,6 +571,11 @@ export const EN_STRINGS = {
'slash.journey.desc': 'An experience map scoring each step of a journey (Mermaid).',
'slash.timeline.label': 'Timeline',
'slash.timeline.desc': 'A chronological sequence of events (Mermaid).',
+ 'slash.asciiDiagram.label': 'Diagram (ASCII)',
+ 'slash.asciiDiagram.desc':
+ 'A text-drawn box diagram, rendered as a crisp auto-fitted figure. Pasted ASCII art is detected automatically.',
+ 'slash.asciiTree.label': 'Folder tree (ASCII)',
+ 'slash.asciiTree.desc': 'A project or folder layout drawn with tree characters.',
'editor.tooltip.undo': 'Undo',
'editor.tooltip.redo': 'Redo',
'editor.tooltip.heading1': 'Heading 1',
@@ -716,6 +726,7 @@ export const EN_STRINGS = {
'print.close': 'Close preview',
'print.tryAgain': 'Try again',
'markdown.diagramError': 'Diagram error:',
+ 'markdown.asciiDiagram': 'ASCII diagram',
'error.boundaryTitle': 'Something went wrong while rendering.',
'error.tryAgain': 'Try again',
'template.pitch-one-pager.name': 'Startup One-Pager',
@@ -800,6 +811,7 @@ export const EN_STRINGS = {
'formatHelp.row.table': 'Table',
'formatHelp.row.callout': 'Callout',
'formatHelp.row.math': 'Math',
+ 'formatHelp.row.asciiDiagram': 'ASCII diagram',
// Preview cover/Contents toggle
'preview.frontMatter.toggle': 'Show cover & contents',
@@ -1266,6 +1278,11 @@ export const STRINGS: Record = {
'template.diagram-mindmap.desc': 'ุฎุฑูุทุฉ ู ุชูุฑุนุฉ ููุฃููุงุฑ ุญูู ู ูุถูุน.',
'template.diagram-timeline.name': 'ุงูุฎุท ุงูุฒู ูู โ ุงูู ุนุงูู ',
'template.diagram-timeline.desc': 'ูุงุฆู ุฉ ุฒู ููุฉ ุจุงูู ุนุงูู .',
+ 'template.diagram-ascii-architecture.name': 'ุจููุฉ ASCII',
+ 'template.diagram-ascii-architecture.desc':
+ 'ู ุฎุทุท ุจููุฉ ุชุญุชูุฉ ู ุฑุณูู ูุตููุงุ ููุตุฏููุฑ ูุงุถุญูุง ูู ุถุจูุท ุงูู ูุงุณ ุชููุงุฆููุง.',
+ 'template.diagram-ascii-tree.name': 'ุดุฌุฑุฉ ู ุฌูุฏุงุช ASCII',
+ 'template.diagram-ascii-tree.desc': 'ูููู ู ุดุฑูุน ู ุฑุณูู ุจู ุญุงุฑู ุงูุดุฌุฑุฉุ ู ุน ุชุณู ูุฉ ุชูุถูุญูุฉ.',
'slash.section.ai': 'ู ุณุงุนุฏุฉ ุงูุฐูุงุก ุงูุงุตุทูุงุนู',
'slash.section.text': 'ูุต',
'slash.section.lists': 'ููุงุฆู ',
@@ -1333,7 +1350,7 @@ export const STRINGS: Record = {
'slash.stepGuide.desc': 'ูููู ุฅุฑุดุงุฏู ู ุฑูููู ู ุน ุนููุงู.',
'slash.help.label': 'ู ุณุงุนุฏุฉ ุงูุชูุณูู',
'slash.help.desc': 'ุงูุชุญ ูุฑูุฉ ู ุฑุฌุนูุฉ ุจุงุฎุชุตุงุฑุงุช ุงูุชูุณูู ููุตุงุฆุญู.',
- 'slash.section.diagram': 'ุงูู ุฎุทุทุงุช (Mermaid)',
+ 'slash.section.diagram': 'ุงูู ุฎุทุทุงุช',
'slash.detail.diagram': 'ู ุฎุทุท',
'slash.flowchart.label': 'ู ุฎุทุท ุงูุณูุงุจู',
'slash.flowchart.desc': 'ุชุฏูู ุนู ููุฉ ุฃู ูุฑุงุฑ ุจู ุฑุจุนุงุช ูุฃุณูู (Mermaid).',
@@ -1357,6 +1374,11 @@ export const STRINGS: Record = {
'slash.journey.desc': 'ุฎุฑูุทุฉ ุชุฌุฑุจุฉ ุชููููู ูู ุฎุทูุฉ ูู ุงูุฑุญูุฉ (Mermaid).',
'slash.timeline.label': 'ุงูุฎุท ุงูุฒู ูู',
'slash.timeline.desc': 'ุชุณูุณู ุฒู ูู ููุฃุญุฏุงุซ (Mermaid).',
+ 'slash.asciiDiagram.label': 'ู ุฎุทุท (ASCII)',
+ 'slash.asciiDiagram.desc':
+ 'ู ุฎุทุท ุตูุงุฏูู ู ุฑุณูู ูุตููุงุ ููุนุฑุถ ูุดูู ูุงุถุญ ู ุถุจูุท ุงูู ูุงุณ ุชููุงุฆููุง. ูููุชุดู ูู ASCII ุงูู ูุตูู ุชููุงุฆููุง.',
+ 'slash.asciiTree.label': 'ุดุฌุฑุฉ ู ุฌูุฏุงุช (ASCII)',
+ 'slash.asciiTree.desc': 'ูููู ู ุดุฑูุน ุฃู ู ุฌูุฏุงุช ู ุฑุณูู ุจู ุญุงุฑู ุงูุดุฌุฑุฉ.',
'editor.tooltip.undo': 'ุชุฑุงุฌุน',
'editor.tooltip.redo': 'ุฅุนุงุฏุฉ',
'editor.tooltip.heading1': 'ุนููุงู 1',
@@ -1507,6 +1529,7 @@ export const STRINGS: Record = {
'print.close': 'ุฅุบูุงู ุงูู ุนุงููุฉ',
'print.tryAgain': 'ุญุงูู ู ุฌุฏุฏูุง',
'markdown.diagramError': 'ุฎุทุฃ ูู ุงูู ุฎุทูุท:',
+ 'markdown.asciiDiagram': 'ู ุฎุทุท ASCII',
'error.boundaryTitle': 'ุญุฏุซ ุฎุทุฃ ุฃุซูุงุก ุงูุนุฑุถ.',
'error.tryAgain': 'ุญุงูู ู ุฌุฏุฏูุง',
'template.pitch-one-pager.name': 'ุตูุญุฉ ุชุนุฑูููุฉ ููุดุฑูุฉ ุงููุงุดุฆุฉ',
@@ -1591,6 +1614,7 @@ export const STRINGS: Record = {
'formatHelp.row.table': 'ุฌุฏูู',
'formatHelp.row.callout': 'ุตูุฏูู ุชูุจูู',
'formatHelp.row.math': 'ู ุนุงุฏูุฉ',
+ 'formatHelp.row.asciiDiagram': 'ู ุฎุทุท ASCII',
// Preview cover/Contents toggle
'preview.frontMatter.toggle': 'ุฅุธูุงุฑ ุงูุบูุงู ูุงูู ุญุชููุงุช',
diff --git a/src/markdown/MarkdownRenderer.tsx b/src/markdown/MarkdownRenderer.tsx
index 81f049c..4e2aa09 100644
--- a/src/markdown/MarkdownRenderer.tsx
+++ b/src/markdown/MarkdownRenderer.tsx
@@ -11,10 +11,13 @@ import rehypeKatex from 'rehype-katex'
import rehypeSlug from 'rehype-slug'
import rehypePrismPlus from 'rehype-prism-plus'
import { rehypeSourceLine } from './plugins/rehypeSourceLine'
+import { rehypeFenceMeta } from './plugins/rehypeFenceMeta'
import { remarkCallouts } from './plugins/remarkCallouts'
import { remarkMarks } from './plugins/remarkMarks'
import { CodeBlock } from './components/CodeBlock'
import { Mermaid } from './components/Mermaid'
+import { AsciiDiagram } from './components/AsciiDiagram'
+import { ASCII_DIAGRAM_LANGUAGES, isAsciiDiagram, parseFenceTitle } from './asciiDiagram'
import type { ResolvedTheme } from '@/types'
interface MarkdownRendererProps {
@@ -26,7 +29,7 @@ interface HastNode {
type: string
tagName?: string
value?: string
- properties?: { className?: string[] | string }
+ properties?: { className?: string[] | string; dataMeta?: unknown }
children?: HastNode[]
}
@@ -69,6 +72,7 @@ const rehypePlugins = [
// First, while mdast positions are intact, so scroll-sync anchors survive
// rehype-raw's reserialization of raw HTML.
rehypeSourceLine,
+ rehypeFenceMeta,
rehypeRaw,
rehypeKatex,
rehypeSlug,
@@ -92,6 +96,17 @@ function MarkdownRendererImpl({ content, resolvedTheme }: MarkdownRendererProps)
if (language === 'mermaid') {
return
}
+ // ```text/```txt/```plain force plain rendering; any other tagged
+ // language is never hijacked โ the heuristic runs on untagged blocks only.
+ if (ASCII_DIAGRAM_LANGUAGES.has(language) || (!language && isAsciiDiagram(raw))) {
+ const meta = codeChild?.properties?.dataMeta
+ return (
+
+ )
+ }
return (
{children}
diff --git a/src/markdown/asciiDiagram.ts b/src/markdown/asciiDiagram.ts
new file mode 100644
index 0000000..335000f
--- /dev/null
+++ b/src/markdown/asciiDiagram.ts
@@ -0,0 +1,138 @@
+/**
+ * Detection and measurement for ASCII / box-drawing diagrams in fenced code
+ * blocks. Pure, single-pass functions โ they run on every untagged block per
+ * (debounced) render, and are unit-tested in tests/asciiDiagram.test.ts.
+ */
+
+/** Fence languages that explicitly mark a block as an ASCII diagram. */
+export const ASCII_DIAGRAM_LANGUAGES: ReadonlySet = new Set([
+ 'ascii',
+ 'diagram',
+ 'ascii-art',
+])
+
+/** Fence languages that force plain rendering โ never a diagram, never highlighted. */
+export const PLAIN_TEXT_LANGUAGES: ReadonlySet = new Set(['text', 'txt', 'plain'])
+
+// Strong signals: box drawing U+2500โ257F, block elements/shades U+2580โ259F,
+// arrows U+2190โ21FF, geometric shapes (โฒ โผ โ โบ, โฆ) U+25A0โ25FF.
+const STRONG_CHARS = /[\u2190-\u21ff\u2500-\u259f\u25a0-\u25ff]/g
+
+// Classic ASCII corners: a `+` joined to a dash run reads as a box edge.
+const CORNER_RUNS = /\+--|--\+/g
+
+// `<->` and `-->` before their two-char substrings so each arrow counts once.
+const ARROW_TOKENS = /<->|<=>|-->|==>|->|=>|<-/g
+
+// A line made only of connector ink (` | `, `+----+----+`, `v v`).
+const CONNECTOR_LINE = /^[-+|=<>^v\s]+$/
+const CONNECTOR_INK = /[-+|]/
+
+const MIN_SCORE = 8
+const MIN_SCORING_LINES = 2
+
+function countMatches(line: string, pattern: RegExp): number {
+ pattern.lastIndex = 0
+ let count = 0
+ while (pattern.exec(line) !== null) count += 1
+ return count
+}
+
+/**
+ * Heuristic for untagged fenced blocks: does this text look like an ASCII
+ * diagram? Unicode diagram characters score directly; the classic `+-|`/arrow
+ * set only counts when used structurally, so code with `=>` arrows, YAML,
+ * diffs, and Markdown tables stay ordinary code blocks.
+ */
+export function isAsciiDiagram(raw: string): boolean {
+ const lines = raw.replace(/\n+$/, '').split('\n')
+ if (lines.length < MIN_SCORING_LINES) return false
+
+ let score = 0
+ let arrowCount = 0
+ let structural = false
+ let scoringLines = 0
+
+ for (const line of lines) {
+ const strong = countMatches(line, STRONG_CHARS)
+ const corners = countMatches(line, CORNER_RUNS)
+ const trimmed = line.trim()
+ const isConnector =
+ trimmed.length > 0 && CONNECTOR_LINE.test(trimmed) && CONNECTOR_INK.test(trimmed)
+
+ const linePoints = strong + corners * 2 + (isConnector ? 2 : 0)
+ if (linePoints > 0) structural = true
+
+ const arrows = countMatches(line, ARROW_TOKENS)
+ arrowCount += arrows
+
+ if (linePoints > 0 || arrows > 0) scoringLines += 1
+ score += linePoints
+ }
+
+ // Arrow tokens alone are everyday code (`=>`, `->`); they only add evidence
+ // once some box/connector structure exists.
+ if (structural) score += arrowCount
+
+ return score >= MIN_SCORE && scoringLines >= MIN_SCORING_LINES
+}
+
+/**
+ * Visual width of a code point in monospace columns: 2 for East Asian
+ * Wide/Fullwidth and emoji, 0 for joiners/variation selectors/combining marks,
+ * else 1. Compact by design โ enough for diagram fit math, not a full UAX #11
+ * implementation.
+ */
+export function codePointWidth(cp: number): number {
+ if (cp === 0x200b || cp === 0x200c || cp === 0x200d || cp === 0xfe0f) return 0
+ if (cp >= 0x0300 && cp <= 0x036f) return 0
+ if (
+ (cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
+ (cp >= 0x2e80 && cp <= 0x303e) || // CJK radicals โฆ punctuation
+ (cp >= 0x3041 && cp <= 0x33ff) || // kana โฆ CJK compatibility
+ (cp >= 0x3400 && cp <= 0x4dbf) || // CJK extension A
+ (cp >= 0x4e00 && cp <= 0x9fff) || // CJK unified
+ (cp >= 0xa000 && cp <= 0xa4cf) || // Yi
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK compatibility ideographs
+ (cp >= 0xfe30 && cp <= 0xfe4f) || // CJK compatibility forms
+ (cp >= 0xff00 && cp <= 0xff60) || // fullwidth forms
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
+ (cp >= 0x2600 && cp <= 0x27bf) || // misc symbols & dingbats (โ โ โ โฆ)
+ (cp >= 0x1f000 && cp <= 0x1faff) // emoji & extended pictographs
+ ) {
+ return 2
+ }
+ return 1
+}
+
+/** Longest line of `text` in visual monospace columns (tabs expanded, wide chars = 2). */
+export function maxVisualColumns(text: string, tabSize = 4): number {
+ let max = 0
+ for (const line of text.split('\n')) {
+ let col = 0
+ for (const ch of line) {
+ const cp = ch.codePointAt(0) ?? 0
+ col = cp === 0x09 ? (Math.floor(col / tabSize) + 1) * tabSize : col + codePointWidth(cp)
+ }
+ if (col > max) max = col
+ }
+ return max
+}
+
+/** Number of rendered rows (trailing newlines stripped). */
+export function diagramRowCount(text: string): number {
+ const trimmed = text.replace(/\n+$/, '')
+ return trimmed.length === 0 ? 1 : trimmed.split('\n').length
+}
+
+/**
+ * Extract `title="โฆ"` (or `title='โฆ'`) from a fence meta string, e.g.
+ * ` ```ascii title="Server layers" `. Anything else in the meta is ignored.
+ */
+export function parseFenceTitle(meta: string | undefined): string | undefined {
+ if (!meta) return undefined
+ const match = /(?:^|\s)title=(?:"([^"]*)"|'([^']*)')/.exec(meta)
+ const title = (match?.[1] ?? match?.[2] ?? '').trim()
+ return title.length > 0 ? title : undefined
+}
diff --git a/src/markdown/components/AsciiDiagram.tsx b/src/markdown/components/AsciiDiagram.tsx
new file mode 100644
index 0000000..8c12afe
--- /dev/null
+++ b/src/markdown/components/AsciiDiagram.tsx
@@ -0,0 +1,42 @@
+import { useMemo, type CSSProperties } from 'react'
+import { diagramRowCount, maxVisualColumns } from '../asciiDiagram'
+import { useLanguage } from '@/i18n'
+
+interface AsciiDiagramProps {
+ code: string
+ /** Optional caption from the fence meta: ```ascii title="โฆ" */
+ title?: string
+}
+
+/**
+ * Renders an ASCII / box-drawing diagram as a chrome-free figure. The measured
+ * column/row counts are emitted as CSS custom properties; document.css (and the
+ * PDF page styles) turn them into an auto-fit font-size via container-query
+ * units, so the same markup is correct in the preview card and the page box.
+ */
+export function AsciiDiagram({ code, title }: AsciiDiagramProps) {
+ const { t } = useLanguage()
+ const text = useMemo(() => code.replace(/\n+$/, ''), [code])
+ const style = useMemo(
+ () =>
+ ({
+ '--diagram-cols': String(Math.max(1, maxVisualColumns(text))),
+ '--diagram-rows': String(diagramRowCount(text)),
+ }) as CSSProperties,
+ [text],
+ )
+
+ return (
+
+ {/* aria-hidden: 500 box-drawing characters are noise to a screen reader;
+ the figure's aria-label carries the meaning. Text stays selectable. */}
+
{text}
+ {title ? {title} : null}
+
+ )
+}
diff --git a/src/markdown/plugins/rehypeFenceMeta.ts b/src/markdown/plugins/rehypeFenceMeta.ts
new file mode 100644
index 0000000..742251c
--- /dev/null
+++ b/src/markdown/plugins/rehypeFenceMeta.ts
@@ -0,0 +1,20 @@
+import { visit } from 'unist-util-visit'
+import type { Element, Root } from 'hast'
+
+/**
+ * Copies the fence meta string (```ascii title="โฆ") from `code.data.meta` into
+ * a real `data-meta` attribute. Must run BEFORE rehype-raw: hast-util-raw
+ * reserializes the whole tree through parse5, which drops `data` fields โ
+ * attributes survive, so the `pre` component override can still read the meta.
+ */
+export function rehypeFenceMeta() {
+ return (tree: Root): void => {
+ visit(tree, 'element', (node: Element) => {
+ if (node.tagName !== 'code') return
+ const meta = (node.data as { meta?: unknown } | undefined)?.meta
+ if (typeof meta !== 'string' || meta.length === 0) return
+ node.properties = node.properties ?? {}
+ node.properties['dataMeta'] = meta
+ })
+ }
+}
diff --git a/src/marketing/marketing.css b/src/marketing/marketing.css
index 8b3fa7d..10fbc76 100644
--- a/src/marketing/marketing.css
+++ b/src/marketing/marketing.css
@@ -1115,6 +1115,42 @@ html.js .mk-reveal.in {
border-radius: 0;
}
+/* ASCII diagram figures โ mirrors the app's .ascii-diagram treatment. */
+.mk-prose .mk-ascii-diagram {
+ margin-block: 1.5em;
+ text-align: center;
+ direction: ltr;
+ container-type: inline-size;
+}
+.mk-prose .mk-ascii-diagram pre {
+ display: inline-block;
+ max-inline-size: 100%;
+ margin: 0;
+ padding: 0.9em 1em;
+ overflow: hidden;
+ border: none;
+ border-radius: 0.625rem;
+ background: color-mix(in srgb, var(--mk-fg) 4%, transparent);
+ color: var(--mk-fg);
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
+ font-size: min(0.8125rem, calc(100cqw / (var(--diagram-cols, 60) * 0.6 + 2)));
+ line-height: 1.2;
+ font-variant-ligatures: none;
+ font-feature-settings:
+ 'liga' 0,
+ 'calt' 0;
+ font-kerning: none;
+ letter-spacing: 0;
+ white-space: pre;
+ tab-size: 4;
+ text-align: start;
+}
+.mk-prose .mk-ascii-diagram figcaption {
+ margin-block-start: 0.6em;
+ font-size: 0.8125rem;
+ color: var(--mk-muted);
+}
+
.mk-prose .callout {
--co: hsl(220 12% 55%);
display: grid;
diff --git a/src/marketing/pages/CheatSheetPage.tsx b/src/marketing/pages/CheatSheetPage.tsx
index 701c159..00be5d8 100644
--- a/src/marketing/pages/CheatSheetPage.tsx
+++ b/src/marketing/pages/CheatSheetPage.tsx
@@ -60,6 +60,11 @@ const GROUPS: SyntaxGroup[] = [
{ element: 'Inline math', syntax: '$E = mc^2$', note: 'KaTeX syntax' },
{ element: 'Display math', syntax: '$$\\int_a^b f(x)\\,dx$$' },
{ element: 'Mermaid diagram', syntax: '```mermaid\nflowchart LR\nA --> B\n```' },
+ {
+ element: 'ASCII diagram',
+ syntax: '```ascii title="Layers"\nโโโโโ โโโโโ\nโ A โโโโบโ B โ\nโโโโโ โโโโโ\n```',
+ note: 'Crisp auto-fitted diagram figure with an optional caption; untagged box art is auto-detected',
+ },
{ element: 'Callout', syntax: ':::note Title\nBody text\n:::', note: 'note / tip / warning / danger' },
{ element: 'Highlight', syntax: '==marked text==' },
{ element: 'Definition list', syntax: 'Term\n: definition' },
diff --git a/src/marketing/pages/TemplatePage.tsx b/src/marketing/pages/TemplatePage.tsx
index d1e38e2..bd1a1ec 100644
--- a/src/marketing/pages/TemplatePage.tsx
+++ b/src/marketing/pages/TemplatePage.tsx
@@ -1,6 +1,5 @@
-import type { ComponentProps, ReactElement } from 'react'
-import { isValidElement } from 'react'
-import ReactMarkdown from 'react-markdown'
+import type { CSSProperties } from 'react'
+import ReactMarkdown, { type Components } from 'react-markdown'
import { useLocation } from 'react-router-dom'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
@@ -10,6 +9,13 @@ import rehypeKatex from 'rehype-katex'
import 'katex/dist/katex.min.css'
import { TEMPLATES } from '@/data/templates'
import { parseFrontmatter } from '@/lib/frontmatter'
+import {
+ ASCII_DIAGRAM_LANGUAGES,
+ diagramRowCount,
+ isAsciiDiagram,
+ maxVisualColumns,
+ parseFenceTitle,
+} from '@/markdown/asciiDiagram'
import { remarkCallouts } from '@/markdown/plugins/remarkCallouts'
import { remarkMarks } from '@/markdown/plugins/remarkMarks'
import { Breadcrumbs, CtaBand } from '../components/blocks'
@@ -34,13 +40,52 @@ const PREVIEW_REMARK_PLUGINS = [
const languageOf = (className: string | undefined): string | undefined =>
/language-([\w-]+)/.exec(className ?? '')?.[1]
+interface PreviewHastNode {
+ type: string
+ tagName?: string
+ value?: string
+ properties?: { className?: string[] | string }
+ data?: { meta?: unknown }
+ children?: PreviewHastNode[]
+}
+
+const hastToText = (node: PreviewHastNode | undefined): string => {
+ if (!node) return ''
+ if (node.type === 'text') return node.value ?? ''
+ return (node.children ?? []).map(hastToText).join('')
+}
+
/**
* Fenced code with the app's chrome: a header bar carrying the language label
- * and a copy button (wired by the site's inline enhancement script).
+ * and a copy button (wired by the site's inline enhancement script). ASCII
+ * diagram fences get the same chrome-free figure treatment as the app, so a
+ * diagram template's marketing page matches what the editor renders.
*/
-function PreviewCodeBlock(props: ComponentProps<'pre'>) {
- const child = props.children as ReactElement<{ className?: string }> | undefined
- const language = isValidElement(child) ? languageOf(child.props.className) : undefined
+const PreviewCodeBlock: Components['pre'] = ({ node, children }) => {
+ const codeChild = (node as PreviewHastNode | undefined)?.children?.find(
+ (c) => c.tagName === 'code',
+ )
+ const classNames = codeChild?.properties?.className
+ const classList = Array.isArray(classNames) ? classNames.join(' ') : classNames
+ const language = languageOf(classList)
+ const raw = hastToText(codeChild)
+
+ if ((language && ASCII_DIAGRAM_LANGUAGES.has(language)) || (!language && isAsciiDiagram(raw))) {
+ const meta = codeChild?.data?.meta
+ const title = parseFenceTitle(typeof meta === 'string' ? meta : undefined)
+ const text = raw.replace(/\n+$/, '')
+ const style = {
+ '--diagram-cols': String(Math.max(1, maxVisualColumns(text))),
+ '--diagram-rows': String(diagramRowCount(text)),
+ } as CSSProperties
+ return (
+
+
{text}
+ {title ? {title} : null}
+
+ )
+ }
+
return (
@@ -49,7 +94,7 @@ function PreviewCodeBlock(props: ComponentProps<'pre'>) {
Copy