feat(notes): render macroplan code blocks as a delivery plan grid
All checks were successful
CI / verify (push) Successful in 2m9s

Follows the tikz pattern: the fence emits a base64 placeholder, then
the post-render hook lazily imports the parser and grid (separate
chunks) and mounts the grid only when a placeholder exists. Mono font
forced on the block because the flag-stacking math assumes Fira Code.
This commit is contained in:
Julien Calixte
2026-07-08 00:01:21 +02:00
parent 70f9a6b718
commit 1b6c7542d7
9 changed files with 154 additions and 6 deletions

View File

@@ -62,7 +62,8 @@ const hasContent = computed(() => !!renderedContent.value)
useMarkdownPostRender(renderedContent, () => ".note-display", { useMarkdownPostRender(renderedContent, () => ".note-display", {
onReady: () => listenToClick(), onReady: () => listenToClick(),
tikz: true tikz: true,
macroplan: true
}) })
watch( watch(

View File

@@ -201,6 +201,7 @@ const { mode, toggleMode } = useEditionMode()
useMarkdownPostRender(content, () => `.note-${sha.value}`, { useMarkdownPostRender(content, () => `.note-${sha.value}`, {
onReady: () => listenToClick(), onReady: () => listenToClick(),
tikz: true, tikz: true,
macroplan: true,
mermaid: () => rawContent.value.includes("```mermaid"), mermaid: () => rawContent.value.includes("```mermaid"),
shikiji: () => isMarkdown.value && rawContent.value.includes("```"), shikiji: () => isMarkdown.value && rawContent.value.includes("```"),
images: () => images: () =>

View File

@@ -79,7 +79,8 @@ const content = computed(() =>
useMarkdownPostRender(content, () => `.note-${classNameId.value}`, { useMarkdownPostRender(content, () => `.note-${classNameId.value}`, {
onReady: () => listenToClick(), onReady: () => listenToClick(),
tikz: true tikz: true,
macroplan: true
}) })
</script> </script>

View File

@@ -95,6 +95,37 @@ const markdownItTikzExtractor = (md: MarkdownIt) => {
} }
} }
const markdownItMacroplanExtractor = (md: MarkdownIt) => {
const defaultFence =
md.renderer.rules.fence ||
function (
tokens: Array<Token>,
index: number,
options: Options,
_: unknown,
self: Renderer
) {
return self.renderToken(tokens, index, options)
}
md.renderer.rules.fence = function (
tokens: Array<Token>,
index: number,
options: Options,
env: unknown,
self: Renderer
) {
const token = tokens[index]
if (token.info.trim() === "macroplan") {
const encoded = encodeUTF8ToBase64(token.content)
return `<div class="macroplan-block" data-macroplan-source="${encoded}"><span class="macroplan-loading">Rendering macroplan…</span></div>\n`
}
return defaultFence(tokens, index, options, env, self)
}
}
const slugger = new GithubSlugger() const slugger = new GithubSlugger()
let tabGroupCounter = 0 let tabGroupCounter = 0
@@ -107,6 +138,7 @@ const md = new MarkdownIt({
}) })
.use(markdownItMermaidExtractor) .use(markdownItMermaidExtractor)
.use(markdownItTikzExtractor) .use(markdownItTikzExtractor)
.use(markdownItMacroplanExtractor)
.use(html5Media) .use(html5Media)
.use(blockEmbedPlugin, { .use(blockEmbedPlugin, {
youtube: { youtube: {

View File

@@ -6,11 +6,13 @@ import {
runTikz, runTikz,
useShikiji useShikiji
} from "@/hooks/useMarkdown.hook" } from "@/hooks/useMarkdown.hook"
import { runMacroplan } from "@/modules/macroplan/runMacroplan"
import { attachSvgDownloads } from "@/utils/svgDownload" import { attachSvgDownloads } from "@/utils/svgDownload"
interface MarkdownPostRenderOptions { interface MarkdownPostRenderOptions {
onReady?: () => void onReady?: () => void
tikz?: boolean tikz?: boolean
macroplan?: boolean
mermaid?: () => boolean mermaid?: () => boolean
shikiji?: () => boolean shikiji?: () => boolean
images?: () => string | null | undefined images?: () => string | null | undefined
@@ -44,6 +46,10 @@ export const useMarkdownPostRender = (
renderJobs.push(runMermaid(`${scope} .mermaid`)) renderJobs.push(runMermaid(`${scope} .mermaid`))
} }
if (options.macroplan) {
renderJobs.push(runMacroplan(`${scope} .macroplan-block`))
}
if (options.shikiji?.()) { if (options.shikiji?.()) {
void useShikiji() void useShikiji()
} }

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest"
import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
import { runMacroplan } from "./runMacroplan"
const placeholder = (source: string): HTMLElement => {
const el = document.createElement("div")
el.className = "macroplan-block"
el.dataset.macroplanSource = encodeUTF8ToBase64(source)
document.body.appendChild(el)
return el
}
describe("runMacroplan", () => {
it("mounts the grid onto a valid placeholder", async () => {
const el = placeholder(
'title = "Plan"\n\n[[feature]]\nname = "Auth"\nstart = 2026-06-01\noriginal = 2026-06-15\n'
)
await runMacroplan(".macroplan-block")
expect(el.dataset.macroplanRendered).toBe("true")
expect(el.querySelector(".macroplan")).not.toBeNull()
expect(el.textContent).toContain("Auth")
el.remove()
})
it("renders the parse error inline for malformed sources", async () => {
const el = placeholder('[[feature]]\nname = "No dates"')
await runMacroplan(".macroplan-block")
expect(el.dataset.macroplanRendered).toBe("error")
expect(el.querySelector(".macroplan-error")?.textContent).toContain(
"missing `start`"
)
el.remove()
})
})

View File

@@ -0,0 +1,55 @@
import { createApp } from "vue"
import { decodeBase64ToUTF8 } from "@/utils/decodeBase64ToUTF8"
const renderMacroplanError = (el: HTMLElement, err: unknown): void => {
const msg = err instanceof Error ? err.message : String(err)
el.textContent = ""
const box = document.createElement("div")
box.className = "macroplan-error"
const title = document.createElement("strong")
title.textContent = "Macroplan error"
const detail = document.createElement("pre")
detail.textContent = msg
box.append(title, detail)
el.appendChild(box)
}
/**
* Mount a MacroplanGrid onto every ```macroplan placeholder in scope.
* The parser (smol-toml + valibot) and the grid component only load
* when a placeholder actually exists, so notes without a plan pay nothing.
*/
export const runMacroplan = async (querySelector: string): Promise<void> => {
const elements = Array.from(
document.querySelectorAll<HTMLElement>(querySelector)
).filter((el) => !el.dataset.macroplanRendered)
if (elements.length === 0) return
const [{ parseMacroplan }, { buildPlan }, { default: MacroplanGrid }] =
await Promise.all([
import("./model/parse"),
import("./model/plan"),
import("./components/MacroplanGrid.vue")
])
for (const el of elements) {
el.dataset.macroplanRendered = "pending"
const encoded = el.dataset.macroplanSource
if (!encoded) {
el.dataset.macroplanRendered = "error"
continue
}
try {
const plan = buildPlan(parseMacroplan(decodeBase64ToUTF8(encoded)))
el.textContent = ""
createApp(MacroplanGrid, { plan }).mount(el)
el.dataset.macroplanRendered = "true"
} catch (err) {
renderMacroplanError(el, err)
el.dataset.macroplanRendered = "error"
}
}
}

View File

@@ -236,11 +236,20 @@ pre.tikz svg {
} }
} }
.tikz-loading { .tikz-loading,
.macroplan-loading {
opacity: 0.6; opacity: 0.6;
font-style: italic; font-style: italic;
} }
/* The grid's milestone-flag stacking math assumes a monospace advance
(BAND_CHAR in MacroplanGrid.vue), and the macroplan app renders in
Fira Code — don't let the note's serif leak in. */
.macroplan-block {
font-family: "Fira Code", ui-monospace, SFMono-Regular, Menlo, Consolas,
monospace;
}
.svg-download-host { .svg-download-host {
position: relative; position: relative;
display: inline-block; display: inline-block;
@@ -282,14 +291,16 @@ pre.tikz svg {
filter: none; filter: none;
} }
.tikz-error { .tikz-error,
.macroplan-error {
border-left: 3px solid var(--color-error); border-left: 3px solid var(--color-error);
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
background: color-mix(in srgb, var(--color-error) 10%, transparent); background: color-mix(in srgb, var(--color-error) 10%, transparent);
text-align: left; text-align: left;
} }
.tikz-error pre { .tikz-error pre,
.macroplan-error pre {
white-space: pre-wrap; white-space: pre-wrap;
font-size: 0.85em; font-size: 0.85em;
} }

View File

@@ -118,7 +118,8 @@ useResizeContainer("note-container", stackedNotes)
useMarkdownPostRender(content, () => ".public-note-view .note-display", { useMarkdownPostRender(content, () => ".public-note-view .note-display", {
onReady: () => listenToClick(), onReady: () => listenToClick(),
tikz: true tikz: true,
macroplan: true
}) })
</script> </script>