fix(markdown): disable directive parsing and add regression coverage

This commit is contained in:
DeCentN2Madness committed 2026-10-06 19:39:41 -07:00
1 parent 60c534d4a2
commit ea2f1eaa05
5 files changed
+291 -17

No files matched your search

+21 -10
View File
@@ -202,9 +202,9 @@ Normal Markdown links remain preferred for ordinary page and article content.
### Stable Heading Links ### Stable Heading Links
Astro automatically generates an ID for each Markdown heading. When a section Astro automatically generates an ID for each Markdown heading. In `.md` files,
needs a stable, editorially controlled fragment link, add an explicit ID with when a section needs a stable, editorially controlled fragment link, add an
Sätteri heading-attribute syntax: explicit ID with Sätteri heading-attribute syntax:
```md ```md
## Accepted Insurance Carriers {#accepted-insurance-heading} ## Accepted Insurance Carriers {#accepted-insurance-heading}
@@ -218,16 +218,27 @@ Link to it with the resulting fragment URL:
Keep explicit IDs unique within the page, lowercase, and hyphenated. Treat a Keep explicit IDs unique within the page, lowercase, and hyphenated. Treat a
published ID as permanent so inbound links do not break when heading text is published ID as permanent so inbound links do not break when heading text is
edited. This syntax is supported in both Markdown and MDX files. edited. The installed MDX parser rejects this shorthand, independently of
directive parsing. In `.mdx` files, use an explicit HTML heading instead:
```mdx
<h2 id="accepted-insurance-heading">Accepted Insurance Carriers</h2>
```
### Markdown Directives ### Markdown Directives
Sätteri directive parsing is enabled for container (`:::name`), leaf Sätteri directive parsing is disabled. The content corpus has no intentional
(`::name`), and text (`:name`) directives. Parsing creates directive nodes; container (`:::name`), leaf (`::name`), or text (`:name`) directives, and no
rendering a named directive requires a corresponding Markdown transformation directive transformation plugins are registered. Enabling parsing silently
plugin. Until a directive and its semantic HTML treatment are registered, do removed literal text such as `:1` in `1:1`, `:00` in times, and `:Plan` after
not use it in published content because the default renderer omits unhandled an adjacent colon. Heading attributes remain enabled.
directive nodes.
Run `npm run test:markdown` after changing the Markdown/MDX configuration. It
builds temporary fixtures and the affected content through the actual Astro
configuration, checking ratios, punctuation, times, URLs, code, and explicit
heading IDs. It makes no production requests and leaves no fixture routes in
the site. Any future directive feature needs compatible rendering and must
preserve these literal-content checks.
### Front Matter CMS ### Front Matter CMS
+2 -1
View File
@@ -11,7 +11,8 @@ export default defineConfig({
processor: satteri({ processor: satteri({
features: { features: {
headingAttributes: true, headingAttributes: true,
directive: true // No directive consumers: parsing them drops literal ratios and times.
directive: false
} }
}), }),
shikiConfig: { theme: 'github-light' } shikiConfig: { theme: 'github-light' }
+1
View File
@@ -18,6 +18,7 @@
"audit:blog": "node tools/audit-blog-content.mjs", "audit:blog": "node tools/audit-blog-content.mjs",
"audit:routes": "node tools/audit-routes.mjs", "audit:routes": "node tools/audit-routes.mjs",
"test:routes": "node --test tools/audit-routes.test.mjs", "test:routes": "node --test tools/audit-routes.test.mjs",
"test:markdown": "node --test tools/markdown.test.mjs",
"generate:sitemap": "node tools/generate-sitemap.mjs", "generate:sitemap": "node tools/generate-sitemap.mjs",
"generate:redirects": "node tools/generate-redirects.mjs" "generate:redirects": "node tools/generate-redirects.mjs"
}, },
+106 -6
View File
@@ -148,12 +148,13 @@ unavailable.
- Extracted image alt text is retained where available. - Extracted image alt text is retained where available.
- Semantic landmarks, skip link, labeled forms, keyboard-operable navigation, - Semantic landmarks, skip link, labeled forms, keyboard-operable navigation,
language/direction attributes, and responsive layouts are present. language/direction attributes, and responsive layouts are present.
- Sätteri heading attributes are enabled for Markdown and MDX, allowing stable - Sätteri heading attributes remain enabled. Markdown supports explicit
explicit fragment IDs such as `{#accepted-insurance-heading}`. The built-site fragment IDs such as `{#accepted-insurance-heading}`; the installed MDX
audit found no duplicate or invalid IDs and no missing same-page fragments. parser requires HTML heading IDs instead. The earlier claim that MDX also
- Sätteri directive parsing is enabled. No named directive transformations are supports the shorthand was corrected during Phase 2 (see below).
registered yet, so unhandled directives must not be added to published - Sätteri directive parsing was disabled during Phase 2 after confirming that
content until their semantic HTML rendering is defined. there are no intentional consumers and that it silently removes literal
colon-containing text. No content or dependency versions were changed.
## Validation Results ## Validation Results
@@ -193,3 +194,102 @@ unavailable.
- Have fluent reviewers approve Spanish content and Arabic RTL presentation. - Have fluent reviewers approve Spanish content and Arabic RTL presentation.
- Select and connect a form backend with spam protection before launch. - Select and connect a form backend with spam protection before launch.
- Validate like/view CORS, credentials, and production behavior after deployment. - Validate like/view CORS, credentials, and production behavior after deployment.
## Phase 2: Markdown Rendering Repair — 2026-10-07 UTC
These are newly verified local rendering findings. The accepted Phase 1 audit
and route evidence remain unchanged. No production requests were made, no
source content was rewritten, and no dependencies were upgraded.
### Inspection and Cause
Before changing configuration, searched the Markdown/MDX corpus, documentation,
parser configuration, scripts, plugins, layouts, and rendering components for
container, leaf, and inline directives and their consumers. There are no
intentional directives or registered directive transformations. Documentation
examples in code fences, ordinary punctuation, URLs, times, ratios, frontmatter
fields, CSS selectors, and explicit heading IDs are not directive consumers.
For all 100 content files, stripped frontmatter and inspected the installed
Sätteri `markdownToMdast` / `mdxToMdast` output with the original
`headingAttributes: true, directive: true` features. This found 24 unintended
`textDirective` nodes in seven files, with zero container or leaf directives.
Sätteri treats colon suffixes such as `:1`, `:00`, and `:Plan` as inline
directives; without a transformation, the renderer drops those nodes. Astro's
MDX integration inherits the same processor features, so both formats were
affected. The original full build and the failing regression build confirmed
the resulting text loss.
Only `directive` was changed to `false`; `headingAttributes: true` is retained.
| Source under `src/content/` | Restored literal text | Unintended nodes |
| --- | --- | ---: |
| `pages/en/aba-therapy.mdx` | `1:1`, `1:2` in headings and prose | 4 |
| `pages/es/aba-therapy.md` | `1:1`, `1:2` in headings and prose | 4 |
| `blog/en/aba-school-readiness-arizona.mdx` | `1:1`, `1:10` in prose and tables | 4 |
| `blog/es/aba-school-readiness-arizona.mdx` | `1:1`, `1:10` in prose and tables | 3 |
| `pages/en/learner-social-club.mdx` | `4:00 PM – 6:00 PM`, twice | 4 |
| `pages/es/learner-social-club.mdx` | `4:00 p. m. – 6:00 p. m.`, twice | 4 |
| `pages/en/faqs.mdx` | `Opportunities:Plan breaks` | 1 |
### Regression and Generated-Output Verification
`npm run test:markdown` creates a temporary Astro project, loads the real
`astro.config.mjs`, and uses the existing dependencies and copied source
components/content. It builds representative `.md` and `.mdx` fixtures plus
all seven affected collection entries through `astro:content`'s `getEntry` and
`render`. The fixtures never enter the production route tree, and all temporary
files are removed after the run.
The 20 tests cover `1:1`, `1:2`, `1:10`, ordinary spaced and adjacent colon
punctuation, English/Spanish times, plain URLs and link destinations (including
port/query/fragment), inline/fenced code examples, MDX embedded markup, explicit
heading IDs and fragment links, and the affected real content. Before the
configuration change, 14 tests failed and six passed. Afterward, all 20 passed.
The full build still generates 97 pages. Comparing the before/after `<main>`
HTML on all 97 pages found changes only in the seven routes corresponding to
the table above. This comparison normalizes the existing random
`ContactObfuscation` IDs in span attributes and script variables; that component
was not changed. Generated `/aba-therapy` and `/es/aba-therapy` each now contain
both ratios in their headings and prose (two occurrences of each ratio).
All existing explicit IDs remain intact. Four automatically generated service
heading IDs now reflect the restored text:
| Route | Previous generated ID | Corrected generated ID |
| --- | --- | --- |
| `/aba-therapy` | `aba-therapy-1-program` | `aba-therapy-11-program` |
| `/aba-therapy` | `aba-therapy-1-academic-readiness-program` | `aba-therapy-12-academic-readiness-program` |
| `/es/aba-therapy` | `programa-de-terapia-aba-1` | `programa-de-terapia-aba-11` |
| `/es/aba-therapy` | `terapia-aba-1-programa-de-preparación-académica` | `terapia-aba-12-programa-de-preparación-académica` |
No source references to the old IDs were found. Compatibility with production
fragment links remains part of the later URL reconciliation; this repair does
not introduce redirects or edit service content.
The installed MDX parser rejects Markdown's `{#id}` shorthand with directive
parsing either enabled or disabled. This is a pre-existing limitation, not a
regression from the repair. No current content uses that shorthand. Tests
verify Markdown shorthand and MDX's supported `<h2 id="...">` syntax. README
guidance was corrected; a broader parser change is outside this phase.
### Validation
Run from `www/`:
```sh
npm run test:markdown
npm run build
npm run audit:links
npm run audit:blog
git diff --check
```
- Markdown regression tests: 20 passed, zero failures.
- Build: passed; 74 checked files, zero errors/warnings/hints, 97 static pages.
- Link audit: zero broken internal links.
- Blog audit: zero content audit failures.
- Diff whitespace check: passed.
- No changes to application content, dependencies, lockfile, Phase 1 evidence,
or `merge-plan.md`. No Phase 3 work was started.
+161
View File
@@ -0,0 +1,161 @@
import assert from 'node:assert/strict';
import { after, before, test } from 'node:test';
import { cp, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { build } from 'astro';
import { load } from 'cheerio';
const project = fileURLToPath(new URL('../', import.meta.url));
const url = 'https://example.org:8443/path?q=1:2#details';
const literalContent = [
'Therapy ratios: 1:1 and 1:2; school ratio: 1:10.',
'Note: ordinary punctuation. Opportunities:Plan breaks.',
'Hours: 4:00 PM – 6:00 PM. Horario: 4:00 p. m. – 6:00 p. m.',
`A URL: ${url}`,
`[Visit](${url})`,
'`1:1 1:2 4:00 :example`',
'```text\n:::container\n::leaf\n:inline\n1:1 1:2 4:00\n```',
'[Jump to ratios](#stable-ratios)'
].join('\n\n');
const contentCases = [
['service-en', 'pages', 'en/aba-therapy'],
['service-es', 'pages', 'es/aba-therapy'],
['club-en', 'pages', 'en/learner-social-club'],
['club-es', 'pages', 'es/learner-social-club'],
['school-en', 'blog', 'en/aba-school-readiness-arizona'],
['school-es', 'blog', 'es/aba-school-readiness-arizona'],
['faq-en', 'pages', 'en/faqs']
];
let fixtureRoot;
const rendered = new Map();
before(async () => {
fixtureRoot = await mkdtemp(path.join(os.tmpdir(), 'aia-markdown-test-'));
// Keep real components, content collections and layouts inside Astro's root.
// Exclude production routes so fixtures can never enter the production build.
await cp(path.join(project, 'src'), path.join(fixtureRoot, 'src'), {
recursive: true,
filter: (source) => source !== path.join(project, 'src/pages')
});
const pages = path.join(fixtureRoot, 'src/pages');
await mkdir(pages);
await symlink(path.join(project, 'node_modules'), path.join(fixtureRoot, 'node_modules'), 'dir');
await writeFile(path.join(fixtureRoot, 'package.json'), '{"type":"module"}\n');
await writeFile(path.join(pages, 'markdown.md'), [
'---\ntitle: Markdown regression fixture\n---',
'## ABA Therapy 1:1 and 1:2 {#stable-ratios}',
literalContent
].join('\n\n'));
await writeFile(path.join(pages, 'mdx.mdx'), [
'---\ntitle: MDX regression fixture\n---',
// The installed MDX parser rejects {#id}; explicit HTML IDs are supported.
'<h2 id="stable-ratios">ABA Therapy 1:1 and 1:2</h2>',
literalContent,
'<span data-mdx="true">Embedded MDX: 1:2</span>'
].join('\n\n'));
for (const [route, collection, id] of contentCases) {
await writeFile(path.join(pages, `${route}.astro`), `---
import { getEntry, render } from 'astro:content';
const entry = await getEntry('${collection}', '${id}');
if (!entry) throw new Error('Missing regression content: ${id}');
const { Content } = await render(entry);
---
<main><Content /></main>
`);
}
const originalCwd = process.cwd();
try {
// Astro's prerender fallback resolves against cwd; keep all output temporary.
process.chdir(fixtureRoot);
await build({
root: pathToFileURL(`${fixtureRoot}${path.sep}`),
configFile: path.relative(fixtureRoot, path.join(project, 'astro.config.mjs')),
logLevel: 'silent',
vite: { cacheDir: path.join(fixtureRoot, '.vite') }
});
} finally {
process.chdir(originalCwd);
}
for (const route of ['markdown', 'mdx', ...contentCases.map(([name]) => name)]) {
rendered.set(route, load(await readFile(path.join(fixtureRoot, 'dist', route, 'index.html'), 'utf8')));
}
}, { timeout: 120_000 });
after(async () => {
if (fixtureRoot) await rm(fixtureRoot, { recursive: true, force: true });
});
for (const format of ['markdown', 'mdx']) {
test(`${format}: literal ratios survive prose and headings`, () => {
const $ = rendered.get(format);
assert.equal($('h2').text(), 'ABA Therapy 1:1 and 1:2');
assert.ok($('p').text().includes('Therapy ratios: 1:1 and 1:2; school ratio: 1:10.'));
});
test(`${format}: ordinary colons and adjacent words remain literal`, () => {
assert.ok(rendered.get(format)('p').text().includes('Note: ordinary punctuation. Opportunities:Plan breaks.'));
});
test(`${format}: English and Spanish times retain their minutes`, () => {
const text = rendered.get(format)('p').text();
assert.ok(text.includes('4:00 PM – 6:00 PM'));
assert.ok(text.includes('4:00 p. m. – 6:00 p. m.'));
});
test(`${format}: URL scheme, port, query and fragment survive`, () => {
const $ = rendered.get(format);
assert.ok($('p').text().includes(`A URL: ${url}`));
assert.equal($('a').filter((_, a) => $(a).text() === 'Visit').attr('href'), url);
});
test(`${format}: inline and fenced code remain literal`, () => {
const $ = rendered.get(format);
assert.ok($('code').toArray().some((node) => $(node).text() === '1:1 1:2 4:00 :example'));
assert.equal($('pre code').text().trim(), ':::container\n::leaf\n:inline\n1:1 1:2 4:00');
});
test(`${format}: explicit heading IDs and fragment links stay intact`, () => {
const $ = rendered.get(format);
assert.equal($('h2').attr('id'), 'stable-ratios');
assert.equal($('#stable-ratios').length, 1);
assert.equal($('a[href="#stable-ratios"]').length, 1);
assert.ok(!$('h2').text().includes('{#'));
});
}
test('MDX embedded markup uses the same literal-text behavior', () => {
assert.equal(rendered.get('mdx')('[data-mdx="true"]').text(), 'Embedded MDX: 1:2');
});
for (const lang of ['en', 'es']) {
test(`${lang}: actual ABA service headings and prose retain both ratios`, () => {
const $ = rendered.get(`service-${lang}`);
for (const ratio of ['1:1', '1:2']) {
assert.ok($('h4').text().includes(ratio));
assert.equal(($('main').text().match(new RegExp(`${ratio}(?!\\d)`, 'g')) ?? []).length, 2);
}
});
test(`${lang}: actual social-club hours retain all four minute suffixes`, () => {
const text = rendered.get(`club-${lang}`)('main').text();
assert.equal((text.match(/4:00/g) ?? []).length, 2);
assert.equal((text.match(/6:00/g) ?? []).length, 2);
const hours = lang === 'en' ? '4:00 PM – 6:00 PM' : '4:00 p. m. – 6:00 p. m.';
assert.equal(text.split(hours).length - 1, 2);
});
test(`${lang}: actual school-readiness prose and table ratios survive`, () => {
const $ = rendered.get(`school-${lang}`);
assert.equal(($('main').text().match(/1:1(?!\d)/g) ?? []).length, lang === 'en' ? 3 : 1);
assert.equal(($('main').text().match(/1:10/g) ?? []).length, lang === 'en' ? 1 : 2);
assert.ok($('table').text().includes('1:10'));
});
}
test('actual FAQ prose retains the word after an adjacent colon', () => {
assert.ok(rendered.get('faq-en')('main').text().includes('Opportunities:Plan breaks'));
});