@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,219 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { basename, join } from 'node:path'
|
|
3
|
+
import { engineVersion } from '../core/version.js'
|
|
4
|
+
import { CODES, EXIT, KsbError } from '../core/errors.js'
|
|
5
|
+
import { writeFileAtomic } from './atomic.js'
|
|
6
|
+
import { MARKER_FILENAME, markerPath, projectDir, resolveHome } from './home.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The central artifact store (SPEC §6.1 / §8). `render` and `replay` archive
|
|
10
|
+
* the canonical copy here; `list`, `open` and future replay/re-skin flows read
|
|
11
|
+
* from it. Nothing in this module reaches outside `resolveHome()`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const ARTIFACT_EXT = '.html'
|
|
15
|
+
/** Sidecar recording where delivery copies of an artifact were written. */
|
|
16
|
+
export const COPIES_SUFFIX = '.copies.json'
|
|
17
|
+
|
|
18
|
+
const markerContent = () => `${MARKER_FILENAME}: kamishibai@${engineVersion()}\n`
|
|
19
|
+
|
|
20
|
+
const writeFailed = (path, cause) =>
|
|
21
|
+
new KsbError({
|
|
22
|
+
code: CODES.WRITE_FAILED,
|
|
23
|
+
message: `could not write to central store at ${path}: ${cause.message}`,
|
|
24
|
+
exitCode: EXIT.VALIDATION,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* CONTRACT B2 — the first-touch safety gate.
|
|
29
|
+
*
|
|
30
|
+
* `~/.kamishibai` is a protected asset: a store that already exists belongs to
|
|
31
|
+
* the user, so we only ever *add* to it. The marker is written exactly once, at
|
|
32
|
+
* creation; back-filling it into an existing store would be a mutation of
|
|
33
|
+
* someone else's directory, and would also lie about who created it.
|
|
34
|
+
*
|
|
35
|
+
* @returns {{root: string, created: boolean}}
|
|
36
|
+
*/
|
|
37
|
+
export function ensureStoreRoot(env = process.env) {
|
|
38
|
+
const root = resolveHome(env)
|
|
39
|
+
if (existsSync(root)) return { root, created: false }
|
|
40
|
+
try {
|
|
41
|
+
mkdirSync(root, { recursive: true })
|
|
42
|
+
writeFileSync(markerPath(env), markerContent(), 'utf8')
|
|
43
|
+
} catch (cause) {
|
|
44
|
+
throw writeFailed(root, cause)
|
|
45
|
+
}
|
|
46
|
+
return { root, created: true }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Compact UTC stamp, e.g. `20260816T195453123Z`. */
|
|
50
|
+
export const archiveStamp = (now = new Date()) => now.toISOString().replace(/[-:.]/g, '')
|
|
51
|
+
|
|
52
|
+
/** How many suffixed names to try before admitting defeat. */
|
|
53
|
+
const MAX_NAME_ATTEMPTS = 1000
|
|
54
|
+
|
|
55
|
+
/** `<slug>`, then `<slug>-<stamp>`, then `<slug>-<stamp>-2`, `-3`, … */
|
|
56
|
+
function* candidateNames(slug, now) {
|
|
57
|
+
yield slug
|
|
58
|
+
const stamped = `${slug}-${archiveStamp(now)}`
|
|
59
|
+
yield stamped
|
|
60
|
+
for (let n = 2; n < MAX_NAME_ATTEMPTS; n += 1) yield `${stamped}-${n}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Write the canonical copy under the first name that is free (CONTRACT B2).
|
|
65
|
+
*
|
|
66
|
+
* The exclusive `wx` flag *is* the check: an `existsSync` test followed by a
|
|
67
|
+
* default (truncating) write leaves a window in which two concurrent renders
|
|
68
|
+
* of the same slug both see "free" and the second silently erases the first.
|
|
69
|
+
* Artifacts in the central store are permanent records, so the filesystem —
|
|
70
|
+
* not a prior lookup — has to be the one that says the name was taken.
|
|
71
|
+
*/
|
|
72
|
+
const writeExclusive = (dir, slug, html, now) => {
|
|
73
|
+
for (const name of candidateNames(slug, now)) {
|
|
74
|
+
const path = join(dir, `${name}${ARTIFACT_EXT}`)
|
|
75
|
+
try {
|
|
76
|
+
writeFileSync(path, html, { encoding: 'utf8', flag: 'wx' })
|
|
77
|
+
return { name, path }
|
|
78
|
+
} catch (cause) {
|
|
79
|
+
if (cause.code !== 'EEXIST') throw writeFailed(path, cause)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
throw writeFailed(
|
|
83
|
+
join(dir, slug),
|
|
84
|
+
new Error(`no free artifact name after ${MAX_NAME_ATTEMPTS} attempts`),
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Archive the canonical copy of a rendered artifact.
|
|
90
|
+
*
|
|
91
|
+
* @param {{project: string, slug: string, html: string, copies?: string[], env?: object}} input
|
|
92
|
+
* @returns {{name: string, path: string, dir: string}}
|
|
93
|
+
*/
|
|
94
|
+
export function archiveArtifact({ project, slug, html, copies = [], env = process.env, now }) {
|
|
95
|
+
ensureStoreRoot(env)
|
|
96
|
+
const dir = projectDir(project, env)
|
|
97
|
+
try {
|
|
98
|
+
mkdirSync(dir, { recursive: true })
|
|
99
|
+
} catch (cause) {
|
|
100
|
+
throw writeFailed(dir, cause)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const { name, path } = writeExclusive(dir, slug, html, now)
|
|
104
|
+
mergeCopiesIndex(dir, name, copies, path)
|
|
105
|
+
return { name, path, dir }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Refresh a canonical copy **that the calling session itself created** with a
|
|
110
|
+
* newer render of the same source (CONTRACT E1, seal patch F1).
|
|
111
|
+
*
|
|
112
|
+
* This is the one sanctioned overwrite of an artifact, and the boundary matters
|
|
113
|
+
* more than the mechanism. The `wx` law above protects *history*: an artifact
|
|
114
|
+
* somebody else's run archived is a record, and records are never rewritten. A
|
|
115
|
+
* live `serve` session's own copy is not history yet — it is the working
|
|
116
|
+
* document being edited, and freezing it at the first render is what let the
|
|
117
|
+
* served IR and the canonical IR drift apart. Comments anchored to a block the
|
|
118
|
+
* canonical did not contain were then unresolvable by the very artifact they
|
|
119
|
+
* were filed against.
|
|
120
|
+
*
|
|
121
|
+
* The caller owns the "did I create this?" judgement; this function only
|
|
122
|
+
* guarantees the write is all-or-nothing (tmp + rename), so a reader — or a
|
|
123
|
+
* `comments add` running at that instant — sees one whole render or the other,
|
|
124
|
+
* never a torn one.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} artifactPath the session's own canonical copy
|
|
127
|
+
* @param {string} html the newer render
|
|
128
|
+
* @returns {string} the artifact path
|
|
129
|
+
*/
|
|
130
|
+
export function refreshArtifact(artifactPath, html) {
|
|
131
|
+
try {
|
|
132
|
+
return writeFileAtomic(artifactPath, html)
|
|
133
|
+
} catch (cause) {
|
|
134
|
+
throw writeFailed(artifactPath, cause)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Record delivery copies against a claimed name, merging with whatever the
|
|
140
|
+
* index already held (CONTRACT B2 — 已存在→不動既有檔案、僅追加).
|
|
141
|
+
*
|
|
142
|
+
* A name freed by a deleted artifact can be re-claimed by `wx`, and its sidecar
|
|
143
|
+
* may outlive the artifact. Writing the new list flat would silently drop the
|
|
144
|
+
* earlier delivery history — the index is a cumulative record, not a snapshot
|
|
145
|
+
* of the last render. Overwriting is only permitted because the content written
|
|
146
|
+
* is a superset of what was there.
|
|
147
|
+
*
|
|
148
|
+
* And because it *is* an overwrite, it goes through the atomic writer (D6): the
|
|
149
|
+
* one legal truncation in the store must still be all-or-nothing, or a crash
|
|
150
|
+
* mid-write turns a cumulative record into an empty one.
|
|
151
|
+
*/
|
|
152
|
+
const mergeCopiesIndex = (dir, name, copies, artifactPath) => {
|
|
153
|
+
const merged = [...new Set([...readCopies(dir, name), ...copies])]
|
|
154
|
+
try {
|
|
155
|
+
writeFileAtomic(join(dir, `${name}${COPIES_SUFFIX}`), `${JSON.stringify(merged)}\n`)
|
|
156
|
+
} catch (cause) {
|
|
157
|
+
throw writeFailed(artifactPath, cause)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const readCopies = (dir, name) => {
|
|
162
|
+
const sidecar = join(dir, `${name}${COPIES_SUFFIX}`)
|
|
163
|
+
if (!existsSync(sidecar)) return []
|
|
164
|
+
try {
|
|
165
|
+
const parsed = JSON.parse(readFileSync(sidecar, 'utf8'))
|
|
166
|
+
return Array.isArray(parsed) ? parsed.filter((p) => typeof p === 'string') : []
|
|
167
|
+
} catch {
|
|
168
|
+
// A corrupt sidecar must not hide the artifact itself: copies are an index,
|
|
169
|
+
// the artifact is the record.
|
|
170
|
+
return []
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Raw store entries for a project, newest last. HTML is returned so callers can
|
|
176
|
+
* read the embedded IR without this layer needing a parser.
|
|
177
|
+
*
|
|
178
|
+
* @returns {Array<{name: string, path: string, html: string, copies: string[], mtimeMs: number}>}
|
|
179
|
+
*/
|
|
180
|
+
export function readEntries({ project, env = process.env }) {
|
|
181
|
+
const dir = projectDir(project, env)
|
|
182
|
+
if (!existsSync(dir)) return []
|
|
183
|
+
|
|
184
|
+
return readdirSync(dir)
|
|
185
|
+
.filter((file) => file.endsWith(ARTIFACT_EXT))
|
|
186
|
+
.map((file) => {
|
|
187
|
+
const path = join(dir, file)
|
|
188
|
+
const name = basename(file, ARTIFACT_EXT)
|
|
189
|
+
return {
|
|
190
|
+
name,
|
|
191
|
+
path,
|
|
192
|
+
html: readFileSync(path, 'utf8'),
|
|
193
|
+
copies: readCopies(dir, name),
|
|
194
|
+
mtimeMs: statSync(path).mtimeMs,
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
.sort((a, b) => a.mtimeMs - b.mtimeMs || a.name.localeCompare(b.name))
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Verbatim CONTRACT token — `open latest` resolves to the newest artifact. */
|
|
201
|
+
export const LATEST = 'latest'
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Resolve `<name|latest>` against a project's store.
|
|
205
|
+
* @returns {{name: string, path: string}|null} null when nothing matches
|
|
206
|
+
*/
|
|
207
|
+
export function findArtifact({ project, name, env = process.env }) {
|
|
208
|
+
const entries = readEntries({ project, env })
|
|
209
|
+
if (entries.length === 0) return null
|
|
210
|
+
if (name === LATEST) {
|
|
211
|
+
const newest = entries[entries.length - 1]
|
|
212
|
+
return { name: newest.name, path: newest.path }
|
|
213
|
+
}
|
|
214
|
+
const wanted = String(name ?? '').endsWith(ARTIFACT_EXT)
|
|
215
|
+
? basename(String(name), ARTIFACT_EXT)
|
|
216
|
+
: String(name ?? '')
|
|
217
|
+
const hit = entries.find((entry) => entry.name === wanted)
|
|
218
|
+
return hit ? { name: hit.name, path: hit.path } : null
|
|
219
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
import { CODES, EXIT, KsbError } from '../core/errors.js'
|
|
5
|
+
import { blockModule, blockTypes, registerBlockModule } from '../blocks/index.js'
|
|
6
|
+
import { PACKAGE_FILES } from './template-format.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The opt-in half of a template package: JS 轉子 override and `x-*` plugin
|
|
10
|
+
* blocks (CONTRACT D3, wayfinder issue 13 裁決 4).
|
|
11
|
+
*
|
|
12
|
+
* There is no sandbox, and that is a decision rather than an omission: 安裝即
|
|
13
|
+
* 信任 is the Obsidian-plugin model the user settled on (issue 13 裁決 4, this
|
|
14
|
+
* slice's P5). What is *not* optional is that a broken extension fails as an
|
|
15
|
+
* extension. Letting an `import()` rejection reach the generic handler would
|
|
16
|
+
* report `KSB_PARSE_FAILED` — telling an agent its document could not be parsed
|
|
17
|
+
* when the truth is a package on disk, which is the mistake F4 B9 named and
|
|
18
|
+
* gave `KSB_COMMAND_LOAD_FAILED` its own code to avoid.
|
|
19
|
+
*
|
|
20
|
+
* The two halves land in different places, and the difference is not
|
|
21
|
+
* incidental. A **plugin block** is new vocabulary: it goes into the global
|
|
22
|
+
* block registry through the same public entry point a third party would use
|
|
23
|
+
* (CONTRACT A4), and so wears the `x-` prefix and passes the same shape check.
|
|
24
|
+
* An **override** is *this template's* answer for a type the SDK already has —
|
|
25
|
+
* `kami/long-form` drawing callouts its own way — so it is template-scoped and
|
|
26
|
+
* reaches the pipeline through the render context. Registering it globally
|
|
27
|
+
* would make one installed package silently change how every other template
|
|
28
|
+
* draws.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const failed = (message) =>
|
|
32
|
+
new KsbError({
|
|
33
|
+
code: CODES.TEMPLATE_EXTENSION_FAILED,
|
|
34
|
+
message,
|
|
35
|
+
path: 'doc.meta.template',
|
|
36
|
+
exitCode: EXIT.VALIDATION,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
/** Plugin types this process already registered, per package — import is once. */
|
|
40
|
+
const registered = new Set()
|
|
41
|
+
|
|
42
|
+
/** Test hygiene: forget which packages registered which plugin types. */
|
|
43
|
+
export const resetLoadedExtensions = () => registered.clear()
|
|
44
|
+
|
|
45
|
+
const importDefault = async (file, what) => {
|
|
46
|
+
let module
|
|
47
|
+
try {
|
|
48
|
+
module = await import(pathToFileURL(file).href)
|
|
49
|
+
} catch (cause) {
|
|
50
|
+
throw failed(`${what} 載入失敗:${cause?.message ?? String(cause)}(檔案:${file})`)
|
|
51
|
+
}
|
|
52
|
+
const mod = module?.default
|
|
53
|
+
if (mod === null || typeof mod !== 'object') {
|
|
54
|
+
throw failed(`${what} 必須 default export 一個模組物件(檔案:${file})`)
|
|
55
|
+
}
|
|
56
|
+
return mod
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const subdirectories = (dir) => {
|
|
60
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) return []
|
|
61
|
+
return readdirSync(dir).filter((entry) => statSync(join(dir, entry)).isDirectory()).sort()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const jsFiles = (dir) => {
|
|
65
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) return []
|
|
66
|
+
return readdirSync(dir).filter((entry) => entry.endsWith('.js')).sort()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Load `rotors/<型別>/override.js` — this template's own conversion for a type.
|
|
71
|
+
*
|
|
72
|
+
* An override of a type nobody registered is refused rather than kept: it can
|
|
73
|
+
* never fire, so keeping it would leave the author believing their callout was
|
|
74
|
+
* being drawn their way while the factory rotor drew it the other way, with
|
|
75
|
+
* exit 0 and a clean `lint`. That is the same invisible-divergence failure the
|
|
76
|
+
* whole guard family in this SDK exists to make loud.
|
|
77
|
+
*/
|
|
78
|
+
async function loadOverrides({ dir, key }) {
|
|
79
|
+
const root = join(dir, PACKAGE_FILES.ROTORS_DIR)
|
|
80
|
+
const table = {}
|
|
81
|
+
for (const type of subdirectories(root)) {
|
|
82
|
+
const file = join(root, type, PACKAGE_FILES.OVERRIDE)
|
|
83
|
+
if (!existsSync(file)) continue
|
|
84
|
+
const what = `模板包 ${key} 的轉子 override \`${type}\``
|
|
85
|
+
const mod = await importDefault(file, what)
|
|
86
|
+
if (mod.type !== type) {
|
|
87
|
+
throw failed(`${what} 自稱 \`${mod.type}\`,與所在目錄不符——一個永遠指不到另一個。`)
|
|
88
|
+
}
|
|
89
|
+
if (typeof mod.render !== 'function') {
|
|
90
|
+
throw failed(`${what} 缺少 \`render\` 函式(轉子的全部工作就是這一個)。`)
|
|
91
|
+
}
|
|
92
|
+
if (blockModule(type) === undefined) {
|
|
93
|
+
throw failed(
|
|
94
|
+
`${what} 覆寫的型別沒有人註冊;已註冊的型別:${blockTypes().join(', ')}。` +
|
|
95
|
+
'覆寫一個不存在的型別永遠不會生效,而產物仍舊畫得出來——所以在這裡報。',
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
table[type] = mod.render
|
|
99
|
+
}
|
|
100
|
+
return Object.freeze(table)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Load `plugins/<型別>.js` — new block vocabulary the package brings with it.
|
|
105
|
+
*
|
|
106
|
+
* Registration goes through `registerBlockModule`, the same public entry point
|
|
107
|
+
* CONTRACT A4 proves a third party can reach without editing a file inside the
|
|
108
|
+
* SDK. Its `TypeError` becomes a KSB_ finding here rather than a stack trace,
|
|
109
|
+
* but the *rule set* is not restated: a second copy of the shape check would be
|
|
110
|
+
* free to disagree with the one the registry actually applies.
|
|
111
|
+
*/
|
|
112
|
+
async function loadPlugins({ dir, key }) {
|
|
113
|
+
const root = join(dir, PACKAGE_FILES.PLUGINS_DIR)
|
|
114
|
+
const loaded = []
|
|
115
|
+
for (const entry of jsFiles(root)) {
|
|
116
|
+
const type = entry.replace(/\.js$/, '')
|
|
117
|
+
const file = join(root, entry)
|
|
118
|
+
const what = `模板包 ${key} 的 plugin block \`${type}\``
|
|
119
|
+
const mod = await importDefault(file, what)
|
|
120
|
+
if (mod.type !== type) {
|
|
121
|
+
throw failed(`${what} 自稱 \`${mod.type}\`,與檔名不符——一個永遠指不到另一個。`)
|
|
122
|
+
}
|
|
123
|
+
loaded.push(type)
|
|
124
|
+
|
|
125
|
+
const stamp = `${key}:${type}`
|
|
126
|
+
if (registered.has(stamp)) continue
|
|
127
|
+
try {
|
|
128
|
+
registerBlockModule(mod)
|
|
129
|
+
} catch (cause) {
|
|
130
|
+
throw failed(`${what} 未通過註冊表形狀校驗:${cause?.message ?? String(cause)}`)
|
|
131
|
+
}
|
|
132
|
+
registered.add(stamp)
|
|
133
|
+
}
|
|
134
|
+
return Object.freeze(loaded)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Load `chrome.js` — this package's fills for its 文體's chrome slots (F7a).
|
|
139
|
+
*
|
|
140
|
+
* Every export must be a function, and nothing here checks the *names*: the
|
|
141
|
+
* list of slots belongs to the layout, and `assertChromeSlots` already refuses
|
|
142
|
+
* a fill for a slot the 文體 does not offer, with the message that explains why
|
|
143
|
+
* a misspelling is silence rather than an error. A second copy of that rule
|
|
144
|
+
* here would be free to disagree with the one the renderer applies.
|
|
145
|
+
*
|
|
146
|
+
* What is checked is the one thing only this layer can see: that a *value* is
|
|
147
|
+
* callable. `ctx.chrome` skips a non-function silently — correct behaviour for
|
|
148
|
+
* an unfilled slot, and indistinguishable from a package that exported a
|
|
149
|
+
* string by mistake and now renders with no masthead and exit 0.
|
|
150
|
+
*/
|
|
151
|
+
async function loadChrome({ dir, key }) {
|
|
152
|
+
const file = join(dir, PACKAGE_FILES.CHROME)
|
|
153
|
+
if (!existsSync(file)) return Object.freeze({})
|
|
154
|
+
const what = `模板包 ${key} 的 ${PACKAGE_FILES.CHROME}`
|
|
155
|
+
const mod = await importDefault(file, what)
|
|
156
|
+
const table = {}
|
|
157
|
+
for (const [slot, fill] of Object.entries(mod)) {
|
|
158
|
+
if (typeof fill !== 'function') {
|
|
159
|
+
throw failed(
|
|
160
|
+
`${what} 的槽位 \`${slot}\` 不是函式(得到 ${typeof fill})。` +
|
|
161
|
+
'非函式的填充會被靜靜跳過——產物照畫、exit 0、槽位空白,沒有人會發現。',
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
table[slot] = fill
|
|
165
|
+
}
|
|
166
|
+
return Object.freeze(table)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Every JS extension a package carries. Pure-data packages take the cheap path:
|
|
171
|
+
* nothing exists on any of the three paths, so nothing is imported at all.
|
|
172
|
+
*
|
|
173
|
+
* @param {{dir: string, key: string}} input
|
|
174
|
+
* @returns {Promise<{overrides: Record<string, Function>, plugins: readonly string[],
|
|
175
|
+
* chrome: Record<string, Function>}>}
|
|
176
|
+
*/
|
|
177
|
+
export async function loadPackageExtensions({ dir, key }) {
|
|
178
|
+
return {
|
|
179
|
+
overrides: await loadOverrides({ dir, key }),
|
|
180
|
+
plugins: await loadPlugins({ dir, key }),
|
|
181
|
+
chrome: await loadChrome({ dir, key }),
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The template package format, as constants — the one place its filenames live.
|
|
3
|
+
*
|
|
4
|
+
* It is its own module so that the reader of a package (`template-package.js`)
|
|
5
|
+
* and the loader of its JS (`template-extensions.js`) can both name the layout
|
|
6
|
+
* without importing each other. Two copies of `'manifest.toml'` is precisely
|
|
7
|
+
* how a format grows a second, slightly different spelling.
|
|
8
|
+
*
|
|
9
|
+
* The shape is wayfinder issue 13 裁決 7's 目錄形 with two engineering
|
|
10
|
+
* simplifications, recorded here because a reader comparing the two will
|
|
11
|
+
* otherwise think one of them drifted:
|
|
12
|
+
*
|
|
13
|
+
* - `core-config.toml` and 轉子/<型別>/config are **one** file: `manifest.toml`,
|
|
14
|
+
* the latter as its `[blockConfig.<型別>]` tables. Both are pure data, and a
|
|
15
|
+
* single descriptor is what keeps the byte-determinism claim checkable.
|
|
16
|
+
* - `core-style` and `stylesheet/` are **one** `stylesheet.css`: the SDK ships
|
|
17
|
+
* no sass pipeline, and issue 13 裁決 6 leaves sass an internal-organisation
|
|
18
|
+
* freedom rather than a public contract.
|
|
19
|
+
*
|
|
20
|
+
* Everything else is issue 13's tree with ASCII directory names: `plugins/`
|
|
21
|
+
* unchanged, 轉子/<型別>/{override} as `rotors/<型別>/override.js`, plus the
|
|
22
|
+
* 預設 md that issue 14's 補充裁定 added to the format.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Verbatim CONTRACT constants — the package directory layout. */
|
|
26
|
+
export const PACKAGE_FILES = Object.freeze({
|
|
27
|
+
MANIFEST: 'manifest.toml',
|
|
28
|
+
STYLESHEET: 'stylesheet.css',
|
|
29
|
+
DEFAULT_DOC: 'default.md',
|
|
30
|
+
ROTORS_DIR: 'rotors',
|
|
31
|
+
OVERRIDE: 'override.js',
|
|
32
|
+
PLUGINS_DIR: 'plugins',
|
|
33
|
+
/**
|
|
34
|
+
* F7a — the package's fills for its 文體's chrome slots.
|
|
35
|
+
*
|
|
36
|
+
* The third JS door, opened for the reason 臨摹 exists to find (AGENTS §3.5
|
|
37
|
+
* 「凡臨摹不出來的,就是模板語言漏考量的東西」). Until this file existed a
|
|
38
|
+
* store package's `chrome` was the literal `{}`: the masthead, the colophon
|
|
39
|
+
* and the rail were things **only a bundled template could have**, so the
|
|
40
|
+
* first real skin anyone tried to 臨摹 came out headless while the factory
|
|
41
|
+
* template it was replacing kept its header. A package format that cannot
|
|
42
|
+
* express the one region every page starts with is not a package format.
|
|
43
|
+
*
|
|
44
|
+
* A flat file rather than a directory, because a slot fill is one function
|
|
45
|
+
* and the layout — not the package — owns the list of slot names
|
|
46
|
+
* (`assertChromeSlots` refuses a name the 文體 does not offer).
|
|
47
|
+
*/
|
|
48
|
+
CHROME: 'chrome.js',
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
/** Verbatim CONTRACT constant — the package descriptor's filename. */
|
|
52
|
+
export const MANIFEST_FILENAME = PACKAGE_FILES.MANIFEST
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A template with no required root form carries the empty string, not a missing
|
|
56
|
+
* key. TOML has no null, and an *absent* key would be indistinguishable from a
|
|
57
|
+
* manifest written by an older engine that did not know about roots — so the
|
|
58
|
+
* "no constraint" case is stated explicitly.
|
|
59
|
+
*/
|
|
60
|
+
export const NO_ROOT = ''
|
|
61
|
+
|
|
62
|
+
/** Default `language` when a package does not state one. */
|
|
63
|
+
export const DEFAULT_LANGUAGE = 'zh-TW'
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Verbatim CONTRACT F7a constant — the 臨摹 workspace namespace.
|
|
67
|
+
*
|
|
68
|
+
* A package under `draft/` is a *work in progress*: it renders, it lints, it can
|
|
69
|
+
* be pointed at with `-t` exactly like any other package, and that is the whole
|
|
70
|
+
* point — the 臨摹 loop is `init draft/<名> → 編輯 → render + snapshot 對照 →
|
|
71
|
+
* lint 綠 → 人驗 → promote`, and every step of it needs a package that really
|
|
72
|
+
* draws. What `draft/` buys is the one thing the store had no way to say: which
|
|
73
|
+
* of the packages on this machine is a finished skin and which is a sketch
|
|
74
|
+
* somebody is still cutting.
|
|
75
|
+
*
|
|
76
|
+
* It is a *marker*, not a fence, for the same reason `reserved` is one
|
|
77
|
+
* (`cli/commands/templates.js`): this surface reports the disk. Hiding a draft
|
|
78
|
+
* would make the listing disagree with the filesystem, and refusing to render
|
|
79
|
+
* one would make the workflow impossible to walk.
|
|
80
|
+
*/
|
|
81
|
+
export const DRAFT_NAMESPACE = 'draft'
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Whether a key names a package in the 臨摹 workspace (CONTRACT F7a H1).
|
|
85
|
+
*
|
|
86
|
+
* One definition, called by both the listing and `promote`, for the reason
|
|
87
|
+
* `isReservedKey` states next door: two spellings of one predicate are free to
|
|
88
|
+
* drift, and the direction they drift in is the bad one — a listing that marks a
|
|
89
|
+
* package `promote` refuses to move, or a `promote` that moves one the listing
|
|
90
|
+
* never called a draft.
|
|
91
|
+
*/
|
|
92
|
+
export function isDraftKey(key) {
|
|
93
|
+
const split = splitTemplateKey(key)
|
|
94
|
+
return split !== null && split.namespace === DRAFT_NAMESPACE
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A namespace or name segment. Anchored and free of dots on purpose: the
|
|
99
|
+
* segments are joined onto the store root, so `..` would let `-t` address any
|
|
100
|
+
* directory on the machine and read a "stylesheet" out of it.
|
|
101
|
+
*/
|
|
102
|
+
const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]*$/
|
|
103
|
+
|
|
104
|
+
/** Split `<namespace>/<name>[@version]`; null when the key is not that shape. */
|
|
105
|
+
export function splitTemplateKey(key) {
|
|
106
|
+
const bare = String(key ?? '').split('@')[0]
|
|
107
|
+
const parts = bare.split('/')
|
|
108
|
+
if (parts.length !== 2) return null
|
|
109
|
+
const [namespace, name] = parts
|
|
110
|
+
if (!SEGMENT.test(namespace) || !SEGMENT.test(name)) return null
|
|
111
|
+
return { namespace, name, key: `${namespace}/${name}` }
|
|
112
|
+
}
|