@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,251 @@
1
+ /**
2
+ * The layout module registry — one 文體's whole knowledge in one place.
3
+ *
4
+ * Layout was the last thing in this SDK that was not a first-class citizen: the
5
+ * root form of an artifact lived inside each template's `components.js`, which
6
+ * made 「replay 換皮不換文體」 a rule two templates happened to obey rather than
7
+ * a structure (wayfinder issue 13 裁決 1). Registering a layout is now the same
8
+ * kind of act as registering a block: implement the interface, call the public
9
+ * entry point once, edit no file inside the SDK.
10
+ *
11
+ * A layout owns **the cut and nothing else** — the outer frame, the grid, the
12
+ * named slots, and the canvas geometry. It owns no wording, no section
13
+ * structure, no sample content: one layout has to be able to carry a dashboard
14
+ * and a maintenance page without knowing which one it is drawing. What a given
15
+ * template *looks like by default* is the template's applied state (its
16
+ * manifest values and its stylesheet), never the layout's.
17
+ */
18
+
19
+ /** Every field a layout module must declare. */
20
+ export const LAYOUT_MODULE_FIELDS = Object.freeze([
21
+ 'name',
22
+ 'canvas',
23
+ 'root',
24
+ 'slots',
25
+ 'defaults',
26
+ 'rootForm',
27
+ 'defaultTemplate',
28
+ 'styleHooks',
29
+ ])
30
+
31
+ /** Verbatim — the two canvas kinds (wayfinder issue 14 裁決 4). */
32
+ export const CANVAS_FLOWING = 'flowing'
33
+ export const CANVAS_BOUNDED = 'bounded'
34
+ export const CANVAS_MODES = Object.freeze([CANVAS_FLOWING, CANVAS_BOUNDED])
35
+
36
+ /** SPEC §12 の同一條:a third-party layout wears the plugin prefix. */
37
+ export const PLUGIN_LAYOUT_PREFIX = 'x-'
38
+
39
+ /** The geometry knobs a layout defines and a template supplies values for. */
40
+ export const LAYOUT_DEFAULT_KEYS = Object.freeze(['maxWidth', 'measure'])
41
+
42
+ /**
43
+ * Optional — **this layout's own geometry knobs**, beyond the two every canvas
44
+ * has, as `{name: factory value}` (CONTRACT F2b G1).
45
+ *
46
+ * `defaults` is the canvas vocabulary *every* 文體 shares: how wide the page may
47
+ * get, how wide a line of text may get, and (bounded only) the fixed logical
48
+ * canvas. It is deliberately closed — a 文體 that grew a third meaning there
49
+ * would be asking every other 文體 to carry it too.
50
+ *
51
+ * A knob is the other half: geometry only one 文體 has. `article` declares the
52
+ * rail — a side region's switch, width and side — because a rail is a way of
53
+ * *cutting* a flowing page, and nothing about a deck wants one. Declaring the
54
+ * knobs here rather than in a table inside `src/render/**` is what keeps the
55
+ * doctrine true for a third party as well: a layout published from outside
56
+ * defines its own knobs and its template turns them, with no edit to the SDK.
57
+ *
58
+ * Values are scalars because a knob is a *setting*, and because the resolved
59
+ * canvas is one flat frozen object the markup and the stylesheet both read.
60
+ * The knob's factory value also fixes its **type**: a template may turn a knob,
61
+ * never redefine what kind of thing it is (`resolveCanvas` / `canvasProblems`).
62
+ */
63
+ export const LAYOUT_KNOBS_FIELD = 'knobs'
64
+
65
+ /**
66
+ * Optional — a layout's claim that its whole page is addressable by 24-grid
67
+ * coordinates, so a block may carry `col`/`row` without being wrapped in a
68
+ * `grid` container (CONTRACT C2 複驗補丁).
69
+ *
70
+ * It has to be *declared*, and `bounded` deliberately does not imply it. A
71
+ * bounded canvas is only a fixed logical size; whether anything lays the grid
72
+ * tracks that make a bare coordinate resolve to a real cell is a separate
73
+ * question, so treating `bounded` as the licence would re-open the exact hole
74
+ * this gate closes — the coordinate would be accepted and then dropped by CSS
75
+ * auto-placement, silently.
76
+ *
77
+ * Since F2f exactly one factory layout declares it: `deck`, whose slides lay
78
+ * 24 real column tracks (`src/blocks/slide.js`). `article` does not and must
79
+ * not. The rule for any layout, factory or third-party, is that the claim and
80
+ * the tracks land together — a layout that declares this without building them
81
+ * is licensing coordinates it will then throw away.
82
+ */
83
+ export const GRID_ADDRESSED_FIELD = 'gridAddressed'
84
+
85
+ /** Names the resolved canvas already occupies — a knob may not shadow one. */
86
+ const RESERVED_KNOB_NAMES = Object.freeze([
87
+ 'layout',
88
+ 'canvas',
89
+ 'canvasSize',
90
+ 'canvasAttrs',
91
+ GRID_ADDRESSED_FIELD,
92
+ ...LAYOUT_DEFAULT_KEYS,
93
+ ])
94
+
95
+ /** A knob name is an identifier: it becomes a `ksb:` marker word and a manifest key. */
96
+ const KNOB_NAME = /^[A-Za-z][A-Za-z0-9]*$/
97
+
98
+ const isScalar = (value) =>
99
+ typeof value === 'boolean' ||
100
+ typeof value === 'string' ||
101
+ (typeof value === 'number' && Number.isFinite(value))
102
+
103
+ const EMPTY = Object.freeze({ modules: Object.freeze([]), byName: new Map(), version: 0 })
104
+
105
+ let current = EMPTY
106
+ let baseline = EMPTY
107
+ let sealed = false
108
+
109
+ const reject = (message) => {
110
+ throw new TypeError(`invalid layout module: ${message}`)
111
+ }
112
+
113
+ const isPositiveNumber = (value) => typeof value === 'number' && Number.isFinite(value) && value > 0
114
+
115
+ /**
116
+ * A bounded canvas is a *fixed logical canvas* scaled to fit (issue 14 裁決 4).
117
+ * Declaring `bounded` without saying how big that canvas is would give the
118
+ * skeleton nothing to scale, and the artifact would ship with a scale factor of
119
+ * one — i.e. silently flowing while claiming to be bounded.
120
+ */
121
+ function assertCanvasGeometry(mod) {
122
+ const defaults = mod.defaults
123
+ if (defaults === null || typeof defaults !== 'object') reject(`\`defaults\` of "${mod.name}" must be an object`)
124
+ for (const key of LAYOUT_DEFAULT_KEYS) {
125
+ if (!isPositiveNumber(defaults[key])) {
126
+ reject(`\`defaults.${key}\` of "${mod.name}" must be a positive number of CSS pixels`)
127
+ }
128
+ }
129
+ if (mod.canvas !== CANVAS_BOUNDED) return
130
+ const size = defaults.canvasSize
131
+ if (size === null || typeof size !== 'object') {
132
+ reject(`"${mod.name}" is \`${CANVAS_BOUNDED}\` but declares no \`defaults.canvasSize\``)
133
+ }
134
+ if (!isPositiveNumber(size.width) || !isPositiveNumber(size.height)) {
135
+ reject(`\`defaults.canvasSize\` of "${mod.name}" needs positive \`width\`/\`height\``)
136
+ }
137
+ }
138
+
139
+ /**
140
+ * A layout's own knobs, checked at registration for the same reason the canvas
141
+ * geometry is: a knob that cannot be resolved fails inside a render, where the
142
+ * message would be about something else entirely.
143
+ *
144
+ * A knob that *shadows* a canvas field is the worst shape available here, and
145
+ * the reason this is a hard refusal rather than a warning: the resolved canvas
146
+ * merges knobs and geometry into one flat object, so a knob called `measure`
147
+ * would be silently overwritten — the template would turn it, the artifact
148
+ * would ignore it, and nothing anywhere would say so.
149
+ */
150
+ function assertKnobs(mod) {
151
+ const knobs = mod[LAYOUT_KNOBS_FIELD]
152
+ if (knobs === undefined) return
153
+ if (knobs === null || typeof knobs !== 'object' || Array.isArray(knobs)) {
154
+ reject(`\`${LAYOUT_KNOBS_FIELD}\` of "${mod.name}" must be an object of scalar defaults`)
155
+ }
156
+ for (const [name, value] of Object.entries(knobs)) {
157
+ if (!KNOB_NAME.test(name)) {
158
+ reject(`knob "${name}" of "${mod.name}" is not an identifier`)
159
+ }
160
+ if (RESERVED_KNOB_NAMES.includes(name)) {
161
+ reject(`knob "${name}" of "${mod.name}" shadows a resolved-canvas field`)
162
+ }
163
+ if (!isScalar(value)) {
164
+ reject(`knob "${name}" of "${mod.name}" must default to a boolean, number or string`)
165
+ }
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Reject anything that would register a half-built layout and fail later,
171
+ * deeper — the same discipline the block registry applies, for the same reason:
172
+ * the failing path is the rarest one, so registration is the only place it can
173
+ * be caught before it matters.
174
+ */
175
+ function assertModuleShape(mod, byName) {
176
+ if (mod === null || typeof mod !== 'object') reject('not an object')
177
+ for (const field of LAYOUT_MODULE_FIELDS) {
178
+ if (mod[field] === undefined) reject(`missing \`${field}\``)
179
+ }
180
+ if (typeof mod.name !== 'string' || mod.name.length === 0) reject('`name` must be a non-empty string')
181
+ if (!CANVAS_MODES.includes(mod.canvas)) {
182
+ reject(`\`canvas\` of "${mod.name}" must be one of: ${CANVAS_MODES.join(', ')}`)
183
+ }
184
+ if (typeof mod.root !== 'function') reject(`\`root\` of "${mod.name}" must be a function`)
185
+ if (!Array.isArray(mod.slots) || mod.slots.some((slot) => typeof slot !== 'string')) {
186
+ reject(`\`slots\` of "${mod.name}" must be an array of slot names`)
187
+ }
188
+ if (!Array.isArray(mod.styleHooks)) reject(`\`styleHooks\` of "${mod.name}" must be an array`)
189
+ if (mod.rootForm !== null && (typeof mod.rootForm !== 'string' || mod.rootForm.length === 0)) {
190
+ reject(`\`rootForm\` of "${mod.name}" must be a block type name or null`)
191
+ }
192
+ // Optional, and boolean when present: a truthy string here would switch off a
193
+ // gate by accident, and the gate it switches off is the one that keeps a
194
+ // coordinate from evaporating.
195
+ const addressed = mod[GRID_ADDRESSED_FIELD]
196
+ if (addressed !== undefined && typeof addressed !== 'boolean') {
197
+ reject(`\`${GRID_ADDRESSED_FIELD}\` of "${mod.name}" must be a boolean when declared`)
198
+ }
199
+ // Every layout ships a built-in generic template, so `render` can never fail
200
+ // for want of one: the resolution chain is 指名模板 → this layout's default.
201
+ if (typeof mod.defaultTemplate !== 'string' || mod.defaultTemplate.length === 0) {
202
+ reject(`\`defaultTemplate\` of "${mod.name}" must name a built-in template`)
203
+ }
204
+ assertCanvasGeometry(mod)
205
+ assertKnobs(mod)
206
+ if (byName.has(mod.name)) reject(`layout "${mod.name}" is already registered`)
207
+ if (sealed && !mod.name.startsWith(PLUGIN_LAYOUT_PREFIX)) {
208
+ reject(`third-party layout "${mod.name}" must start with \`${PLUGIN_LAYOUT_PREFIX}\``)
209
+ }
210
+ }
211
+
212
+ const snapshotWith = (previous, mod) => {
213
+ const modules = Object.freeze([...previous.modules, mod])
214
+ const byName = new Map(previous.byName)
215
+ byName.set(mod.name, mod)
216
+ return Object.freeze({ modules, byName, version: previous.version + 1 })
217
+ }
218
+
219
+ /**
220
+ * Register one layout module — the public extension point (CONTRACT C1).
221
+ *
222
+ * @param {{name: string, canvas: string, root: Function, slots: readonly string[],
223
+ * defaults: {maxWidth: number, measure: number, canvasSize?: {width: number, height: number}},
224
+ * rootForm: string|null, defaultTemplate: string, styleHooks: readonly string[],
225
+ * knobs?: Record<string, boolean|number|string>}} mod
226
+ */
227
+ export function registerLayout(mod) {
228
+ assertModuleShape(mod, current.byName)
229
+ current = snapshotWith(current, mod)
230
+ return mod.name
231
+ }
232
+
233
+ /** Freeze the factory 文體 set as the one `resetLayoutRegistry` returns to. */
234
+ export function sealCoreLayouts() {
235
+ baseline = current
236
+ sealed = true
237
+ return baseline.version
238
+ }
239
+
240
+ /** Drop every layout registered after the core seal (test hygiene). */
241
+ export function resetLayoutRegistry() {
242
+ current = baseline
243
+ return current.version
244
+ }
245
+
246
+ export const layoutModules = () => current.modules
247
+ export const layoutModule = (name) => current.byName.get(name)
248
+ export const layoutNames = () => current.modules.map((mod) => mod.name)
249
+ export const pluginLayoutNames = () =>
250
+ current.modules.filter((mod) => !baseline.byName.has(mod.name)).map((mod) => mod.name)
251
+ export const layoutRegistryVersion = () => current.version
@@ -0,0 +1,172 @@
1
+ import { el } from '../blocks/element.js'
2
+ import { CANVAS_BOUNDED } from './registry.js'
3
+
4
+ /**
5
+ * `resume` — the 文體 of a printed curriculum vitae (13 號票 v1 五版面之五).
6
+ *
7
+ * The other four layouts answer questions about a screen: how a long page is
8
+ * read, how a board of cards is scanned, how a deck is presented, how a site is
9
+ * scrolled. This one answers a question about **paper**. A résumé is read on a
10
+ * screen only incidentally; it is sent as a PDF, opened by someone who prints
11
+ * it or flips through it a page at a time, and judged partly on whether it fits
12
+ * on one sheet. Every decision below follows from that single fact.
13
+ *
14
+ * ## The canvas is a sheet of A4 (CONTRACT F2e R10)
15
+ *
16
+ * 210mm x 297mm at 96dpi is 793.7 x 1122.5 CSS pixels, so the fixed logical
17
+ * canvas is **794 x 1123**. That is not an aesthetic choice the way the deck's
18
+ * 1280x720 or the one-page screen's 1440x900 were: those are shapes a window
19
+ * has, this is the shape the artifact will physically be. On screen the
20
+ * skeleton scales the sheet to fit the window like any other bounded canvas
21
+ * (`src/render/scale-to-fit.client.js`); on paper it stands down and the sheet
22
+ * is drawn at exactly its natural size, which is exactly one printed page.
23
+ *
24
+ * The measurement that settles the number is in the contract: this SDK's own
25
+ * PDF export prints A4 with 14mm margins (`src/export/pdf.js`), leaving a
26
+ * printable box of about 688.8 x 1017.9px. A sheet declared at 794 x 1123 loses
27
+ * 105px off the right edge and 105px off the bottom of every page unless the
28
+ * skin claims the whole page box back with `@page { margin: 0 }` — silently,
29
+ * with no error anywhere. So the skins on this 文體 declare it, and the two
30
+ * facts (canvas size, page box) are pinned together.
31
+ *
32
+ * ## The cut: one top-level section is one sheet
33
+ *
34
+ * The parser nests every heading under its parent, so a document's top-level
35
+ * `section` blocks are its chapters, and on this 文體 a chapter is a sheet. The
36
+ * test is 「是不是頂層 section」 and deliberately **not** 「是不是 level 1」, the
37
+ * same reasoning `card` and `one-page` record: the two sentences pick out
38
+ * identical blocks for an ordinary document and part company only for one that
39
+ * starts at `##`, where the level test would yield a résumé with no pages at
40
+ * all because of a heading-depth detail nobody thought about.
41
+ *
42
+ * A layout cannot measure text, so it cannot paginate by content — that is the
43
+ * browser's job and it does it on paper. What a layout *can* do is take the
44
+ * author's own top-level heading as the page break, which is the only honest
45
+ * signal available at the cutting layer. The 預設 md teaches the convention:
46
+ * `#` starts a sheet, `##` is a band inside one.
47
+ *
48
+ * ## Why nothing is drawn outside a sheet
49
+ *
50
+ * This is where the 文體 parts company with `one-page`, and printing is the
51
+ * whole reason. On a site the masthead gets a screen of its own (a hero) and
52
+ * the colophon floats below every canvas, because on a screen「小一點的頁尾」is
53
+ * a reasonable thing to be. On paper it is not: an element sitting after the
54
+ * last sheet is laid onto **the next page**, so every résumé would print one
55
+ * extra sheet carrying a single line of small type. And a masthead with a sheet
56
+ * to itself would be an entire A4 page holding a name.
57
+ *
58
+ * So the head chrome joins the **first** sheet and the foot chrome joins the
59
+ * **last** one, and the outer frame's only children are sheets.
60
+ *
61
+ * ## Why there is no knob here
62
+ *
63
+ * How a sheet is padded, how the name is set, how the contact line is arranged:
64
+ * every one of those is read by the stylesheet and by nothing else, which makes
65
+ * them decoration, and decoration lives in the skin. The logical canvas is not
66
+ * a knob either — it is `defaults.canvasSize`, the one extra field every
67
+ * bounded 文體 has, required at registration time.
68
+ *
69
+ * ## Why it does **not** declare `gridAddressed`
70
+ *
71
+ * Same shape as `one-page`: a sheet's child is exactly one `section`, so the 24
72
+ * tracks would be laid on a region with a single child, and a coordinate an
73
+ * author wrote *inside* the section would not be a direct child of the region
74
+ * at all — CSS auto-placement would drop it in silence while the placement gate
75
+ * (`src/render/placement.js`), which takes the declaration at face value, had
76
+ * stopped refusing it. The claim and the tracks land together, so neither lands.
77
+ */
78
+
79
+ /** Verbatim — the block type one sheet is made of. */
80
+ const SHEET_BLOCK = 'section'
81
+
82
+ /** Verbatim — the three regions of a résumé, as class names. */
83
+ const RESUME_CLASS = 'resume'
84
+ const SHEET_CLASS = 'sheet'
85
+ const SHEET_INNER_CLASS = 'sheet-inner'
86
+
87
+ /**
88
+ * Verbatim — the fixed logical canvas of **one sheet**, in CSS pixels.
89
+ *
90
+ * A4 portrait at 96dpi: 210mm -> 793.7 -> 794, 297mm -> 1122.5 -> 1123. Unlike
91
+ * every other logical canvas in this SDK the number is not chosen, it is
92
+ * converted: the artifact ends up on this piece of paper.
93
+ */
94
+ const SHEET_CANVAS = Object.freeze({ width: 794, height: 1123 })
95
+
96
+ const isSheet = (block) => block?.type === SHEET_BLOCK
97
+
98
+ export default Object.freeze({
99
+ name: 'resume',
100
+
101
+ canvas: CANVAS_BOUNDED,
102
+
103
+ /** No required root form: a résumé draws whatever sits under `doc`. */
104
+ rootForm: null,
105
+
106
+ /** The built-in generic template of this 文體 — `render` never wants for one. */
107
+ defaultTemplate: 'kami/resume',
108
+
109
+ /**
110
+ * Head and foot, named for where they land rather than for what they hold:
111
+ * both are drawn *inside* a sheet, so neither is a page-level region the way
112
+ * `one-page`'s `page-lead` / `page-foot` are.
113
+ */
114
+ slots: Object.freeze(['resume-head', 'resume-foot']),
115
+
116
+ styleHooks: Object.freeze([RESUME_CLASS, SHEET_CLASS, SHEET_INNER_CLASS]),
117
+
118
+ /**
119
+ * Factory geometry, in CSS pixels. Three numbers, three different jobs:
120
+ *
121
+ * - `canvasSize` 794x1123 — the sheet of A4 that gets scaled to fit.
122
+ * - `maxWidth` 660 — how wide the content region inside a sheet may grow,
123
+ * i.e. the sheet minus a printer-safe margin on each side.
124
+ * - `measure` 620 — the width a *line of text* wants. This is the first 文體
125
+ * where it is not 740 (CONTRACT C3's number for every screen 文體), and the
126
+ * reason is the paper: 740 plus any margin at all is wider than an A4
127
+ * sheet, so keeping it would mean either type running off the page or a
128
+ * page with no margin.
129
+ */
130
+ defaults: Object.freeze({ maxWidth: 660, measure: 620, canvasSize: SHEET_CANVAS }),
131
+
132
+ /**
133
+ * Sheets, in document order, with the head on the first and the foot on the
134
+ * last.
135
+ *
136
+ * A sheet is drawn only when something goes on it: a document with no
137
+ * top-level sections and no head chrome produces no sheets at all, rather than
138
+ * one blank page. `...(x ? [n] : [])` rather than `x ? n : null`, because a
139
+ * `null` child is a real, invisible, permanent empty comment node.
140
+ *
141
+ * The canvas attributes come from `ctx.canvas`, never from the constant
142
+ * above: a template may not move the logical canvas, but the resolved canvas
143
+ * is the one place that answer is computed, and a second computation here is
144
+ * a scale factor free to disagree with the one the skeleton uses.
145
+ */
146
+ root: (doc, ctx) => {
147
+ const children = doc?.children ?? []
148
+ const sections = children.filter(isSheet)
149
+ const head = [
150
+ ...ctx.chrome('resume-head', ctx.meta),
151
+ ...ctx.renderChildren(children.filter((block) => !isSheet(block))),
152
+ ]
153
+ const foot = ctx.chrome('resume-foot', ctx.meta)
154
+ const bodies = sections.map((block, index) => [
155
+ ctx.renderBlock(block, { index, total: sections.length }),
156
+ ])
157
+ const pages = bodies.length > 0 ? bodies : head.length + foot.length > 0 ? [[]] : []
158
+ const sheet = (nodes) =>
159
+ el('section', { class: SHEET_CLASS, ...(ctx.canvas?.canvasAttrs ?? {}) }, [
160
+ el('div', { class: SHEET_INNER_CLASS }, nodes),
161
+ ])
162
+ return el('article', { class: RESUME_CLASS, 'data-template': ctx.templateKey }, [
163
+ ...pages.map((nodes, index) =>
164
+ sheet([
165
+ ...(index === 0 ? head : []),
166
+ ...nodes,
167
+ ...(index === pages.length - 1 ? foot : []),
168
+ ]),
169
+ ),
170
+ ])
171
+ },
172
+ })
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Which 文體 an *installed* template rides — the inversion that let F2f close
3
+ * the `deckTemplateKeys` deferral.
4
+ *
5
+ * Whether `---` cuts a page is a 文體 fact, so the parser has to know which
6
+ * layout the document's template rides. For the two factory templates it can
7
+ * read that off the layout registry (每個 layout 自帶內建通用模板). For a
8
+ * package sitting in `~/.kamishibai/templates/` it cannot: resolving an
9
+ * arbitrary key to a manifest means reading a TOML file off disk, which is the
10
+ * delivery layer's job, and a parser that reached for it would be importing
11
+ * *upwards* through two layers — the one direction the architecture forbids
12
+ * (issues/08). F3 landed the store loader and left this deferred for exactly
13
+ * that reason, which is why `init -l deck` had to be refused outright: a
14
+ * scaffolded deck package would have had pages that silently never split.
15
+ *
16
+ * So the dependency is inverted, the same way the block, layout and template
17
+ * registries already are. The delivery layer, which knows the store, registers
18
+ * a **source** that can enumerate `{key, layout}` pairs; the parser reads the
19
+ * index and imports nothing new. Neither side learns about the other.
20
+ *
21
+ * A source rather than a plain map because the store is not static: packages
22
+ * appear when someone installs one, and a map captured at import time is a map
23
+ * the package installed a second ago can never be in.
24
+ */
25
+
26
+ let sources = Object.freeze([])
27
+
28
+ const reject = (message) => {
29
+ throw new TypeError(`invalid template layout source: ${message}`)
30
+ }
31
+
32
+ /**
33
+ * Register one source of `{key, layout}` pairs.
34
+ *
35
+ * Shape-checked at registration for the reason every other registry here states:
36
+ * a source that cannot enumerate fails on the rarest path — a document whose
37
+ * `---` should have split — and the failure there looks like a parser bug.
38
+ *
39
+ * @param {{entries: () => Array<{key: string, layout: string}>}} source
40
+ */
41
+ export function registerTemplateLayoutSource(source) {
42
+ if (source === null || typeof source !== 'object') reject('not an object')
43
+ if (typeof source.entries !== 'function') reject('`entries` must be a function')
44
+ sources = Object.freeze([...sources, source])
45
+ return sources.length
46
+ }
47
+
48
+ /** Drop every registered source (test hygiene, and re-installation on a new HOME). */
49
+ export function resetTemplateLayoutSources() {
50
+ sources = Object.freeze([])
51
+ return sources.length
52
+ }
53
+
54
+ /**
55
+ * Every installed template's `{key, layout}`, in registration order.
56
+ *
57
+ * A source that throws is skipped rather than allowed to take the caller down:
58
+ * the question being asked is 「這份文件的 `---` 要不要切頁」, and one unreadable
59
+ * package on disk must not stop an unrelated document from being parsed. The
60
+ * package itself is not thereby excused — the moment anyone tries to *draw*
61
+ * with it, `loadStoreTemplate` refuses it loudly by name.
62
+ */
63
+ export function templateLayoutEntries() {
64
+ const found = []
65
+ for (const source of sources) {
66
+ let entries
67
+ try {
68
+ entries = source.entries()
69
+ } catch {
70
+ continue
71
+ }
72
+ if (!Array.isArray(entries)) continue
73
+ for (const entry of entries) {
74
+ if (typeof entry?.key === 'string' && typeof entry?.layout === 'string') found.push(entry)
75
+ }
76
+ }
77
+ return found
78
+ }
@@ -0,0 +1,38 @@
1
+ import { CODES, EXIT, KsbError } from '../core/errors.js'
2
+ import { IR_SCRIPT_TYPE } from '../core/ir.js'
3
+
4
+ const SCRIPT_SOURCE = `<script[^>]*type\\s*=\\s*["']${IR_SCRIPT_TYPE.replace('+', '\\+')}["'][^>]*>([\\s\\S]*?)<\\/script>`
5
+
6
+ /** Raw payloads of every embedded IR script found in an artifact. */
7
+ export function extractIrPayloads(html) {
8
+ const re = new RegExp(SCRIPT_SOURCE, 'gi')
9
+ const payloads = []
10
+ let m
11
+ while ((m = re.exec(html)) !== null) payloads.push(m[1])
12
+ return payloads
13
+ }
14
+
15
+ const irError = (code, message) => new KsbError({ code, message, exitCode: EXIT.VALIDATION })
16
+
17
+ /**
18
+ * Recover the single IR envelope an artifact carries, or fail with a pinned code.
19
+ *
20
+ * Shared by `replay` and by the export chain: both have to answer "what is this
21
+ * artifact, really?" from the embedded record rather than from its markup, and
22
+ * two copies of these three failure codes would eventually disagree about what
23
+ * a malformed artifact is called.
24
+ */
25
+ export function readEmbeddedIr(html, path) {
26
+ const payloads = extractIrPayloads(html)
27
+ if (payloads.length === 0) {
28
+ throw irError(CODES.IR_MISSING, `no embedded IR found in ${path}; nothing to replay`)
29
+ }
30
+ if (payloads.length > 1) {
31
+ throw irError(CODES.IR_DUPLICATE, `${path} carries ${payloads.length} IR payloads, expected 1`)
32
+ }
33
+ try {
34
+ return JSON.parse(payloads[0])
35
+ } catch (cause) {
36
+ throw irError(CODES.IR_UNPARSABLE, `embedded IR in ${path} is not valid JSON: ${cause.message}`)
37
+ }
38
+ }
@@ -0,0 +1,103 @@
1
+ import { containerSyntax } from '../blocks/index.js'
2
+
3
+ const COLON = 0x3a
4
+
5
+ /**
6
+ * markdown-it block rule for the Markdown-superset container fences:
7
+ *
8
+ * :::note ::::grid
9
+ * body :::place col=1 colSpan=16
10
+ * ::: 左欄。
11
+ * :::
12
+ * ::::
13
+ *
14
+ * Which names are legal is **not** written here. A block module declares
15
+ * `syntax.containers`, and this rule asks the registry — so a third-party
16
+ * container block needs no parser edit, which was the one surface the F1 slice
17
+ * left behind (wayfinder issue 13). Emits `kami_container_open` /
18
+ * `kami_container_close` tokens carrying the whole parameter string in `info`;
19
+ * splitting name from parameters is the module's business, not the lexer's.
20
+ *
21
+ * Modelled on markdown-it-container, including its nesting rule: a fence closes
22
+ * on a bare colon run at least as long as its own. Unlike the original, nesting
23
+ * *depth* is counted, so an inner fence of the same length no longer lets its
24
+ * closing line close the outer one — without that, `:::grid` containing
25
+ * `:::place` would end at the first `:::` and silently lose everything after it.
26
+ */
27
+ export function containerPlugin(md) {
28
+ const rule = (state, startLine, endLine, silent) => {
29
+ if (state.sCount[startLine] - state.blkIndent >= 4) return false
30
+
31
+ let start = state.bMarks[startLine] + state.tShift[startLine]
32
+ let max = state.eMarks[startLine]
33
+ if (start + 3 > max) return false
34
+ if (state.src.charCodeAt(start) !== COLON) return false
35
+
36
+ let pos = state.skipChars(start, COLON)
37
+ const markerLen = pos - start
38
+ if (markerLen < 3) return false
39
+
40
+ const params = state.src.slice(pos, max).trim()
41
+ const known = containerSyntax()
42
+ if (!known.has(params.split(/\s+/)[0])) return false
43
+ if (silent) return true
44
+
45
+ let nextLine = startLine
46
+ let autoClosed = false
47
+ let depth = 1
48
+ for (;;) {
49
+ nextLine += 1
50
+ if (nextLine >= endLine) break
51
+ start = state.bMarks[nextLine] + state.tShift[nextLine]
52
+ max = state.eMarks[nextLine]
53
+ if (start < max && state.sCount[nextLine] < state.blkIndent) break
54
+ if (state.src.charCodeAt(start) !== COLON) continue
55
+ if (state.sCount[nextLine] - state.blkIndent >= 4) continue
56
+ pos = state.skipChars(start, COLON)
57
+ if (pos - start < markerLen) continue
58
+ const rest = state.src.slice(pos, max).trim()
59
+ if (rest.length > 0) {
60
+ // An opener of a *recognised* container nests; anything else is text
61
+ // this rule has no opinion about, and counting it would mis-balance the
62
+ // very fence it is trying to close.
63
+ if (known.has(rest.split(/\s+/)[0])) depth += 1
64
+ continue
65
+ }
66
+ depth -= 1
67
+ if (depth > 0) continue
68
+ autoClosed = true
69
+ break
70
+ }
71
+
72
+ const oldParent = state.parentType
73
+ const oldLineMax = state.lineMax
74
+ state.parentType = 'kami_container'
75
+ state.lineMax = nextLine
76
+
77
+ const open = state.push('kami_container_open', 'div', 1)
78
+ open.markup = ':'.repeat(markerLen)
79
+ open.block = true
80
+ open.info = params
81
+ open.map = [startLine, nextLine]
82
+
83
+ state.md.block.tokenize(state, startLine + 1, nextLine)
84
+
85
+ const close = state.push('kami_container_close', 'div', -1)
86
+ close.markup = ':'.repeat(markerLen)
87
+ close.block = true
88
+
89
+ state.parentType = oldParent
90
+ state.lineMax = oldLineMax
91
+ state.line = nextLine + (autoClosed ? 1 : 0)
92
+ return true
93
+ }
94
+
95
+ md.block.ruler.before('fence', 'kami_container', rule, {
96
+ alt: ['paragraph', 'reference', 'blockquote', 'list'],
97
+ })
98
+
99
+ // 刻意不註冊 kami_container 的 renderer rule:container 的標記屬於 block 模組
100
+ // (src/blocks/<型別>.js 的 render),parser 不得自行產出一份。
101
+ // 容器 token 一律由 walkTokens 交回宣告它的模組,永遠不會走到 renderer;
102
+ // 「body 有 callout 標記則 IR 必有 callout block」的反向對稱斷言看守這一點。
103
+ }