feat(notes): render macroplan code blocks as a delivery plan grid
All checks were successful
CI / verify (push) Successful in 2m9s
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:
@@ -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(
|
||||||
|
|||||||
@@ -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: () =>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
40
src/modules/macroplan/runMacroplan.spec.ts
Normal file
40
src/modules/macroplan/runMacroplan.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
55
src/modules/macroplan/runMacroplan.ts
Normal file
55
src/modules/macroplan/runMacroplan.ts
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user