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)
})
})