@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,158 @@
1
+ /**
2
+ * The block module registry — one block type's whole knowledge in one place.
3
+ *
4
+ * Before this existed, adding one block type meant editing eleven files spread
5
+ * across every layer (wayfinder issue 13): the type list, the JSON Schema, the
6
+ * example table, the renderer table, two template manifests, two stylesheets,
7
+ * and the parser. The slicing axis was the *layer*, which is right for the
8
+ * pipeline and wrong for extension — because the unit of extension is the
9
+ * block, and a block crosses every layer. A plugin block was therefore not
10
+ * "unimplemented" but structurally impossible.
11
+ *
12
+ * Registration replaces a frozen snapshot with a new frozen snapshot; no map is
13
+ * ever mutated in place. `version` ticks on every change so downstream caches
14
+ * (the compiled Ajv validator, most importantly) can tell that the vocabulary
15
+ * they were built against is stale.
16
+ */
17
+
18
+ /** Every field a block module must declare. `syntax`/`validate`/`create`/`nesting` are optional. */
19
+ export const BLOCK_MODULE_FIELDS = Object.freeze([
20
+ 'type',
21
+ 'schema',
22
+ 'example',
23
+ 'render',
24
+ 'styleHooks',
25
+ ])
26
+
27
+ /**
28
+ * SPEC §12 — a plugin block wears an `x-` prefix so that core and third-party
29
+ * vocabularies can never collide, and so a template's admission rule can name
30
+ * the whole class of them without knowing any single one.
31
+ */
32
+ export const PLUGIN_TYPE_PREFIX = 'x-'
33
+
34
+ /**
35
+ * What a template writes in `manifest.blocks` to admit the whole plugin class
36
+ * rather than naming blocks that did not exist when it was written.
37
+ */
38
+ export const PLUGIN_TYPE_WILDCARD = `${PLUGIN_TYPE_PREFIX}*`
39
+
40
+ /** SPEC §10.3 — every user-facing failure code wears this prefix. */
41
+ const ERROR_CODE_PREFIX = 'KSB_'
42
+
43
+ const EMPTY = Object.freeze({ modules: Object.freeze([]), byType: new Map(), version: 0 })
44
+
45
+ /** The current snapshot. Replaced wholesale, never edited. */
46
+ let current = EMPTY
47
+ /** The snapshot `resetBlockRegistry()` returns to — the core vocabulary alone. */
48
+ let baseline = EMPTY
49
+ /** Whether the core vocabulary has been closed; after that, only plugins may register. */
50
+ let sealed = false
51
+
52
+ const reject = (message) => {
53
+ throw new TypeError(`invalid block module: ${message}`)
54
+ }
55
+
56
+ /**
57
+ * A module that can say "no" must also say *how the refusal is reported*.
58
+ *
59
+ * `validate` is optional; `invalid` is not optional once `validate` exists. The
60
+ * spec gate reads `mod.invalid.message(problems)` and `mod.invalid.code` at the
61
+ * moment a document is refused — the one moment a `TypeError` there would turn a
62
+ * clear authoring error into a stack trace, on the rarest path, where no happy-
63
+ * path test would ever reach it. Registration is the only place this can be
64
+ * caught before it matters.
65
+ */
66
+ function assertReportableValidate(mod) {
67
+ if (mod.validate === undefined) return
68
+ const where = `"${mod.type}"`
69
+ if (typeof mod.validate !== 'function') reject(`\`validate\` of ${where} must be a function`)
70
+ const invalid = mod.invalid
71
+ if (invalid === null || typeof invalid !== 'object') {
72
+ reject(`${where} declares \`validate\` but no \`invalid\` {code, message}`)
73
+ }
74
+ if (typeof invalid.code !== 'string' || !invalid.code.startsWith(ERROR_CODE_PREFIX)) {
75
+ reject(`\`invalid.code\` of ${where} must be a \`${ERROR_CODE_PREFIX}\` code`)
76
+ }
77
+ if (typeof invalid.message !== 'function') {
78
+ reject(`\`invalid.message\` of ${where} must be a function of the problem list`)
79
+ }
80
+ }
81
+
82
+ /** Reject anything that would register a half-built module and fail later, deeper. */
83
+ function assertModuleShape(mod, byType) {
84
+ if (mod === null || typeof mod !== 'object') reject('not an object')
85
+ for (const field of BLOCK_MODULE_FIELDS) {
86
+ if (mod[field] === undefined || mod[field] === null) reject(`missing \`${field}\``)
87
+ }
88
+ if (typeof mod.type !== 'string' || mod.type.length === 0) reject('`type` must be a non-empty string')
89
+ if (typeof mod.render !== 'function') reject(`\`render\` of "${mod.type}" must be a function`)
90
+ if (typeof mod.schema !== 'object') reject(`\`schema\` of "${mod.type}" must be an object`)
91
+ if (typeof mod.example !== 'object') reject(`\`example\` of "${mod.type}" must be an object`)
92
+ if (mod.example.block === undefined && typeof mod.example.source !== 'string') {
93
+ reject(`\`example\` of "${mod.type}" must carry \`block\` or \`source\``)
94
+ }
95
+ if (!Array.isArray(mod.styleHooks)) reject(`\`styleHooks\` of "${mod.type}" must be an array`)
96
+ assertReportableValidate(mod)
97
+ if (byType.has(mod.type)) reject(`block type "${mod.type}" is already registered`)
98
+ // Once the core vocabulary is sealed, everything further is a plugin and must
99
+ // wear the prefix (SPEC §12). Without it a plugin could quietly occupy a name
100
+ // the core vocabulary might later want, and no reader of an IR payload could
101
+ // tell which blocks came from the engine and which from a third party.
102
+ if (sealed && !mod.type.startsWith(PLUGIN_TYPE_PREFIX)) {
103
+ reject(`plugin block type "${mod.type}" must start with \`${PLUGIN_TYPE_PREFIX}\``)
104
+ }
105
+ }
106
+
107
+ const snapshotWith = (previous, mod) => {
108
+ const modules = Object.freeze([...previous.modules, mod])
109
+ const byType = new Map(previous.byType)
110
+ byType.set(mod.type, mod)
111
+ return Object.freeze({ modules, byType, version: previous.version + 1 })
112
+ }
113
+
114
+ /**
115
+ * Register one block module. The public extension point (CONTRACT A4): a third
116
+ * party reaches this and nothing else, and never edits a file inside the SDK.
117
+ *
118
+ * @param {{type: string, schema: object, example: object, render: Function,
119
+ * styleHooks: readonly string[], syntax?: object, validate?: Function,
120
+ * create?: Function, nesting?: {arrays?: string[], matrices?: string[]}}} mod
121
+ */
122
+ export function registerBlockModule(mod) {
123
+ assertModuleShape(mod, current.byType)
124
+ current = snapshotWith(current, mod)
125
+ return mod.type
126
+ }
127
+
128
+ /**
129
+ * Freeze the current vocabulary as the one `resetBlockRegistry` returns to.
130
+ * Called once, by the core vocabulary's own index — a plugin never calls it.
131
+ */
132
+ export function sealCoreRegistry() {
133
+ baseline = current
134
+ sealed = true
135
+ return baseline.version
136
+ }
137
+
138
+ /** Drop every module registered after the core seal (test hygiene). */
139
+ export function resetBlockRegistry() {
140
+ current = baseline
141
+ return current.version
142
+ }
143
+
144
+ /** Registered modules, in registration order. */
145
+ export const blockModules = () => current.modules
146
+
147
+ /** One module by type name, or undefined. */
148
+ export const blockModule = (type) => current.byType.get(type)
149
+
150
+ /** Registered type names, in registration order — the canonical block vocabulary. */
151
+ export const blockTypes = () => current.modules.map((mod) => mod.type)
152
+
153
+ /** The types registered beyond the core vocabulary (SPEC §12 `x-*` blocks). */
154
+ export const pluginBlockTypes = () =>
155
+ current.modules.filter((mod) => !baseline.byType.has(mod.type)).map((mod) => mod.type)
156
+
157
+ /** Bumped whenever the vocabulary changes; caches key off it. */
158
+ export const registryVersion = () => current.version
@@ -0,0 +1,19 @@
1
+ /**
2
+ * JSON Schema fragments shared by more than one block module.
3
+ *
4
+ * These are vocabulary *primitives* (a string, a list of child blocks), not a
5
+ * per-block table: nothing here knows which types exist. Each block module
6
+ * assembles its own `schema` out of them, and `core/schema.js` assembles the
7
+ * document schema out of the modules — one direction, no second list.
8
+ */
9
+
10
+ export const str = Object.freeze({ type: 'string' })
11
+
12
+ /** A flat array of child blocks. */
13
+ export const children = Object.freeze({ type: 'array', items: { $ref: '#/$defs/block' } })
14
+
15
+ /** A row of table cells — plain strings, already compiled to inline HTML. */
16
+ export const cells = Object.freeze({ type: 'array', items: str })
17
+
18
+ /** `list.items` is one block array *per list item* — an array of arrays. */
19
+ export const itemGroups = Object.freeze({ type: 'array', items: children })
@@ -0,0 +1,53 @@
1
+ import { el } from './element.js'
2
+ import { str, children } from './schema-parts.js'
3
+
4
+ /** Heading levels an HTML document actually has. */
5
+ const MIN_LEVEL = 1
6
+ const MAX_LEVEL = 6
7
+
8
+ /**
9
+ * `section` — a heading and everything under it, already nested by the parser.
10
+ *
11
+ * The level is clamped rather than trusted: `id` and `h{level}` both flow into
12
+ * markup, and an out-of-range level would emit an element that does not exist.
13
+ */
14
+ export default Object.freeze({
15
+ type: 'section',
16
+
17
+ schema: Object.freeze({
18
+ required: ['title', 'level', 'children'],
19
+ properties: {
20
+ title: str,
21
+ level: { type: 'integer', minimum: MIN_LEVEL, maximum: MAX_LEVEL },
22
+ children,
23
+ },
24
+ }),
25
+
26
+ example: Object.freeze({
27
+ block: { id: 'b2', type: 'section', title: '章節標題', level: 1, children: [] },
28
+ }),
29
+
30
+ nesting: Object.freeze({ arrays: Object.freeze(['children']) }),
31
+
32
+ styleHooks: Object.freeze([
33
+ 'section',
34
+ 'section-l1',
35
+ 'section-l2',
36
+ 'section-l3',
37
+ 'section-l4',
38
+ 'section-l5',
39
+ 'section-l6',
40
+ 'section-title',
41
+ ]),
42
+
43
+ create: ({ title, level, children: kids }) =>
44
+ Object.freeze({ type: 'section', title, level, children: kids }),
45
+
46
+ render: (block, ctx) => {
47
+ const level = Math.min(Math.max(block.level ?? MIN_LEVEL, MIN_LEVEL), MAX_LEVEL)
48
+ return el('section', { class: `section section-l${level}`, id: block.id }, [
49
+ el(`h${level}`, { class: 'section-title' }, [block.title]),
50
+ ...ctx.renderChildren(block.children),
51
+ ])
52
+ },
53
+ })
@@ -0,0 +1,104 @@
1
+ import { el } from './element.js'
2
+ import { children } from './schema-parts.js'
3
+ import { GRID_COLUMNS, placementStyle } from './placement.js'
4
+
5
+ /**
6
+ * `slide` — one page of a deck.
7
+ *
8
+ * `<section class="slide">` verbatim: the class attribute stays exactly one
9
+ * token so the container is greppable as a constant, and per-slide variation
10
+ * lives in the markup inside rather than in extra classes on the frame.
11
+ *
12
+ * The `slide-lead` slot is how a template puts something *before* a slide's own
13
+ * blocks — `kami/slides` uses it to give the opening slide the document's own
14
+ * identity. The rule "which slide gets it" is the template's, so this module
15
+ * only hands over the position and asks.
16
+ *
17
+ * ## What the slide asks the canvas for (F2f)
18
+ *
19
+ * A slide is where a deck's fixed logical canvas actually lands, and where its
20
+ * 24 grid tracks actually are. Both facts are the **layout's**, so this module
21
+ * asks `ctx.canvas` for them instead of assuming either:
22
+ *
23
+ * - `canvasAttrs` non-null → this artifact has a fixed logical page, so the
24
+ * section *is* that page and wears the size the skeleton scales it by.
25
+ * - `gridAddressed` → the layout has declared the whole page addressable, so
26
+ * the content region lays the tracks that make a bare `col: 17` resolve.
27
+ *
28
+ * A third-party deck-rooted layout that is flowing and unaddressed therefore
29
+ * gets exactly the markup this module emitted before F2f, byte for byte.
30
+ */
31
+
32
+ /**
33
+ * Verbatim — the tracks a grid-addressed slide lays on its content region.
34
+ *
35
+ * Columns are 24 explicit `1fr` tracks, identical in form to the `grid` block's
36
+ * (`src/blocks/grid.js`), because they are the same 24 columns: one vocabulary,
37
+ * one geometry, spelled the same way in both places so a coordinate means the
38
+ * same thing wherever an author writes it.
39
+ *
40
+ * Rows are 24 **explicit `auto`** tracks, and that asymmetry is deliberate. A
41
+ * row of a fixed fraction of the slide's height would be the more literally
42
+ * "normalised" canvas, and it would also break 「不包=預設流式」 (issue 14
43
+ * 裁決 2): content with no placement is auto-placed one item per row, so every
44
+ * paragraph taller than 1/24 of the page — which is nearly all of them — would
45
+ * spill into the next item. Declaring the rows as `auto` keeps `row: 12` a real
46
+ * declared line rather than an implicit one, and keeps unplaced blocks flowing
47
+ * down the slide exactly as they always have. What is given up is that a row
48
+ * index addresses an *order*, not a fraction of the height.
49
+ */
50
+ export const SLIDE_GRID_STYLE =
51
+ `display: grid; grid-template-columns: repeat(${GRID_COLUMNS}, minmax(0, 1fr)); ` +
52
+ `grid-template-rows: repeat(${GRID_COLUMNS}, minmax(0, auto))`
53
+
54
+ export default Object.freeze({
55
+ type: 'slide',
56
+
57
+ schema: Object.freeze({ required: ['children'], properties: { children } }),
58
+
59
+ example: Object.freeze({
60
+ block: {
61
+ id: 'b16',
62
+ type: 'slide',
63
+ children: [{ id: 'b17', type: 'prose', html: '單張投影片的內容。' }],
64
+ },
65
+ }),
66
+
67
+ nesting: Object.freeze({ arrays: Object.freeze(['children']) }),
68
+
69
+ styleHooks: Object.freeze(['slide', 'slide-inner', 'slide-cell']),
70
+
71
+ create: ({ children: kids }) => Object.freeze({ type: 'slide', children: kids }),
72
+
73
+ /**
74
+ * On a grid-addressed slide every direct child of the content region becomes
75
+ * a **cell**, carrying that child's placement as inline geometry — the same
76
+ * shape, and the same `placementStyle`, the `grid` block uses.
77
+ *
78
+ * Every child, not only the placed ones: an unplaced block dropped straight
79
+ * into a 24-column grid would be one column wide. `placementStyle` already
80
+ * says what "no placement" means on a grid — `auto / span 24`, the full
81
+ * width — so wrapping uniformly is what makes 「不包=預設流式」 survive the
82
+ * tracks instead of being quietly contradicted by them. The chrome a template
83
+ * fills the `slide-lead` slot with is wrapped for the same reason: it is a
84
+ * direct child too, and an unwrapped masthead would be a 1/24-wide masthead.
85
+ */
86
+ render: (block, ctx) => {
87
+ const addressed = ctx.canvas?.gridAddressed === true
88
+ const cell = (node, source) =>
89
+ el('div', { class: 'slide-cell', style: placementStyle(source) }, [node])
90
+ const drawable = ctx.drawableChildren(block.children)
91
+ const lead = ctx.chrome('slide-lead', ctx.position)
92
+ const inner = addressed
93
+ ? [
94
+ ...lead.map((node) => cell(node, null)),
95
+ ...drawable.map((kid, index) =>
96
+ cell(ctx.renderBlock(kid, { index, total: drawable.length }), kid),
97
+ ),
98
+ ]
99
+ : [...lead, ...ctx.renderChildren(block.children)]
100
+ return el('section', { class: 'slide', ...(ctx.canvas?.canvasAttrs ?? {}) }, [
101
+ el('div', { class: 'slide-inner', ...(addressed ? { style: SLIDE_GRID_STYLE } : {}) }, inner),
102
+ ])
103
+ },
104
+ })
@@ -0,0 +1,83 @@
1
+ import { el } from './element.js'
2
+ import { str } from './schema-parts.js'
3
+
4
+ /**
5
+ * `stat` — the KPI wall (wayfinder issue 11 P1「資料」「stat(KPI 磚)」).
6
+ *
7
+ * One block is a *wall* of bricks rather than a single brick, following the
8
+ * shape the data group already uses: `table` is a whole table and `list` a
9
+ * whole list, never one cell and never one item. A lone tile would also have
10
+ * nothing to be a tile *of* — the reading of a KPI is comparative, so the row
11
+ * is the unit that carries meaning.
12
+ *
13
+ * ## Why the item has exactly two fields (issue 13 裁決 5 的同一條)
14
+ *
15
+ * `label` and `value`, both strings, `additionalProperties: false`. Everything
16
+ * a KPI tile is usually *also* asked to carry — unit, delta, trend arrow,
17
+ * target, threshold colour, icon, variant — is semantics layered on top of the
18
+ * measurement, and 13 號票 裁決 5 put that class of growth in a plugin kind
19
+ * rather than in the core schema. The battle-map palette (狀態色, effort) is
20
+ * the named example there and would arrive here first if the door were left
21
+ * open.
22
+ *
23
+ * `value` is a string, not a number, and that is not laziness: a KPI reads
24
+ * `87%`, `3.2x`, `NT$1.2M`. Formatting is a decision the author has already
25
+ * made by the time the block exists, and re-deriving it here would need a
26
+ * locale, a precision and a unit — three fields the narrow rule just refused.
27
+ *
28
+ * No inline geometry (unlike `grid`): a stat wall carries no coordinates in the
29
+ * IR, so there is no geometry that *is* the block. Side-by-side is paint, and
30
+ * paint is the template's, through the four hooks below.
31
+ */
32
+
33
+ /** One brick: what was measured, and what it measured. */
34
+ const statItem = Object.freeze({
35
+ type: 'object',
36
+ required: ['label', 'value'],
37
+ properties: { label: str, value: str },
38
+ additionalProperties: false,
39
+ })
40
+
41
+ export default Object.freeze({
42
+ type: 'stat',
43
+
44
+ schema: Object.freeze({
45
+ required: ['items'],
46
+ properties: { items: { type: 'array', minItems: 1, items: statItem } },
47
+ }),
48
+
49
+ example: Object.freeze({
50
+ block: {
51
+ id: 'b22',
52
+ type: 'stat',
53
+ items: [
54
+ { label: '核心詞彙', value: '16 種' },
55
+ { label: '出廠文體', value: '5 種' },
56
+ { label: '外部請求', value: '0' },
57
+ ],
58
+ },
59
+ }),
60
+
61
+ styleHooks: Object.freeze(['stat', 'stat-tile', 'stat-value', 'stat-label']),
62
+
63
+ create: ({ items }) => Object.freeze({ type: 'stat', items }),
64
+
65
+ /**
66
+ * Value before label, in document order.
67
+ *
68
+ * A screen reader reads them in this order too, and the number is the thing
69
+ * the reader came for; the label only says what it was. A stylesheet that
70
+ * wants the label on top says so with `order`, which is paint.
71
+ */
72
+ render: (block) =>
73
+ el(
74
+ 'div',
75
+ { class: 'stat' },
76
+ (block.items ?? []).map((item) =>
77
+ el('div', { class: 'stat-tile' }, [
78
+ el('span', { class: 'stat-value' }, [item.value]),
79
+ el('span', { class: 'stat-label' }, [item.label]),
80
+ ]),
81
+ ),
82
+ ),
83
+ })
@@ -0,0 +1,50 @@
1
+ import { el, rawEl } from './element.js'
2
+ import { cells } from './schema-parts.js'
3
+
4
+ /**
5
+ * `table` — head row plus body rows, every cell already inline-compiled.
6
+ *
7
+ * Two style hooks only, `.table-wrap` and `.table`. A stylesheet that needs to
8
+ * treat a two-column table differently selects on column count rather than
9
+ * asking the IR to carry an extra field: 模板樣式不得要求 IR 多帶資訊.
10
+ */
11
+ export default Object.freeze({
12
+ type: 'table',
13
+
14
+ schema: Object.freeze({
15
+ required: ['head', 'rows'],
16
+ properties: { head: cells, rows: { type: 'array', items: cells } },
17
+ }),
18
+
19
+ example: Object.freeze({
20
+ block: { id: 'b9', type: 'table', head: ['欄位', '意義'], rows: [['id', 'block 識別碼']] },
21
+ }),
22
+
23
+ styleHooks: Object.freeze(['table-wrap', 'table']),
24
+
25
+ create: ({ head, rows }) => Object.freeze({ type: 'table', head, rows }),
26
+
27
+ render: (block) =>
28
+ el('div', { class: 'table-wrap' }, [
29
+ el('table', { class: 'table' }, [
30
+ el('thead', null, [
31
+ el(
32
+ 'tr',
33
+ null,
34
+ block.head.map((cell) => rawEl('th', null, cell)),
35
+ ),
36
+ ]),
37
+ el(
38
+ 'tbody',
39
+ null,
40
+ block.rows.map((row) =>
41
+ el(
42
+ 'tr',
43
+ null,
44
+ row.map((cell) => rawEl('td', null, cell)),
45
+ ),
46
+ ),
47
+ ),
48
+ ]),
49
+ ]),
50
+ })
@@ -0,0 +1,80 @@
1
+ import { el } from './element.js'
2
+ import { str } from './schema-parts.js'
3
+
4
+ /**
5
+ * `timeline` — 時間軸 (wayfinder issue 11 P1「資料」).
6
+ *
7
+ * An ordered list of moments: each entry is `when` it happened and `what`
8
+ * happened, both plain strings.
9
+ *
10
+ * ## Why an entry carries no child blocks
11
+ *
12
+ * The obvious next field is a body — a paragraph, a callout, anything under
13
+ * each moment. It is deliberately absent, and the reason is structural rather
14
+ * than a taste call: nested blocks reach the id-assigning walk through a
15
+ * module's `nesting` declaration, which names *top-level* keys on the block
16
+ * (`children`, `slides`, `items`). Blocks buried inside an array of objects
17
+ * are reachable by neither shape, so they would silently receive no ids —
18
+ * and a block with no id has no block path, cannot be commented on, and
19
+ * cannot be reported when it fails validation. Growing the nesting vocabulary
20
+ * to a third shape is a registry change, and the registry is exactly what this
21
+ * slice may not touch.
22
+ *
23
+ * So an entry is flat, and a timeline that needs prose under a moment is a
24
+ * timeline followed by prose — or a plugin kind, per issue 13 裁決 5.
25
+ *
26
+ * `time` is a string and no format is imposed. A timeline legitimately mixes
27
+ * `2026-08`, `Q3`, `第三戰役` and `未定`; a date type would refuse three of
28
+ * those, and normalising them would need a calendar this block has no business
29
+ * knowing about.
30
+ */
31
+
32
+ /** One moment: when, and what. */
33
+ const timelineEntry = Object.freeze({
34
+ type: 'object',
35
+ required: ['time', 'label'],
36
+ properties: { time: str, label: str },
37
+ additionalProperties: false,
38
+ })
39
+
40
+ export default Object.freeze({
41
+ type: 'timeline',
42
+
43
+ schema: Object.freeze({
44
+ required: ['items'],
45
+ properties: { items: { type: 'array', minItems: 1, items: timelineEntry } },
46
+ }),
47
+
48
+ example: Object.freeze({
49
+ block: {
50
+ id: 'b23',
51
+ type: 'timeline',
52
+ items: [
53
+ { time: '2026-08-19', label: '轉子接口定案,block 模組化' },
54
+ { time: '2026-08-24', label: '五文體終局,骨架資產歸位' },
55
+ { time: '2026-08-25', label: '四型資料 block 落地' },
56
+ ],
57
+ },
58
+ }),
59
+
60
+ styleHooks: Object.freeze(['timeline', 'timeline-entry', 'timeline-time', 'timeline-label']),
61
+
62
+ create: ({ items }) => Object.freeze({ type: 'timeline', items }),
63
+
64
+ /**
65
+ * `<ol>` rather than `<div>`: a timeline *is* an ordered sequence, and the
66
+ * ordering is semantic, not visual. A reader on a screen reader hears "list,
67
+ * 3 items" and can move through it; a stack of divs is silent about that.
68
+ */
69
+ render: (block) =>
70
+ el(
71
+ 'ol',
72
+ { class: 'timeline' },
73
+ (block.items ?? []).map((entry) =>
74
+ el('li', { class: 'timeline-entry' }, [
75
+ el('span', { class: 'timeline-time' }, [entry.time]),
76
+ el('span', { class: 'timeline-label' }, [entry.label]),
77
+ ]),
78
+ ),
79
+ ),
80
+ })
@@ -0,0 +1,51 @@
1
+ import { CODES, EXIT, KsbError } from '../../core/errors.js'
2
+ import { clearServeRecords, isAlive, readServeRecords } from '../../delivery/run.js'
3
+
4
+ /**
5
+ * `kamishibai close` (CONTRACT E1).
6
+ *
7
+ * Idempotent by construction: "there is no server running" is a *success*, not
8
+ * an error. The command answers one question — "is anything this SDK started
9
+ * still running?" — and the only honest post-condition is "no". A `close` that
10
+ * failed when the server was already gone would train every caller to ignore
11
+ * its exit code, which is how the orphan-daemon trap (G2) reopens.
12
+ *
13
+ * Every recorded server is signalled, not just the newest: an orphan is by
14
+ * definition the one nobody remembers, so `close` must not depend on being told
15
+ * which process to stop.
16
+ *
17
+ * @returns {{result: {ok: true, closed: number[], stale: number[]}, exitCode: number}}
18
+ */
19
+ export function closeCommand(options = {}, env = process.env) {
20
+ const records = readServeRecords(env)
21
+ const closed = []
22
+ const stale = []
23
+ const failures = []
24
+
25
+ for (const record of records) {
26
+ if (!isAlive(record.pid)) {
27
+ stale.push(record.pid)
28
+ continue
29
+ }
30
+ try {
31
+ process.kill(record.pid, 'SIGTERM')
32
+ closed.push(record.pid)
33
+ } catch (cause) {
34
+ failures.push(`${record.pid}(${cause.message})`)
35
+ }
36
+ }
37
+
38
+ if (failures.length > 0) {
39
+ // The pidfile is deliberately left intact: a server we could not stop is
40
+ // still running, and forgetting it would make the next `close` claim there
41
+ // was nothing to do.
42
+ throw new KsbError({
43
+ code: CODES.SERVE_CLOSE_FAILED,
44
+ message: `無法終止 serve 程序:${failures.join('、')}`,
45
+ exitCode: EXIT.VALIDATION,
46
+ })
47
+ }
48
+
49
+ clearServeRecords(env)
50
+ return { result: { ok: true, closed, stale }, exitCode: EXIT.OK }
51
+ }
@@ -0,0 +1,73 @@
1
+ import { EXIT, usageError } from '../../core/errors.js'
2
+ import { readArtifact } from '../../delivery/read.js'
3
+ import { readEmbeddedIr } from '../../parser/artifact.js'
4
+ import { appendComment, listComments, resolveComment } from '../../delivery/comments.js'
5
+ import { collectBlockIds } from '../../core/blocks.js'
6
+
7
+ /**
8
+ * `kamishibai comments <artifact> [--json]`
9
+ * `kamishibai comments resolve <artifact> <id>`
10
+ * `kamishibai comments add <artifact> <blockId> <text…>`
11
+ *
12
+ * The Agent end of SPEC §13.1's loop: read what a human said about which block,
13
+ * edit the IR, `replay`, then mark it resolved.
14
+ *
15
+ * `add` is the CLI mirror of the overlay's POST and shares its one
16
+ * implementation. It exists so the write path — including the anchor check that
17
+ * makes a comment trustworthy — is reachable and assertable without a browser;
18
+ * two write paths that only *ought* to validate the same way is exactly the
19
+ * drift that would let an unanchored comment into the log.
20
+ */
21
+
22
+ const SUBCOMMANDS = Object.freeze(['resolve', 'add'])
23
+
24
+ const listing = (artifactPath) => {
25
+ const { path } = readArtifact(artifactPath)
26
+ return { result: listComments(path), exitCode: EXIT.OK }
27
+ }
28
+
29
+ const adding = (artifactPath, blockId, textParts) => {
30
+ const { html, path } = readArtifact(artifactPath)
31
+ const entry = appendComment({
32
+ artifactPath: path,
33
+ blockId,
34
+ text: textParts.join(' '),
35
+ // Legal anchors are read off the artifact's own embedded IR — the same
36
+ // source the served page shows, so an id that was pickable is appendable.
37
+ blockIds: collectBlockIds(readEmbeddedIr(html, path).doc),
38
+ })
39
+ return { result: { ok: true, ...entry }, exitCode: EXIT.OK }
40
+ }
41
+
42
+ const resolving = (artifactPath, id) => {
43
+ const { path } = readArtifact(artifactPath)
44
+ const entry = resolveComment({ artifactPath: path, id })
45
+ return { result: { ok: true, ...entry }, exitCode: EXIT.OK }
46
+ }
47
+
48
+ /**
49
+ * @param {string[]} args the raw positional words, so the three documented call
50
+ * forms stay spelled exactly as CONTRACT E2 writes them
51
+ */
52
+ export function commentsCommand(args = []) {
53
+ const [head, ...rest] = args
54
+
55
+ if (head === undefined) throw usageError('comments 需要一個產物路徑')
56
+
57
+ if (!SUBCOMMANDS.includes(head)) {
58
+ if (rest.length > 0) throw usageError(`comments 不認得多餘的參數:${rest.join(' ')}`)
59
+ return listing(head)
60
+ }
61
+
62
+ const [artifact, ...tail] = rest
63
+ if (artifact === undefined) throw usageError(`comments ${head} 需要一個產物路徑`)
64
+
65
+ if (head === 'resolve') {
66
+ if (tail.length !== 1) throw usageError('comments resolve <artifact> <id> 需要恰好一個留言 id')
67
+ return resolving(artifact, tail[0])
68
+ }
69
+
70
+ if (tail.length < 2) throw usageError('comments add <artifact> <blockId> <text> 需要 block id 與留言內容')
71
+ const [blockId, ...text] = tail
72
+ return adding(artifact, blockId, text)
73
+ }