Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions src/data/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
`,
},
{
Expand Down
9 changes: 5 additions & 4 deletions src/markdown/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -96,9 +96,10 @@ function MarkdownRendererImpl({ content, resolvedTheme }: MarkdownRendererProps)
if (language === 'mermaid') {
return <Mermaid code={raw} resolvedTheme={resolvedTheme} />
}
// ```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 (
<AsciiDiagram
Expand Down
27 changes: 25 additions & 2 deletions src/markdown/asciiDiagram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,33 @@ export const ASCII_DIAGRAM_LANGUAGES: ReadonlySet<string> = 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<string> = 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<string> = new Set(['text', 'txt', 'plaintext'])

/** The explicit opt-out: never a diagram, whatever the content looks like. */
export const FORCED_PLAIN_LANGUAGES: ReadonlySet<string> = 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.
Expand Down
2 changes: 1 addition & 1 deletion src/marketing/pages/CheatSheetPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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==' },
Expand Down
5 changes: 2 additions & 3 deletions src/marketing/pages/TemplatePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,10 @@
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'
Expand Down Expand Up @@ -70,7 +69,7 @@
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+$/, '')
Expand Down Expand Up @@ -228,7 +227,7 @@

export const Component = TemplatePage

export const getStaticPaths = (): string[] =>

Check warning on line 230 in src/marketing/pages/TemplatePage.tsx

View workflow job for this annotation

GitHub Actions / verify

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
TEMPLATES.map((template) => `templates/${template.id}`)

// Branded 404 for unmatched slugs reached via client-side navigation.
Expand Down
41 changes: 25 additions & 16 deletions tests/asciiDiagram.test.ts
Original file line number Diff line number Diff line change
@@ -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) ——————————————————————————
Expand Down Expand Up @@ -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)
})
})

Expand Down
Loading