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. */} + + {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 ( +
+ + {title ?
{title}
: null} +
+ ) + } + return (
@@ -49,7 +94,7 @@ function PreviewCodeBlock(props: ComponentProps<'pre'>) { Copy
-
{props.children}
+
{children}
) } diff --git a/src/pdf/pageStyles.ts b/src/pdf/pageStyles.ts index 9051a23..7052b8f 100644 --- a/src/pdf/pageStyles.ts +++ b/src/pdf/pageStyles.ts @@ -124,6 +124,18 @@ export function buildPageCss(config: PdfConfig): string { width: auto; height: auto; } + /* ASCII diagram figures are unbreakable text: a max-height alone would not + reflow them, so the page box converts to a font-size cap instead โ€” + height โ‰ˆ rows ร— 1.2 line-height (+ padding slack). The 45mm reserve + keeps a preceding heading, the figure margins, and a caption on the same + page. The width term mirrors document.css; the smallest bound wins. */ + .scripto-doc .ascii-diagram pre { + font-size: min( + 0.82em, + calc(100cqw / (var(--diagram-cols, 60) * 0.6 + 2)), + calc(${round(Math.max(40, height - top - bottom - 45))}mm / (var(--diagram-rows, 10) * 1.2 + 6)) + ); + } /* Tables flow across pages: header repeats, rows never split. */ .scripto-doc .table-wrap { break-inside: auto; overflow: visible; border: none; } .scripto-doc table { break-inside: auto; } diff --git a/src/styles/document.css b/src/styles/document.css index 147306f..3f594b0 100644 --- a/src/styles/document.css +++ b/src/styles/document.css @@ -1556,3 +1556,51 @@ font-family: 'JetBrains Mono', monospace; text-align: left; } + +/* ---------- ASCII / box-drawing diagrams ---------- */ +/* Deliberately chrome-free (no header, line numbers, or syntax colors) and + kept LAST in this sheet so it out-cascades the skins' heavy `pre` / + `.code-block` treatments. Skins may theme `.ascii-diagram` deliberately. */ +.scripto-doc .ascii-diagram { + margin: 1.5em 0; + text-align: center; + break-inside: avoid; + page-break-inside: avoid; + direction: ltr; + container-type: inline-size; +} +.scripto-doc .ascii-diagram pre { + display: inline-block; + max-inline-size: 100%; + margin: 0; + padding: 0.9em 1em; + overflow: hidden; + background: color-mix(in srgb, var(--doc-fg) 3.5%, transparent); + border: none; + border-radius: 10px; + box-shadow: none; + color: var(--doc-fg); + font-family: 'JetBrains Mono', ui-monospace, monospace; + /* Auto-fit: 0.6 โ‰ˆ JetBrains Mono advance width in em; the +2 term absorbs + the horizontal padding. --diagram-cols/rows are emitted per-figure by the + AsciiDiagram component from measured visual columns (tabs expanded, + wide chars counted as 2). */ + font-size: min(0.82em, calc(100cqw / (var(--diagram-cols, 60) * 0.6 + 2))); + /* Fixed leading, independent of --doc-leading: stacked โ”‚ glyphs must touch. */ + 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; + direction: ltr; +} +.scripto-doc .ascii-diagram figcaption { + margin-top: 0.7em; + font-size: 0.82em; + color: var(--doc-muted); +} diff --git a/tests/asciiDiagram.test.ts b/tests/asciiDiagram.test.ts new file mode 100644 index 0000000..bf15d82 --- /dev/null +++ b/tests/asciiDiagram.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from 'vitest' +import { + ASCII_DIAGRAM_LANGUAGES, + PLAIN_TEXT_LANGUAGES, + codePointWidth, + diagramRowCount, + isAsciiDiagram, + maxVisualColumns, + parseFenceTitle, +} from '../src/markdown/asciiDiagram' + +// โ€”โ€” Fixtures (verbatim from the feature spec) โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€” + +const FIXTURE_A = ` 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) +` + +const FIXTURE_B = ` +-------------+ +--------------+ + users -->| Cloudflare | -----> | Nginx | + +-------------+ +------+-------+ + | + +-----------+-----------+ + v v + +-----------+ +-----------+ + | Next.js | | Worker | + +-----------+ +-----------+ +` + +const FIXTURE_C = `src/ +โ”œโ”€โ”€ markdown/ +โ”‚ โ”œโ”€โ”€ MarkdownRenderer.tsx +โ”‚ โ””โ”€โ”€ components/ +โ”‚ โ”œโ”€โ”€ Mermaid.tsx +โ”‚ โ””โ”€โ”€ AsciiDiagram.tsx +โ””โ”€โ”€ styles/ + โ””โ”€โ”€ document.css +` + +const FIXTURE_D = `โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ โœ… ุงู„ู†ุดุฑ ุงู„ุชู„ู‚ุงุฆูŠ โ”‚ โ”€โ”€โ”€โ–บ โ”‚ ๐Ÿ“ฆ Object Storage โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +` + +// โ€”โ€” Negative fixtures โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€”โ€” + +const NEGATIVE_JS = `const add = (a, b) => a + b +const ids = items.map((item) => item.id) +const bigger = list.filter((x) => x > 2) +bigger.forEach((x) => log(x)) +const gte = (a, b) => a >= b +const pipe = (f, g) => (x) => g(f(x)) +` + +const NEGATIVE_YAML = `services: + web: + image: nginx:latest + ports: + - "80:80" + environment: + - NODE_ENV=production + worker: + image: app-worker + depends_on: + - redis +` + +const NEGATIVE_DIFF = `diff --git a/src/app.ts b/src/app.ts +--- a/src/app.ts ++++ b/src/app.ts +@@ -1,6 +1,7 @@ +-const port = 3000 ++const port = Number(process.env.PORT) ++const host = '0.0.0.0' + const app = express() +-app.listen(port) ++app.listen(port, host) +` + +const NEGATIVE_TABLE = `| Feature | Status | Owner | +| --------- | ------ | ------- | +| Login | Done | Aya | +| Billing | WIP | Omar | +| Exports | Todo | Lina | +` + +describe('isAsciiDiagram', () => { + it('detects Unicode box-drawing diagrams (fixture A)', () => { + expect(isAsciiDiagram(FIXTURE_A)).toBe(true) + }) + + it('detects classic ASCII diagrams (fixture B)', () => { + expect(isAsciiDiagram(FIXTURE_B)).toBe(true) + }) + + it('detects tree listings (fixture C)', () => { + expect(isAsciiDiagram(FIXTURE_C)).toBe(true) + }) + + it('detects diagrams with wide characters (fixture D)', () => { + expect(isAsciiDiagram(FIXTURE_D)).toBe(true) + }) + + it('ignores code with fat arrows', () => { + expect(isAsciiDiagram(NEGATIVE_JS)).toBe(false) + }) + + it('ignores YAML', () => { + expect(isAsciiDiagram(NEGATIVE_YAML)).toBe(false) + }) + + it('ignores diffs', () => { + expect(isAsciiDiagram(NEGATIVE_DIFF)).toBe(false) + }) + + it('ignores GFM tables', () => { + expect(isAsciiDiagram(NEGATIVE_TABLE)).toBe(false) + }) + + it('ignores single lines and empty blocks', () => { + expect(isAsciiDiagram('')).toBe(false) + expect(isAsciiDiagram('โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”')).toBe(false) + }) + + it('needs the signals spread across at least two lines', () => { + expect(isAsciiDiagram('โ”Œโ”ฌโ”ฌโ”ฌโ”ฌโ”ฌโ”ฌโ”ฌโ”ฌโ”\nplain prose here')).toBe(false) + }) +}) + +describe('language routing sets', () => { + it('recognises all three explicit aliases', () => { + expect(ASCII_DIAGRAM_LANGUAGES.has('ascii')).toBe(true) + expect(ASCII_DIAGRAM_LANGUAGES.has('diagram')).toBe(true) + expect(ASCII_DIAGRAM_LANGUAGES.has('ascii-art')).toBe(true) + expect(ASCII_DIAGRAM_LANGUAGES.has('js')).toBe(false) + }) + + it('recognises the plain-text escape hatch', () => { + expect(PLAIN_TEXT_LANGUAGES.has('text')).toBe(true) + expect(PLAIN_TEXT_LANGUAGES.has('txt')).toBe(true) + expect(PLAIN_TEXT_LANGUAGES.has('plain')).toBe(true) + }) + + it('escape hatch beats the heuristic (fixture A under ```text)', () => { + // The renderer never calls the heuristic for tagged blocks; assert the + // routing contract: 'text' is plain, not a diagram alias. + expect(PLAIN_TEXT_LANGUAGES.has('text')).toBe(true) + expect(ASCII_DIAGRAM_LANGUAGES.has('text')).toBe(false) + }) +}) + +describe('codePointWidth', () => { + it('treats box drawing and arrows as single width', () => { + expect(codePointWidth('โ”‚'.codePointAt(0) ?? 0)).toBe(1) + expect(codePointWidth('โ†’'.codePointAt(0) ?? 0)).toBe(1) + expect(codePointWidth('A'.codePointAt(0) ?? 0)).toBe(1) + expect(codePointWidth('ุจ'.codePointAt(0) ?? 0)).toBe(1) + }) + + it('treats CJK, Hangul, and fullwidth as double width', () => { + expect(codePointWidth('ๆผข'.codePointAt(0) ?? 0)).toBe(2) + expect(codePointWidth('ํ•œ'.codePointAt(0) ?? 0)).toBe(2) + expect(codePointWidth('๏ผก'.codePointAt(0) ?? 0)).toBe(2) + }) + + it('treats emoji as double width', () => { + expect(codePointWidth('โœ…'.codePointAt(0) ?? 0)).toBe(2) + expect(codePointWidth('๐Ÿ“ฆ'.codePointAt(0) ?? 0)).toBe(2) + }) + + it('treats joiners and variation selectors as zero width', () => { + expect(codePointWidth(0xfe0f)).toBe(0) + expect(codePointWidth(0x200d)).toBe(0) + }) +}) + +describe('maxVisualColumns', () => { + it('counts plain monospace columns', () => { + expect(maxVisualColumns('abc\nabcdef\nab')).toBe(6) + }) + + it('expands tabs to the next tab stop', () => { + expect(maxVisualColumns('\tx')).toBe(5) + expect(maxVisualColumns('ab\tx', 4)).toBe(5) + }) + + it('counts wide characters as two columns', () => { + expect(maxVisualColumns('โœ…ok')).toBe(4) + expect(maxVisualColumns('ๆผขๅญ—')).toBe(4) + }) + + it('is stable for fixture D box alignment math', () => { + const lines = FIXTURE_D.replace(/\n+$/, '').split('\n') + expect(maxVisualColumns(lines[0])).toBeGreaterThan(40) + }) +}) + +describe('diagramRowCount', () => { + it('counts rendered rows without trailing newlines', () => { + expect(diagramRowCount('a\nb\nc\n')).toBe(3) + expect(diagramRowCount('a')).toBe(1) + expect(diagramRowCount('')).toBe(1) + }) +}) + +describe('parseFenceTitle', () => { + it('parses double-quoted titles', () => { + expect(parseFenceTitle('title="Server layers"')).toBe('Server layers') + }) + + it('parses single-quoted titles', () => { + expect(parseFenceTitle("title='Project layout'")).toBe('Project layout') + }) + + it('ignores other meta and surrounding noise', () => { + expect(parseFenceTitle('foo=1 title="Caption" bar')).toBe('Caption') + expect(parseFenceTitle('subtitle="nope"')).toBeUndefined() + expect(parseFenceTitle('title=""')).toBeUndefined() + expect(parseFenceTitle(undefined)).toBeUndefined() + expect(parseFenceTitle('')).toBeUndefined() + }) +})