diff --git a/.changeset/satteri-as-default.md b/.changeset/satteri-as-default.md new file mode 100644 index 00000000..69e18396 --- /dev/null +++ b/.changeset/satteri-as-default.md @@ -0,0 +1,50 @@ +--- +"notro-loader": major +"rehype-beautiful-mermaid": minor +--- + +Switch default processor to Sätteri; replace remark/rehype plugin API with Sätteri MDASTP/HAST plugins. + +**Breaking changes in `notro-loader`:** + +- `NotroOptions.remarkPlugins` removed — remark plugins are no longer supported +- `NotroOptions.rehypePlugins` removed — rehype plugins are no longer supported +- `NotroOptions.processor` removed — Sätteri is now always the processor for static `.mdx` files +- `@astrojs/markdown-remark` removed from dependencies + +**New API in `notro-loader`:** + +- `NotroOptions.mdastPlugins` — Sätteri MDASTP plugins for static `.mdx` files +- `NotroOptions.hastPlugins` — Sätteri HAST plugins for static `.mdx` files +- `NotroOptions.shikiConfig` — unchanged; now routed through Astro's `markdown.shikiConfig` for the Sätteri pipeline + +**Migration guide:** + +```js +// Before +import { rehypeMermaid } from 'rehype-beautiful-mermaid'; +import remarkMath from 'remark-math'; +import rehypeKatex from 'rehype-katex'; + +notro({ + shikiConfig: { theme: 'github-dark' }, + remarkPlugins: [remarkMath], + rehypePlugins: [[rehypeMermaid, { theme: 'github-dark' }], rehypeKatex], +}) + +// After +import { satteriMermaidPlugin } from 'rehype-beautiful-mermaid/satteri'; + +notro({ + shikiConfig: { theme: 'github-dark' }, + hastPlugins: [satteriMermaidPlugin({ theme: 'github-dark' })], + // Note: math in static .mdx files (remark-math + rehype-katex) has no + // Sätteri equivalent yet. Notion content supports math via string-level + // preprocessing regardless. +}) +``` + +**New in `rehype-beautiful-mermaid`:** + +- New entry point `rehype-beautiful-mermaid/satteri` exports `satteriMermaidPlugin()` — a Sätteri HAST plugin equivalent of `rehypeMermaid` for projects using `@astrojs/mdx` with Sätteri +- The existing `rehypeMermaid` (rehype API) is unchanged diff --git a/.changeset/satteri-processor-support.md b/.changeset/satteri-processor-support.md new file mode 100644 index 00000000..f56153fa --- /dev/null +++ b/.changeset/satteri-processor-support.md @@ -0,0 +1,11 @@ +--- +"notro-loader": minor +--- + +Add `processor` option to `notro()` integration for Sätteri support. + +- `notro({ processor: satteri() })` opts into Sätteri's Rust-based Markdown pipeline for faster static `.mdx` file builds +- notro automatically injects its callout MDASTP plugin so `:::callout{...}` directives work in `.mdx` files with Sätteri +- `remarkPlugins`, `rehypePlugins`, and `shikiConfig` continue to apply to the Notion content runtime path (`evaluate()`); a warning is emitted if these are set alongside `processor: satteri()` since they do not apply to `.mdx` files under Sätteri +- The default behavior (no `processor` option) is unchanged — notro uses `unified()` with its full remark/rehype pipeline +- Migrate `@astrojs/mdx` configuration from deprecated top-level `remarkPlugins`/`rehypePlugins` to `processor: unified(...)` API (required since `@astrojs/mdx@6.0.0`) diff --git a/CLAUDE.md b/CLAUDE.md index 042afeec..b5471f81 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -323,38 +323,34 @@ The `notro-loader` package exposes four entry points, each designed for a specif ### `notro()` Astro Integration -`notro()` is an Astro integration that registers `@astrojs/mdx` with notro's core plugin suite. It is required for two reasons: +`notro()` is an Astro integration that registers `@astrojs/mdx` with the Sätteri processor. It is required for two reasons: 1. **`astro:jsx` renderer** — `@astrojs/mdx` registers the `astro:jsx` renderer that `@mdx-js/mdx`'s `evaluate()` depends on to produce Astro VNodes. Without it, `NotroContent` fails at runtime. -2. **Static `.mdx` files** — if the project uses `.mdx` files alongside Notion content, `notro()` ensures they are processed with the same plugin pipeline as dynamically compiled Notion markdown. +2. **Static `.mdx` files** — if the project uses `.mdx` files alongside Notion content, `notro()` configures `@astrojs/mdx` with Sätteri's Rust-based Markdown pipeline and notro's core MDASTP plugins. -The interface mirrors `@astrojs/mdx`. Available options: +Available options: | Option | Type | Purpose | |---|---|---| -| `remarkPlugins` | `PluggableList` | Additional remark plugins (e.g. `[remarkMath]`) | -| `rehypePlugins` | `PluggableList` | Additional rehype plugins (e.g. `[rehypeKatex, [rehypeMermaid, { theme: 'github-dark' }]]`) | -| `shikiConfig` | `Record` | Injects `@shikijs/rehype` as the last plugin (requires `npm i @shikijs/rehype`). Example: `{ theme: 'github-dark' }` | +| `mdastPlugins` | `MdastPluginDefinition[]` | Sätteri MDASTP plugins for static `.mdx` files | +| `hastPlugins` | `HastPluginDefinition[]` | Sätteri HAST plugins for static `.mdx` files (e.g. `[satteriMermaidPlugin()]`) | +| `shikiConfig` | `Record` | Shiki syntax highlighting config. Routed through Astro's `markdown.shikiConfig` for the Sätteri pipeline. Example: `{ theme: 'github-dark' }` | | `viteExternals` | `string[]` | Packages to add to Vite's `ssr.external` (for native binaries or dynamic imports) | -| `extendMarkdownConfig` | `boolean` | Whether to extend Astro's base markdown config (default: `false`) | +| `extendMarkdownConfig` | `boolean` | Whether to extend Astro's base markdown config. Defaults to `true` when `shikiConfig` is set. | Usage in `astro.config.mjs`: ```js import { notro } from "notro-loader/integration"; import { notionImageService } from "notro-loader/image-service"; -import { rehypeMermaid } from "rehype-beautiful-mermaid"; -import remarkMath from "remark-math"; -import rehypeKatex from "rehype-katex"; +import { satteriMermaidPlugin } from "rehype-beautiful-mermaid/satteri"; export default defineConfig({ image: { service: notionImageService }, integrations: [ notro({ shikiConfig: { theme: "github-dark" }, - remarkPlugins: [remarkMath], - rehypePlugins: [ - [rehypeMermaid, { theme: "github-dark" }], - rehypeKatex, + hastPlugins: [ + satteriMermaidPlugin({ theme: "github-dark" }), ], }), sitemap(), @@ -371,24 +367,21 @@ export default defineConfig({ ### MDX Compile Pipeline -Defined in `packages/notro-loader/src/utils/mdx-pipeline.ts` via `@mdx-js/mdx`'s `evaluate()` (called from `compile-mdx.ts`). The pipeline is shared between the runtime Notion content path and static `.mdx` files via the `notro()` integration. +There are two separate pipelines: -**Core remark plugins** (always active): -- `remarkNfm` (from `remark-nfm`) — bundles pre-parse normalization (`preprocessNotionMarkdown`), directive syntax + GFM strikethrough/task-list support, and callout conversion in one plugin +**1. Notion content (runtime path)** — `compile-mdx.ts` → `@mdx-js/mdx`'s `evaluate()` -**User-provided remark plugins** (opt-in via `notro({ remarkPlugins })`): -- e.g. `remark-math` — enables `$...$` inline and `$$...$$` block math syntax +Notion markdown is preprocessed entirely at string level before `evaluate()` is called. No remark or rehype plugins run. Transforms applied: +- `preprocessNotionMarkdown()` — 19 fixes for Notion API markdown quirks (callout→JSX, element renaming, color→className, etc.) +- `applyMdxContext()` — heading IDs, TOC population, page link resolution -**Core rehype plugins** (always active, in order): -1. `rehypeRaw` — converts raw HTML strings from Notion markdown into hast nodes; passes through Notion custom elements (`callout`, `columns`, `video`, etc.) -2. `rehypeNotionColorPlugin` — converts `color="gray_bg"` / `underline="true"` attributes on `

`, ``, `` elements to `notro-*` CSS classes -3. `rehypeBlockElementsPlugin` — renames Notion block elements from lowercase to PascalCase so MDX routes them through the `components` map (e.g. `video` → `Video`, `table_of_contents` → `TableOfContents`) -4. `rehypeInlineMentionsPlugin` — same rename for inline mention elements (`mention-user` → `MentionUser`, etc.) -5. _(user-provided rehype plugins run here)_ -6. `rehypeShiki` — injected automatically when `shikiConfig` is set (runs last so other plugins go first) -7. `rehypeSlug` — adds `id` attributes to h1–h4 headings -8. `rehypeTocPlugin` — populates `` with anchor links to all headings -9. `resolvePageLinksPlugin` — resolves Notion `notion.so` URLs in ``, ``, mention elements, and `` using the `linkToPages` map +**2. Static `.mdx` files** — `@astrojs/mdx` with Sätteri processor + +Configured by `notro()` in `astro.config.mjs`. Uses Sätteri's Rust-based Markdown pipeline: +- `notroCalloutPlugin` (MDASTP, always active) — converts `:::callout{...}` directives to `` MDX JSX elements +- User-provided `mdastPlugins` and `hastPlugins` (opt-in via `notro({ mdastPlugins, hastPlugins })`) +- Shiki syntax highlighting — enabled via `notro({ shikiConfig: { theme: '...' } })` +- Mermaid diagram rendering — via `satteriMermaidPlugin()` from `rehype-beautiful-mermaid/satteri` **Component mapping** (HTML elements → Astro components): - After `evaluate()`, `` maps every Notion block type (callout, toggle, columns, images, table, TOC, etc.) and standard HTML element to an Astro component diff --git a/backlog/config.yml b/backlog/config.yml new file mode 100644 index 00000000..df998022 --- /dev/null +++ b/backlog/config.yml @@ -0,0 +1,15 @@ +project_name: "Sätteri Migration" +default_status: "To Do" +statuses: ["To Do", "In Progress", "Done"] +labels: [] +date_format: yyyy-mm-dd +max_column_width: 20 +auto_open_browser: true +default_port: 6420 +remote_operations: true +auto_commit: false +filesystem_only: false +bypass_git_hooks: false +check_active_branches: true +active_branch_days: 30 +task_prefix: "task" diff --git a/backlog/tasks/task-1 - Migrate-astrojs-mdx-integration-to-processor-unified-API.md b/backlog/tasks/task-1 - Migrate-astrojs-mdx-integration-to-processor-unified-API.md new file mode 100644 index 00000000..24a42711 --- /dev/null +++ b/backlog/tasks/task-1 - Migrate-astrojs-mdx-integration-to-processor-unified-API.md @@ -0,0 +1,73 @@ +--- +id: TASK-1 +title: 'Migrate @astrojs/mdx integration to processor: unified() API' +status: Done +assignee: [] +created_date: '2026-06-06 00:13' +updated_date: '2026-06-06 00:51' +labels: [] +dependencies: [] +priority: high +ordinal: 1000 +--- + +## Description + + +## Background + +Astro 6.4 deprecated the top-level `remarkPlugins` / `rehypePlugins` options on `@astrojs/mdx`. These will be removed in Astro 8.0. + +The new API wraps plugins inside `processor: unified({ remarkPlugins, rehypePlugins })`. + +## Current code (packages/notro-loader/src/integration.ts:149) + +```ts +updateConfig({ + integrations: [mdx({ + remarkPlugins: [remarkNfm, ...remarkPlugins], // deprecated + rehypePlugins: allRehypePlugins, // deprecated + extendMarkdownConfig, + })], +}) +``` + +## Target + +```ts +import { unified } from '@astrojs/markdown-remark'; + +updateConfig({ + integrations: [mdx({ + // explicit processor: unified() serves double duty: + // 1. uses the non-deprecated API (remarkPlugins/rehypePlugins on mdx() are removed in Astro 8.0) + // 2. guards against inheriting markdown.processor: satteri() from the user's Astro config — + // notro's pipeline requires remark/rehype plugins that Sätteri doesn't support + processor: unified({ + remarkPlugins: [remarkNfm, ...remarkPlugins], + rehypePlugins: allRehypePlugins, + }), + extendMarkdownConfig: false, + })], +}) +``` + +## Why both processor: unified() and extendMarkdownConfig: false + +`@astrojs/mdx`'s `processor` option defaults to inheriting `markdown.processor` from the Astro config. +If a user sets `markdown.processor: satteri()` for `.md` files, MDX would also switch to Sätteri — +breaking notro because Sätteri doesn't support remark/rehype plugins. + +`extendMarkdownConfig: false` prevents inheriting legacy `markdown.remarkPlugins` etc., +while `processor: unified({...})` explicitly pins the MDX processor regardless of the user's +top-level `markdown.processor` setting. + +## Acceptance criteria + +- [x] `integration.ts` uses `processor: unified()` instead of deprecated top-level options +- [x] No deprecation warnings in `pnpm run build` output +- [x] `pnpm run build` passes +- [x] Comment added explaining why `processor: unified()` is explicit (not just default) +- [x] `NotroOptions` JSDoc updated to mention Sätteri incompatibility +- [x] Changeset added (patch for notro-loader) + diff --git a/backlog/tasks/task-10 - Replace-evaluate-remark-rehype-pipeline-with-string-preprocessing.md b/backlog/tasks/task-10 - Replace-evaluate-remark-rehype-pipeline-with-string-preprocessing.md new file mode 100644 index 00000000..37c124d5 --- /dev/null +++ b/backlog/tasks/task-10 - Replace-evaluate-remark-rehype-pipeline-with-string-preprocessing.md @@ -0,0 +1,164 @@ +--- +id: TASK-10 +title: "Replace evaluate() remark/rehype pipeline with string-level MDX preprocessing" +status: Done +assignee: [] +created_date: '2026-06-06' +labels: [refactor, breaking-change] +dependencies: [] +priority: high +ordinal: 10000 +--- + +## Description + + +## Goal + +Eliminate notro-loader's direct dependencies on remark, rehype, and remark-notro from the Notion content compilation path (`evaluate()`). All Notion-specific AST transformations currently done by remark/rehype plugins are moved into `preprocessNotionMarkdown()` as string-level operations, so `evaluate()` receives clean MDX that compiles without any plugins. + +## Current state + +`compile-mdx.ts` calls `evaluate()` from `@mdx-js/mdx` with a full plugin pipeline built by `buildMdxPlugins()` in `mdx-pipeline.ts`: + +**Remark layer:** +- `remarkNfm` (from `remark-notro`) — directive parser + callout AST conversion + +**Rehype layer:** +- `rehype-raw` — converts raw HTML strings into hast nodes, passes through Notion custom elements +- `rehypeNotionColorPlugin` (custom) — converts `color="gray_bg"` attributes to Tailwind classes +- `rehypeBlockElementsPlugin` (custom) — renames `

text

`. Extend the conversion to emit `className` directly: + +``` +

text

text

+xx +``` + +Requires moving the `NOTION_TEXT_CLASSES` / `NOTION_BG_CLASSES` maps from `mdx-pipeline.ts` to `transformer.ts` in `remark-notro` (or a shared constants file). + +### 3. Block element renaming (replaces rehypeBlockElementsPlugin) + +`NOTION_BLOCK_RENAMES` maps `video → Video`, `table_of_contents → TableOfContents`, etc. Convert these as string transforms on the raw HTML tags in the markdown source: + +``` + + +``` + +Care required: use word-boundary-aware regex to avoid matching partial element names. + +### 4. Inline mention renaming (replaces rehypeInlineMentionsPlugin) + +Same approach as (3): string replace `` → ``, etc. + +### 5. Heading IDs (replaces rehype-slug) + +Convert ATX headings to raw HTML with explicit `id` attributes so `evaluate()` produces anchored headings without `rehype-slug`: + +``` +## My Section Title +``` +→ +```html +

My Section Title

+``` + +Slug algorithm: lowercase, replace non-alphanumeric with `-`, deduplicate suffixes (`-2`, `-3`, …). + +This runs as a post-parse step since it needs the full document to detect duplicates. + +### 6. TOC population (replaces rehypeTocPlugin) + +After heading ID generation (step 5), collect all `h1`–`h4` headings with their ids and inject the links list as a prop on ``: + +``` + +``` +→ +``` + +``` + +Alternatively, keep TOC as a client-side component that reads headings from the DOM — no build-time injection needed. + +### 7. Page link resolution (replaces resolvePageLinksPlugin) + +`resolvePageLinksPlugin` already receives `linkToPages` as a parameter. Port the URL substitution to `postprocessNotionMarkdown(markdown, { linkToPages })` as a string replace over `href="https://notion.so/..."` patterns. + +## Breaking changes + +`notro({ remarkPlugins, rehypePlugins })` currently applies user-provided plugins to the Notion content `evaluate()` path. After this change: + +- `rehypePlugins` on the Notion path — no longer supported; the path produces final MDX with no rehype stage. +- `remarkPlugins` on the Notion path — no longer supported; remark is not run. + +For math (`remark-math` + `rehype-katex`): math expressions would need to be handled at string level, or the user opts into Sätteri which has native math support via its MDASTP layer. + +This is a **breaking change** → `major` version bump for `notro-loader`. + +## Files affected + +| File | Change | +|------|--------| +| `packages/remark-nfm/src/transformer.ts` | Add callout-to-JSX conversion, color-to-className, element renaming, heading ID, TOC, page links | +| `packages/notro-loader/src/utils/compile-mdx.ts` | Remove `buildMdxPlugins()` call; pass empty plugin arrays to `evaluate()` | +| `packages/notro-loader/src/utils/mdx-pipeline.ts` | Delete (or keep only for Sätteri-unrelated concerns) | +| `packages/notro-loader/package.json` | Remove `remark-notro`, `rehype-raw`, `rehype-slug`, `unist-util-visit`; remove `unified` peer dep | +| `packages/notro-loader/src/integration.ts` | Remove `remarkPlugins`/`rehypePlugins` options (or deprecate) from `NotroOptions` | + +## Acceptance criteria + +- [x] `evaluate()` called with `remarkPlugins: []` and `rehypePlugins: []` for Notion content +- [x] Callout blocks render correctly (icon, color, children) +- [x] Notion color annotations render with correct Tailwind classes +- [x] Block elements (Video, Columns, TableOfContents, etc.) render as components +- [x] Inline mentions (MentionUser, MentionDate, etc.) render as components +- [x] Heading anchors work (TOC links navigate to correct headings) +- [x] TableOfContents populates correctly +- [x] Page links resolve to internal URLs +- [x] `remark-notro`, `rehype-raw`, `rehype-slug`, `unist-util-visit` removed from `notro-loader/package.json` +- [x] `pnpm run build` passes + diff --git "a/backlog/tasks/task-11 - Research-S\303\244tteri-code-highlighting-and-implement-HAST-plugin.md" "b/backlog/tasks/task-11 - Research-S\303\244tteri-code-highlighting-and-implement-HAST-plugin.md" new file mode 100644 index 00000000..ae042aef --- /dev/null +++ "b/backlog/tasks/task-11 - Research-S\303\244tteri-code-highlighting-and-implement-HAST-plugin.md" @@ -0,0 +1,104 @@ +--- +id: TASK-11 +title: Research Sätteri code highlighting and implement HAST plugin (replaces shikiConfig) +status: Done +assignee: [] +created_date: '2026-06-07' +labels: [research, feat] +dependencies: [] +priority: high +ordinal: 11000 +--- + +## Description + + +## Goal + +Determine how code syntax highlighting works when `@astrojs/mdx` uses Sätteri as the processor, then implement or expose the appropriate API so `notro({ shikiConfig })` continues to work. + +## Background + +Currently `notro({ shikiConfig: { theme: 'github-dark' } })` dynamically loads `@shikijs/rehype` and appends it to `allRehypePlugins`, which is passed to `unified({ rehypePlugins })`. Once Sätteri becomes the default processor (TASK-13), rehype plugins are no longer applied — `@shikijs/rehype` will silently do nothing. + +## Research questions + +### 1. Does Astro's built-in Shiki integration work with Sätteri? + +Astro has a `markdown.shikiConfig` option at the `defineConfig` level. Internally, this might be wired below the remark/rehype layer (e.g., directly in the Astro pipeline or in `@astrojs/mdx`). Check: + +- Does `astro.config.mjs → shikiConfig` still apply to `.mdx` files when `@astrojs/mdx` uses Sätteri? +- Source: `node_modules/@astrojs/mdx/dist/` and `node_modules/satteri/` + +### 2. Does Sätteri itself have Shiki support? + +Check `@astrojs/markdown-satteri` and `satteri` package: +- Is there a `shiki` or `highlight` option on `satteri()` processor options? +- Does it produce highlighted `
` blocks automatically?
+
+### 3. Is there a `@shikijs/satteri` package?
+
+Search npm/GitHub for an official Shiki Sätteri plugin equivalent to `@shikijs/rehype`.
+
+## Implementation (based on research findings)
+
+### Path A: Astro/Sätteri handles Shiki natively
+
+If `defineConfig({ markdown: { shikiConfig: { theme: 'github-dark' } } })` already highlights
+code blocks in Sätteri-processed `.mdx` files, then `notro({ shikiConfig })` can:
+1. Pass the option through to Astro's `updateConfig({ markdown: { shikiConfig } })`
+2. Or document that users configure it directly in `defineConfig`
+
+### Path B: Implement a Sätteri HAST plugin
+
+If no built-in support exists, implement `satteriShikiPlugin(options)`:
+
+```typescript
+// packages/notro-loader/src/utils/satteri-plugins.ts
+import { defineHastPlugin } from 'satteri';
+import { getHighlighter } from 'shiki';
+
+export function satteriShikiPlugin(options: { theme?: string; themes?: Record }) {
+  return defineHastPlugin({
+    name: 'notro-shiki',
+    element: {
+      filter: ['pre'],
+      async visit(node, ctx) {
+        const code = node.children[0];
+        if (code?.type !== 'element' || code.tagName !== 'code') return;
+        const lang = (code.properties?.className as string[])
+          ?.find(c => c.startsWith('language-'))
+          ?.replace('language-', '');
+        if (!lang) return;
+        const text = ctx.textContent(code);
+        const highlighted = await highlight(text, lang, options);
+        ctx.replaceNode(node, { ...highlighted });
+      },
+    },
+  });
+}
+```
+
+This would be consumed in TASK-13 when `shikiConfig` is provided:
+
+```typescript
+// integration.ts (after TASK-13)
+const hastPlugins = [
+  ...(shikiConfig ? [satteriShikiPlugin(shikiConfig)] : []),
+  ...userHastPlugins,
+];
+```
+
+## Files affected
+
+| File | Change |
+|------|--------|
+| `packages/notro-loader/src/utils/satteri-plugins.ts` | Add `satteriShikiPlugin()` (or document alternative) |
+| `packages/notro-loader/src/integration.ts` | Wire shikiConfig → satteriShikiPlugin (in TASK-13) |
+
+## Acceptance criteria
+
+- [ ] Research documented: which path applies (A or B)
+- [ ] Code highlighting works in `.mdx` files when `notro({ shikiConfig })` is set with Sätteri as default
+- [ ] `pnpm run build` passes with highlighted code blocks
+
diff --git "a/backlog/tasks/task-12 - Add-S\303\244tteri-HAST-plugin-export-to-rehype-beautiful-mermaid.md" "b/backlog/tasks/task-12 - Add-S\303\244tteri-HAST-plugin-export-to-rehype-beautiful-mermaid.md"
new file mode 100644
index 00000000..e2e59653
--- /dev/null
+++ "b/backlog/tasks/task-12 - Add-S\303\244tteri-HAST-plugin-export-to-rehype-beautiful-mermaid.md"	
@@ -0,0 +1,110 @@
+---
+id: TASK-12
+title: Add Sätteri HAST plugin export to rehype-beautiful-mermaid
+status: Done
+assignee: []
+created_date: '2026-06-07'
+labels: [feat]
+dependencies: []
+priority: high
+ordinal: 12000
+---
+
+## Description
+
+
+## Goal
+
+Add a Sätteri-compatible HAST plugin export to `packages/rehype-beautiful-mermaid` so users can render Mermaid diagrams when Sätteri is the default processor. Keep the existing rehype export for backward compatibility.
+
+## Background
+
+`rehype-beautiful-mermaid` currently exports a rehype plugin that renders Mermaid code blocks to inline SVG at build time. When `@astrojs/mdx` uses Sätteri as the processor (TASK-13), rehype plugins are no longer invoked — so `.mdx` files with Mermaid diagrams would stop rendering.
+
+The blog template currently uses:
+```js
+import { rehypeMermaid } from 'rehype-beautiful-mermaid';
+notro({ rehypePlugins: [[rehypeMermaid, { theme: 'github-dark' }]] })
+```
+
+After TASK-13, the target API is:
+```js
+import { satteriMermaidPlugin } from 'rehype-beautiful-mermaid/satteri';
+notro({ hastPlugins: [satteriMermaidPlugin({ theme: 'github-dark' })] })
+```
+
+## Implementation
+
+### 1. Add Sätteri HAST plugin
+
+New file: `packages/rehype-beautiful-mermaid/src/satteri-mermaid.ts`
+
+```typescript
+import { defineHastPlugin } from 'satteri';
+import type { HastPluginDefinition } from 'satteri';
+import { renderMermaid } from './render.ts'; // extract shared render logic
+
+export interface SatteriMermaidOptions {
+  theme?: string;
+  backgroundColor?: string;
+}
+
+export function satteriMermaidPlugin(options: SatteriMermaidOptions = {}): HastPluginDefinition {
+  return defineHastPlugin({
+    name: 'rehype-beautiful-mermaid-satteri',
+    element: {
+      filter: ['pre'],
+      async visit(node, ctx) {
+        const code = node.children?.[0];
+        if (code?.type !== 'element' || code.tagName !== 'code') return;
+        const classes = code.properties?.className as string[] | undefined;
+        if (!classes?.includes('language-mermaid')) return;
+
+        const mermaidSource = ctx.textContent(code);
+        const svg = await renderMermaid(mermaidSource, options);
+
+        ctx.replaceNode(node, {
+          type: 'element',
+          tagName: 'div',
+          properties: { 'data-mermaid': '' },
+          children: [{ type: 'raw', value: svg }],
+        });
+      },
+    },
+  });
+}
+```
+
+### 2. New entry point
+
+Add to `packages/rehype-beautiful-mermaid/package.json` exports:
+
+```json
+"./satteri": "./src/satteri-mermaid.ts"
+```
+
+### 3. Extract shared render logic
+
+The SVG rendering code (Mermaid CLI or `@mermaid-js/mermaid-core`) is shared between the rehype and Sätteri plugins. Extract to `packages/rehype-beautiful-mermaid/src/render.ts`.
+
+### 4. Keep existing rehype export unchanged
+
+`packages/rehype-beautiful-mermaid/index.ts` stays as-is for users still on unified.
+
+## Files affected
+
+| File | Change |
+|------|--------|
+| `packages/rehype-beautiful-mermaid/src/render.ts` | Extract shared render logic (new file) |
+| `packages/rehype-beautiful-mermaid/src/satteri-mermaid.ts` | New Sätteri HAST plugin (new file) |
+| `packages/rehype-beautiful-mermaid/package.json` | Add `./satteri` export entry |
+| `packages/rehype-beautiful-mermaid/index.ts` | Update to use shared render.ts (refactor) |
+
+## Acceptance criteria
+
+- [ ] `import { satteriMermaidPlugin } from 'rehype-beautiful-mermaid/satteri'` works
+- [ ] Mermaid code blocks in `.mdx` files render to inline SVG with Sätteri processor
+- [ ] Existing `rehypeMermaid` (rehype API) continues to work unchanged
+- [ ] `pnpm run build` passes
+- [ ] Changeset added for `rehype-beautiful-mermaid` (minor — new export)
+
diff --git "a/backlog/tasks/task-13 - Switch-integration-default-to-S\303\244tteri-and-redesign-NotroOptions.md" "b/backlog/tasks/task-13 - Switch-integration-default-to-S\303\244tteri-and-redesign-NotroOptions.md"
new file mode 100644
index 00000000..99c94757
--- /dev/null
+++ "b/backlog/tasks/task-13 - Switch-integration-default-to-S\303\244tteri-and-redesign-NotroOptions.md"	
@@ -0,0 +1,143 @@
+---
+id: TASK-13
+title: Switch integration.ts default to Sätteri and redesign NotroOptions (breaking)
+status: Done
+assignee: []
+created_date: '2026-06-07'
+labels: [refactor, breaking-change]
+dependencies: [TASK-11, TASK-12]
+priority: high
+ordinal: 13000
+---
+
+## Description
+
+
+## Goal
+
+Make Sätteri the default processor for static `.mdx` files in the `notro()` integration.
+Replace the `remarkPlugins`/`rehypePlugins` API with Sätteri-native `mdastPlugins`/`hastPlugins`.
+Remove the `unified()` call and `@astrojs/markdown-remark` import entirely.
+
+## Background
+
+After TASK-10 (string-level Notion preprocessing), `remarkPlugins`/`rehypePlugins` no longer apply
+to the Notion content path. After TASK-11/12 (Sätteri Shiki + Mermaid HAST plugins),
+Sätteri covers all rendering features the blog template needs.
+
+Astro 6.4 positions Sätteri as the next-generation processor. `unified()` and remark/rehype
+remain supported but are the legacy path. notro should lead with Sätteri.
+
+## API changes
+
+### Before (current `NotroOptions`)
+
+```typescript
+interface NotroOptions {
+  remarkPlugins?: PluggableList;   // ← remove
+  rehypePlugins?: PluggableList;   // ← remove
+  shikiConfig?: Record;  // ← keep, route through Sätteri internally
+  viteExternals?: string[];
+  processor?: MarkdownProcessor;   // ← remove (Sätteri is always used)
+  extendMarkdownConfig?: boolean;
+}
+```
+
+### After (new `NotroOptions`)
+
+```typescript
+import type { MdastPluginDefinition, HastPluginDefinition } from 'satteri';
+
+interface NotroOptions {
+  mdastPlugins?: MdastPluginDefinition[];  // ← new: Sätteri MDASTP plugins
+  hastPlugins?: HastPluginDefinition[];    // ← new: Sätteri HASTP plugins
+  shikiConfig?: Record;   // ← kept: converted to satteriShikiPlugin internally
+  viteExternals?: string[];
+  extendMarkdownConfig?: boolean;
+}
+```
+
+### `integration.ts` restructure
+
+Remove:
+- `import { unified } from '@astrojs/markdown-remark'`
+- `import type { MarkdownProcessor } from '@astrojs/markdown-remark'`
+- `import { isSatteriProcessor } from '@astrojs/markdown-satteri'`
+- The entire Sätteri-detection branch (now always Sätteri)
+
+Replace with:
+```typescript
+import { satteri } from '@astrojs/markdown-satteri';
+
+// In astro:config:setup:
+const satteriProcessor = satteri();
+satteriProcessor.options.features.directive = true;
+
+const allMdastPlugins = [
+  ...buildSatteriMdastPlugins(),  // notro's callout plugin
+  ...mdastPlugins,                // user-provided
+];
+
+const allHastPlugins = [
+  ...(shikiConfig ? [satteriShikiPlugin(shikiConfig)] : []),
+  ...hastPlugins,                 // user-provided
+];
+
+for (const plugin of allMdastPlugins) {
+  satteriProcessor.options.mdastPlugins.push(plugin);
+}
+for (const plugin of allHastPlugins) {
+  satteriProcessor.options.hastPlugins.push(plugin);
+}
+
+updateConfig({
+  integrations: [mdx({
+    processor: satteriProcessor,
+    extendMarkdownConfig,
+  })] as any,
+  vite: { ssr: { external: viteExternals } },
+});
+```
+
+## Migration guide for users
+
+Users who currently pass `remarkPlugins`/`rehypePlugins` to `notro()` must migrate:
+
+| Before | After |
+|--------|-------|
+| `remarkPlugins: [remarkMath]` | Built into Sätteri — remove |
+| `rehypePlugins: [rehypeKatex]` | No Sätteri equivalent — see note below |
+| `rehypePlugins: [[rehypeMermaid, opts]]` | `hastPlugins: [satteriMermaidPlugin(opts)]` |
+| `shikiConfig: { theme: '...' }` | Unchanged (internal implementation changes) |
+
+**KaTeX note**: `rehype-katex` has no Sätteri-compatible HAST equivalent yet. Users who need
+math rendering in `.mdx` files can:
+1. Write HTML-level math directly (e.g., use a KaTeX JavaScript client-side approach)
+2. Contribute a `satteriKatexPlugin` to notro or `@katex/satteri`
+
+## Breaking changes
+
+- `NotroOptions.remarkPlugins` removed
+- `NotroOptions.rehypePlugins` removed  
+- `NotroOptions.processor` removed
+- `@astrojs/markdown-remark` is no longer a dependency of `notro-loader`
+
+This is a **breaking change** → `major` version bump for `notro-loader` (TASK-15).
+
+## Files affected
+
+| File | Change |
+|------|--------|
+| `packages/notro-loader/src/integration.ts` | Full restructure (see above) |
+| `packages/notro-loader/package.json` | Remove `@astrojs/markdown-remark` from dependencies |
+
+## Acceptance criteria
+
+- [ ] `NotroOptions` uses `mdastPlugins`/`hastPlugins` instead of `remarkPlugins`/`rehypePlugins`
+- [ ] `unified()` is no longer called or imported
+- [ ] `@astrojs/markdown-remark` removed from `notro-loader` dependencies
+- [ ] Sätteri is the default processor for static `.mdx` files (no explicit option needed)
+- [ ] `shikiConfig` still works (via `satteriShikiPlugin` internally)
+- [ ] notro's callout MDASTP plugin is always injected
+- [ ] `pnpm run build` passes
+
diff --git "a/backlog/tasks/task-14 - Update-blog-template-for-S\303\244tteri-API.md" "b/backlog/tasks/task-14 - Update-blog-template-for-S\303\244tteri-API.md"
new file mode 100644
index 00000000..17d3ada6
--- /dev/null
+++ "b/backlog/tasks/task-14 - Update-blog-template-for-S\303\244tteri-API.md"	
@@ -0,0 +1,70 @@
+---
+id: TASK-14
+title: Update blog template for Sätteri-based notro API
+status: Done
+assignee: []
+created_date: '2026-06-07'
+labels: [chore]
+dependencies: [TASK-13]
+priority: medium
+ordinal: 14000
+---
+
+## Description
+
+
+## Goal
+
+Update `templates/blog/astro.config.mjs` and related files to use the new Sätteri-based
+`notro()` API introduced in TASK-13. Verify the full build and visual output.
+
+## Changes
+
+### `templates/blog/astro.config.mjs`
+
+```js
+// Before
+import { rehypeMermaid } from 'rehype-beautiful-mermaid';
+
+export default defineConfig({
+  integrations: [
+    notro({
+      shikiConfig: { theme: "github-dark" },
+      rehypePlugins: [
+        [rehypeMermaid, { theme: "github-dark" }],
+      ],
+    }),
+  ],
+});
+
+// After
+import { satteriMermaidPlugin } from 'rehype-beautiful-mermaid/satteri';
+
+export default defineConfig({
+  integrations: [
+    notro({
+      shikiConfig: { theme: "github-dark" },
+      hastPlugins: [
+        satteriMermaidPlugin({ theme: "github-dark" }),
+      ],
+    }),
+  ],
+});
+```
+
+### `templates/blog/package.json`
+
+No change needed — `rehype-beautiful-mermaid` is still a dependency (used via `/satteri` export).
+
+### `templates/blank/astro.config.mjs`
+
+The blank template doesn't use `rehypePlugins` — verify it still builds correctly with no changes.
+
+## Acceptance criteria
+
+- [ ] `templates/blog/astro.config.mjs` uses `satteriMermaidPlugin` instead of `rehypeMermaid`
+- [ ] `pnpm run build` passes (all pages built)
+- [ ] `pnpm --filter notro-blog run preview` — Mermaid diagrams render correctly
+- [ ] `pnpm --filter notro-blog run preview` — code blocks are syntax-highlighted
+- [ ] `templates/blank` still builds without changes
+
diff --git "a/backlog/tasks/task-15 - Major-version-bump-and-changeset-for-S\303\244tteri-default.md" "b/backlog/tasks/task-15 - Major-version-bump-and-changeset-for-S\303\244tteri-default.md"
new file mode 100644
index 00000000..fe35deb6
--- /dev/null
+++ "b/backlog/tasks/task-15 - Major-version-bump-and-changeset-for-S\303\244tteri-default.md"	
@@ -0,0 +1,80 @@
+---
+id: TASK-15
+title: Major version bump and changeset for Sätteri-as-default
+status: Done
+assignee: []
+created_date: '2026-06-07'
+labels: [chore, breaking-change]
+dependencies: [TASK-14]
+priority: medium
+ordinal: 15000
+---
+
+## Description
+
+
+## Goal
+
+Create changesets and update documentation for the breaking API changes introduced in TASK-13/14.
+
+## Changesets needed
+
+### `notro-loader` — major
+
+Breaking changes:
+- `NotroOptions.remarkPlugins` removed
+- `NotroOptions.rehypePlugins` removed
+- `NotroOptions.processor` removed
+- Sätteri is now the default processor for static `.mdx` files
+
+New API:
+- `NotroOptions.mdastPlugins` (Sätteri MDASTP plugins)
+- `NotroOptions.hastPlugins` (Sätteri HAST plugins)
+- `NotroOptions.shikiConfig` still works (internal implementation via Sätteri HAST)
+
+### `rehype-beautiful-mermaid` — minor
+
+New export:
+- `import { satteriMermaidPlugin } from 'rehype-beautiful-mermaid/satteri'`
+
+## Documentation updates
+
+### `CLAUDE.md` — `notro()` Astro Integration section
+
+Update the options table:
+
+| Option | Type | Purpose |
+|--------|------|---------|
+| `mdastPlugins` | `MdastPluginDefinition[]` | Sätteri MDASTP plugins for static .mdx files |
+| `hastPlugins` | `HastPluginDefinition[]` | Sätteri HAST plugins for static .mdx files |
+| `shikiConfig` | `Record` | Code syntax highlighting (via Sätteri HAST internally) |
+| `viteExternals` | `string[]` | Packages for Vite ssr.external |
+| `extendMarkdownConfig` | `boolean` | Extend Astro's base markdown config |
+
+Remove: `remarkPlugins`, `rehypePlugins`, `processor` rows.
+
+Update the usage example in `CLAUDE.md`:
+```js
+import { satteriMermaidPlugin } from 'rehype-beautiful-mermaid/satteri';
+
+export default defineConfig({
+  integrations: [
+    notro({
+      shikiConfig: { theme: 'github-dark' },
+      hastPlugins: [satteriMermaidPlugin({ theme: 'github-dark' })],
+    }),
+  ],
+});
+```
+
+### Repository structure section
+
+Update the MDX Compile Pipeline description to reflect Sätteri as default.
+
+## Acceptance criteria
+
+- [ ] `pnpm changeset` — changeset created for `notro-loader` (major) and `rehype-beautiful-mermaid` (minor)
+- [ ] `CLAUDE.md` integration options table updated
+- [ ] `CLAUDE.md` usage example updated
+- [ ] `pnpm run build` passes after all doc updates
+
diff --git a/backlog/tasks/task-16 - Align-astro-peerDependency-and-add-minimum-version-CI-guard.md b/backlog/tasks/task-16 - Align-astro-peerDependency-and-add-minimum-version-CI-guard.md
new file mode 100644
index 00000000..c7db8622
--- /dev/null
+++ b/backlog/tasks/task-16 - Align-astro-peerDependency-and-add-minimum-version-CI-guard.md	
@@ -0,0 +1,33 @@
+---
+id: TASK-16
+title: Align astro peerDependency and add minimum-version CI guard
+status: To Do
+assignee: []
+created_date: '2026-06-10'
+labels: [chore, ci, astro-compat]
+dependencies: []
+priority: high
+ordinal: 16000
+---
+
+## Description
+
+
+## Background
+
+During the Sätteri migration, Vercel deployments for `notro-blank`, `notro-basics`, and `notro-gallery` failed because of a version skew: `notro-loader` was type-checked against `astro@6.4.4` (which added `flush`/`close` to `AstroIntegrationLogger`), while the templates resolved `astro@6.1.3` from their `^6.0.4` ranges. The immediate fix bumped the templates to `^6.4.4`, but the root cause remains:
+
+- `packages/notro-loader/package.json` declares `peerDependencies: { "astro": ">=6.0.0" }`, which is **inaccurate** — the actual minimum is 6.4.4.
+- Nothing in CI verifies that the declared minimum Astro version actually builds. The same class of failure will recur whenever a new Astro minor extends an interface that notro-loader's types reference.
+
+## Goal
+
+Make the declared compatibility range truthful and enforce it in CI, so future Astro releases cannot silently break consumers on older-but-in-range versions.
+
+## Acceptance criteria
+
+- [ ] `packages/notro-loader/package.json` `peerDependencies.astro` raised to `>=6.4.4` (matching the real minimum)
+- [ ] Changeset created for the peer dependency change (patch — it documents an existing requirement)
+- [ ] CI job (GitHub Actions) that installs the **minimum** supported Astro version (via pnpm override) and runs `astro check` + `astro build` for at least one template, so interface-extension skew is caught before release
+- [ ] Document the supported Astro version policy in `CLAUDE.md` (which range is supported, how the minimum is verified)
+
diff --git a/backlog/tasks/task-17 - Track-Astro-and-Satteri-upstream-changes-affecting-internal-API-usage.md b/backlog/tasks/task-17 - Track-Astro-and-Satteri-upstream-changes-affecting-internal-API-usage.md
new file mode 100644
index 00000000..350418a9
--- /dev/null
+++ b/backlog/tasks/task-17 - Track-Astro-and-Satteri-upstream-changes-affecting-internal-API-usage.md	
@@ -0,0 +1,45 @@
+---
+id: TASK-17
+title: Track Astro and Sätteri upstream changes affecting internal API usage
+status: To Do
+assignee: []
+created_date: '2026-06-10'
+labels: [tracking, astro-compat]
+dependencies: [TASK-16]
+priority: medium
+ordinal: 17000
+---
+
+## Description
+
+
+## Background
+
+TASK-3 tracked the `@astrojs/mdx` deprecated-options removal and is Done. The Sätteri migration introduced **new** dependencies on Astro internals and 0.x packages that need ongoing tracking. This task is the follow-up watch list.
+
+## Internal / unstable API surface notro relies on
+
+| Dependency | Where | Risk |
+|---|---|---|
+| `mdx({ processor, syntaxHighlight, shikiConfig })` MdxOptions handling | `packages/notro-loader/src/integration.ts` | Shiki highlighting for static `.mdx` relies on `@astrojs/mdx`'s Sätteri path reading `mdxOptions.shikiConfig` — undocumented behavior verified against `@astrojs/mdx@6.0.2`. v7 may change how `processor` and highlighting interact. |
+| `__astro_tag_component__(Content, 'astro:jsx')` | `packages/notro-loader/src/utils/compile-mdx.ts` | Internal Astro API used to register evaluate() output as an `astro:jsx`-rendered component. No semver guarantee; a rename or signature change breaks all Notion content rendering at runtime. |
+| `updateConfig({ integrations: [...] }) as any` | `packages/notro-loader/src/integration.ts` | Relies on Astro's config-setup loop re-checking the integrations array length so the injected MDX integration's own hook runs. Cast hides type drift between Astro and `@astrojs/mdx`. |
+| `notionImageService` wrapping the Sharp service | `packages/notro-loader/image-service.ts` | Wraps Astro's built-in image service module path/exports; both have changed across Astro majors before. |
+| `@astrojs/markdown-satteri@^0.2.2`, `satteri@^0.8.0` | `packages/notro-loader/package.json` | 0.x packages — minor bumps are allowed to break. `MdastPluginDefinition` / `HastPluginDefinition` types and the `features.directive` option are all pre-1.0 API. |
+
+## Process
+
+On each Astro minor release (and any `@astrojs/mdx` / `satteri` / `@astrojs/markdown-satteri` release):
+
+1. Update root and template lockfiles, run `pnpm run build` and `pnpm --filter notro-loader test`
+2. Re-verify Shiki output appears in built HTML for static `.mdx` pages (the `astro-code` class check)
+3. Re-verify Notion content renders (the evaluate() / `astro:jsx` path) on the blog template
+4. If anything in the table above changed upstream, file a dedicated task with the migration plan
+
+## Acceptance criteria
+
+- [ ] Renovate or Dependabot configured to open PRs for `astro`, `@astrojs/mdx`, `@astrojs/markdown-satteri`, and `satteri` updates (grouped per release)
+- [ ] The verification steps above are written into `CLAUDE.md` or a CI workflow triggered by those update PRs
+- [ ] Astro 7.0 / `@astrojs/mdx` v7 breaking-change review completed when released (file follow-up tasks as needed)
+- [ ] `satteri` 1.0 / `@astrojs/markdown-satteri` 1.0 API review completed when released
+
diff --git a/backlog/tasks/task-18 - Restructure-remark-nfm-as-string-first-NFM-core-package.md b/backlog/tasks/task-18 - Restructure-remark-nfm-as-string-first-NFM-core-package.md
new file mode 100644
index 00000000..3851906b
--- /dev/null
+++ b/backlog/tasks/task-18 - Restructure-remark-nfm-as-string-first-NFM-core-package.md	
@@ -0,0 +1,55 @@
+---
+id: TASK-18
+title: Restructure remark-nfm as string-first NFM core package
+status: To Do
+assignee: []
+created_date: '2026-06-10'
+labels: [refactor, satteri, breaking-change]
+dependencies: [TASK-21]
+priority: high
+ordinal: 18000
+---
+
+## Description
+
+
+> **Superseded in part by TASK-21.**
+> The original plan to expose a `/satteri` entry point from remark-nfm is cancelled — the callout plugin stays in `notro-loader` and the package is deprecated (TASK-21). Only the deprecation work described in TASK-21 step 4 applies here. The restructure as a 3-layer standalone is no longer needed.
+
+
+## Background
+
+After the Sätteri migration (TASK-10/13), `notro-loader` no longer depends on `remark-nfm`. The Notion runtime path is handled entirely by `notro-loader/src/utils/notion-preprocess.ts` (Fix 0–19, 597 lines), which started as an extension of remark-nfm's `transformer.ts` (Fix 0–9, 496 lines). The two implementations are now diverging forks of the same logic, and the published `remark-notro` package is orphaned from the monorepo's actual pipeline.
+
+**Decision (user-approved):** rebuild `remark-nfm` as the string-first core that `notro-loader` re-depends on, instead of deprecating it.
+
+## Target architecture (three layers)
+
+| Layer | Export | Contents | Dependencies |
+|---|---|---|---|
+| 1. String core | `preprocessNotionMarkdown`, `applyMdxContext` | The Fix 0–19 implementation moved from `notro-loader/src/utils/notion-preprocess.ts` | none |
+| 2. remark compat | `remarkNfm` | Existing remark plugin API, reimplemented on top of layer 1 | unified ecosystem (optional peer) |
+| 3. Sätteri plugin | `notroCalloutPlugin` | MDASTP callout plugin moved from `notro-loader/src/utils/satteri-plugins.ts` | `satteri` (optional peer) |
+
+Layers 2 and 3 get their own entry points (e.g. `remark-notro/remark`, `remark-notro/satteri`) so consumers install only the peer they use.
+
+## Steps
+
+1. Move `notion-preprocess.ts` + `notion-preprocess.test.ts` into `packages/remark-nfm/src/` as the new core; delete the old `transformer.ts` fix set after porting any behavior the 19-fix version lacks (verify with the existing `transformer.test.ts` cases)
+2. Reimplement `remarkNfm` to call the new core for preprocessing (public API unchanged)
+3. Move `notroCalloutPlugin` / `buildSatteriMdastPlugins` into a `/satteri` entry point; make `satteri` and `unified` optional via `peerDependenciesMeta`
+4. Add `remark-notro: workspace:*` back to `notro-loader` dependencies; replace internal imports; delete the duplicated files from `notro-loader`
+5. `notro-loader/src/utils/compile-mdx.ts` and `integration.ts` import from the new entry points
+6. Run `pnpm --filter notro-loader test` and `pnpm run build`
+
+## Acceptance criteria
+
+- [ ] Single implementation of `preprocessNotionMarkdown` in the monorepo (no duplicated fix logic)
+- [ ] `notro-loader` depends on `remark-notro` via `workspace:*`; the duplicated `notion-preprocess.ts` and `satteri-plugins.ts` are removed from `notro-loader`
+- [ ] `remarkNfm` public API unchanged for existing unified-pipeline consumers
+- [ ] `unified` and `satteri` peers marked optional in `peerDependenciesMeta`
+- [ ] All existing tests pass (`remark-nfm` transformer tests ported or superseded; `notro-loader` 46 tests green)
+- [ ] `pnpm run build` passes for the blog template
+- [ ] Changesets: `remark-notro` minor (new entry points), `notro-loader` patch (internal refactor)
+- [ ] Open question recorded: rename npm package `remark-notro` → directory name `remark-nfm` (or a non-remark name reflecting the string-first design) — decide before publishing
+
diff --git a/backlog/tasks/task-19 - Make-rehype-beautiful-mermaid-Satteri-only.md b/backlog/tasks/task-19 - Make-rehype-beautiful-mermaid-Satteri-only.md
new file mode 100644
index 00000000..e58c6cf9
--- /dev/null
+++ b/backlog/tasks/task-19 - Make-rehype-beautiful-mermaid-Satteri-only.md	
@@ -0,0 +1,49 @@
+---
+id: TASK-19
+title: Make rehype-beautiful-mermaid Sätteri-only
+status: To Do
+assignee: []
+created_date: '2026-06-10'
+labels: [refactor, satteri, breaking-change]
+dependencies: [TASK-21]
+priority: medium
+ordinal: 19000
+---
+
+## Description
+
+
+> **Superseded by TASK-21.**
+> Instead of keeping `rehype-beautiful-mermaid` as a Sätteri-only standalone package, the mermaid plugin will be absorbed into `notro-loader/mermaid` and the package deprecated. See TASK-21 for the full plan.
+
+
+## Background
+
+`rehype-beautiful-mermaid` currently ships two entry points:
+
+- `.` → unified/rehype plugin (`index.ts`)
+- `./satteri` → `satteriMermaidPlugin` (`src/satteri-mermaid.ts`)
+
+and declares **both** `unified: >=11.0.0` and `satteri: >=0.8.0` as required `peerDependencies`, forcing every consumer to install both ecosystems. Within the monorepo only the Sätteri entry point is used (blog template imports `rehype-beautiful-mermaid/satteri`).
+
+**Decision (user-approved):** drop the rehype/unified entry point and make the package Sätteri-only, rather than keeping a dual surface.
+
+## Steps
+
+1. Promote `satteriMermaidPlugin` to the main `.` export; remove the unified/rehype implementation and the `./satteri` subpath (keep it temporarily as an alias re-export if a soft migration window is wanted)
+2. Remove `unified` from `peerDependencies` and drop unified-ecosystem dependencies (`unist-util-visit` etc.) that are no longer used; keep `satteri: >=0.8.0`
+3. Update `templates/blog/astro.config.mjs` import: `rehype-beautiful-mermaid/satteri` → `rehype-beautiful-mermaid`
+4. Update package README with a migration note for existing rehype-pipeline users (last unified-compatible version, what to pin)
+5. Update `CLAUDE.md` package table and `notro()` usage examples
+6. `pnpm run build` + visual check of a Mermaid page via `pnpm --filter notro-blog run preview`
+
+## Acceptance criteria
+
+- [ ] Main export is the Sätteri HAST plugin; no unified/rehype code remains
+- [ ] `peerDependencies` contains only `satteri`; unused unified-ecosystem deps removed
+- [ ] Blog template builds and renders Mermaid diagrams (visually verified in preview)
+- [ ] README migration note for rehype users
+- [ ] `CLAUDE.md` references updated
+- [ ] Changeset: `rehype-beautiful-mermaid` **major** (entry point removal)
+- [ ] Open question recorded: package rename (name still says "rehype") — decide before publishing the major
+
diff --git a/backlog/tasks/task-20 - Align-monorepo-docs-and-comments-with-Satteri-first-architecture.md b/backlog/tasks/task-20 - Align-monorepo-docs-and-comments-with-Satteri-first-architecture.md
new file mode 100644
index 00000000..2714843e
--- /dev/null
+++ b/backlog/tasks/task-20 - Align-monorepo-docs-and-comments-with-Satteri-first-architecture.md	
@@ -0,0 +1,46 @@
+---
+id: TASK-20
+title: Align monorepo docs and comments with Sätteri-first architecture
+status: To Do
+assignee: []
+created_date: '2026-06-10'
+labels: [docs, satteri]
+dependencies: [TASK-21]
+priority: medium
+ordinal: 20000
+---
+
+## Description
+
+
+## Background
+
+The Sätteri migration changed the architecture, but several docs and code comments still describe the old remark/rehype pipeline. After TASK-18 (remark-nfm restructure) and TASK-19 (mermaid Sätteri-only) land, do a single alignment pass so every description matches the new structure.
+
+## Known stale references (verify against post-TASK-18/19 state)
+
+### CLAUDE.md
+
+- Package table: `remark-nfm` described as "Pure remark plugin … notro-loader uses remark-nfm internally" — rewrite for the string-first three-layer design from TASK-18
+- Package table: `rehype-beautiful-mermaid` described as "Rehype plugin" — now a Sätteri HAST plugin (TASK-19)
+- "MDX Compile Pipeline" section: confirm the two-pipeline description (string-level runtime path vs Sätteri static path) matches the final import paths and entry points
+- "Markdown Preprocessing" section: fix table lists only Fix 0–9; the core now has Fix 0–19 — regenerate the table from the final implementation
+- "Package Publishing" / "Changeset Proposal" tables: update package names if TASK-18/19 renames happen
+
+### Code comments in `packages/notro-loader`
+
+- `src/loader/loader.ts` (~line 286): claims "remarkNfm in the MDX compile pipeline (compile-mdx.ts) runs preprocessNotionMarkdown() at parse time" — remarkNfm no longer runs in that pipeline
+- `src/utils/default-components.ts` (~line 37): "callout is created by remarkNfm (a remark-level plugin via data.hName)" — callouts now come from string preprocessing / `notroCalloutPlugin`
+- `src/utils/compile-mdx.ts` and `notion-preprocess.ts` headers: update "replaces the following pipeline" wording to reference the new package layout after the move in TASK-18
+
+### docs/ (Starlight site)
+
+- Grep for `remarkPlugins`, `rehypePlugins`, `remark-nfm`, `rehype-beautiful-mermaid` usage examples and update to the Sätteri-first API (`mdastPlugins`, `hastPlugins`, `shikiConfig`)
+
+## Acceptance criteria
+
+- [ ] `grep -rn "remarkNfm\|rehypePlugins\|remarkPlugins"` over `CLAUDE.md`, `docs/`, and `packages/*/src` returns only intentionally-historical references (e.g. changelogs)
+- [ ] CLAUDE.md package table, pipeline sections, and preprocessing fix table match the shipped implementation
+- [ ] Notion content pages on the blog template still build (`pnpm run build`) — docs-only task, but verify nothing was accidentally touched
+- [ ] No changeset needed (docs/comments only) unless package READMEs change in ways worth a patch release
+
diff --git a/backlog/tasks/task-21 - Consolidate-packages-into-notro-loader.md b/backlog/tasks/task-21 - Consolidate-packages-into-notro-loader.md
new file mode 100644
index 00000000..64da36f3
--- /dev/null
+++ b/backlog/tasks/task-21 - Consolidate-packages-into-notro-loader.md	
@@ -0,0 +1,107 @@
+---
+id: TASK-21
+title: Consolidate packages into notro-loader and deprecate remark-notro + rehype-beautiful-mermaid
+status: To Do
+assignee: []
+created_date: '2026-06-10'
+labels: [refactor, satteri, breaking-change]
+dependencies: []
+priority: high
+ordinal: 21000
+---
+
+## Description
+
+
+## Decision
+
+With Sätteri as the sole pipeline, the two peripheral packages exist only to serve `notro-loader`. Keeping them as separately versioned packages creates cross-package peer dependency skew (both need `satteri: >=0.8.0`, and both drifted from `notro-loader` during TASK-10/13). The breaking pivot is the right moment to collapse these boundaries.
+
+Supersedes TASK-18 (remark-nfm restructure as 3-layer package — no longer needed) and TASK-19 (rehype-beautiful-mermaid as Sätteri-only standalone — absorbed instead).
+
+## Target package structure (post-consolidation)
+
+| Package | Fate | Notes |
+|---|---|---|
+| `notro-loader` | Expanded | Absorbs mermaid plugin; string preprocessing already internal |
+| `notro-ui` | Unchanged | Copy-and-own CLI, independent concern |
+| `create-notro` | Updated | Import paths in scaffolded templates change |
+| `notro-md-sync` | Unchanged | Separate use case |
+| `remark-notro` | Deprecated | Publish one final shim release pointing to `notro-loader`; archive repo dir |
+| `rehype-beautiful-mermaid` | Deprecated | Publish one final shim release pointing to `notro-loader/mermaid`; archive repo dir |
+
+## Steps
+
+### 1. Absorb mermaid plugin into notro-loader
+
+- Copy `packages/rehype-beautiful-mermaid/src/satteri-mermaid.ts` into `packages/notro-loader/src/mermaid.ts`
+- Add `./mermaid` entry point to `packages/notro-loader/package.json` exports:
+  ```json
+  "./mermaid": "./mermaid.ts"
+  ```
+- Add root `packages/notro-loader/mermaid.ts` barrel file
+- Remove `rehype-beautiful-mermaid` from `templates/blog/package.json`; update `astro.config.mjs` import:
+  ```diff
+  -import { satteriMermaidPlugin } from "rehype-beautiful-mermaid/satteri";
+  +import { satteriMermaidPlugin } from "notro-loader/mermaid";
+  ```
+- `hast-util-from-html-isomorphic`, `hast-util-to-string`, `unist-util-visit` that were in rehype-beautiful-mermaid's deps: move to notro-loader deps if not already there
+
+### 2. Confirm string preprocessing is already fully internal
+
+`preprocessNotionMarkdown` / `applyMdxContext` already live in `notro-loader/src/utils/notion-preprocess.ts` (Fix 0–19). The `remark-notro` package has an older fork (Fix 0–9). No code needs to move — just deprecate.
+
+### 3. Verify notroCalloutPlugin stays in notro-loader
+
+Already at `notro-loader/src/utils/satteri-plugins.ts`. No movement needed.
+
+### 4. Deprecate remark-notro
+
+- Publish a final `remark-notro@0.0.12` whose `index.ts` re-exports from `notro-loader` with a deprecation warning comment, or simply publish with `npm deprecate remark-notro "Merged into notro-loader. Import preprocessNotionMarkdown from notro-loader/utils."`
+- Update `packages/remark-nfm/package.json` with `"deprecated": "Merged into notro-loader"`
+- Archive `packages/remark-nfm/` (move to `packages/archive/remark-nfm/` or add a root-level `DEPRECATED.md`)
+
+### 5. Deprecate rehype-beautiful-mermaid
+
+- Publish a final `rehype-beautiful-mermaid@0.2.0` that re-exports from `notro-loader/mermaid`
+- `npm deprecate rehype-beautiful-mermaid "Merged into notro-loader. Import satteriMermaidPlugin from notro-loader/mermaid."`
+- Archive `packages/rehype-beautiful-mermaid/`
+
+### 6. Update downstream
+
+- `templates/blog/astro.config.mjs` — update import (step 1 above)
+- `create-notro` scaffolded templates — update any template files that reference the deprecated packages
+- `CLAUDE.md` — package table, "Published packages" changeset list, example usage in `notro()` section (covered by TASK-20)
+
+### 7. Changesets
+
+- `notro-loader` **minor** — new `./mermaid` entry point added
+- `remark-notro` **patch** — deprecation notice only
+- `rehype-beautiful-mermaid` **patch** — deprecation re-export shim
+
+## API surface post-consolidation
+
+```ts
+// Main content loader + components (unchanged)
+import { loader, NotroContent } from "notro-loader";
+
+// Astro integration (unchanged)
+import { notro } from "notro-loader/integration";
+
+// Pure TS utilities (unchanged)
+import { preprocessNotionMarkdown } from "notro-loader/utils";
+
+// Mermaid plugin (NEW entry point, replaces rehype-beautiful-mermaid/satteri)
+import { satteriMermaidPlugin } from "notro-loader/mermaid";
+```
+
+## Acceptance criteria
+
+- [ ] `notro-loader/mermaid` entry point exports `satteriMermaidPlugin` (same API as `rehype-beautiful-mermaid/satteri`)
+- [ ] Blog template builds with updated imports; Mermaid diagrams render (visually verified via `pnpm --filter notro-blog run preview`)
+- [ ] `rehype-beautiful-mermaid` removed from `templates/blog/package.json` and workspace install
+- [ ] `remark-notro` and `rehype-beautiful-mermaid` marked deprecated in their respective `package.json`
+- [ ] `pnpm run build` passes; `pnpm --filter notro-loader test` (46 tests) green
+- [ ] Changesets created for all three packages
+- [ ] CLAUDE.md package table and usage examples updated (or deferred to TASK-20)
+
diff --git a/backlog/tasks/task-22 - Add-PR-validation-CI-workflow.md b/backlog/tasks/task-22 - Add-PR-validation-CI-workflow.md
new file mode 100644
index 00000000..b90f4e85
--- /dev/null
+++ b/backlog/tasks/task-22 - Add-PR-validation-CI-workflow.md	
@@ -0,0 +1,64 @@
+---
+id: TASK-22
+title: Add PR validation CI workflow (test, type-check, build)
+status: To Do
+assignee: []
+created_date: '2026-06-10'
+labels: [ci, chore]
+dependencies: []
+priority: high
+ordinal: 22000
+---
+
+## Description
+
+
+## Problem
+
+The repository has only one CI workflow: `.github/workflows/release.yml` (changeset publish on push to `main`). There is no workflow that runs on pull requests. As a result:
+
+- Type errors and test failures can reach `main` undetected
+- The build (`astro check + astro build`) is never verified on PRs
+- The first signal of a broken build is a failed Vercel deployment (already happened for `notro-blank`, `notro-basics`, `notro-gallery` in this session)
+
+## Proposed workflow: `.github/workflows/ci.yml`
+
+```yaml
+on:
+  push:
+    branches: [main]
+  pull_request:
+    branches: [main]
+
+jobs:
+  test:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+      - uses: pnpm/action-setup@v4
+      - uses: actions/setup-node@v4
+        with:
+          node-version: '>=24.8.0'
+          cache: pnpm
+      - run: pnpm install --frozen-lockfile
+      - run: pnpm test                          # vitest (notro-loader 46 tests)
+      - run: pnpm --filter notro-blog run build # astro check + astro build
+    env:
+      NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
+      NOTION_DATASOURCE_ID_BLOG: ${{ secrets.NOTION_DATASOURCE_ID_BLOG }}
+```
+
+Notes:
+- `pnpm run build` at root already delegates to `pnpm --filter notro-blog run build` which runs `astro check` (TypeScript) + `astro build`
+- Notion secrets are required for the build; add them to GitHub Actions environment or use a cached data-store artifact strategy
+- Alternatively, run `astro check` only (type-check without fetching live Notion data) as a cheaper gate
+
+## Acceptance criteria
+
+- [ ] `.github/workflows/ci.yml` created and passing on the `main` branch
+- [ ] Workflow triggers on PRs to `main`
+- [ ] `pnpm test` (vitest) passes in CI
+- [ ] TypeScript check (`astro check` or `tsc --noEmit`) passes in CI
+- [ ] Build step passes (either with real Notion data via secrets, or with a recorded data-store fixture)
+- [ ] README or CONTRIBUTING note on how to run the same checks locally
+
diff --git a/backlog/tasks/task-23 - Add-unit-tests-for-notro-loader-utils-public-API.md b/backlog/tasks/task-23 - Add-unit-tests-for-notro-loader-utils-public-API.md
new file mode 100644
index 00000000..f22c0ef3
--- /dev/null
+++ b/backlog/tasks/task-23 - Add-unit-tests-for-notro-loader-utils-public-API.md	
@@ -0,0 +1,38 @@
+---
+id: TASK-23
+title: Add unit tests for notro-loader/utils public API (getPlainText, getMultiSelect, hasTag, buildLinkToPages)
+status: To Do
+assignee: []
+created_date: '2026-06-10'
+labels: [test, chore]
+dependencies: []
+priority: medium
+ordinal: 23000
+---
+
+## Description
+
+
+## Problem
+
+The `notro-loader/utils` entry point exports four public functions used in every blog template page:
+
+- `getPlainText(property)` — extracts plain text from any Notion property type
+- `getMultiSelect(property)` — returns multi-select options array
+- `hasTag(property, tagName)` — checks for a tag in a multi_select property
+- `buildLinkToPages(entries, options)` — builds the `linkToPages` map for inter-page link resolution
+
+These functions have **zero tests**. They contain non-trivial conditional logic (10+ type branches in `getPlainText` alone) and are called in every template's `.astro` pages. Regressions here would silently produce broken slug resolution, missing tags, or broken page links without any CI signal.
+
+The related functions `normalizeNotionPresignedUrl` and `markdownHasPresignedUrls` in `notion-url.ts` are already covered by `notion-url.test.ts` (24 tests). This task fills the remaining gap.
+
+## Acceptance criteria
+
+- [ ] `packages/notro-loader/src/utils/notion.test.ts` created alongside `notion.ts`
+- [ ] `getPlainText` — at least one test per property type: `rich_text`, `title`, `select`, `multi_select`, `number`, `url`, `email`, `phone_number`, `date`, `unique_id` (with and without prefix), and `undefined`/empty cases
+- [ ] `getMultiSelect` — tests for `multi_select` property, non-`multi_select` type, and `undefined`
+- [ ] `hasTag` — tests for tag present, tag absent, non-`multi_select` type, and `undefined`
+- [ ] `buildLinkToPages` — tests for normal mapping, duplicate ID warning (spy on `console.warn`), empty array
+- [ ] All 46 existing notro-loader tests still pass after adding the new file
+- [ ] No new dependencies required (vitest is already in `devDependencies`)
+
diff --git a/backlog/tasks/task-24 - Eliminate-as-any-casts-in-loader-files-property-check.md b/backlog/tasks/task-24 - Eliminate-as-any-casts-in-loader-files-property-check.md
new file mode 100644
index 00000000..64880284
--- /dev/null
+++ b/backlog/tasks/task-24 - Eliminate-as-any-casts-in-loader-files-property-check.md	
@@ -0,0 +1,46 @@
+---
+id: TASK-24
+title: Eliminate `as any` casts in loader.ts files-property URL expiry check
+status: To Do
+assignee: []
+created_date: '2026-06-10'
+labels: [chore, type-safety]
+dependencies: []
+priority: low
+ordinal: 24000
+---
+
+## Description
+
+
+## Problem
+
+`packages/notro-loader/src/loader/loader.ts` lines 59–63 use two `as any[]` casts to iterate over `files`-type Notion properties:
+
+```typescript
+for (const prop of Object.values(data.properties ?? {}) as any[]) {
+  if (prop?.type !== "files") continue;
+  const files = prop.files as any[];
+  if (files?.some((f: any) => f.type === "file" && isPresignedUrlExpired(f.file.url)))
+```
+
+`data.properties` is typed as `PageWithMarkdownType["properties"]`, which comes from `pageObjectResponseSchema` in `schema.ts`. `PropertyPageObjectResponseType` is a discriminated union on `type`. After the `if (prop?.type !== "files") continue;` guard, TypeScript should be able to narrow `prop` to the `files` variant — but only if the Zod-inferred type is structured as a proper discriminated union.
+
+The `filesPropertyPageObjectResponseSchema` already exists in `schema.ts` (line ~575) and defines the files array type. The `as any` casts bypass this type information entirely, hiding potential type mismatches.
+
+## Steps
+
+1. Check whether `PropertyPageObjectResponseType` is a `z.union([...])` of discriminated objects in `schema.ts` — if so, TypeScript can narrow after the `type !== "files"` guard with no change
+2. If the union is not discriminated (e.g. wrapped in `z.record` that loses type info), extract the `files` variant type explicitly and use a type predicate or a cast to the specific schema type instead of `any[]`
+3. Remove the two `// eslint-disable-next-line @typescript-eslint/no-explicit-any` comments
+
+## Context
+
+The `@ts-expect-error` at `remark-nfm/nfm.ts:53` (for `this` binding in a unified plugin) and the `as any` at `integration.ts:122` (Astro's `updateConfig` type accepts `any[]` but the type definition is wrong) are separate issues — both are legitimate TypeScript limitations that cannot be resolved without upstream changes. This task is scoped only to `loader.ts`.
+
+## Acceptance criteria
+
+- [ ] `loader.ts` lines 59–63: no `as any[]` or `as any` casts remain
+- [ ] The change compiles with `tsc --noEmit` (or `pnpm run build`)
+- [ ] All 46 existing notro-loader tests still pass
+
diff --git a/backlog/tasks/task-25 - Deduplicate-withRetry-and-fix-silent-drops-in-live-loader.md b/backlog/tasks/task-25 - Deduplicate-withRetry-and-fix-silent-drops-in-live-loader.md
new file mode 100644
index 00000000..a7325782
--- /dev/null
+++ b/backlog/tasks/task-25 - Deduplicate-withRetry-and-fix-silent-drops-in-live-loader.md	
@@ -0,0 +1,49 @@
+---
+id: TASK-25
+title: Deduplicate withRetry() and fix silent page drops in live-loader
+status: To Do
+assignee: []
+created_date: '2026-06-10'
+labels: [refactor, reliability]
+dependencies: []
+priority: medium
+ordinal: 25000
+---
+
+## Description
+
+
+## Problem
+
+`withRetry()` is implemented twice with diverging behavior:
+
+| Location | Logger | Signature |
+|---|---|---|
+| `loader.ts:107–138` | `AstroIntegrationLogger` — logs each retry | `withRetry(label, fn, logger)` |
+| `live-loader.ts:33–56` | None — silent | `withRetry(fn)` |
+
+If the retry backoff formula (`RETRY_DELAYS_MS = [1000, 2000, 4000]`) or the retryable status-code list changes, both files must be updated independently. Any divergence is invisible to CI.
+
+Additionally, error handling is inconsistent between the two loaders:
+
+- **`loader.ts`** (build path): catches API errors, logs a warning per page, and continues the build.
+- **`live-loader.ts`** (SSR/live path): catches all errors and returns `null` silently (`catch { return null; }`). Users have no way to know why a page disappeared from live results without checking raw server logs.
+
+## Steps
+
+1. Extract `withRetry()` to a shared utility in `packages/notro-loader/src/utils/retry.ts`
+   - Signature: `withRetry(fn: () => Promise, opts: { label?: string; logger?: { warn: (m: string) => void }; delays?: number[] }): Promise`
+   - `delays` defaults to `[1000, 2000, 4000]`
+   - Logs retries only when `opts.logger` is provided
+2. Replace both implementations with `import { withRetry } from '../utils/retry.ts'`
+3. Add at least `warn` logging to `live-loader.ts`'s `fetchPageWithMarkdown` catch block so page failures are visible
+4. Add unit tests for `withRetry` in `retry.test.ts` (mock timers with vitest `vi.useFakeTimers()`)
+
+## Acceptance criteria
+
+- [ ] Single `withRetry()` implementation in `src/utils/retry.ts`
+- [ ] `loader.ts` and `live-loader.ts` both use the shared implementation
+- [ ] Live-loader logs a warning when a page fetch fails (not silent anymore)
+- [ ] `retry.test.ts` covers: successful first try, retry on 429/500/503, non-retryable failure on 401/403/404, exhausted retries throw
+- [ ] All 46 existing notro-loader tests still pass
+
diff --git a/backlog/tasks/task-3 - Track-astrojs-mdx-API-removal-in-Astro-8.0.md b/backlog/tasks/task-3 - Track-astrojs-mdx-API-removal-in-Astro-8.0.md
new file mode 100644
index 00000000..7856cf01
--- /dev/null
+++ b/backlog/tasks/task-3 - Track-astrojs-mdx-API-removal-in-Astro-8.0.md	
@@ -0,0 +1,33 @@
+---
+id: TASK-3
+title: Track @astrojs/mdx API removal in Astro 8.0
+status: Done
+assignee: []
+created_date: '2026-06-06 00:13'
+labels: []
+dependencies: [TASK-1]
+priority: medium
+ordinal: 3000
+---
+
+## Description
+
+
+## Background
+
+Astro 8.0 will remove the deprecated top-level plugin options from both `markdown.*` and `@astrojs/mdx`:
+- `markdown.remarkPlugins`
+- `markdown.rehypePlugins`
+- `markdown.remarkRehype`
+- `markdown.gfm`
+- `markdown.smartypants`
+
+TASK-1 migrates notro ahead of this removal. This task tracks the Astro 8.0 release and ensures notro-loader is compatible before the major version lands.
+
+## Acceptance criteria
+
+- [x] Monitor Astro 8.0 release notes and changelog — TASK-1 fully migrated notro to `processor: unified()`, completing the migration to the new API before Astro 8.0 removes the deprecated options.
+- [x] Verify `pnpm run build` still passes — confirmed passing on Astro 6.4.4 with the new `processor: unified()` API (no deprecated options in use).
+- [x] Update peer dependency in `packages/notro-loader/package.json` if needed — upgraded to `astro: ^6.4.4` and `@astrojs/mdx: ^6.0.2`.
+- [x] Check if `@astrojs/mdx` v7+ introduces any further breaking changes to the `processor` API — migration is complete; notro no longer uses any deprecated APIs that would break in Astro 8.0.
+
diff --git "a/backlog/tasks/task-4 - Port-remarkNfm-to-S\303\244tteri-MDASTP-plugin.md" "b/backlog/tasks/task-4 - Port-remarkNfm-to-S\303\244tteri-MDASTP-plugin.md"
new file mode 100644
index 00000000..d3bf93f6
--- /dev/null
+++ "b/backlog/tasks/task-4 - Port-remarkNfm-to-S\303\244tteri-MDASTP-plugin.md"	
@@ -0,0 +1,106 @@
+---
+id: TASK-4
+title: Port remarkNfm to Sätteri MDASTP plugin
+status: Done
+assignee: []
+created_date: '2026-06-06 00:14'
+labels: []
+dependencies: []
+priority: low
+ordinal: 4000
+---
+
+## Description
+
+
+## Background
+
+When Sätteri becomes the default Astro processor, `@astrojs/mdx` will use Sätteri's pipeline for static `.mdx` files. notro currently adds `remarkNfm` to `@astrojs/mdx`, which needs to be ported for Sätteri compatibility.
+
+**Important: scope is static `.mdx` files only.** The Notion content path (`compileMdxForAstro()` → `@mdx-js/mdx`'s `evaluate()`) is completely independent of `@astrojs/mdx` and stays on unified permanently. Only the processing of user-authored `.mdx` files is affected.
+
+## Architecture: why most fixes can't be MDASTP plugins
+
+`transformer.ts` contains `preprocessNotionMarkdown()`, which runs **before the Markdown parser** (it patches `self.parser` in unified). It consists of **Fix 0–Fix 15 string-level regex transformations** applied to raw Markdown text.
+
+Sätteri's MDASTP plugin API runs **after** the Rust parser. There is no pre-parse hook equivalent. However, these fixes exist to handle Notion API's quirky output — and for user-authored `.mdx` files, they are **unnecessary** (users write well-formed Markdown, not Notion API output).
+
+Therefore:
+- **`preprocessNotionMarkdown()` does NOT need to be ported** for the `@astrojs/mdx` Sätteri path
+- Only the **MDAST-level callout conversion** in `nfm.ts` needs porting
+
+## What actually needs porting
+
+### 1. Callout conversion (MDAST-level, must be ported)
+
+`nfm.ts` transforms `containerDirective` nodes named `"callout"` into `` hast elements:
+
+```ts
+// Current remark transform (nfm.ts:111-131)
+visit(tree, "containerDirective", (node) => {
+  if (node.name !== "callout") return;
+  node.data = {
+    hName: "callout",
+    hProperties: { color: attrs.color, icon: attrs.icon },
+  };
+});
+```
+
+Sätteri has native directive support (`features: { directive: true }`), so `:::callout{...}` is parsed. The MDASTP visitor needs to rename it to a `` element:
+
+```ts
+import { defineMdastPlugin } from 'satteri'; // or '@astrojs/markdown-satteri'
+
+const notroCalloutPlugin = defineMdastPlugin({
+  name: 'notro-callout',
+  containerDirective(node, ctx) {
+    if (node.name !== 'callout') return;
+    // Return as rawHtml or use ctx to rename the node
+    const attrs = node.attributes ?? {};
+    const attrStr = [
+      attrs.color ? `color="${attrs.color}"` : '',
+      attrs.icon  ? `icon="${attrs.icon}"`   : '',
+    ].filter(Boolean).join(' ');
+    return { rawHtml: `${node.children.map(/* serialize */...)}` };
+  },
+});
+```
+
+### 2. GFM strikethrough and task list items (native in Sätteri)
+
+`nfm.ts` adds `micromark-extension-gfm-strikethrough` and `micromark-extension-gfm-task-list-item` via `self.data()`. In Sätteri, these are **built-in** and enabled via the GFM feature flag — no porting needed.
+
+### 3. Flow-only directive restriction (verify or reimplement)
+
+`nfm.ts` removes the `text`-level directive trigger from the micromark directive extension to prevent `:` in time strings (e.g. `10:00`) from being mis-parsed as inline directives. Verify whether Sätteri's native directive support has the same issue and whether a workaround is needed.
+
+## Full fix inventory (transformer.ts)
+
+All 15 fixes in `preprocessNotionMarkdown()` are pre-parse string transforms — **none need porting** for the Sätteri `@astrojs/mdx` path:
+
+| Fix | Description | Needs porting? |
+|-----|-------------|----------------|
+| 0 | Escaped inline math `\$…\$` → `$…$` (migration) | No — user `.mdx` files don't have this |
+| 1 | `---` divider setext H2 prevention | No |
+| 2 | Callout HTML → directive syntax | No (handled by MDAST callout conversion above) |
+| 3 | Block-level color annotations → raw HTML `

` | No — this is MDAST handled by `rehypeNotionColorPlugin` | +| 4 | `` wrapping in `

` | No | +| 5 | Inline equation `$\`...\`$` → `$...$` | No | +| 6 | `` stripping | No | +| 7 | `` isolation | No | +| 8 | Block-level HTML closing tag blank line injection | No | +| 9 | Markdown links inside `` → `` | No | +| 10 | Tab-indented content inside `
`/`` dedent | No | +| 11 | LaTeX command backslash restore | No | +| 12 | Blockquote lazy continuation prevention | No | +| 13 | Single `\n` block boundary expansion to `\n\n` | No | +| 15 | `**bold**` → `` for CJK punctuation workaround | No | + +## Acceptance criteria + +- [ ] Sätteri MDASTP plugin for callout conversion implemented +- [ ] `:::callout{icon="..." color="..."}` in `.mdx` files renders correctly with Sätteri +- [ ] Verify Sätteri's directive support doesn't mis-parse `:` in time strings (e.g. `10:00`, `18:30`) +- [ ] GFM strikethrough and task list items verified working via Sätteri native GFM feature +- [ ] New package `packages/notro-satteri/` created or added as export in `remark-nfm` + diff --git "a/backlog/tasks/task-5 - Port-core-rehype-plugins-to-S\303\244tteri-HAST-plugins.md" "b/backlog/tasks/task-5 - Port-core-rehype-plugins-to-S\303\244tteri-HAST-plugins.md" new file mode 100644 index 00000000..22876e36 --- /dev/null +++ "b/backlog/tasks/task-5 - Port-core-rehype-plugins-to-S\303\244tteri-HAST-plugins.md" @@ -0,0 +1,82 @@ +--- +id: TASK-5 +title: Port core rehype plugins to Sätteri HAST plugins +status: Done +assignee: [] +created_date: '2026-06-06 00:14' +labels: [] +dependencies: [TASK-4] +priority: low +ordinal: 5000 +--- + +## Description + + +## Background + +The core rehype plugins in `packages/notro-loader/src/utils/mdx-pipeline.ts` need to be ported to Sätteri HAST plugin API for full Sätteri `@astrojs/mdx` compatibility. Depends on TASK-4. + +**Scope reminder**: This is for static `.mdx` file processing via `@astrojs/mdx`. The Notion content path (`evaluate()`) stays on unified and is unaffected. + +## Plugins: porting analysis + +| Plugin | Location | What it does | Porting approach | +|--------|----------|--------------|-----------------| +| `rehypeRaw` | external | Parses raw HTML strings into hast nodes | **Likely unnecessary** — Sätteri's Rust parser handles raw HTML natively. Verify. | +| `rehypeNotionColorPlugin` | `mdx-pipeline.ts` | `color=` attr on `

//` → Tailwind CSS classes | Port to Sätteri HAST plugin with `element.filter` | +| `rehypeBlockElementsPlugin` | `mdx-pipeline.ts` | Lowercase Notion block elements → PascalCase for MDX component map | **Needs research**: Sätteri uses `oxc` (not `acorn`) for MDX — verify whether the lowercase→PascalCase rename trick still works with oxc-based JSX compilation | +| `rehypeInlineMentionsPlugin` | `mdx-pipeline.ts` | `mention-user` etc. → `MentionUser` etc. | Same concern as `rehypeBlockElementsPlugin` | +| `rehypeSlug` | external | `id` attrs on h1–h4 | Port to Sätteri HAST plugin (straightforward) | +| `rehypeTocPlugin` | `mdx-pipeline.ts` | Populates `` with heading anchor links | Port to Sätteri HAST plugin; runs after slug plugin | +| `resolvePageLinksPlugin` | `mdx-pipeline.ts` | Resolves `notion.so` URLs using `linkToPages` map | **Requires design work** (see below) | + +## Key open question: PascalCase rename with oxc-based MDX + +`rehypeBlockElementsPlugin` renames `

` — MDX treats any tagged element with - * attributes as JSX, so the node type is mdxJsxFlowElement, not element. - * These nodes use `name` + `attributes[]` instead of `tagName` + `properties`. - * - * Handles: - * - Block-level:

, - * - Inline: , - */ -const rehypeNotionColorPlugin: Plugin<[], Root> = () => { - return (tree) => { - // Handle standard hast element nodes (produced by rehype-raw from raw HTML - // blocks — e.g. `

` that appears at block level without - // any other attributes that would trigger MDX JSX parsing) - visit(tree, 'element', (node: Element) => { - const props = node.properties ?? {}; - const color = props.color; - const isBlockEl = /^(p|h[1-6])$/.test(node.tagName); - const isSpan = node.tagName === 'span'; - - if (!isBlockEl && !isSpan) return; - - // Convert color attribute to CSS class - if (typeof color === 'string') { - const cls = notionColorToClass(color); - delete props.color; - appendClass(props, cls); - node.properties = props; - } - - // Convert underline attribute on spans to CSS class - if (isSpan && (props.underline === 'true' || props.underline === true)) { - delete props.underline; - appendClass(props, 'underline'); - node.properties = props; - } - }); - - // Handle MDX JSX nodes (mdxJsxFlowElement / mdxJsxTextElement). - // @mdx-js/mdx parses any HTML element with attributes (e.g. `

`) - // as a JSX element. These nodes use `name` + `attributes[]` (array of - // {type:'mdxJsxAttribute', name, value}) instead of `tagName` + `properties`. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - visit(tree, (node: any) => { - if (node.type !== 'mdxJsxFlowElement' && node.type !== 'mdxJsxTextElement') return; - const name: string = node.name ?? ''; - const isBlockEl = /^(p|h[1-6])$/.test(name); - const isSpan = name === 'span'; - - if (!isBlockEl && !isSpan) return; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const attrs: any[] = Array.isArray(node.attributes) ? node.attributes : []; - const classesToAdd: string[] = []; - - // Filter out color/underline attributes, collecting their values - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const filteredAttrs = attrs.filter((attr: any) => { - if (attr.type !== 'mdxJsxAttribute') return true; - if (attr.name === 'color') { - const cls = notionColorToClass(String(attr.value ?? '')); - if (cls) classesToAdd.push(cls); - return false; - } - if (isSpan && attr.name === 'underline' && String(attr.value) === 'true') { - classesToAdd.push('underline'); - return false; - } - return true; - }); - - if (classesToAdd.length === 0) return; - - // Append to existing class attribute or add a new one - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const classAttr = filteredAttrs.find((attr: any) => - attr.type === 'mdxJsxAttribute' && (attr.name === 'class' || attr.name === 'className'), - ); - if (classAttr) { - classAttr.value = [String(classAttr.value ?? ''), ...classesToAdd].filter(Boolean).join(' '); - } else { - filteredAttrs.push({ type: 'mdxJsxAttribute', name: 'class', value: classesToAdd.join(' ') }); - } - - node.attributes = filteredAttrs; - }); - }; -}; - -// ── Notion element name → PascalCase component name mapping ────────────── -// -// MDX's component-substitution rule: -// - PascalCase names → component variable: _jsx(Video, ...) ← components map IS consulted -// - lowercase names → HTML string: _jsx("video", ...) ← components map is IGNORED -// -// This applies to ALL elements in the MDX compile tree, whether they come -// from the MDX source, remark plugins, or raw HTML processed by rehype-raw. -// Elements from raw HTML (Notion markdown) end up as `mdxJsxFlowElement` -// nodes with their original lowercase names. Renaming them to PascalCase here -// enables the `components` prop to substitute them with Astro components. -// -// There are two sets of renames: -// 1. NOTION_BLOCK_RENAMES — block-level elements (mdxJsxFlowElement) -// 2. NOTION_MENTION_RENAMES — inline mention elements (mdxJsxTextElement) - -// Block-level Notion elements from raw HTML in markdown. -// The target PascalCase names must match keys in defaultComponents / notroComponents. -const NOTION_BLOCK_RENAMES = new Map([ - ['table_of_contents', 'TableOfContents'], - ['video', 'Video'], - ['audio', 'Audio'], - ['file', 'FileBlock'], - ['pdf', 'PdfBlock'], - ['columns', 'Columns'], - ['column', 'Column'], - ['page', 'PageRef'], - ['database', 'DatabaseRef'], - ['details', 'Details'], - ['summary', 'Summary'], - ['empty-block', 'EmptyBlock'], - // Table elements — Notion outputs raw ...
HTML. - // Renaming to PascalCase enables the `components` prop to override them. - ['table', 'TableBlock'], - ['thead', 'TableHead'], - ['tbody', 'TableBody'], - ['colgroup', 'TableColgroup'], - ['col', 'TableCol'], - ['tr', 'TableRow'], - ['th', 'TableHeaderCell'], - ['td', 'TableCell'], -]); - -/** - * Rehype plugin: renames Notion block-level elements from lowercase to - * PascalCase so MDX generates a components-map lookup instead of a - * plain HTML string. - * - * Notion block elements (video, audio, table_of_contents, columns, etc.) - * arrive as `mdxJsxFlowElement` nodes — the MDX JSX parser processes all - * inline/block HTML as JSX. With lowercase names, MDX compiles them as - * `_jsx("video", ...)` (literal string), which bypasses the `components` - * prop entirely. Renaming to PascalCase makes MDX emit `_jsx(Video, ...)`, - * which looks up `_components.Video` at runtime. - * - * Must run before rehypeSlug and rehypeTocPlugin. Component keys in - * defaultComponents / notroComponents must use the same PascalCase names. - */ -const rehypeBlockElementsPlugin: Plugin<[], Root> = () => { - return (tree) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - visit(tree, (node: any) => { - // Block elements appear as mdxJsxFlowElement at the top level, - // but may appear as mdxJsxTextElement when consecutive blocks appear - // without blank lines in the Notion markdown (grouped into a

). - if (node.type !== 'mdxJsxFlowElement' && node.type !== 'mdxJsxTextElement') return; - const renamed = NOTION_BLOCK_RENAMES.get(node.name); - if (renamed) node.name = renamed; - }); - }; -}; - -// Inline mention elements from Notion markdown. -// Hyphenated-lowercase names also compile as plain HTML strings in MDX. -const NOTION_MENTION_RENAMES = new Map([ - ['mention-user', 'MentionUser'], - ['mention-page', 'MentionPage'], - ['mention-database', 'MentionDatabase'], - ['mention-data-source', 'MentionDataSource'], - ['mention-agent', 'MentionAgent'], - ['mention-date', 'MentionDate'], -]); - -/** - * Rehype plugin: renames Notion inline mention elements from hyphenated- - * lowercase (mention-user, mention-date…) to PascalCase (MentionUser, - * MentionDate…) so MDX generates a components-map lookup instead of a - * plain HTML string. - * - * Must run before hast-util-to-estree (i.e. before @mdx-js/mdx compiles - * the tree). Component keys in defaultComponents / notroComponents must - * use the same PascalCase names. - */ -const rehypeInlineMentionsPlugin: Plugin<[], Root> = () => { - return (tree) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - visit(tree, (node: any) => { - // Notion mentions come through as mdxJsxTextElement nodes because - // MDX's JSX parser processes inline HTML like - if (node.type !== 'mdxJsxTextElement' && node.type !== 'mdxJsxFlowElement') return; - const renamed = NOTION_MENTION_RENAMES.get(node.name); - if (renamed) node.name = renamed; - }); - }; -}; - - - -function resolveNotionUrl( - url: string, - linkToPages: LinkToPages, -): { href: string; isExternal: boolean } { - // Notion URLs end with the page ID (32-char hex, with or without dashes). - // Example: https://www.notion.so/My-Page-Title-abc123def456... - // Strip dashes from both the URL and the ID, then check whether the URL - // ends with the normalised ID. Using endsWith() instead of includes() - // prevents a shorter ID from matching a different longer ID that happens - // to contain it as a substring (e.g. "abc" matching "abc123"). - const urlNoDash = url.replace(/-/g, ''); - for (const [pageId, info] of Object.entries(linkToPages)) { - const idNoDash = pageId.replace(/-/g, ''); - if (urlNoDash === idNoDash || urlNoDash.endsWith(idNoDash)) { - return { href: `/${info.url}`, isExternal: false }; - } - } - return { href: url, isExternal: true }; -} - -type ResolveOptions = { linkToPages: LinkToPages }; - -/** Read the `url` attribute value from an mdxJsxFlowElement/mdxJsxTextElement. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function getUrlFromMdxJsx(node: any): string | undefined { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const attr = node.attributes?.find((a: any) => a.type === 'mdxJsxAttribute' && a.name === 'url'); - return typeof attr?.value === 'string' ? attr.value : undefined; -} - -/** Set the `url` attribute on an mdxJsxFlowElement/mdxJsxTextElement. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function setUrlOnMdxJsx(node: any, href: string): void { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const attr = node.attributes?.find((a: any) => a.type === 'mdxJsxAttribute' && a.name === 'url'); - if (attr) { - attr.value = href; - } else { - node.attributes = [...(node.attributes ?? []), { type: 'mdxJsxAttribute', name: 'url', value: href }]; - } -} - -/** - * Rehype plugin: resolves Notion page/database URLs in hast elements. - * Handles , , , , and . - * - * Notion page/database block elements (, ) come through as - * regular hast `element` nodes. Inline mention elements come through as - * mdxJsxTextElement nodes (renamed to MentionPage etc. by - * rehypeInlineMentionsPlugin which runs before this plugin). - */ -const resolvePageLinksPlugin: Plugin<[ResolveOptions], Root> = (options) => { - const { linkToPages } = options; - return (tree) => { - // Handle hast elements (standard links to Notion pages). - visit(tree, 'element', (node: Element) => { - if (node.tagName === 'a') { - const rawHref = node.properties?.href; - const href = typeof rawHref === 'string' ? rawHref : undefined; - if (href?.includes('notion.so')) { - const { href: resolved, isExternal } = resolveNotionUrl(href, linkToPages); - if (!isExternal) { - node.properties = { ...node.properties, href: resolved }; - } - } - } - }); - - // Handle MDX JSX nodes for page/database references and inline mentions. - // By the time this plugin runs, rehypeBlockElementsPlugin has renamed: - // page → PageRef, database → DatabaseRef - // And rehypeInlineMentionsPlugin has renamed: - // mention-page → MentionPage, mention-database → MentionDatabase - // eslint-disable-next-line @typescript-eslint/no-explicit-any - visit(tree, (node: any) => { - if (node.type !== 'mdxJsxTextElement' && node.type !== 'mdxJsxFlowElement') return; - if ( - node.name !== 'PageRef' && - node.name !== 'DatabaseRef' && - node.name !== 'MentionPage' && - node.name !== 'MentionDatabase' - ) return; - const url = getUrlFromMdxJsx(node); - if (url) { - const { href } = resolveNotionUrl(url, linkToPages); - setUrlOnMdxJsx(node, href); - } - }); - }; -}; - -// ── TOC population ───────────────────────────────────────────────────────── - -/** - * Rehype plugin: populates elements with anchor links - * generated from all h1–h4 headings in the document. - * - * Must run AFTER rehype-slug so that headings already have id attributes. - * Performs a two-pass traversal: - * 1. Collect every h1–h4 that has an id (added by rehype-slug). - * 2. Replace the children of each with a