@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,376 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { CODES, EXIT, KsbError } from '../core/errors.js'
|
|
4
|
+
import { validateGateDeclaration } from '../core/lint-gates.js'
|
|
5
|
+
import { isReservedKey, registerTemplateSource, resetTemplateSources } from '../render/templates.js'
|
|
6
|
+
import {
|
|
7
|
+
LAYOUT_KNOBS_FIELD,
|
|
8
|
+
layoutModule,
|
|
9
|
+
registerTemplateLayoutSource,
|
|
10
|
+
resetTemplateLayoutSources,
|
|
11
|
+
} from '../layouts/index.js'
|
|
12
|
+
import { templatePackageDir, templatesRoot } from './home.js'
|
|
13
|
+
import { loadPackageExtensions } from './template-extensions.js'
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_LANGUAGE,
|
|
16
|
+
MANIFEST_FILENAME,
|
|
17
|
+
NO_ROOT,
|
|
18
|
+
PACKAGE_FILES,
|
|
19
|
+
splitTemplateKey,
|
|
20
|
+
} from './template-format.js'
|
|
21
|
+
import { parseToml } from './toml.js'
|
|
22
|
+
|
|
23
|
+
export { MANIFEST_FILENAME, NO_ROOT, PACKAGE_FILES, splitTemplateKey }
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A template package on disk, **loaded** rather than merely listed (CONTRACT F3 D1).
|
|
27
|
+
*
|
|
28
|
+
* E3 gave the store a template namespace and stopped one step short: a package
|
|
29
|
+
* could be registered, listed by `templates`, counted by `debug` — and then
|
|
30
|
+
* `render -t` came back `KSB_TEMPLATE_NOT_FOUND`, because the resolver read a
|
|
31
|
+
* static registry of the two templates the engine happened to bundle (量測報告
|
|
32
|
+
* §5.2). The namespace was a ledger of things nobody could use. This module is
|
|
33
|
+
* the other half: the same directory, read back into the object the render
|
|
34
|
+
* pipeline already consumes.
|
|
35
|
+
*
|
|
36
|
+
* The default path is **pure data** (wayfinder issue 13 裁決 7): a manifest that
|
|
37
|
+
* declares which 文體 the package rides and how its knobs are turned, a
|
|
38
|
+
* stylesheet, and a 預設 md that shows what the template looks like when nobody
|
|
39
|
+
* has written anything yet (issue 14 補充裁定). Nothing here is executable, so
|
|
40
|
+
* nothing here needs to be trusted. JS is opt-in, and lives next door in
|
|
41
|
+
* `template-extensions.js`.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
const invalid = (message, path = 'doc.meta.template') =>
|
|
45
|
+
new KsbError({
|
|
46
|
+
code: CODES.TEMPLATE_PACKAGE_INVALID,
|
|
47
|
+
message,
|
|
48
|
+
path,
|
|
49
|
+
exitCode: EXIT.VALIDATION,
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const subdirectories = (dir) => {
|
|
53
|
+
if (!existsSync(dir)) return []
|
|
54
|
+
return readdirSync(dir)
|
|
55
|
+
.filter((entry) => statSync(join(dir, entry)).isDirectory())
|
|
56
|
+
.sort()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Every package directory in the store, sorted by `<namespace>/<name>`.
|
|
61
|
+
*
|
|
62
|
+
* A directory counts as a package when it holds a manifest — the same rule the
|
|
63
|
+
* listing has always used, kept in one place so `templates` and `render` can
|
|
64
|
+
* never disagree about what is installed.
|
|
65
|
+
*
|
|
66
|
+
* @returns {Array<{namespace: string, name: string, key: string, dir: string, path: string}>}
|
|
67
|
+
*/
|
|
68
|
+
export function packageDirs(env = process.env) {
|
|
69
|
+
const root = templatesRoot(env)
|
|
70
|
+
const found = []
|
|
71
|
+
for (const namespace of subdirectories(root)) {
|
|
72
|
+
for (const name of subdirectories(join(root, namespace))) {
|
|
73
|
+
const dir = join(root, namespace, name)
|
|
74
|
+
const path = join(dir, PACKAGE_FILES.MANIFEST)
|
|
75
|
+
if (!existsSync(path)) continue
|
|
76
|
+
found.push({ namespace, name, key: `${namespace}/${name}`, dir, path })
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return found
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** `<namespace>/<name>` of every package installed in the store. */
|
|
83
|
+
export const storeTemplateKeys = (env = process.env) => packageDirs(env).map((pkg) => pkg.key)
|
|
84
|
+
|
|
85
|
+
const jsFilesUnder = (dir) => {
|
|
86
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) return []
|
|
87
|
+
return readdirSync(dir).flatMap((entry) => {
|
|
88
|
+
const full = join(dir, entry)
|
|
89
|
+
if (statSync(full).isDirectory()) return jsFilesUnder(full)
|
|
90
|
+
return full.endsWith('.js') ? [full] : []
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Whether a package carries executable code (CONTRACT P5).
|
|
96
|
+
*
|
|
97
|
+
* There is no sandbox — 安裝即信任 is the settled trust model (issue 13 裁決 4).
|
|
98
|
+
* What a trust-on-install model still owes its user is *legibility*: being able
|
|
99
|
+
* to see, without opening a single file, which of the packages installed here
|
|
100
|
+
* will run code when they draw. So the listing says so, and this is where it
|
|
101
|
+
* learns the answer.
|
|
102
|
+
*/
|
|
103
|
+
export const packageHasCode = (dir) =>
|
|
104
|
+
jsFilesUnder(join(dir, PACKAGE_FILES.ROTORS_DIR)).length > 0 ||
|
|
105
|
+
jsFilesUnder(join(dir, PACKAGE_FILES.PLUGINS_DIR)).length > 0 ||
|
|
106
|
+
// F7a's third door counts too. A package whose chrome is JS runs code when it
|
|
107
|
+
// draws, exactly like a rotor override does, and the whole use of this field
|
|
108
|
+
// is to let a reader see that without opening a file (CONTRACT P5).
|
|
109
|
+
existsSync(join(dir, PACKAGE_FILES.CHROME))
|
|
110
|
+
|
|
111
|
+
const requireString = (table, key, where) => {
|
|
112
|
+
const value = table[key]
|
|
113
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
114
|
+
throw invalid(
|
|
115
|
+
`模板包 ${where} 的 ${PACKAGE_FILES.MANIFEST} 缺少字串欄位 \`${key}\`` +
|
|
116
|
+
`(目前值:${JSON.stringify(value ?? null)})。`,
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
return value
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const optionalNumber = (table, key, where) => {
|
|
123
|
+
const value = table[key]
|
|
124
|
+
if (value === undefined) return undefined
|
|
125
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
126
|
+
throw invalid(
|
|
127
|
+
`模板包 ${where} 的 \`${key}\` 必須是正數的 CSS 像素值,得到 ${JSON.stringify(value)}。`,
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
return value
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const optionalString = (table, key, where, fallback) => {
|
|
134
|
+
const value = table[key]
|
|
135
|
+
if (value === undefined) return fallback
|
|
136
|
+
if (typeof value !== 'string') {
|
|
137
|
+
throw invalid(`模板包 ${where} 的 \`${key}\` 必須是字串,得到 ${JSON.stringify(value)}。`)
|
|
138
|
+
}
|
|
139
|
+
return value
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The 文體's own knobs, as this package turns them (CONTRACT F2b G1/G7).
|
|
144
|
+
*
|
|
145
|
+
* A pure-data package must be able to turn every knob its 文體 declares —
|
|
146
|
+
* `rail = true` in a `manifest.toml` is the whole of 「第三方也能有側欄」, and a
|
|
147
|
+
* loader that only understood the two canvas dimensions would have made the
|
|
148
|
+
* knob a privilege of the two templates that ship inside the SDK.
|
|
149
|
+
*
|
|
150
|
+
* Which knobs exist is asked of the layout, never listed here: this layer knows
|
|
151
|
+
* the store, not the 文體 axis. An unregistered layout yields nothing and the
|
|
152
|
+
* render path refuses the package by name a moment later
|
|
153
|
+
* (`KSB_LAYOUT_NOT_FOUND`), which is the better error of the two.
|
|
154
|
+
*
|
|
155
|
+
* The type is enforced against the knob's own factory value, because TOML will
|
|
156
|
+
* happily carry `rail = "true"` — a string that is `true` to every branch that
|
|
157
|
+
* ever tests it.
|
|
158
|
+
*/
|
|
159
|
+
function knobValues(table, layout, where) {
|
|
160
|
+
const declared = layoutModule(layout)?.[LAYOUT_KNOBS_FIELD]
|
|
161
|
+
if (declared === undefined) return {}
|
|
162
|
+
const turned = {}
|
|
163
|
+
for (const [name, factory] of Object.entries(declared)) {
|
|
164
|
+
const value = table[name]
|
|
165
|
+
if (value === undefined) continue
|
|
166
|
+
if (typeof value !== typeof factory) {
|
|
167
|
+
throw invalid(
|
|
168
|
+
`模板包 ${where} 的旋鈕 \`${name}\` 必須是 ${typeof factory},` +
|
|
169
|
+
`得到 ${JSON.stringify(value)}。`,
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
turned[name] = value
|
|
173
|
+
}
|
|
174
|
+
return turned
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The manifest as the render layer's data, refused loudly when it is not.
|
|
179
|
+
*
|
|
180
|
+
* Every refusal names the field, because the failure this guards against is a
|
|
181
|
+
* package that half-loads: a missing `layout` defaulted to the article 文體
|
|
182
|
+
* would draw *some* artifact and call it this template's, which is the same
|
|
183
|
+
* silent-wrong-output shape `manifest.blocks` and the root-form guard exist to
|
|
184
|
+
* refuse (CONTRACT D2).
|
|
185
|
+
*/
|
|
186
|
+
export function readPackageManifest({ namespace, name, dir, path }) {
|
|
187
|
+
const where = `${namespace}/${name}`
|
|
188
|
+
if (!existsSync(path)) {
|
|
189
|
+
throw invalid(`模板包 ${where} 缺少 ${PACKAGE_FILES.MANIFEST}(找過:${path})。`)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let table
|
|
193
|
+
try {
|
|
194
|
+
table = parseToml(readFileSync(path, 'utf8'))
|
|
195
|
+
} catch (cause) {
|
|
196
|
+
throw invalid(`模板包 ${where} 的 ${PACKAGE_FILES.MANIFEST} 讀不出來:${cause.message}`)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (requireString(table, 'namespace', where) !== namespace || requireString(table, 'name', where) !== name) {
|
|
200
|
+
throw invalid(
|
|
201
|
+
`模板包 ${where} 的 ${PACKAGE_FILES.MANIFEST} 自稱 ` +
|
|
202
|
+
`\`${table.namespace}/${table.name}\`,與所在目錄不符。` +
|
|
203
|
+
'解析鍵取自目錄,宣稱取自檔案——兩者不同就會有一個永遠指不到。',
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const blocks = table.blocks
|
|
208
|
+
if (!Array.isArray(blocks) || blocks.length === 0 || blocks.some((b) => typeof b !== 'string')) {
|
|
209
|
+
throw invalid(
|
|
210
|
+
`模板包 ${where} 的 \`blocks\` 必須是非空的 block 型別字串陣列——` +
|
|
211
|
+
'它是模板的准入表,空表等於一份畫不出任何東西的模板。',
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const blockConfig = table.blockConfig
|
|
216
|
+
if (blockConfig !== undefined && (blockConfig === null || typeof blockConfig !== 'object' || Array.isArray(blockConfig))) {
|
|
217
|
+
throw invalid(`模板包 ${where} 的 \`blockConfig\` 必須是 \`[blockConfig.<型別>]\` 具名表。`)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// F7b — `[gates]`: which of `lint`'s declared-threshold rules this package
|
|
221
|
+
// opts into, and at what number (SPEC §5.4). The *shape* is judged by the
|
|
222
|
+
// linter's own spec table rather than restated here, for the reason this file
|
|
223
|
+
// gives twice already: two spellings of one rule drift, and the direction they
|
|
224
|
+
// drift in is a manifest that loads with a gate the linter has never heard of.
|
|
225
|
+
const gates = validateGateDeclaration(table.gates, where)
|
|
226
|
+
|
|
227
|
+
const root = optionalString(table, 'root', where, NO_ROOT)
|
|
228
|
+
const layout = requireString(table, 'layout', where)
|
|
229
|
+
return Object.freeze({
|
|
230
|
+
namespace,
|
|
231
|
+
name,
|
|
232
|
+
version: requireString(table, 'version', where),
|
|
233
|
+
description: optionalString(table, 'description', where, ''),
|
|
234
|
+
language: optionalString(table, 'language', where, DEFAULT_LANGUAGE),
|
|
235
|
+
layout,
|
|
236
|
+
root: root === NO_ROOT ? null : root,
|
|
237
|
+
blocks: Object.freeze([...blocks]),
|
|
238
|
+
blockConfig: Object.freeze(blockConfig ?? {}),
|
|
239
|
+
gates,
|
|
240
|
+
...(optionalNumber(table, 'maxWidth', where) === undefined ? {} : { maxWidth: table.maxWidth }),
|
|
241
|
+
...(optionalNumber(table, 'measure', where) === undefined ? {} : { measure: table.measure }),
|
|
242
|
+
...knobValues(table, layout, where),
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const requireFile = (dir, filename, where, why) => {
|
|
247
|
+
const path = join(dir, filename)
|
|
248
|
+
if (!existsSync(path)) {
|
|
249
|
+
throw invalid(`模板包 ${where} 缺少 ${filename}——${why}(找過:${path})。`)
|
|
250
|
+
}
|
|
251
|
+
return path
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Loaded packages, so one process never imports the same extension twice. */
|
|
255
|
+
const cache = new Map()
|
|
256
|
+
|
|
257
|
+
/** Test hygiene: forget every loaded package (and let extensions be re-registered). */
|
|
258
|
+
export function resetTemplatePackageCache() {
|
|
259
|
+
cache.clear()
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Load one store package into the object `renderWithTemplatePackage` consumes.
|
|
264
|
+
*
|
|
265
|
+
* @returns {Promise<object|undefined>} undefined when no package lives at that key
|
|
266
|
+
*/
|
|
267
|
+
export async function loadStoreTemplate(key, env = process.env) {
|
|
268
|
+
const split = splitTemplateKey(key)
|
|
269
|
+
if (split === null) return undefined
|
|
270
|
+
const { namespace, name } = split
|
|
271
|
+
const dir = templatePackageDir(namespace, name, env)
|
|
272
|
+
const path = join(dir, PACKAGE_FILES.MANIFEST)
|
|
273
|
+
if (!existsSync(path)) return undefined
|
|
274
|
+
if (cache.has(dir)) return cache.get(dir)
|
|
275
|
+
|
|
276
|
+
const where = split.key
|
|
277
|
+
const manifest = readPackageManifest({ namespace, name, dir, path })
|
|
278
|
+
const stylesheet = requireFile(
|
|
279
|
+
dir,
|
|
280
|
+
PACKAGE_FILES.STYLESHEET,
|
|
281
|
+
where,
|
|
282
|
+
'模板包的皮是必需品,沒有它的產物只是一份沒有樣式的骨架',
|
|
283
|
+
)
|
|
284
|
+
// The 預設 md is part of the format, not a courtesy (issue 14 補充裁定):
|
|
285
|
+
// 「預設長怎樣」歸模板, and its carrier is the starter document that renders
|
|
286
|
+
// into the template's own default appearance. A package without one cannot
|
|
287
|
+
// answer the question it exists to answer.
|
|
288
|
+
const source = requireFile(
|
|
289
|
+
dir,
|
|
290
|
+
PACKAGE_FILES.DEFAULT_DOC,
|
|
291
|
+
where,
|
|
292
|
+
'預設 md 是「這個模板長什麼樣」的唯一載體,缺了它包就沒有開箱樣貌',
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
const { overrides, plugins, chrome } = await loadPackageExtensions({ dir, key: where })
|
|
296
|
+
|
|
297
|
+
const template = Object.freeze({
|
|
298
|
+
manifest,
|
|
299
|
+
key: where,
|
|
300
|
+
// F7a: a store package can fill its 文體's chrome slots too (`chrome.js`).
|
|
301
|
+
// This used to be the literal `{}` — a hard-coded claim that only a bundled
|
|
302
|
+
// template may have a masthead, which made the first serious 臨摹 come out
|
|
303
|
+
// headless while the factory skin it replaced kept its header.
|
|
304
|
+
chrome,
|
|
305
|
+
language: manifest.language,
|
|
306
|
+
/** A pure-data package ships no fonts: nothing to subset, nothing to embed. */
|
|
307
|
+
fonts: Object.freeze([]),
|
|
308
|
+
styles: () => readFileSync(stylesheet, 'utf8'),
|
|
309
|
+
overrides,
|
|
310
|
+
plugins,
|
|
311
|
+
dir,
|
|
312
|
+
source,
|
|
313
|
+
})
|
|
314
|
+
cache.set(dir, template)
|
|
315
|
+
return template
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The store as a template source the render layer can consult (CONTRACT D1). */
|
|
319
|
+
export const storeTemplateSource = (env = process.env) =>
|
|
320
|
+
Object.freeze({
|
|
321
|
+
keys: () => storeTemplateKeys(env),
|
|
322
|
+
load: (key) => loadStoreTemplate(key, env),
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The store as a **layout index** the parser can consult (CONTRACT F2f E4).
|
|
327
|
+
*
|
|
328
|
+
* It answers one question and reads one field: which 文體 does each installed
|
|
329
|
+
* package ride. That is what turns `---` into a page break for a store deck
|
|
330
|
+
* package — the half F3 deferred (`src/layouts/template-index.js` carries the
|
|
331
|
+
* reasoning).
|
|
332
|
+
*
|
|
333
|
+
* Two packages are deliberately left out of the answer:
|
|
334
|
+
*
|
|
335
|
+
* - one whose manifest will not read. It cannot be drawn with either, and
|
|
336
|
+
* `loadStoreTemplate` will say so by name the moment anybody tries; letting
|
|
337
|
+
* the exception out here would break the parse of an unrelated document
|
|
338
|
+
* because of a broken package the document never mentions.
|
|
339
|
+
* - one squatting on a factory namespace (封緘 F2). `resolveTemplate` refuses
|
|
340
|
+
* those, so treating one as a deck template would split a document's pages
|
|
341
|
+
* for a key that then cannot draw them.
|
|
342
|
+
*/
|
|
343
|
+
export const storeLayoutSource = (env = process.env) =>
|
|
344
|
+
Object.freeze({
|
|
345
|
+
entries: () =>
|
|
346
|
+
packageDirs(env).flatMap((pkg) => {
|
|
347
|
+
if (isReservedKey(pkg.key)) return []
|
|
348
|
+
try {
|
|
349
|
+
return [{ key: pkg.key, layout: readPackageManifest(pkg).layout }]
|
|
350
|
+
} catch {
|
|
351
|
+
return []
|
|
352
|
+
}
|
|
353
|
+
}),
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Wire the store into the resolution chain.
|
|
358
|
+
*
|
|
359
|
+
* The render layer owns the chain and knows nothing about `~/.kamishibai`; this
|
|
360
|
+
* layer owns the store and knows nothing about how a template is drawn. Calling
|
|
361
|
+
* this is what joins them, and every command that can resolve a template key
|
|
362
|
+
* calls it — `test_f3_store_is_wired_into_every_render_path` is the gate that
|
|
363
|
+
* says so, because a path that forgot would silently answer
|
|
364
|
+
* `KSB_TEMPLATE_NOT_FOUND` for a package sitting right there on disk, which is
|
|
365
|
+
* the exact failure this slice exists to remove.
|
|
366
|
+
*/
|
|
367
|
+
export function installTemplateStore(env = process.env) {
|
|
368
|
+
resetTemplateSources()
|
|
369
|
+
registerTemplateSource(storeTemplateSource(env))
|
|
370
|
+
// The same act, for the other consumer: the parser has to know which 文體 an
|
|
371
|
+
// installed package rides before it decides what `---` means. Wiring the two
|
|
372
|
+
// together here is what keeps them from disagreeing — a command that had the
|
|
373
|
+
// loader but not the index would draw a deck package as one long slide.
|
|
374
|
+
resetTemplateLayoutSources()
|
|
375
|
+
registerTemplateLayoutSource(storeLayoutSource(env))
|
|
376
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { CODES, EXIT, KsbError } from '../core/errors.js'
|
|
4
|
+
import { writeFileAtomic } from './atomic.js'
|
|
5
|
+
import { templatePackageDir, templatesRoot } from './home.js'
|
|
6
|
+
import {
|
|
7
|
+
DRAFT_NAMESPACE,
|
|
8
|
+
MANIFEST_FILENAME,
|
|
9
|
+
PACKAGE_FILES,
|
|
10
|
+
isDraftKey,
|
|
11
|
+
splitTemplateKey,
|
|
12
|
+
} from './template-format.js'
|
|
13
|
+
import { parseToml, stringifyToml } from './toml.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `promote` — the last step of the 臨摹 loop (CONTRACT F7a H1).
|
|
17
|
+
*
|
|
18
|
+
* `init draft/<名>` opens a workspace; everything between it and here is the
|
|
19
|
+
* Agent's own reading-and-writing (H2's Not-building: the SDK grows no
|
|
20
|
+
* image-parsing hand). What the SDK owes the workflow is its *end*: the moment
|
|
21
|
+
* a sketch stops being a sketch has to be a mechanical move with a receipt, not
|
|
22
|
+
* a `mv` somebody types and a manifest they remember to edit.
|
|
23
|
+
*
|
|
24
|
+
* Remembering is exactly what fails. The package directory and the manifest's
|
|
25
|
+
* own `namespace`/`name` are two statements of one fact, and a hand-moved
|
|
26
|
+
* package leaves them disagreeing: the store lists it at its new key (that is
|
|
27
|
+
* read off the path) while the manifest — the thing `readPackage` prefers, and
|
|
28
|
+
* the thing a human opens — still names `draft/`. So the rewrite travels with
|
|
29
|
+
* the move, in one function, and neither can happen without the other.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const refuse = (code, message, exitCode, path) =>
|
|
33
|
+
new KsbError({ code, message, exitCode, ...(path === undefined ? {} : { path }) })
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The key a caller wrote, split — or a usage refusal naming which of the two
|
|
37
|
+
* arguments was malformed.
|
|
38
|
+
*
|
|
39
|
+
* Shape is judged before anything touches the disk, per the same 複驗 F-2 rule
|
|
40
|
+
* `validateScaffoldRequest` records: a command that rejects your input must
|
|
41
|
+
* leave the store exactly as it found it, and a validation that runs after the
|
|
42
|
+
* side effects is an epilogue rather than a gate.
|
|
43
|
+
*/
|
|
44
|
+
const requireKey = (key, role) => {
|
|
45
|
+
const split = splitTemplateKey(key)
|
|
46
|
+
if (split === null) {
|
|
47
|
+
throw refuse(
|
|
48
|
+
CODES.USAGE,
|
|
49
|
+
`${role} "${key}" 不是 \`<namespace>/<name>\` 形;兩段皆須以英數開頭,` +
|
|
50
|
+
'其後只准英數、`_` 與 `-`(它們會直接成為庫裡的目錄名)。',
|
|
51
|
+
EXIT.USAGE,
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
return split
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Every file and directory sitting under a package directory, recursively empty-safe. */
|
|
58
|
+
const isEmptyDir = (dir) => existsSync(dir) && readdirSync(dir).length === 0
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Everything judgeable without writing (CONTRACT F7a H1 三拒絕面).
|
|
62
|
+
*
|
|
63
|
+
* The three refusals are three *different* mistakes and carry three different
|
|
64
|
+
* codes, because an agent keying on the code has to be able to tell "I named a
|
|
65
|
+
* package that is not there" from "I aimed at a name somebody already owns":
|
|
66
|
+
*
|
|
67
|
+
* - 來源非 `draft/` → `KSB_USAGE`. Not a missing thing, a wrong kind of thing.
|
|
68
|
+
* `promote` is the workspace's exit; asked to move a package that was never
|
|
69
|
+
* in the workspace it has no meaning to execute, and silently doing a rename
|
|
70
|
+
* would make the command's name a lie.
|
|
71
|
+
* - 來源不存在 → `KSB_TEMPLATE_NOT_FOUND`, the code the render path already
|
|
72
|
+
* raises for a key with nothing behind it. One spelling of one refusal.
|
|
73
|
+
* - 目標撞名 → `KSB_TEMPLATE_PACKAGE_EXISTS`, the same code `init` raises for
|
|
74
|
+
* the same fact, and refused for the same reason it is there: the occupied
|
|
75
|
+
* directory may hold a stylesheet somebody is editing, and a `promote` that
|
|
76
|
+
* merged into it would destroy work while reporting success.
|
|
77
|
+
*
|
|
78
|
+
* A reserved target is refused on top of the three, because the alternative is
|
|
79
|
+
* `promote` cheerfully manufacturing the one thing `init` and `resolveTemplate`
|
|
80
|
+
* both exist to prevent — a package at `kami/<名>` that lists as official and
|
|
81
|
+
* can never be loaded. A `draft/` target is refused too: that is a rename, and
|
|
82
|
+
* a command called `promote` that quietly performs one is worse than one that
|
|
83
|
+
* says it does not do that.
|
|
84
|
+
*
|
|
85
|
+
* @param {{from: string, to: string, reserved?: readonly string[], env?: object}} request
|
|
86
|
+
* @returns {{source: object, target: object, sourceDir: string, targetDir: string}}
|
|
87
|
+
*/
|
|
88
|
+
export function validatePromoteRequest({ from, to, reserved = [], env = process.env }) {
|
|
89
|
+
const source = requireKey(from, '來源模板鍵')
|
|
90
|
+
const target = requireKey(to, '目標模板鍵')
|
|
91
|
+
|
|
92
|
+
if (!isDraftKey(source.key)) {
|
|
93
|
+
throw refuse(
|
|
94
|
+
CODES.USAGE,
|
|
95
|
+
`promote 只搬 \`${DRAFT_NAMESPACE}/\` 底下的臨摹草稿,而來源是 ${source.key}。` +
|
|
96
|
+
'轉正是工作區的出口,不是通用的改名指令——' +
|
|
97
|
+
'對一個本來就不在工作區裡的包執行它,沒有任何一件事會被「轉正」。',
|
|
98
|
+
EXIT.USAGE,
|
|
99
|
+
'doc.meta.template',
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (target.namespace === DRAFT_NAMESPACE) {
|
|
104
|
+
throw refuse(
|
|
105
|
+
CODES.USAGE,
|
|
106
|
+
`promote 的目標不得留在 \`${DRAFT_NAMESPACE}/\`(收到 ${target.key}):` +
|
|
107
|
+
'那是改名,不是轉正。要換草稿名字請自己搬目錄。',
|
|
108
|
+
EXIT.USAGE,
|
|
109
|
+
'doc.meta.template',
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (reserved.includes(target.namespace)) {
|
|
114
|
+
throw refuse(
|
|
115
|
+
CODES.TEMPLATE_NAMESPACE_RESERVED,
|
|
116
|
+
`\`${target.namespace}\` 是出廠保留的 namespace(保留:${[...reserved].join(', ')}),` +
|
|
117
|
+
'不得作為轉正目標——冒用出廠名的包在列表上與出廠包無從分辨,而且永遠載不起來。',
|
|
118
|
+
EXIT.VALIDATION,
|
|
119
|
+
'doc.meta.template',
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const sourceDir = templatePackageDir(source.namespace, source.name, env)
|
|
124
|
+
const targetDir = templatePackageDir(target.namespace, target.name, env)
|
|
125
|
+
|
|
126
|
+
if (!existsSync(join(sourceDir, MANIFEST_FILENAME))) {
|
|
127
|
+
throw refuse(
|
|
128
|
+
CODES.TEMPLATE_NOT_FOUND,
|
|
129
|
+
`找不到臨摹草稿 ${source.key}(${sourceDir} 沒有 ${MANIFEST_FILENAME})。` +
|
|
130
|
+
`草稿由 \`kamishibai init ${source.key}\` 開出來,用 \`kamishibai templates\` 看現有的有哪些。`,
|
|
131
|
+
EXIT.VALIDATION,
|
|
132
|
+
'doc.meta.template',
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 三件套**任一**存在即拒,與 `scaffoldTemplatePackage` 同一條(複驗 F-3):
|
|
137
|
+
// 只看 manifest.toml 的話,一個「manifest 被刪、stylesheet 還在」的半成品
|
|
138
|
+
// 目錄會被當成空地,而那正是最可能有人正在改的狀態。
|
|
139
|
+
const occupied = [PACKAGE_FILES.MANIFEST, PACKAGE_FILES.STYLESHEET, PACKAGE_FILES.DEFAULT_DOC]
|
|
140
|
+
.filter((filename) => existsSync(join(targetDir, filename)))
|
|
141
|
+
if (occupied.length > 0) {
|
|
142
|
+
throw refuse(
|
|
143
|
+
CODES.TEMPLATE_PACKAGE_EXISTS,
|
|
144
|
+
`模板包 ${target.key} 的目錄已經有東西了(${targetDir}):${occupied.join('、')}。` +
|
|
145
|
+
'promote 不覆寫既有的檔案:裡面的 stylesheet 與預設 md 可能是有人正在改的東西。',
|
|
146
|
+
EXIT.VALIDATION,
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { source, target, sourceDir, targetDir }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Rewrite the moved package's own account of its identity.
|
|
155
|
+
*
|
|
156
|
+
* Parsed and re-emitted rather than string-replaced: `draft` is a plausible
|
|
157
|
+
* substring of a description, a block name or a stylesheet path, and a textual
|
|
158
|
+
* swap would rewrite whichever of them happened to match. The writer preserves
|
|
159
|
+
* insertion order, so every other key comes back byte-for-byte where it was.
|
|
160
|
+
*
|
|
161
|
+
* `name` travels with `namespace` because the two are one fact with the
|
|
162
|
+
* directory: `promote draft/x acme/y` that rewrote only the namespace would
|
|
163
|
+
* leave a manifest calling itself `acme/x` inside a directory called `acme/y`,
|
|
164
|
+
* and the listing (which reads the manifest) and the resolver (which reads the
|
|
165
|
+
* path) would then disagree about what is installed.
|
|
166
|
+
*/
|
|
167
|
+
const rewriteIdentity = (dir, target) => {
|
|
168
|
+
const path = join(dir, MANIFEST_FILENAME)
|
|
169
|
+
let table
|
|
170
|
+
try {
|
|
171
|
+
table = parseToml(readFileSync(path, 'utf8'))
|
|
172
|
+
} catch (cause) {
|
|
173
|
+
throw refuse(
|
|
174
|
+
CODES.TEMPLATE_PACKAGE_INVALID,
|
|
175
|
+
`轉正後無法解析 ${path}:${cause.message}。` +
|
|
176
|
+
'包已經搬到新位置,但 manifest 讀不動——請手動修好 manifest 再列一次 templates。',
|
|
177
|
+
EXIT.VALIDATION,
|
|
178
|
+
'doc.meta.template',
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
writeFileAtomic(path, stringifyToml({ ...table, namespace: target.namespace, name: target.name }))
|
|
182
|
+
return path
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Move one 臨摹 draft out of the workspace and into a real namespace.
|
|
187
|
+
*
|
|
188
|
+
* Zero residue is a named acceptance face rather than a side effect: a
|
|
189
|
+
* `promote` that left the draft in place would leave two packages claiming to
|
|
190
|
+
* be the same skin, and the next `render -t draft/<名>` would keep drawing the
|
|
191
|
+
* stale one — which looks exactly like the promotion never happening, except
|
|
192
|
+
* that it did. So the source directory is gone when this returns, and the
|
|
193
|
+
* emptied `draft/` namespace directory goes with it.
|
|
194
|
+
*
|
|
195
|
+
* @param {{from: string, to: string, reserved?: readonly string[], env?: object}} input
|
|
196
|
+
* @returns {{from: string, to: string, dir: string, manifest: string, source: string}}
|
|
197
|
+
*/
|
|
198
|
+
export function promoteTemplatePackage({ from, to, reserved = [], env = process.env }) {
|
|
199
|
+
const { source, target, sourceDir, targetDir } = validatePromoteRequest({
|
|
200
|
+
from,
|
|
201
|
+
to,
|
|
202
|
+
reserved,
|
|
203
|
+
env,
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
// The target namespace directory need not exist yet — `common-dev/` is
|
|
208
|
+
// brand new the first time anything is promoted into it, and `rename` will
|
|
209
|
+
// not create a parent for you.
|
|
210
|
+
mkdirSync(dirname(targetDir), { recursive: true })
|
|
211
|
+
renameSync(sourceDir, targetDir)
|
|
212
|
+
} catch (cause) {
|
|
213
|
+
throw refuse(
|
|
214
|
+
CODES.WRITE_FAILED,
|
|
215
|
+
`無法把 ${source.key} 搬到 ${target.key}(${sourceDir} → ${targetDir}):${cause.message}。`,
|
|
216
|
+
EXIT.VALIDATION,
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const manifest = rewriteIdentity(targetDir, target)
|
|
221
|
+
|
|
222
|
+
// 零殘留:草稿的 namespace 目錄空了就一併收掉。`packageDirs` 不看沒有 manifest
|
|
223
|
+
// 的目錄,所以留著不會出現在列表上——但它會出現在 `ls`,而一個空的 `draft/`
|
|
224
|
+
// 讀起來像「還有草稿沒清」。失敗不致命(別人可能正好在裡面開新草稿),所以
|
|
225
|
+
// 這一步不擋成功的搬移。
|
|
226
|
+
try {
|
|
227
|
+
const draftRoot = join(templatesRoot(env), source.namespace)
|
|
228
|
+
if (isEmptyDir(draftRoot)) rmdirSync(draftRoot)
|
|
229
|
+
} catch {
|
|
230
|
+
// 目錄非空或被別人握著:轉正本身已經完成,這只是清掃。
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
from: source.key,
|
|
235
|
+
to: target.key,
|
|
236
|
+
dir: targetDir,
|
|
237
|
+
manifest,
|
|
238
|
+
source: join(targetDir, PACKAGE_FILES.DEFAULT_DOC),
|
|
239
|
+
}
|
|
240
|
+
}
|