diff --git a/www/README.md b/www/README.md index 29d9456..3818927 100644 --- a/www/README.md +++ b/www/README.md @@ -202,9 +202,9 @@ Normal Markdown links remain preferred for ordinary page and article content. ### Stable Heading Links -Astro automatically generates an ID for each Markdown heading. When a section -needs a stable, editorially controlled fragment link, add an explicit ID with -Sätteri heading-attribute syntax: +Astro automatically generates an ID for each Markdown heading. In `.md` files, +when a section needs a stable, editorially controlled fragment link, add an +explicit ID with Sätteri heading-attribute syntax: ```md ## 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 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 +

Accepted Insurance Carriers

+``` ### Markdown Directives -Sätteri directive parsing is enabled for container (`:::name`), leaf -(`::name`), and text (`:name`) directives. Parsing creates directive nodes; -rendering a named directive requires a corresponding Markdown transformation -plugin. Until a directive and its semantic HTML treatment are registered, do -not use it in published content because the default renderer omits unhandled -directive nodes. +Sätteri directive parsing is disabled. The content corpus has no intentional +container (`:::name`), leaf (`::name`), or text (`:name`) directives, and no +directive transformation plugins are registered. Enabling parsing silently +removed literal text such as `:1` in `1:1`, `:00` in times, and `:Plan` after +an adjacent colon. Heading attributes remain enabled. + +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 diff --git a/www/astro.config.mjs b/www/astro.config.mjs index 31fdb7e..1156ab8 100644 --- a/www/astro.config.mjs +++ b/www/astro.config.mjs @@ -11,7 +11,8 @@ export default defineConfig({ processor: satteri({ features: { headingAttributes: true, - directive: true + // No directive consumers: parsing them drops literal ratios and times. + directive: false } }), shikiConfig: { theme: 'github-light' } diff --git a/www/package.json b/www/package.json index cbfc1f1..9dca340 100644 --- a/www/package.json +++ b/www/package.json @@ -18,6 +18,7 @@ "audit:blog": "node tools/audit-blog-content.mjs", "audit:routes": "node tools/audit-routes.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:redirects": "node tools/generate-redirects.mjs" }, diff --git a/www/reports/migration-summary.md b/www/reports/migration-summary.md index f663543..8e1375f 100644 --- a/www/reports/migration-summary.md +++ b/www/reports/migration-summary.md @@ -148,12 +148,13 @@ unavailable. - Extracted image alt text is retained where available. - Semantic landmarks, skip link, labeled forms, keyboard-operable navigation, language/direction attributes, and responsive layouts are present. -- Sätteri heading attributes are enabled for Markdown and MDX, allowing stable - explicit fragment IDs such as `{#accepted-insurance-heading}`. The built-site - audit found no duplicate or invalid IDs and no missing same-page fragments. -- Sätteri directive parsing is enabled. No named directive transformations are - registered yet, so unhandled directives must not be added to published - content until their semantic HTML rendering is defined. +- Sätteri heading attributes remain enabled. Markdown supports explicit + fragment IDs such as `{#accepted-insurance-heading}`; the installed MDX + parser requires HTML heading IDs instead. The earlier claim that MDX also + supports the shorthand was corrected during Phase 2 (see below). +- Sätteri directive parsing was disabled during Phase 2 after confirming that + there are no intentional consumers and that it silently removes literal + colon-containing text. No content or dependency versions were changed. ## Validation Results @@ -193,3 +194,102 @@ unavailable. - Have fluent reviewers approve Spanish content and Arabic RTL presentation. - Select and connect a form backend with spam protection before launch. - 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 `
` +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 `

` 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. diff --git a/www/tools/markdown.test.mjs b/www/tools/markdown.test.mjs new file mode 100644 index 0000000..e223f50 --- /dev/null +++ b/www/tools/markdown.test.mjs @@ -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. + '

ABA Therapy 1:1 and 1:2

', + literalContent, + 'Embedded MDX: 1:2' + ].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); +--- +
+`); + } + + 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')); +});