@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.
- package/README.md +192 -0
- package/package.json +54 -0
- package/src/blocks/board.js +210 -0
- package/src/blocks/callout.js +63 -0
- package/src/blocks/code.js +28 -0
- package/src/blocks/deck.js +76 -0
- package/src/blocks/diagram.js +265 -0
- package/src/blocks/element.js +51 -0
- package/src/blocks/graph.js +264 -0
- package/src/blocks/grid.js +156 -0
- package/src/blocks/index.js +106 -0
- package/src/blocks/list.js +50 -0
- package/src/blocks/placement.js +119 -0
- package/src/blocks/prose.js +28 -0
- package/src/blocks/quote.js +25 -0
- package/src/blocks/raw.js +47 -0
- package/src/blocks/registry.js +158 -0
- package/src/blocks/schema-parts.js +19 -0
- package/src/blocks/section.js +53 -0
- package/src/blocks/slide.js +104 -0
- package/src/blocks/stat.js +83 -0
- package/src/blocks/table.js +50 -0
- package/src/blocks/timeline.js +80 -0
- package/src/cli/commands/close.js +51 -0
- package/src/cli/commands/comments.js +73 -0
- package/src/cli/commands/debug.js +34 -0
- package/src/cli/commands/example.js +22 -0
- package/src/cli/commands/export.js +10 -0
- package/src/cli/commands/init.js +53 -0
- package/src/cli/commands/lint.js +93 -0
- package/src/cli/commands/list.js +54 -0
- package/src/cli/commands/open.js +31 -0
- package/src/cli/commands/promote.js +38 -0
- package/src/cli/commands/render.js +31 -0
- package/src/cli/commands/replay.js +49 -0
- package/src/cli/commands/schema.js +10 -0
- package/src/cli/commands/serve.js +178 -0
- package/src/cli/commands/setup.js +64 -0
- package/src/cli/commands/snapshot.js +29 -0
- package/src/cli/commands/templates.js +75 -0
- package/src/cli/deliver.js +54 -0
- package/src/cli/emit.js +29 -0
- package/src/cli/format.js +153 -0
- package/src/cli/index.js +365 -0
- package/src/cli/registry.js +18 -0
- package/src/core/blocks.js +147 -0
- package/src/core/diagram.js +282 -0
- package/src/core/errors.js +156 -0
- package/src/core/example.js +109 -0
- package/src/core/ir.js +62 -0
- package/src/core/lint-gates.js +427 -0
- package/src/core/lint.js +281 -0
- package/src/core/scan.js +84 -0
- package/src/core/schema.js +88 -0
- package/src/core/spec-check.js +33 -0
- package/src/core/validate.js +44 -0
- package/src/core/version.js +18 -0
- package/src/core/vocabulary.js +140 -0
- package/src/delivery/atomic.js +71 -0
- package/src/delivery/comments.js +180 -0
- package/src/delivery/home.js +70 -0
- package/src/delivery/open.js +31 -0
- package/src/delivery/project.js +141 -0
- package/src/delivery/read.js +109 -0
- package/src/delivery/run.js +95 -0
- package/src/delivery/scaffold-blueprints.js +728 -0
- package/src/delivery/store.js +219 -0
- package/src/delivery/template-extensions.js +183 -0
- package/src/delivery/template-format.js +112 -0
- package/src/delivery/template-package.js +376 -0
- package/src/delivery/template-promote.js +240 -0
- package/src/delivery/template-scaffold.js +181 -0
- package/src/delivery/templates.js +192 -0
- package/src/delivery/toml.js +195 -0
- package/src/delivery/write.js +35 -0
- package/src/export/browser.js +130 -0
- package/src/export/index.js +96 -0
- package/src/export/pdf.js +25 -0
- package/src/export/png.js +40 -0
- package/src/export/pptx.js +48 -0
- package/src/export/slides.js +33 -0
- package/src/export/snapshot.js +33 -0
- package/src/layouts/article.js +103 -0
- package/src/layouts/canvas.js +144 -0
- package/src/layouts/card.js +128 -0
- package/src/layouts/deck.js +88 -0
- package/src/layouts/index.js +90 -0
- package/src/layouts/one-page.js +161 -0
- package/src/layouts/registry.js +251 -0
- package/src/layouts/resume.js +172 -0
- package/src/layouts/template-index.js +78 -0
- package/src/parser/artifact.js +38 -0
- package/src/parser/container.js +103 -0
- package/src/parser/index.js +223 -0
- package/src/parser/tokens.js +265 -0
- package/src/render/board-filter.client.js +80 -0
- package/src/render/compile.js +29 -0
- package/src/render/context.js +98 -0
- package/src/render/element.js +32 -0
- package/src/render/fonts.js +129 -0
- package/src/render/graph-hover.client.js +148 -0
- package/src/render/html.js +52 -0
- package/src/render/index.js +241 -0
- package/src/render/measure.js +60 -0
- package/src/render/placement.js +136 -0
- package/src/render/playback.client.js +74 -0
- package/src/render/scale-to-fit.client.js +136 -0
- package/src/render/scale.js +41 -0
- package/src/render/skeleton.js +131 -0
- package/src/render/ssr.js +24 -0
- package/src/render/styles.js +56 -0
- package/src/render/templates.js +191 -0
- package/src/serve/daemon.js +117 -0
- package/src/serve/overlay.js +213 -0
- package/src/serve/protocol.js +36 -0
- package/src/serve/server.js +264 -0
- package/templates/kami/cards/components.js +40 -0
- package/templates/kami/cards/index.js +25 -0
- package/templates/kami/cards/manifest.js +67 -0
- package/templates/kami/cards/styles.css +389 -0
- package/templates/kami/long-form/components.js +102 -0
- package/templates/kami/long-form/index.js +25 -0
- package/templates/kami/long-form/manifest.js +87 -0
- package/templates/kami/long-form/styles.css +481 -0
- package/templates/kami/one-page/components.js +48 -0
- package/templates/kami/one-page/index.js +27 -0
- package/templates/kami/one-page/manifest.js +65 -0
- package/templates/kami/one-page/styles.css +375 -0
- package/templates/kami/resume/components.js +51 -0
- package/templates/kami/resume/index.js +27 -0
- package/templates/kami/resume/manifest.js +65 -0
- package/templates/kami/resume/styles.css +424 -0
- package/templates/kami/slides/components.js +41 -0
- package/templates/kami/slides/index.js +26 -0
- package/templates/kami/slides/manifest.js +64 -0
- 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
|
+
}
|