From 1b6c7542d787ed7ae83bdee9ffecb6b98632d62e Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Wed, 8 Jul 2026 00:01:21 +0200 Subject: [PATCH] feat(notes): render macroplan code blocks as a delivery plan grid 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. --- src/components/FluxNote.vue | 3 +- src/components/StackedNote.vue | 1 + src/components/StackedPublicNote.vue | 3 +- src/hooks/useMarkdown.hook.ts | 32 +++++++++++++ src/hooks/useMarkdownPostRender.hook.ts | 6 +++ src/modules/macroplan/runMacroplan.spec.ts | 40 ++++++++++++++++ src/modules/macroplan/runMacroplan.ts | 55 ++++++++++++++++++++++ src/styles/app.css | 17 +++++-- src/views/PublicNoteView.vue | 3 +- 9 files changed, 154 insertions(+), 6 deletions(-) create mode 100644 src/modules/macroplan/runMacroplan.spec.ts create mode 100644 src/modules/macroplan/runMacroplan.ts diff --git a/src/components/FluxNote.vue b/src/components/FluxNote.vue index 62752ea..cf31bad 100644 --- a/src/components/FluxNote.vue +++ b/src/components/FluxNote.vue @@ -62,7 +62,8 @@ const hasContent = computed(() => !!renderedContent.value) useMarkdownPostRender(renderedContent, () => ".note-display", { onReady: () => listenToClick(), - tikz: true + tikz: true, + macroplan: true }) watch( diff --git a/src/components/StackedNote.vue b/src/components/StackedNote.vue index dbfb72f..ec8bac6 100644 --- a/src/components/StackedNote.vue +++ b/src/components/StackedNote.vue @@ -201,6 +201,7 @@ const { mode, toggleMode } = useEditionMode() useMarkdownPostRender(content, () => `.note-${sha.value}`, { onReady: () => listenToClick(), tikz: true, + macroplan: true, mermaid: () => rawContent.value.includes("```mermaid"), shikiji: () => isMarkdown.value && rawContent.value.includes("```"), images: () => diff --git a/src/components/StackedPublicNote.vue b/src/components/StackedPublicNote.vue index a3d2609..ddcad0f 100644 --- a/src/components/StackedPublicNote.vue +++ b/src/components/StackedPublicNote.vue @@ -79,7 +79,8 @@ const content = computed(() => useMarkdownPostRender(content, () => `.note-${classNameId.value}`, { onReady: () => listenToClick(), - tikz: true + tikz: true, + macroplan: true }) diff --git a/src/hooks/useMarkdown.hook.ts b/src/hooks/useMarkdown.hook.ts index 32251ae..0eca1d4 100644 --- a/src/hooks/useMarkdown.hook.ts +++ b/src/hooks/useMarkdown.hook.ts @@ -95,6 +95,37 @@ const markdownItTikzExtractor = (md: MarkdownIt) => { } } +const markdownItMacroplanExtractor = (md: MarkdownIt) => { + const defaultFence = + md.renderer.rules.fence || + function ( + tokens: Array, + index: number, + options: Options, + _: unknown, + self: Renderer + ) { + return self.renderToken(tokens, index, options) + } + + md.renderer.rules.fence = function ( + tokens: Array, + index: number, + options: Options, + env: unknown, + self: Renderer + ) { + const token = tokens[index] + + if (token.info.trim() === "macroplan") { + const encoded = encodeUTF8ToBase64(token.content) + return `
Rendering macroplan…
\n` + } + + return defaultFence(tokens, index, options, env, self) + } +} + const slugger = new GithubSlugger() let tabGroupCounter = 0 @@ -107,6 +138,7 @@ const md = new MarkdownIt({ }) .use(markdownItMermaidExtractor) .use(markdownItTikzExtractor) + .use(markdownItMacroplanExtractor) .use(html5Media) .use(blockEmbedPlugin, { youtube: { diff --git a/src/hooks/useMarkdownPostRender.hook.ts b/src/hooks/useMarkdownPostRender.hook.ts index 63bd111..66efadf 100644 --- a/src/hooks/useMarkdownPostRender.hook.ts +++ b/src/hooks/useMarkdownPostRender.hook.ts @@ -6,11 +6,13 @@ import { runTikz, useShikiji } from "@/hooks/useMarkdown.hook" +import { runMacroplan } from "@/modules/macroplan/runMacroplan" import { attachSvgDownloads } from "@/utils/svgDownload" interface MarkdownPostRenderOptions { onReady?: () => void tikz?: boolean + macroplan?: boolean mermaid?: () => boolean shikiji?: () => boolean images?: () => string | null | undefined @@ -44,6 +46,10 @@ export const useMarkdownPostRender = ( renderJobs.push(runMermaid(`${scope} .mermaid`)) } + if (options.macroplan) { + renderJobs.push(runMacroplan(`${scope} .macroplan-block`)) + } + if (options.shikiji?.()) { void useShikiji() } diff --git a/src/modules/macroplan/runMacroplan.spec.ts b/src/modules/macroplan/runMacroplan.spec.ts new file mode 100644 index 0000000..5dfa531 --- /dev/null +++ b/src/modules/macroplan/runMacroplan.spec.ts @@ -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() + }) +}) diff --git a/src/modules/macroplan/runMacroplan.ts b/src/modules/macroplan/runMacroplan.ts new file mode 100644 index 0000000..df57bb6 --- /dev/null +++ b/src/modules/macroplan/runMacroplan.ts @@ -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 => { + const elements = Array.from( + document.querySelectorAll(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" + } + } +} diff --git a/src/styles/app.css b/src/styles/app.css index 6b62733..f2fe184 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -236,11 +236,20 @@ pre.tikz svg { } } -.tikz-loading { +.tikz-loading, +.macroplan-loading { opacity: 0.6; 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 { position: relative; display: inline-block; @@ -282,14 +291,16 @@ pre.tikz svg { filter: none; } -.tikz-error { +.tikz-error, +.macroplan-error { border-left: 3px solid var(--color-error); padding: 0.5rem 0.75rem; background: color-mix(in srgb, var(--color-error) 10%, transparent); text-align: left; } -.tikz-error pre { +.tikz-error pre, +.macroplan-error pre { white-space: pre-wrap; font-size: 0.85em; } diff --git a/src/views/PublicNoteView.vue b/src/views/PublicNoteView.vue index 1c0fb53..bd5c399 100644 --- a/src/views/PublicNoteView.vue +++ b/src/views/PublicNoteView.vue @@ -118,7 +118,8 @@ useResizeContainer("note-container", stackedNotes) useMarkdownPostRender(content, () => ".public-note-view .note-display", { onReady: () => listenToClick(), - tikz: true + tikz: true, + macroplan: true })