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'));
+});