@kamishibai/sdk 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +192 -0
  2. package/package.json +54 -0
  3. package/src/blocks/board.js +210 -0
  4. package/src/blocks/callout.js +63 -0
  5. package/src/blocks/code.js +28 -0
  6. package/src/blocks/deck.js +76 -0
  7. package/src/blocks/diagram.js +265 -0
  8. package/src/blocks/element.js +51 -0
  9. package/src/blocks/graph.js +264 -0
  10. package/src/blocks/grid.js +156 -0
  11. package/src/blocks/index.js +106 -0
  12. package/src/blocks/list.js +50 -0
  13. package/src/blocks/placement.js +119 -0
  14. package/src/blocks/prose.js +28 -0
  15. package/src/blocks/quote.js +25 -0
  16. package/src/blocks/raw.js +47 -0
  17. package/src/blocks/registry.js +158 -0
  18. package/src/blocks/schema-parts.js +19 -0
  19. package/src/blocks/section.js +53 -0
  20. package/src/blocks/slide.js +104 -0
  21. package/src/blocks/stat.js +83 -0
  22. package/src/blocks/table.js +50 -0
  23. package/src/blocks/timeline.js +80 -0
  24. package/src/cli/commands/close.js +51 -0
  25. package/src/cli/commands/comments.js +73 -0
  26. package/src/cli/commands/debug.js +34 -0
  27. package/src/cli/commands/example.js +22 -0
  28. package/src/cli/commands/export.js +10 -0
  29. package/src/cli/commands/init.js +53 -0
  30. package/src/cli/commands/lint.js +93 -0
  31. package/src/cli/commands/list.js +54 -0
  32. package/src/cli/commands/open.js +31 -0
  33. package/src/cli/commands/promote.js +38 -0
  34. package/src/cli/commands/render.js +31 -0
  35. package/src/cli/commands/replay.js +49 -0
  36. package/src/cli/commands/schema.js +10 -0
  37. package/src/cli/commands/serve.js +178 -0
  38. package/src/cli/commands/setup.js +64 -0
  39. package/src/cli/commands/snapshot.js +29 -0
  40. package/src/cli/commands/templates.js +75 -0
  41. package/src/cli/deliver.js +54 -0
  42. package/src/cli/emit.js +29 -0
  43. package/src/cli/format.js +153 -0
  44. package/src/cli/index.js +365 -0
  45. package/src/cli/registry.js +18 -0
  46. package/src/core/blocks.js +147 -0
  47. package/src/core/diagram.js +282 -0
  48. package/src/core/errors.js +156 -0
  49. package/src/core/example.js +109 -0
  50. package/src/core/ir.js +62 -0
  51. package/src/core/lint-gates.js +427 -0
  52. package/src/core/lint.js +281 -0
  53. package/src/core/scan.js +84 -0
  54. package/src/core/schema.js +88 -0
  55. package/src/core/spec-check.js +33 -0
  56. package/src/core/validate.js +44 -0
  57. package/src/core/version.js +18 -0
  58. package/src/core/vocabulary.js +140 -0
  59. package/src/delivery/atomic.js +71 -0
  60. package/src/delivery/comments.js +180 -0
  61. package/src/delivery/home.js +70 -0
  62. package/src/delivery/open.js +31 -0
  63. package/src/delivery/project.js +141 -0
  64. package/src/delivery/read.js +109 -0
  65. package/src/delivery/run.js +95 -0
  66. package/src/delivery/scaffold-blueprints.js +728 -0
  67. package/src/delivery/store.js +219 -0
  68. package/src/delivery/template-extensions.js +183 -0
  69. package/src/delivery/template-format.js +112 -0
  70. package/src/delivery/template-package.js +376 -0
  71. package/src/delivery/template-promote.js +240 -0
  72. package/src/delivery/template-scaffold.js +181 -0
  73. package/src/delivery/templates.js +192 -0
  74. package/src/delivery/toml.js +195 -0
  75. package/src/delivery/write.js +35 -0
  76. package/src/export/browser.js +130 -0
  77. package/src/export/index.js +96 -0
  78. package/src/export/pdf.js +25 -0
  79. package/src/export/png.js +40 -0
  80. package/src/export/pptx.js +48 -0
  81. package/src/export/slides.js +33 -0
  82. package/src/export/snapshot.js +33 -0
  83. package/src/layouts/article.js +103 -0
  84. package/src/layouts/canvas.js +144 -0
  85. package/src/layouts/card.js +128 -0
  86. package/src/layouts/deck.js +88 -0
  87. package/src/layouts/index.js +90 -0
  88. package/src/layouts/one-page.js +161 -0
  89. package/src/layouts/registry.js +251 -0
  90. package/src/layouts/resume.js +172 -0
  91. package/src/layouts/template-index.js +78 -0
  92. package/src/parser/artifact.js +38 -0
  93. package/src/parser/container.js +103 -0
  94. package/src/parser/index.js +223 -0
  95. package/src/parser/tokens.js +265 -0
  96. package/src/render/board-filter.client.js +80 -0
  97. package/src/render/compile.js +29 -0
  98. package/src/render/context.js +98 -0
  99. package/src/render/element.js +32 -0
  100. package/src/render/fonts.js +129 -0
  101. package/src/render/graph-hover.client.js +148 -0
  102. package/src/render/html.js +52 -0
  103. package/src/render/index.js +241 -0
  104. package/src/render/measure.js +60 -0
  105. package/src/render/placement.js +136 -0
  106. package/src/render/playback.client.js +74 -0
  107. package/src/render/scale-to-fit.client.js +136 -0
  108. package/src/render/scale.js +41 -0
  109. package/src/render/skeleton.js +131 -0
  110. package/src/render/ssr.js +24 -0
  111. package/src/render/styles.js +56 -0
  112. package/src/render/templates.js +191 -0
  113. package/src/serve/daemon.js +117 -0
  114. package/src/serve/overlay.js +213 -0
  115. package/src/serve/protocol.js +36 -0
  116. package/src/serve/server.js +264 -0
  117. package/templates/kami/cards/components.js +40 -0
  118. package/templates/kami/cards/index.js +25 -0
  119. package/templates/kami/cards/manifest.js +67 -0
  120. package/templates/kami/cards/styles.css +389 -0
  121. package/templates/kami/long-form/components.js +102 -0
  122. package/templates/kami/long-form/index.js +25 -0
  123. package/templates/kami/long-form/manifest.js +87 -0
  124. package/templates/kami/long-form/styles.css +481 -0
  125. package/templates/kami/one-page/components.js +48 -0
  126. package/templates/kami/one-page/index.js +27 -0
  127. package/templates/kami/one-page/manifest.js +65 -0
  128. package/templates/kami/one-page/styles.css +375 -0
  129. package/templates/kami/resume/components.js +51 -0
  130. package/templates/kami/resume/index.js +27 -0
  131. package/templates/kami/resume/manifest.js +65 -0
  132. package/templates/kami/resume/styles.css +424 -0
  133. package/templates/kami/slides/components.js +41 -0
  134. package/templates/kami/slides/index.js +26 -0
  135. package/templates/kami/slides/manifest.js +64 -0
  136. package/templates/kami/slides/styles.css +406 -0
@@ -0,0 +1,223 @@
1
+ import MarkdownIt from 'markdown-it'
2
+ import matter from 'gray-matter'
3
+ import * as B from '../core/blocks.js'
4
+ import { CODES, validationError } from '../core/errors.js'
5
+ import {
6
+ DEFAULT_LAYOUT,
7
+ defaultTemplateOf,
8
+ layoutModules,
9
+ templateLayoutEntries,
10
+ } from '../layouts/index.js'
11
+ import { containerPlugin } from './container.js'
12
+ import { walkTokens, nestSections } from './tokens.js'
13
+
14
+ /** Verbatim — the root form that makes an artifact a deck. */
15
+ const DECK_ROOT = 'deck'
16
+
17
+ /**
18
+ * Which template draws a document nobody named a template for.
19
+ *
20
+ * Derived, not typed: every layout ships a built-in generic template, so the
21
+ * resolution chain is 指名模板 → that 文體's default, and `render` can never fail
22
+ * for want of a template (CONTRACT C1 補丁). What used to be one global default
23
+ * is now one per 文體, and this is the flowing one.
24
+ *
25
+ * A function, not a constant, for the same reason `blockTypes()` is one: a list
26
+ * frozen at import is a list a third party can never appear in. Registration
27
+ * happens at whatever moment a plugin is loaded, which is not guaranteed to be
28
+ * before this module's first evaluation.
29
+ */
30
+ export const defaultTemplateKey = () => defaultTemplateOf(DEFAULT_LAYOUT)
31
+
32
+ /**
33
+ * The template keys under which `---` stops being decoration and cuts a slide.
34
+ *
35
+ * A page break is a *文體* fact, not a template's: it means something exactly
36
+ * where the artifact has pages. Read off the registry on every call, so a
37
+ * third-party deck layout's built-in template inherits it — the frozen version
38
+ * of this list could only ever contain `kami/slides`, which made the sentence
39
+ * below a claim the code did not keep.
40
+ *
41
+ * **Installed packages count too** (F2f, closing the F3 deferral). Until F2f a
42
+ * store package riding the `deck` 文體 got no `---` page break here, because
43
+ * resolving an arbitrary key to a manifest means reading the store — and this
44
+ * layer may not reach for it (issues/08). That deferral is why F3's `init` had
45
+ * to refuse `-l deck` outright: the alternative was scaffolding a deck package
46
+ * whose pages silently never split. The dependency is now inverted instead
47
+ * (`src/layouts/template-index.js`), so both halves land together: the key is
48
+ * read off an index the delivery layer fills, and this layer still imports
49
+ * nothing it did not import before.
50
+ *
51
+ * Read on every call, never frozen at import: a package installed one command
52
+ * ago is a package a frozen list could never contain.
53
+ */
54
+ export const deckTemplateKeys = () => {
55
+ const decks = layoutModules().filter((layout) => layout.rootForm === DECK_ROOT)
56
+ const names = new Set(decks.map((layout) => layout.name))
57
+ return [
58
+ ...new Set([
59
+ ...decks.map((layout) => layout.defaultTemplate),
60
+ ...templateLayoutEntries()
61
+ .filter((entry) => names.has(entry.layout))
62
+ .map((entry) => entry.key),
63
+ ]),
64
+ ]
65
+ }
66
+
67
+ /** `<namespace>/<name>` — the version suffix is advisory (see render/templates.js). */
68
+ const bareKey = (key) => String(key ?? '').split('@')[0]
69
+
70
+ const createMd = () => {
71
+ const md = new MarkdownIt({ html: true, linkify: false, typographer: false })
72
+ md.use(containerPlugin)
73
+ return md
74
+ }
75
+
76
+ const pad2 = (value) => String(value).padStart(2, '0')
77
+
78
+ /** `YYYY-MM-DD` assembled from calendar *fields* — never sliced off an ISO string. */
79
+ const localCalendarDay = (d) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
80
+ const utcCalendarDay = (d) =>
81
+ `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`
82
+
83
+ /**
84
+ * YAML resolves a bare `date: 2026-08-17` to exactly UTC midnight: that value is
85
+ * a *calendar day*, not an instant, so reading it back in UTC is what keeps it
86
+ * stable everywhere. Anything with a time component is a real instant.
87
+ */
88
+ const isCalendarDayOnly = (d) =>
89
+ d.getUTCHours() === 0 &&
90
+ d.getUTCMinutes() === 0 &&
91
+ d.getUTCSeconds() === 0 &&
92
+ d.getUTCMilliseconds() === 0
93
+
94
+ /**
95
+ * Coerce one frontmatter value into a meta string, or `undefined` to drop it.
96
+ *
97
+ * Two failures live here, both of them silent:
98
+ *
99
+ * - A *bare* `date:` is a Date object, so a `typeof === 'string'` gate threw
100
+ * the field away entirely — quoted dates shipped, unquoted ones vanished.
101
+ * - `toISOString().slice(0, 10)` re-reads the instant in UTC, so
102
+ * `2026-08-18 01:00:00 +08:00` displayed as 2026-08-17. A date the author
103
+ * wrote by hand must not come back a day earlier.
104
+ *
105
+ * So the day is assembled from calendar fields, picking the calendar the value
106
+ * was written in (SPEC-facing normal form: `YYYY-MM-DD`).
107
+ */
108
+ const asMetaString = (value) => {
109
+ if (typeof value === 'string') return value.length > 0 ? value : undefined
110
+ if (value instanceof Date && Number.isFinite(value.getTime())) {
111
+ return isCalendarDayOnly(value) ? utcCalendarDay(value) : localCalendarDay(value)
112
+ }
113
+ return undefined
114
+ }
115
+
116
+ /** Frontmatter → doc meta with a deterministic key order. */
117
+ const buildMeta = (data) => {
118
+ const meta = {}
119
+ for (const key of B.META_KEY_ORDER) {
120
+ const value = asMetaString(data[key])
121
+ if (value !== undefined) meta[key] = value
122
+ }
123
+ const extras = Object.keys(data)
124
+ .filter((k) => !B.META_KEY_ORDER.includes(k) && k !== 'template')
125
+ .sort()
126
+ for (const key of extras) {
127
+ const value = asMetaString(data[key])
128
+ if (value !== undefined) meta[key] = value
129
+ }
130
+ if (meta.title === undefined) meta.title = 'Untitled'
131
+ return meta
132
+ }
133
+
134
+ /**
135
+ * Cut the flat item stream into slides on `hr` markers and wrap them in a deck.
136
+ * N separators yield N+1 slides — the segment count of the source, verbatim.
137
+ */
138
+ const buildDeck = (items) => {
139
+ const groups = [[]]
140
+ for (const item of items) {
141
+ if (item.kind === 'hr') groups.push([])
142
+ else groups[groups.length - 1].push(item)
143
+ }
144
+ return B.create.deck({ slides: groups.map((group) => B.create.slide({ children: nestSections(group) })) })
145
+ }
146
+
147
+ /**
148
+ * Compile the Markdown superset (frontmatter + fenced block types + `:::`
149
+ * callouts) into a canonical block tree.
150
+ *
151
+ * @param {string} source
152
+ * @param {{template?: string}} [options] CLI `-t` override; it decides deck mode
153
+ * just as frontmatter `template:` does, so the two entry points cannot disagree.
154
+ * @returns {{doc: object, templateKey: string}}
155
+ */
156
+ export function parseMarkdown(source, options = {}) {
157
+ if (typeof source !== 'string' || source.trim().length === 0) {
158
+ throw validationError('input is empty', CODES.INPUT_EMPTY, 'doc')
159
+ }
160
+
161
+ let front
162
+ try {
163
+ front = matter(source)
164
+ } catch (cause) {
165
+ throw validationError(
166
+ `frontmatter is not valid YAML: ${cause.message}`,
167
+ CODES.PARSE_FAILED,
168
+ 'doc.meta',
169
+ )
170
+ }
171
+
172
+ const md = createMd()
173
+ const env = {}
174
+ let tokens
175
+ try {
176
+ tokens = md.parse(front.content, env)
177
+ } catch (cause) {
178
+ throw validationError(
179
+ `markdown could not be compiled: ${cause.message}`,
180
+ CODES.PARSE_FAILED,
181
+ 'doc',
182
+ )
183
+ }
184
+
185
+ const ctx = {
186
+ inline: (token) => (token ? md.renderer.renderInline(token.children ?? [], md.options, env) : ''),
187
+ }
188
+
189
+ const meta = buildMeta(front.data ?? {})
190
+ const declared =
191
+ typeof front.data?.template === 'string' && front.data.template.length > 0
192
+ ? front.data.template
193
+ : defaultTemplateKey()
194
+ const templateKey =
195
+ typeof options.template === 'string' && options.template.length > 0
196
+ ? options.template
197
+ : declared
198
+
199
+ const flat = walkTokens(tokens, ctx)
200
+ const children =
201
+ deckTemplateKeys().includes(bareKey(templateKey)) ? [buildDeck(flat)] : nestSections(flat)
202
+
203
+ return { doc: B.withIds(B.doc({ meta, children })), templateKey }
204
+ }
205
+
206
+ /** Parse a canonical block-tree JSON document (the data-view entry point). */
207
+ export function parseBlockTree(source) {
208
+ let parsed
209
+ try {
210
+ parsed = JSON.parse(source)
211
+ } catch (cause) {
212
+ throw validationError(`input is not valid JSON: ${cause.message}`, CODES.PARSE_FAILED, 'doc')
213
+ }
214
+ const node = parsed?.type === 'doc' ? parsed : parsed?.doc
215
+ if (node?.type !== 'doc') {
216
+ throw validationError('JSON input has no `doc` block at its root', CODES.PARSE_FAILED, 'doc')
217
+ }
218
+ const templateKey =
219
+ typeof parsed?.template === 'string' && parsed.template.length > 0
220
+ ? parsed.template
221
+ : defaultTemplateKey()
222
+ return { doc: B.withIds(node), templateKey }
223
+ }
@@ -0,0 +1,265 @@
1
+ import * as B from '../core/blocks.js'
2
+ import { PLACEMENT_KEYS, containerSyntax } from '../blocks/index.js'
3
+
4
+ const DEFAULT_INTENT = 'unspecified'
5
+
6
+ /**
7
+ * Hand one `:::` fence back to the module that declared it.
8
+ *
9
+ * A module's `build` may answer with one block (`:::note` → a callout) or with
10
+ * a *list* of blocks (`:::place` → the blocks it contained, each now carrying a
11
+ * placement). The second shape is what lets placement live on the child, the
12
+ * way `grid-column` does in CSS, without inventing a cell block nobody asked
13
+ * for. An unclaimed name never reaches here: `container.js` refuses to open a
14
+ * fence the registry does not recognise.
15
+ */
16
+ const buildContainer = (info, kids) => {
17
+ const raw = String(info ?? '').trim()
18
+ const name = raw.split(/\s+/)[0]
19
+ const mod = containerSyntax().get(name)
20
+ if (mod === undefined) return kids
21
+ const built = mod.syntax.build({ name, params: raw.slice(name.length).trim(), children: kids })
22
+ return Array.isArray(built) ? built : [built]
23
+ }
24
+
25
+ /** The placement fields a heading marker picked up from an enclosing `:::place`. */
26
+ const placementOf = (item) => {
27
+ const out = {}
28
+ for (const key of PLACEMENT_KEYS) {
29
+ if (item?.[key] !== undefined) out[key] = item[key]
30
+ }
31
+ return out
32
+ }
33
+
34
+ const findClose = (tokens, openIndex) => {
35
+ const openType = tokens[openIndex].type
36
+ const closeType = openType.replace(/_open$/, '_close')
37
+ let depth = 0
38
+ for (let i = openIndex; i < tokens.length; i += 1) {
39
+ if (tokens[i].type === openType) depth += 1
40
+ else if (tokens[i].type === closeType) {
41
+ depth -= 1
42
+ if (depth === 0) return i
43
+ }
44
+ }
45
+ return tokens.length - 1
46
+ }
47
+
48
+ const rawSubtype = (info) => (info.startsWith('raw-svg') ? 'svg' : 'html')
49
+
50
+ const intentOf = (info, content) => {
51
+ const fromInfo = /intent=(?:"([^"]*)"|'([^']*)')/.exec(info)
52
+ if (fromInfo) return fromInfo[1] ?? fromInfo[2]
53
+ const fromAttr = /data-intent=(?:"([^"]*)"|'([^']*)')/.exec(content)
54
+ if (fromAttr) return fromAttr[1] ?? fromAttr[2]
55
+ return DEFAULT_INTENT
56
+ }
57
+
58
+ /**
59
+ * ```` ```diagram ```` — the fence body is **JSON**, pinned (CONTRACT D2).
60
+ *
61
+ * JSON rather than YAML because the fence body and the IR block are then the
62
+ * same text: an author can paste `example diagram` straight into a fence, and
63
+ * the parser needs no second syntax whose failure modes differ from the IR's.
64
+ *
65
+ * A body that will not parse becomes an explicitly unrenderable diagram block
66
+ * rather than an exception here: by the time the render pipeline validates, the
67
+ * block has an id and therefore a block path to report (SPEC §10.3), which a
68
+ * throw from inside the token walk could not produce.
69
+ */
70
+ const parseDiagram = (token) => {
71
+ let spec
72
+ try {
73
+ spec = JSON.parse(token.content)
74
+ } catch (cause) {
75
+ return B.diagramParseError(`fence 內容不是合法 JSON:${cause.message}`)
76
+ }
77
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) {
78
+ return B.diagramParseError('fence 內容必須是一個 JSON 物件(diagram block 的欄位)')
79
+ }
80
+ return B.create.diagram({ kind: spec.kind, nodes: spec.nodes, edges: spec.edges })
81
+ }
82
+
83
+ const parseFence = (token) => {
84
+ const info = (token.info ?? '').trim()
85
+ if (/^diagram\b/.test(info)) return parseDiagram(token)
86
+ if (/^raw(-html|-svg)?\b/.test(info)) {
87
+ return B.create.raw({
88
+ subtype: rawSubtype(info),
89
+ intent: intentOf(info, token.content),
90
+ html: token.content.replace(/\n+$/, ''),
91
+ })
92
+ }
93
+ return B.create.code({ lang: info.split(/\s+/)[0] ?? '', text: token.content.replace(/\n+$/, '') })
94
+ }
95
+
96
+ const parseTable = (tokens, openIndex, inline) => {
97
+ const end = findClose(tokens, openIndex)
98
+ const head = []
99
+ const rows = []
100
+ let currentRow = null
101
+ let inHead = false
102
+ for (let i = openIndex + 1; i < end; i += 1) {
103
+ const t = tokens[i]
104
+ if (t.type === 'thead_open') inHead = true
105
+ else if (t.type === 'thead_close') inHead = false
106
+ else if (t.type === 'tr_open') currentRow = []
107
+ else if (t.type === 'tr_close') {
108
+ if (currentRow !== null && !inHead) rows.push(currentRow)
109
+ currentRow = null
110
+ } else if (t.type === 'th_open' || t.type === 'td_open') {
111
+ const cell = inline(tokens[i + 1])
112
+ if (inHead) head.push(cell)
113
+ else if (currentRow !== null) currentRow.push(cell)
114
+ }
115
+ }
116
+ return { block: B.create.table({ head, rows }), next: end + 1 }
117
+ }
118
+
119
+ /**
120
+ * Compile one `<ul>`/`<ol>` token run into a real `list` block.
121
+ *
122
+ * S1 flattened lists into a prose HTML string, which meant a callout inside a
123
+ * list item had to be rendered by markdown-it — a second rendering path that
124
+ * disagreed with the Callout component — unless the list frame was thrown away
125
+ * entirely. Both halves of that trade-off are gone once list items carry real
126
+ * blocks: the `<ul>` frame survives *and* every child stays in the block tree.
127
+ */
128
+ const parseList = (tokens, openIndex, closeIndex, ctx, ordered) => {
129
+ const items = []
130
+ let i = openIndex + 1
131
+ while (i < closeIndex) {
132
+ if (tokens[i].type !== 'list_item_open') {
133
+ i += 1
134
+ continue
135
+ }
136
+ const itemClose = findClose(tokens, i)
137
+ items.push(walkTokens(tokens.slice(i + 1, itemClose), ctx))
138
+ i = itemClose + 1
139
+ }
140
+ return B.create.list({ ordered, items })
141
+ }
142
+
143
+ /**
144
+ * Walk a markdown-it token stream into flat block items. Headings become
145
+ * `{kind:'heading'}` markers and thematic breaks `{kind:'hr'}` markers;
146
+ * `nestSections` turns the former into section blocks and drops the latter,
147
+ * while deck compilation (parser/index.js) cuts slides on the latter.
148
+ */
149
+ export function walkTokens(tokens, ctx) {
150
+ const { inline } = ctx
151
+ const out = []
152
+ let i = 0
153
+
154
+ while (i < tokens.length) {
155
+ const t = tokens[i]
156
+ switch (t.type) {
157
+ case 'heading_open': {
158
+ out.push({
159
+ kind: 'heading',
160
+ level: Number(t.tag.slice(1)),
161
+ title: tokens[i + 1].content,
162
+ })
163
+ i = findClose(tokens, i) + 1
164
+ break
165
+ }
166
+ case 'paragraph_open': {
167
+ const close = findClose(tokens, i)
168
+ out.push(B.create.prose(inline(tokens[i + 1])))
169
+ i = close + 1
170
+ break
171
+ }
172
+ case 'fence': {
173
+ out.push(parseFence(t))
174
+ i += 1
175
+ break
176
+ }
177
+ case 'blockquote_open': {
178
+ const close = findClose(tokens, i)
179
+ out.push(B.create.quote(walkTokens(tokens.slice(i + 1, close), ctx)))
180
+ i = close + 1
181
+ break
182
+ }
183
+ case 'kami_container_open': {
184
+ const close = findClose(tokens, i)
185
+ out.push(...buildContainer(t.info, walkTokens(tokens.slice(i + 1, close), ctx)))
186
+ i = close + 1
187
+ break
188
+ }
189
+ case 'table_open': {
190
+ const { block, next } = parseTable(tokens, i, inline)
191
+ out.push(block)
192
+ i = next
193
+ break
194
+ }
195
+ case 'bullet_list_open':
196
+ case 'ordered_list_open': {
197
+ const close = findClose(tokens, i)
198
+ out.push(parseList(tokens, i, close, ctx, t.type === 'ordered_list_open'))
199
+ i = close + 1
200
+ break
201
+ }
202
+ case 'hr': {
203
+ out.push({ kind: 'hr' })
204
+ i += 1
205
+ break
206
+ }
207
+ case 'html_block': {
208
+ out.push(
209
+ B.create.raw({
210
+ subtype: 'html',
211
+ intent: intentOf('', t.content),
212
+ html: t.content.replace(/\n+$/, ''),
213
+ }),
214
+ )
215
+ i += 1
216
+ break
217
+ }
218
+ default:
219
+ i += 1
220
+ }
221
+ }
222
+ return out
223
+ }
224
+
225
+ /** Recurse into whichever nesting shape a container block uses. */
226
+ const nestChildren = (item) => {
227
+ if (Array.isArray(item.items)) return { ...item, items: item.items.map(nestSections) }
228
+ if (Array.isArray(item.children)) return { ...item, children: nestSections(item.children) }
229
+ return item
230
+ }
231
+
232
+ /**
233
+ * Turn flat heading markers into a nested section tree (recursively).
234
+ *
235
+ * `hr` markers are dropped here: in document mode a thematic break carries no
236
+ * block meaning (a pre-existing, inventoried behaviour). Deck mode consumes
237
+ * them *before* calling this, which is the one place they mean anything.
238
+ */
239
+ export function nestSections(items) {
240
+ const root = { children: [] }
241
+ const stack = [{ level: 0, node: root }]
242
+
243
+ for (const item of items) {
244
+ if (item.kind === 'hr') continue
245
+ if (item.kind === 'heading') {
246
+ while (stack.length > 1 && stack[stack.length - 1].level >= item.level) stack.pop()
247
+ // A heading inside a `:::place` keeps the coordinates the fence gave it:
248
+ // `nestSections` builds a fresh node, so anything not copied across here
249
+ // is silently dropped — and a placement that vanishes leaves a document
250
+ // whose layout is not the one its author wrote.
251
+ const node = {
252
+ type: 'section',
253
+ ...placementOf(item),
254
+ title: item.title,
255
+ level: item.level,
256
+ children: [],
257
+ }
258
+ stack[stack.length - 1].node.children.push(node)
259
+ stack.push({ level: item.level, node })
260
+ continue
261
+ }
262
+ stack[stack.length - 1].node.children.push(nestChildren(item))
263
+ }
264
+ return root.children
265
+ }
@@ -0,0 +1,80 @@
1
+ /*
2
+ * Board lane filter (SPEC section 3.4, wayfinder issue 11 P5 "board filter").
3
+ *
4
+ * A block-keyed client asset: the render layer embeds it only into artifacts
5
+ * that actually contain a `board` block, which is what keeps every artifact
6
+ * drawn before this existed byte for byte unchanged.
7
+ *
8
+ * Every character here is ASCII, and that is a size decision rather than a
9
+ * stylistic one: client assets are fed to `collectCodepoints`, so one section
10
+ * sign or one em dash in a comment pulls a whole font subset (~8.5KB) into
11
+ * every artifact that carries this file. The same scan also bans the four
12
+ * sequences that would put the HTML tokenizer into its double-escaped state,
13
+ * where the closing tag of the script element stops closing it and the rest of
14
+ * the artifact -- the embedded IR included -- is swallowed as script data
15
+ * (CONTRACT F2g H2). Both rules are pinned by
16
+ * `test_f2g_skeleton_assets_cannot_derail_the_parser`.
17
+ *
18
+ * Deliberately ES5-shaped and dependency-free: it is embedded verbatim into one
19
+ * offline file, so it must parse in any evergreen browser without a build step.
20
+ *
21
+ * The DOM contract is stated by `src/blocks/board.js` and read here: `data-board`
22
+ * scopes one board, and `data-board-lane` is carried by the button, by the lane
23
+ * and by every card, so this file never needs to know how a lane relates to its
24
+ * cards. Without JS every lane simply stays visible and the board reads as the
25
+ * full, unfiltered kanban -- the filter is an addition, never a prerequisite.
26
+ *
27
+ * The leading semicolon is the same guard `scale-to-fit.client.js` carries and
28
+ * for the same reason: assets are concatenated with a newline between them, and
29
+ * `})()` followed by a line starting with `(` is one expression to a JS parser --
30
+ * the previous asset's return value would be called as a function and this whole
31
+ * file would never run. Pinned by `test_f6a_assets_survive_concatenation`.
32
+ */
33
+ ;(function () {
34
+ var HIDE_RULE = '.board-lane[hidden]{display:none !important}'
35
+ var boards = Array.prototype.slice.call(document.querySelectorAll('.board[data-board]'))
36
+ if (boards.length === 0) return
37
+
38
+ /*
39
+ * The hiding rule ships with the behaviour, not with the skin.
40
+ *
41
+ * `hidden` is display:none in every UA stylesheet, and a template that gives
42
+ * `.board-lane` any display of its own silently wins over it -- the button
43
+ * would then toggle a class that changes nothing, which is worse than having
44
+ * no filter at all because it looks like one.
45
+ */
46
+ var rules = document.createElement('style')
47
+ rules.textContent = HIDE_RULE
48
+ document.head.appendChild(rules)
49
+
50
+ function apply(board, lane) {
51
+ var lanes = Array.prototype.slice.call(board.querySelectorAll('.board-lane'))
52
+ for (var i = 0; i < lanes.length; i += 1) {
53
+ var name = lanes[i].getAttribute('data-board-lane')
54
+ lanes[i].hidden = lane !== null && name !== lane
55
+ }
56
+ var buttons = Array.prototype.slice.call(board.querySelectorAll('.board-filter'))
57
+ for (var j = 0; j < buttons.length; j += 1) {
58
+ var pressed = lane !== null && buttons[j].getAttribute('data-board-lane') === lane
59
+ buttons[j].setAttribute('aria-pressed', pressed ? 'true' : 'false')
60
+ }
61
+ board.setAttribute('data-board-filter', lane === null ? '' : lane)
62
+ }
63
+
64
+ for (var b = 0; b < boards.length; b += 1) {
65
+ (function (board) {
66
+ var selected = null
67
+ var buttons = Array.prototype.slice.call(board.querySelectorAll('.board-filter'))
68
+ for (var k = 0; k < buttons.length; k += 1) {
69
+ (function (button) {
70
+ button.addEventListener('click', function () {
71
+ var lane = button.getAttribute('data-board-lane')
72
+ selected = selected === lane ? null : lane
73
+ apply(board, selected)
74
+ })
75
+ })(buttons[k])
76
+ }
77
+ apply(board, null)
78
+ })(boards[b])
79
+ }
80
+ })()
@@ -0,0 +1,29 @@
1
+ import { parseBlockTree, parseMarkdown } from '../parser/index.js'
2
+ import { renderArtifact } from './index.js'
3
+
4
+ /**
5
+ * Source text → finished artifact, with no filesystem in sight.
6
+ *
7
+ * `render` and `serve` must agree byte for byte on what a source file becomes —
8
+ * a preview that draws something the exported artifact does not is worse than
9
+ * no preview. Sharing one function is what makes that agreement structural
10
+ * rather than a matter of keeping two call sites in step.
11
+ *
12
+ * The template override reaches the *parser*, not just the renderer: whether
13
+ * `---` is a page break is a template decision, so resolving it later would
14
+ * compile a deck source as a document and then draw the empty result with the
15
+ * deck template.
16
+ *
17
+ * @param {{source: string, format: 'markdown'|'json', template?: string,
18
+ * generator?: string, createdAt: string}} input
19
+ * @returns {Promise<{html: string, ir: object, slides: number|undefined}>}
20
+ */
21
+ export async function compileDocument({ source, format, template, generator, createdAt }) {
22
+ const parsed = format === 'json' ? parseBlockTree(source) : parseMarkdown(source, { template })
23
+ return await renderArtifact({
24
+ doc: parsed.doc,
25
+ templateKey: template ?? parsed.templateKey,
26
+ createdAt,
27
+ generator,
28
+ })
29
+ }
@@ -0,0 +1,98 @@
1
+ import { blockModule } from '../blocks/index.js'
2
+
3
+ const EMPTY_CONFIG = Object.freeze({})
4
+ const ROOT_POSITION = Object.freeze({ index: 0, total: 1 })
5
+
6
+ const asNodes = (value) => (Array.isArray(value) ? value : value === undefined ? [] : [value])
7
+
8
+ /**
9
+ * The render context a block module is handed.
10
+ *
11
+ * A block module knows what its own type *is*; it deliberately knows nothing
12
+ * about the page it lands on. Everything situational reaches it through here,
13
+ * which is what keeps a module portable enough to be published by a third party:
14
+ *
15
+ * - `templateKey` / `meta` — who is drawing, and the document's own identity
16
+ * - `config(type)` — the template's settings for a block type
17
+ * (`轉子/<型別>/config` in wayfinder issue 13)
18
+ * - `chrome(name, payload)`— a slot the template may fill; empty when it does not
19
+ * - `layoutOf(block)` — geometry measured by the layer above (A5)
20
+ * - `canvas` — the resolved canvas geometry of this artifact (C3)
21
+ * - `position` — where this block sits among its siblings
22
+ * - `renderBlock` / `renderChildren` / `drawableChildren` — recursion, so
23
+ * nesting stays uniform
24
+ *
25
+ * @param {{template: object, doc: object, layouts: Map<string, object>,
26
+ * canvas?: object, overrides?: Record<string, Function>}} input
27
+ */
28
+ export function createRenderContext({
29
+ template,
30
+ doc,
31
+ layouts,
32
+ canvas = EMPTY_CONFIG,
33
+ overrides = EMPTY_CONFIG,
34
+ }) {
35
+ const { namespace, name } = template.manifest
36
+ const blockConfig = template.manifest.blockConfig ?? EMPTY_CONFIG
37
+ const chromeTable = template.chrome ?? EMPTY_CONFIG
38
+
39
+ const ctx = {
40
+ templateKey: `${namespace}/${name}`,
41
+ meta: doc.meta ?? EMPTY_CONFIG,
42
+ position: ROOT_POSITION,
43
+ config: (type) => blockConfig[type] ?? EMPTY_CONFIG,
44
+ chrome: (slot, payload) => {
45
+ const fill = chromeTable[slot]
46
+ return typeof fill === 'function' ? asNodes(fill(payload ?? EMPTY_CONFIG, ctx)) : []
47
+ },
48
+ layoutOf: (block) => layouts.get(block?.id),
49
+ canvas,
50
+ renderBlock,
51
+ renderChildren,
52
+ drawableChildren,
53
+ }
54
+
55
+ /**
56
+ * Render one block, or `null` when no module claims its type.
57
+ *
58
+ * Skipping an unknown type is what a renderer does; it is not the *only*
59
+ * answer here, because `assertTemplateVocabulary` has already refused any
60
+ * document the template cannot express (CONTRACT C4). By this point a miss
61
+ * means a block nobody registered at all.
62
+ *
63
+ * A template package's 轉子 override wins over the registered module, and
64
+ * *only* for the artifact this context is drawing (CONTRACT D3). The
65
+ * admission check still runs against `manifest.blocks` first: an override is
66
+ * a different conversion for a type the template already declared, never a
67
+ * back door into a vocabulary it did not.
68
+ */
69
+ function renderBlock(block, position = ROOT_POSITION) {
70
+ const type = block?.type
71
+ const mod = blockModule(type)
72
+ if (mod === undefined) return null
73
+ const render = overrides[type] ?? mod.render
74
+ return render(block, { ...ctx, position })
75
+ }
76
+
77
+ /**
78
+ * The children that will actually be drawn, in order.
79
+ *
80
+ * A container that has to pair each child with something of its own — a grid
81
+ * cell carrying that child's placement — cannot use `renderChildren` alone,
82
+ * because the returned array is already filtered and would silently pair the
83
+ * wrong child with the wrong coordinates.
84
+ */
85
+ function drawableChildren(blocks) {
86
+ if (!Array.isArray(blocks)) return []
87
+ return blocks.filter((block) => blockModule(block?.type) !== undefined)
88
+ }
89
+
90
+ function renderChildren(blocks) {
91
+ const drawable = drawableChildren(blocks)
92
+ return drawable.map((block, index) =>
93
+ renderBlock(block, { index, total: drawable.length }),
94
+ )
95
+ }
96
+
97
+ return ctx
98
+ }