fix(markdown): disable directive parsing and add regression coverage
This commit is contained in:
5 files changed
+291
-17
No files matched your search
+21
-10
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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'));
|
||||||
|
});
|
||||||
Reference in new issue
Block a user