From 217e807d5ea5ed16c3962b281f92803a2d444601 Mon Sep 17 00:00:00 2001 From: aashir-tech Date: Tue, 14 Jul 2026 23:15:28 +0530 Subject: [PATCH] feat: detect AI-tagged ASCII diagrams in text/txt/plaintext fences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI assistants habitually wrap ASCII art in ```text / ```plaintext fences, which the previous hard escape hatch forced to plain code. Routing is now centralized in shouldRenderAsDiagram(): - explicit aliases (ascii, diagram, ascii-art, asciiart, ascii-diagram) always render as diagrams (aliases extended, case-insensitive) - untagged AND text/txt/plaintext fences are heuristic-gated: box art becomes a figure, ordinary text stays a plain code block - ```plain is the new hard opt-out (never a diagram) - real languages are still never hijacked Both pre overrides (app renderer + marketing TemplatePage) share the router; docs/cheat-sheet/template copy updated; 51 unit tests green; verified in the browser (text-tagged art → diagram, text-tagged prose → code, plain → code). --- README.md | 2 +- src/data/templates.ts | 4 +-- src/markdown/MarkdownRenderer.tsx | 9 +++--- src/markdown/asciiDiagram.ts | 27 +++++++++++++++-- src/marketing/pages/CheatSheetPage.tsx | 2 +- src/marketing/pages/TemplatePage.tsx | 5 ++-- tests/asciiDiagram.test.ts | 41 ++++++++++++++++---------- 7 files changed, 61 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index adb680b..86e16f7 100644 --- a/README.md +++ b/README.md @@ -64,7 +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 +- ASCII / box-drawing diagrams (` ```ascii `; auto-detected in untagged and ` ```text ` fences, ` ```plain ` opts out) — crisp, auto-fitted figures with optional `title="…"` captions #### 📄 PDF export (Paged.js) diff --git a/src/data/templates.ts b/src/data/templates.ts index 1f7d4e5..91deac1 100644 --- a/src/data/templates.ts +++ b/src/data/templates.ts @@ -1626,8 +1626,8 @@ Everything below runs on a single server behind Cloudflare. MongoDB (Docker) Redis (queues) \`\`\` -Untagged ASCII art in a plain fence is detected automatically; tag a fence -as \`text\` to force plain rendering instead. +ASCII art in untagged (or \`text\`-tagged) fences is detected automatically; +tag a fence as \`plain\` to force plain rendering instead. `, }, { diff --git a/src/markdown/MarkdownRenderer.tsx b/src/markdown/MarkdownRenderer.tsx index 4e2aa09..bb79673 100644 --- a/src/markdown/MarkdownRenderer.tsx +++ b/src/markdown/MarkdownRenderer.tsx @@ -17,7 +17,7 @@ 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 { parseFenceTitle, shouldRenderAsDiagram } from './asciiDiagram' import type { ResolvedTheme } from '@/types' interface MarkdownRendererProps { @@ -96,9 +96,10 @@ 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))) { + // Explicit ascii aliases always render as diagrams; untagged and + // text/txt/plaintext fences are heuristic-gated (AI habitually tags + // ASCII art that way); ```plain opts out; real languages never hijacked. + if (shouldRenderAsDiagram(language, raw)) { const meta = codeChild?.properties?.dataMeta return ( = new Set([ 'ascii', 'diagram', 'ascii-art', + 'asciiart', + 'ascii-diagram', ]) -/** Fence languages that force plain rendering — never a diagram, never highlighted. */ -export const PLAIN_TEXT_LANGUAGES: ReadonlySet = new Set(['text', 'txt', 'plain']) +/** + * Plain-ish languages AI assistants habitually put around ASCII art. These are + * heuristic-gated: box art renders as a diagram, ordinary text stays a plain + * code block. + */ +export const HEURISTIC_LANGUAGES: ReadonlySet = new Set(['text', 'txt', 'plaintext']) + +/** The explicit opt-out: never a diagram, whatever the content looks like. */ +export const FORCED_PLAIN_LANGUAGES: ReadonlySet = new Set(['plain']) + +/** + * Central routing for the `pre` overrides (app renderer + marketing template + * pages): explicit aliases always win, `plain` always opts out, untagged and + * plain-ish fences fall through to the content heuristic, and every other + * tagged language is never hijacked. + */ +export function shouldRenderAsDiagram(language: string, raw: string): boolean { + const lang = language.toLowerCase() + if (ASCII_DIAGRAM_LANGUAGES.has(lang)) return true + if (FORCED_PLAIN_LANGUAGES.has(lang)) return false + if (lang === '' || HEURISTIC_LANGUAGES.has(lang)) return isAsciiDiagram(raw) + return false +} // Strong signals: box drawing U+2500–257F, block elements/shades U+2580–259F, // arrows U+2190–21FF, geometric shapes (▲ ▼ ◄ ►, …) U+25A0–25FF. diff --git a/src/marketing/pages/CheatSheetPage.tsx b/src/marketing/pages/CheatSheetPage.tsx index 00be5d8..a92f179 100644 --- a/src/marketing/pages/CheatSheetPage.tsx +++ b/src/marketing/pages/CheatSheetPage.tsx @@ -63,7 +63,7 @@ const GROUPS: SyntaxGroup[] = [ { 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', + note: 'Crisp auto-fitted diagram figure with an optional caption; box art in untagged or ```text fences is auto-detected (```plain opts out)', }, { element: 'Callout', syntax: ':::note Title\nBody text\n:::', note: 'note / tip / warning / danger' }, { element: 'Highlight', syntax: '==marked text==' }, diff --git a/src/marketing/pages/TemplatePage.tsx b/src/marketing/pages/TemplatePage.tsx index bd1a1ec..1f64456 100644 --- a/src/marketing/pages/TemplatePage.tsx +++ b/src/marketing/pages/TemplatePage.tsx @@ -10,11 +10,10 @@ import 'katex/dist/katex.min.css' import { TEMPLATES } from '@/data/templates' import { parseFrontmatter } from '@/lib/frontmatter' import { - ASCII_DIAGRAM_LANGUAGES, diagramRowCount, - isAsciiDiagram, maxVisualColumns, parseFenceTitle, + shouldRenderAsDiagram, } from '@/markdown/asciiDiagram' import { remarkCallouts } from '@/markdown/plugins/remarkCallouts' import { remarkMarks } from '@/markdown/plugins/remarkMarks' @@ -70,7 +69,7 @@ const PreviewCodeBlock: Components['pre'] = ({ node, children }) => { const language = languageOf(classList) const raw = hastToText(codeChild) - if ((language && ASCII_DIAGRAM_LANGUAGES.has(language)) || (!language && isAsciiDiagram(raw))) { + if (shouldRenderAsDiagram(language ?? '', raw)) { const meta = codeChild?.data?.meta const title = parseFenceTitle(typeof meta === 'string' ? meta : undefined) const text = raw.replace(/\n+$/, '') diff --git a/tests/asciiDiagram.test.ts b/tests/asciiDiagram.test.ts index bf15d82..07a3bd9 100644 --- a/tests/asciiDiagram.test.ts +++ b/tests/asciiDiagram.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from 'vitest' import { ASCII_DIAGRAM_LANGUAGES, - PLAIN_TEXT_LANGUAGES, codePointWidth, diagramRowCount, isAsciiDiagram, maxVisualColumns, parseFenceTitle, + shouldRenderAsDiagram, } from '../src/markdown/asciiDiagram' // —— Fixtures (verbatim from the feature spec) —————————————————————————— @@ -150,25 +150,34 @@ describe('isAsciiDiagram', () => { }) }) -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) +describe('shouldRenderAsDiagram (language routing)', () => { + it('explicit aliases always render as diagrams, whatever the content', () => { + for (const lang of ['ascii', 'diagram', 'ascii-art', 'asciiart', 'ascii-diagram']) { + expect(ASCII_DIAGRAM_LANGUAGES.has(lang)).toBe(true) + expect(shouldRenderAsDiagram(lang, 'just prose')).toBe(true) + } }) - 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('untagged fences are heuristic-gated', () => { + expect(shouldRenderAsDiagram('', FIXTURE_B)).toBe(true) + expect(shouldRenderAsDiagram('', NEGATIVE_JS)).toBe(false) }) - 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) + it('AI-style text/txt/plaintext fences are heuristic-gated too', () => { + for (const lang of ['text', 'txt', 'plaintext', 'TEXT']) { + expect(shouldRenderAsDiagram(lang, FIXTURE_A)).toBe(true) + expect(shouldRenderAsDiagram(lang, NEGATIVE_YAML)).toBe(false) + } + }) + + it('```plain is the hard opt-out, even for box art', () => { + expect(shouldRenderAsDiagram('plain', FIXTURE_A)).toBe(false) + }) + + it('real languages are never hijacked (fixture B inside ```js)', () => { + expect(shouldRenderAsDiagram('js', FIXTURE_B)).toBe(false) + expect(shouldRenderAsDiagram('python', FIXTURE_A)).toBe(false) + expect(shouldRenderAsDiagram('mermaid', FIXTURE_A)).toBe(false) }) })