@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,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
|
+
}
|