@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,140 @@
|
|
|
1
|
+
import { walkBlocks } from './blocks.js'
|
|
2
|
+
import { PLUGIN_TYPE_PREFIX, PLUGIN_TYPE_WILDCARD, pluginBlockTypes } from '../blocks/index.js'
|
|
3
|
+
import { CODES, validationError } from './errors.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `manifest.blocks` is a *guard*, not a brochure.
|
|
7
|
+
*
|
|
8
|
+
* A template draws the block types it knows and skips the rest. Skipping is the
|
|
9
|
+
* right behaviour for a renderer — but as the only behaviour it is a silent
|
|
10
|
+
* data-loss channel: handing a deck tree to `kami/long-form` (via `replay -t`
|
|
11
|
+
* or a block-tree JSON that names the wrong template) dropped every slide,
|
|
12
|
+
* produced an empty-bodied artifact, and still reported exit 0, `lint` 0 and a
|
|
13
|
+
* slide count. Nothing in the pipeline could see it.
|
|
14
|
+
*
|
|
15
|
+
* So the vocabulary a template declares is checked against the document before
|
|
16
|
+
* anything is drawn. A template that cannot express the document must say so
|
|
17
|
+
* (CONTRACT C4) rather than quietly render less than it was given.
|
|
18
|
+
*
|
|
19
|
+
* Two claims are checked, because one cannot stand in for the other:
|
|
20
|
+
*
|
|
21
|
+
* - `manifest.blocks` — which types this template can draw.
|
|
22
|
+
* - `manifest.root` — which *shape* the document must have at its root.
|
|
23
|
+
*
|
|
24
|
+
* The mirror case is what forces the second: `kami/slides` must declare the
|
|
25
|
+
* whole long-form vocabulary (prose, list, table … are all legal inside a
|
|
26
|
+
* slide), so a flat document tree passes the type check completely — and then
|
|
27
|
+
* the template draws only `deck.slides`, i.e. nothing, with a progress readout
|
|
28
|
+
* of `1 / 0` contradicting itself on screen. Type-legal, shape-wrong.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** Every block type present in a tree, in document order of first appearance. */
|
|
32
|
+
export function collectBlockTypes(doc) {
|
|
33
|
+
const seen = new Map()
|
|
34
|
+
for (const { block, path } of walkBlocks(doc)) {
|
|
35
|
+
const type = block?.type
|
|
36
|
+
if (typeof type !== 'string' || seen.has(type)) continue
|
|
37
|
+
seen.set(type, path)
|
|
38
|
+
}
|
|
39
|
+
return seen
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The block types sitting directly under `doc` — the document's root form. */
|
|
43
|
+
const rootForms = (doc) =>
|
|
44
|
+
(doc?.children ?? []).map((block) => block?.type).filter((type) => typeof type === 'string')
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Throw unless the document's root form matches the one its 文體 requires.
|
|
48
|
+
*
|
|
49
|
+
* The requirement is the **layout's**, and only the layout's. 「replay 換皮不換
|
|
50
|
+
* 文體」 stopped being a rule two templates happened to obey and became a
|
|
51
|
+
* structure every deck template inherits (wayfinder issue 13 裁決 1) — which is
|
|
52
|
+
* exactly the claim a `manifest.root ?? layout.rootForm` fallback would give
|
|
53
|
+
* back: a template declaring `root: 'section'` on the `deck` layout would have
|
|
54
|
+
* *overwritten* its 文體's requirement, sail past this guard, and then be drawn
|
|
55
|
+
* by a root form that looks for a deck and finds none. The artifact that comes
|
|
56
|
+
* out has an empty body and a playback script attached to it — the exact zombie
|
|
57
|
+
* `src/layouts/deck.js` claims to have abolished.
|
|
58
|
+
*
|
|
59
|
+
* A template's own `manifest.root` is therefore a *restatement*, not a source:
|
|
60
|
+
* `assertLayoutRootForm` refuses any template whose restatement disagrees, so
|
|
61
|
+
* by the time this runs the two are known to be the same sentence.
|
|
62
|
+
*/
|
|
63
|
+
function assertRootForm({ doc, manifest, layout, key }) {
|
|
64
|
+
const required = layout === undefined ? manifest.root : layout.rootForm
|
|
65
|
+
if (typeof required !== 'string' || required.length === 0) return
|
|
66
|
+
const present = rootForms(doc)
|
|
67
|
+
if (present.includes(required)) return
|
|
68
|
+
const got = present.length === 0 ? '空的 doc' : `doc 形(頂層為 ${[...new Set(present)].join(', ')})`
|
|
69
|
+
throw validationError(
|
|
70
|
+
`template "${key}" 需要 \`${required}\` 根形,收到 ${got}。` +
|
|
71
|
+
`簡報須以 ${required} 語料著作(frontmatter \`template: ${key}\` + \`---\` 切頁符),` +
|
|
72
|
+
'書轉簡報屬文體轉換而非換皮:replay 只換皮,不重寫文體。' +
|
|
73
|
+
`否則此模板只會畫出 ${required} 以外一律丟棄的空殼。`,
|
|
74
|
+
CODES.TEMPLATE_BLOCK_UNSUPPORTED,
|
|
75
|
+
'doc.children',
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `manifest.blocks` stays the **one** admission rule, for plugin blocks too.
|
|
81
|
+
*
|
|
82
|
+
* A manifest is written before any given plugin exists, so it cannot name
|
|
83
|
+
* `x-gallery` by hand — but it can admit the *class*: listing the `x-*` wildcard
|
|
84
|
+
* says "this template is willing to draw plugin blocks". Admitting every
|
|
85
|
+
* registered plugin unconditionally would have been the easy answer and the
|
|
86
|
+
* wrong one — it hands a template blocks it has no stylesheet for, and the
|
|
87
|
+
* guard exists precisely because a block nobody painted renders as invisible
|
|
88
|
+
* data loss with exit 0.
|
|
89
|
+
*
|
|
90
|
+
* Registration is checked *before* either form of declaration, and that order
|
|
91
|
+
* is the point. An `x-…` name nobody registered has no module, so it would sail
|
|
92
|
+
* through admission and then be dropped by `renderChildren` — exit 0, block
|
|
93
|
+
* gone, which is the exact data-loss channel this guard exists to make loud.
|
|
94
|
+
* Requiring it only on the wildcard branch left the hole open on the other one:
|
|
95
|
+
* a manifest that hand-types `x-galery` for `x-gallery` is a typo, and a typo
|
|
96
|
+
* must not be the thing that silently deletes content (seal F7).
|
|
97
|
+
*/
|
|
98
|
+
function admits(manifest, type) {
|
|
99
|
+
const declared = manifest.blocks ?? []
|
|
100
|
+
const isPlugin = type.startsWith(PLUGIN_TYPE_PREFIX)
|
|
101
|
+
if (isPlugin && !pluginBlockTypes().includes(type)) return false
|
|
102
|
+
if (declared.includes(type)) return true
|
|
103
|
+
// The class wildcard admits plugin blocks only; it is never a way for a
|
|
104
|
+
// template to acquire core types it never declared.
|
|
105
|
+
return isPlugin && declared.includes(PLUGIN_TYPE_WILDCARD)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Throw unless every block type in `doc` is admitted by the template.
|
|
110
|
+
*
|
|
111
|
+
* Refusal is loud, for core and plugin types alike: this guard's whole reason
|
|
112
|
+
* for existing is that silently drawing less than you were given is a data-loss
|
|
113
|
+
* channel nothing downstream can see (CONTRACT C4). A plugin block is not a
|
|
114
|
+
* softer case — an unadmitted one means the template has no paint for it.
|
|
115
|
+
*/
|
|
116
|
+
function assertDeclaredTypes({ doc, manifest, key }) {
|
|
117
|
+
for (const [type, path] of collectBlockTypes(doc)) {
|
|
118
|
+
if (admits(manifest, type)) continue
|
|
119
|
+
throw validationError(
|
|
120
|
+
`template "${key}" 的詞彙表不含 block 型別 \`${type}\`;` +
|
|
121
|
+
`該模板宣告:${[...(manifest.blocks ?? [])].join(', ')}。改用能表達它的模板,` +
|
|
122
|
+
'或先把文件轉成該模板的詞彙——渲染層不會靜默濾除看不懂的 block。',
|
|
123
|
+
CODES.TEMPLATE_BLOCK_UNSUPPORTED,
|
|
124
|
+
path,
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Assert that a template can express this document, in both type and shape.
|
|
131
|
+
*
|
|
132
|
+
* @param {{doc: object, manifest: {namespace: string, name: string,
|
|
133
|
+
* blocks: string[], root?: string|null}, layout?: {rootForm?: string|null}}} input
|
|
134
|
+
* @throws {import('./errors.js').KsbError} exit 1 / KSB_TEMPLATE_BLOCK_UNSUPPORTED
|
|
135
|
+
*/
|
|
136
|
+
export function assertTemplateVocabulary({ doc, manifest, layout }) {
|
|
137
|
+
const key = `${manifest.namespace}/${manifest.name}`
|
|
138
|
+
assertDeclaredTypes({ doc, manifest, key })
|
|
139
|
+
assertRootForm({ doc, manifest, layout, key })
|
|
140
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { appendFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { basename, dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Crash-safe file replacement: write a sibling temp file, then `rename` it over
|
|
6
|
+
* the target (CONTRACT D6, the S2 ride-along).
|
|
7
|
+
*
|
|
8
|
+
* The sidecar index is the case that forced this. `writeFileSync` truncates
|
|
9
|
+
* first and writes after, so a process killed in between leaves a **zero-byte
|
|
10
|
+
* or half-written** index — and the reader treats a corrupt sidecar as "no
|
|
11
|
+
* delivery copies", i.e. the record is silently gone rather than visibly broken.
|
|
12
|
+
* `rename` within one directory is atomic: a reader sees either the whole old
|
|
13
|
+
* file or the whole new one, never a torn one.
|
|
14
|
+
*
|
|
15
|
+
* The temp name carries the pid and a per-process counter, so two concurrent
|
|
16
|
+
* writers cannot land on the same scratch file and corrupt each other — which
|
|
17
|
+
* is the same reasoning that makes the artifact write exclusive (`wx`).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
let sequence = 0
|
|
21
|
+
|
|
22
|
+
const tempPathFor = (targetPath) => {
|
|
23
|
+
sequence += 1
|
|
24
|
+
return join(dirname(targetPath), `.${basename(targetPath)}.${process.pid}.${sequence}.tmp`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Append one record line to an append-only log (CONTRACT E2 的留言 sidecar).
|
|
29
|
+
*
|
|
30
|
+
* The rename dance above is the wrong tool here and would be actively worse: a
|
|
31
|
+
* read-all/rewrite-all cycle turns an append-only history into a full-file
|
|
32
|
+
* replacement, so a crash — or a second writer — can lose records that were
|
|
33
|
+
* already durable. A single `O_APPEND` write of one line is what an append log
|
|
34
|
+
* actually needs: the kernel places every writer at the current end of file, so
|
|
35
|
+
* concurrent appends interleave by record instead of overwriting each other.
|
|
36
|
+
*
|
|
37
|
+
* The line is written in one call for that reason; callers must therefore pass
|
|
38
|
+
* one complete record **without** its trailing newline — this function adds the
|
|
39
|
+
* terminator, so that the record and its delimiter cannot be split across two
|
|
40
|
+
* writes and interleaved with somebody else's.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} targetPath absolute path of the log
|
|
43
|
+
* @param {string} line one record, without its trailing newline
|
|
44
|
+
* @returns {string} the target path
|
|
45
|
+
*/
|
|
46
|
+
export function appendLineAtomic(targetPath, line) {
|
|
47
|
+
if (line.includes('\n')) {
|
|
48
|
+
throw new Error('append record must be a single line; embedded newline would split the record')
|
|
49
|
+
}
|
|
50
|
+
appendFileSync(targetPath, `${line}\n`, { encoding: 'utf8', flag: 'a' })
|
|
51
|
+
return targetPath
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} targetPath absolute path to replace
|
|
56
|
+
* @param {string} data file contents
|
|
57
|
+
* @returns {string} the target path
|
|
58
|
+
*/
|
|
59
|
+
export function writeFileAtomic(targetPath, data) {
|
|
60
|
+
const tmp = tempPathFor(targetPath)
|
|
61
|
+
try {
|
|
62
|
+
writeFileSync(tmp, data, { encoding: 'utf8', flag: 'wx' })
|
|
63
|
+
renameSync(tmp, targetPath)
|
|
64
|
+
} catch (cause) {
|
|
65
|
+
// A failed write must not leave scratch files behind: the store is a
|
|
66
|
+
// permanent record, and residue there is indistinguishable from content.
|
|
67
|
+
rmSync(tmp, { force: true })
|
|
68
|
+
throw cause
|
|
69
|
+
}
|
|
70
|
+
return targetPath
|
|
71
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, resolve } from 'node:path'
|
|
3
|
+
import { CODES, EXIT, KsbError } from '../core/errors.js'
|
|
4
|
+
import { appendLineAtomic } from './atomic.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The block-anchored comment loop (CONTRACT E2, SPEC §13.1).
|
|
8
|
+
*
|
|
9
|
+
* Two properties carry the whole design:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Comments are data, never edits.** Nothing here touches the artifact. A
|
|
12
|
+
* comment is a row in a log beside it, so the artifact stays exactly what
|
|
13
|
+
* the renderer produced and the file remains the single source of truth.
|
|
14
|
+
* 2. **The log is append-only.** Resolving a comment appends a *new* record
|
|
15
|
+
* with the same id and `status: "resolved"`; it never rewrites the line that
|
|
16
|
+
* recorded the original opinion. A human's comment is testimony — the
|
|
17
|
+
* record of what was said must survive the decision to act on it, which is
|
|
18
|
+
* also what lets an Agent see that a comment was raised at all after it has
|
|
19
|
+
* been dealt with.
|
|
20
|
+
*
|
|
21
|
+
* Reading folds the log by id, last record wins. Every record therefore has
|
|
22
|
+
* the same five keys, which is what keeps `comments --json` a homogeneous array
|
|
23
|
+
* rather than a union of two shapes.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Verbatim CONTRACT constant — the sidecar suffix, appended to the artifact path. */
|
|
27
|
+
export const COMMENTS_SUFFIX = '.comments.jsonl'
|
|
28
|
+
/** Verbatim CONTRACT constant — comment fields, in output order. */
|
|
29
|
+
export const COMMENT_KEYS = Object.freeze(['id', 'blockId', 'text', 'ts', 'status'])
|
|
30
|
+
/** Verbatim CONTRACT constant — the two legal statuses. */
|
|
31
|
+
export const STATUS_OPEN = 'open'
|
|
32
|
+
export const STATUS_RESOLVED = 'resolved'
|
|
33
|
+
|
|
34
|
+
/** `<canonical>.comments.jsonl` — beside the artifact it belongs to. */
|
|
35
|
+
export function commentsPath(artifactPath) {
|
|
36
|
+
return `${resolve(artifactPath)}${COMMENTS_SUFFIX}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const validationError = (code, message) =>
|
|
40
|
+
new KsbError({ code, message, exitCode: EXIT.VALIDATION })
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Raw records in write order. A malformed line is skipped rather than fatal:
|
|
44
|
+
* the log may be appended to by a server that died mid-line, and one torn
|
|
45
|
+
* record must not make every earlier comment unreadable.
|
|
46
|
+
*/
|
|
47
|
+
export function readCommentRecords(artifactPath) {
|
|
48
|
+
const path = commentsPath(artifactPath)
|
|
49
|
+
if (!existsSync(path)) return []
|
|
50
|
+
let text
|
|
51
|
+
try {
|
|
52
|
+
text = readFileSync(path, 'utf8')
|
|
53
|
+
} catch (cause) {
|
|
54
|
+
throw validationError(
|
|
55
|
+
CODES.ARTIFACT_UNREADABLE,
|
|
56
|
+
`could not read comments sidecar ${path}: ${cause.message}`,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
const records = []
|
|
60
|
+
for (const line of text.split('\n')) {
|
|
61
|
+
if (line.trim().length === 0) continue
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(line)
|
|
64
|
+
if (parsed && typeof parsed.id === 'string') records.push(parsed)
|
|
65
|
+
} catch {
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return records
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Exactly the five pinned keys, in the pinned order. */
|
|
73
|
+
const toEntry = (record) => ({
|
|
74
|
+
id: String(record.id),
|
|
75
|
+
blockId: String(record.blockId ?? ''),
|
|
76
|
+
text: String(record.text ?? ''),
|
|
77
|
+
ts: String(record.ts ?? ''),
|
|
78
|
+
status: record.status === STATUS_RESOLVED ? STATUS_RESOLVED : STATUS_OPEN,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Effective comments: folded by id (last record wins), ordered by the first
|
|
83
|
+
* time each id appeared, so resolving a comment never reorders the list.
|
|
84
|
+
*
|
|
85
|
+
* @returns {Array<{id: string, blockId: string, text: string, ts: string, status: string}>}
|
|
86
|
+
*/
|
|
87
|
+
export function listComments(artifactPath) {
|
|
88
|
+
const order = []
|
|
89
|
+
const byId = new Map()
|
|
90
|
+
for (const record of readCommentRecords(artifactPath)) {
|
|
91
|
+
if (!byId.has(record.id)) order.push(record.id)
|
|
92
|
+
byId.set(record.id, record)
|
|
93
|
+
}
|
|
94
|
+
return order.map((id) => toEntry(byId.get(id)))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** `c1`, `c2`, … — one past the highest id already in the log. */
|
|
98
|
+
const nextCommentId = (records) => {
|
|
99
|
+
let highest = 0
|
|
100
|
+
for (const record of records) {
|
|
101
|
+
const match = /^c(\d+)$/.exec(String(record.id))
|
|
102
|
+
if (match !== null) highest = Math.max(highest, Number(match[1]))
|
|
103
|
+
}
|
|
104
|
+
return `c${highest + 1}`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const appendRecord = (artifactPath, entry) => {
|
|
108
|
+
const path = commentsPath(artifactPath)
|
|
109
|
+
try {
|
|
110
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
111
|
+
appendLineAtomic(path, JSON.stringify(entry))
|
|
112
|
+
} catch (cause) {
|
|
113
|
+
throw validationError(
|
|
114
|
+
CODES.WRITE_FAILED,
|
|
115
|
+
`could not append to comments sidecar ${path}: ${cause.message}`,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
return entry
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Append one comment, anchored to a block that must exist.
|
|
123
|
+
*
|
|
124
|
+
* `blockIds` is supplied by the caller (read off the artifact's IR) rather than
|
|
125
|
+
* parsed here: this layer never learns what an artifact looks like inside, and
|
|
126
|
+
* the server and the CLI must validate against the *same* set or the two write
|
|
127
|
+
* paths would disagree about which anchors are legal.
|
|
128
|
+
*
|
|
129
|
+
* @param {{artifactPath: string, blockId: string, text: string,
|
|
130
|
+
* blockIds: Set<string>|string[], now?: Date}} input
|
|
131
|
+
*/
|
|
132
|
+
export function appendComment({ artifactPath, blockId, text, blockIds, now = new Date() }) {
|
|
133
|
+
const legal = blockIds instanceof Set ? blockIds : new Set(blockIds ?? [])
|
|
134
|
+
const anchor = String(blockId ?? '').trim()
|
|
135
|
+
const body = String(text ?? '').trim()
|
|
136
|
+
|
|
137
|
+
if (anchor.length === 0) {
|
|
138
|
+
throw validationError(CODES.BLOCK_NOT_FOUND, 'comment must name a blockId')
|
|
139
|
+
}
|
|
140
|
+
if (!legal.has(anchor)) {
|
|
141
|
+
throw validationError(
|
|
142
|
+
CODES.BLOCK_NOT_FOUND,
|
|
143
|
+
`block "${anchor}" is not in this artifact's IR; known ids: ${[...legal].join(', ')}`,
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
if (body.length === 0) {
|
|
147
|
+
throw validationError(CODES.INPUT_EMPTY, 'comment text must not be empty')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const id = nextCommentId(readCommentRecords(artifactPath))
|
|
151
|
+
return appendRecord(artifactPath, {
|
|
152
|
+
id,
|
|
153
|
+
blockId: anchor,
|
|
154
|
+
text: body,
|
|
155
|
+
ts: now.toISOString(),
|
|
156
|
+
status: STATUS_OPEN,
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Mark a comment resolved by appending a resolution record. The original line
|
|
162
|
+
* stays exactly where it was.
|
|
163
|
+
*
|
|
164
|
+
* @param {{artifactPath: string, id: string, now?: Date}} input
|
|
165
|
+
*/
|
|
166
|
+
export function resolveComment({ artifactPath, id, now = new Date() }) {
|
|
167
|
+
const wanted = String(id ?? '').trim()
|
|
168
|
+
const current = listComments(artifactPath).find((entry) => entry.id === wanted)
|
|
169
|
+
if (current === undefined) {
|
|
170
|
+
throw validationError(
|
|
171
|
+
CODES.COMMENT_NOT_FOUND,
|
|
172
|
+
`no comment "${wanted}" on ${resolve(artifactPath)}`,
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
return appendRecord(artifactPath, {
|
|
176
|
+
...current,
|
|
177
|
+
ts: now.toISOString(),
|
|
178
|
+
status: STATUS_RESOLVED,
|
|
179
|
+
})
|
|
180
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The single resolver for the central store root (CONTRACT B1).
|
|
6
|
+
*
|
|
7
|
+
* Every path that touches `~/.kamishibai` must come through here. Two callers
|
|
8
|
+
* each doing their own `join(homedir(), …)` is how a test suite ends up writing
|
|
9
|
+
* into the developer's real store: one of them will miss the override, and
|
|
10
|
+
* nothing downstream can tell that it did.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Verbatim CONTRACT constant. */
|
|
14
|
+
export const HOME_ENV = 'KAMISHIBAI_HOME'
|
|
15
|
+
/** Verbatim CONTRACT constant — default store root is `~/.kamishibai`. */
|
|
16
|
+
export const DEFAULT_HOME_DIRNAME = '.kamishibai'
|
|
17
|
+
/** Verbatim CONTRACT constant — 庫佈局 `artifacts/<專案名>/<slug>.html`. */
|
|
18
|
+
export const ARTIFACTS_DIRNAME = 'artifacts'
|
|
19
|
+
/** Verbatim CONTRACT constant — 標記檔名. */
|
|
20
|
+
export const MARKER_FILENAME = 'created-by'
|
|
21
|
+
/** Verbatim CONTRACT constant — `templates/<namespace>/<name>/manifest.toml`. */
|
|
22
|
+
export const TEMPLATES_DIRNAME = 'templates'
|
|
23
|
+
/** Verbatim CONTRACT constant — `<KAMISHIBAI_HOME>/run/serve.pid`. */
|
|
24
|
+
export const RUN_DIRNAME = 'run'
|
|
25
|
+
/** Verbatim CONTRACT constant — the serve pidfile's basename. */
|
|
26
|
+
export const SERVE_PIDFILE = 'serve.pid'
|
|
27
|
+
|
|
28
|
+
/** Absolute store root: `KAMISHIBAI_HOME` wins, else `~/.kamishibai`. */
|
|
29
|
+
export function resolveHome(env = process.env) {
|
|
30
|
+
const override = env?.[HOME_ENV]
|
|
31
|
+
if (typeof override === 'string' && override.trim().length > 0) {
|
|
32
|
+
return resolve(override.trim())
|
|
33
|
+
}
|
|
34
|
+
return join(homedir(), DEFAULT_HOME_DIRNAME)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `<HOME>/artifacts` */
|
|
38
|
+
export function artifactsRoot(env = process.env) {
|
|
39
|
+
return join(resolveHome(env), ARTIFACTS_DIRNAME)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `<HOME>/artifacts/<project>` */
|
|
43
|
+
export function projectDir(project, env = process.env) {
|
|
44
|
+
return join(artifactsRoot(env), project)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `<HOME>/created-by` */
|
|
48
|
+
export function markerPath(env = process.env) {
|
|
49
|
+
return join(resolveHome(env), MARKER_FILENAME)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `<HOME>/templates` — the template namespace root (CONTRACT E3). */
|
|
53
|
+
export function templatesRoot(env = process.env) {
|
|
54
|
+
return join(resolveHome(env), TEMPLATES_DIRNAME)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `<HOME>/templates/<namespace>/<name>` */
|
|
58
|
+
export function templatePackageDir(namespace, name, env = process.env) {
|
|
59
|
+
return join(templatesRoot(env), namespace, name)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** `<HOME>/run` — ephemeral process state, never a record. */
|
|
63
|
+
export function runDir(env = process.env) {
|
|
64
|
+
return join(resolveHome(env), RUN_DIRNAME)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `<HOME>/run/serve.pid` */
|
|
68
|
+
export function servePidfile(env = process.env) {
|
|
69
|
+
return join(runDir(env), SERVE_PIDFILE)
|
|
70
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { CODES, EXIT, KsbError } from '../core/errors.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The single cross-platform opener (SPEC §8 開啟三通道 ①). One chain, tried in
|
|
6
|
+
* order — WSL first because that is where this SDK is developed and `xdg-open`
|
|
7
|
+
* there resolves to a Linux browser that cannot show the file to the user.
|
|
8
|
+
*/
|
|
9
|
+
export const OPEN_CHAIN = Object.freeze(['wslview', 'xdg-open', 'explorer.exe', 'open'])
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} target absolute path of the artifact to open
|
|
13
|
+
* @param {(cmd: string, args: string[]) => void} [run] injected for tests
|
|
14
|
+
* @returns {string} the launcher that succeeded
|
|
15
|
+
*/
|
|
16
|
+
export function openPath(target, run = (cmd, args) => execFileSync(cmd, args, { stdio: 'ignore' })) {
|
|
17
|
+
const tried = []
|
|
18
|
+
for (const cmd of OPEN_CHAIN) {
|
|
19
|
+
try {
|
|
20
|
+
run(cmd, [target])
|
|
21
|
+
return cmd
|
|
22
|
+
} catch {
|
|
23
|
+
tried.push(cmd)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
throw new KsbError({
|
|
27
|
+
code: CODES.OPEN_FAILED,
|
|
28
|
+
message: `could not open ${target}; tried: ${tried.join(', ')}`,
|
|
29
|
+
exitCode: EXIT.VALIDATION,
|
|
30
|
+
})
|
|
31
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
3
|
+
import { CODES, EXIT, KsbError } from '../core/errors.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Project resolution (SPEC §9.1). The order is contractual:
|
|
7
|
+
*
|
|
8
|
+
* `--project` → `.kamishibai.toml` → git root → cwd
|
|
9
|
+
*
|
|
10
|
+
* The anchor file sits above git root in the order on purpose: a non-git
|
|
11
|
+
* directory (or a sub-repo checked out inside another repo) still needs a
|
|
12
|
+
* stable project identity, and running from a nested subdirectory must resolve
|
|
13
|
+
* to the same project as running from its root.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Verbatim CONTRACT constant — the project anchor file. */
|
|
17
|
+
export const ANCHOR_FILENAME = '.kamishibai.toml'
|
|
18
|
+
const GIT_MARKER = '.git'
|
|
19
|
+
|
|
20
|
+
/** Resolution layers, in contract order — exported so tests read the真源. */
|
|
21
|
+
export const RESOLUTION_ORDER = Object.freeze(['--project', ANCHOR_FILENAME, 'git-root', 'cwd'])
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Project names are validated in two tiers, because the two sources are not
|
|
25
|
+
* the same kind of input (seal 補釘 F1):
|
|
26
|
+
*
|
|
27
|
+
* - What the user *typed* (`--project`) is rejected when illegal. Silently
|
|
28
|
+
* rewriting it would archive into a directory they never asked for.
|
|
29
|
+
* - What we *derived* (anchor file / git root / cwd) is normalised. Nobody
|
|
30
|
+
* chooses to work in `~/Google Drive/My Project`, and S1 ran there fine —
|
|
31
|
+
* refusing to render because a parent folder has a space is our bug, not
|
|
32
|
+
* the user's.
|
|
33
|
+
*/
|
|
34
|
+
const INVALID_NAME = /[/\\\s]/
|
|
35
|
+
|
|
36
|
+
/** Substituted for any character that may not appear in a directory name. */
|
|
37
|
+
export const NAME_SEPARATOR = '-'
|
|
38
|
+
/** Last resort when normalisation leaves nothing usable. */
|
|
39
|
+
export const FALLBACK_PROJECT = 'unnamed-project'
|
|
40
|
+
|
|
41
|
+
const rejectName = (name, why) =>
|
|
42
|
+
new KsbError({
|
|
43
|
+
code: CODES.USAGE,
|
|
44
|
+
message: `invalid project name "${name}": ${why}`,
|
|
45
|
+
exitCode: EXIT.USAGE,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
/** Strict tier — for names the user supplied verbatim. */
|
|
49
|
+
export function assertProjectName(name) {
|
|
50
|
+
const trimmed = String(name ?? '').trim()
|
|
51
|
+
if (trimmed.length === 0) throw rejectName(name, 'must not be empty')
|
|
52
|
+
if (trimmed === '.' || trimmed === '..' || trimmed.includes('..')) {
|
|
53
|
+
throw rejectName(trimmed, 'must not contain path traversal')
|
|
54
|
+
}
|
|
55
|
+
if (INVALID_NAME.test(trimmed)) {
|
|
56
|
+
throw rejectName(trimmed, 'must not contain path separators or whitespace')
|
|
57
|
+
}
|
|
58
|
+
return trimmed
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Lenient tier — deterministic normalisation for derived names.
|
|
63
|
+
*
|
|
64
|
+
* Deterministic is the whole point: the same directory must resolve to the
|
|
65
|
+
* same project on every run, or a project's history silently splits in two.
|
|
66
|
+
* The result is then put through the strict check, so normalisation can never
|
|
67
|
+
* emit something the strict tier would have rejected.
|
|
68
|
+
*/
|
|
69
|
+
export function normaliseProjectName(name) {
|
|
70
|
+
const normalised = String(name ?? '')
|
|
71
|
+
.trim()
|
|
72
|
+
// eslint-disable-next-line no-control-regex
|
|
73
|
+
.replace(/[/\\\s\u0000-\u001f]+/g, NAME_SEPARATOR)
|
|
74
|
+
.replace(/\.{2,}/g, NAME_SEPARATOR)
|
|
75
|
+
.replace(new RegExp(`\\${NAME_SEPARATOR}{2,}`, 'g'), NAME_SEPARATOR)
|
|
76
|
+
.replace(new RegExp(`^\\${NAME_SEPARATOR}+|\\${NAME_SEPARATOR}+$`, 'g'), '')
|
|
77
|
+
|
|
78
|
+
if (normalised.length === 0 || normalised === '.') return FALLBACK_PROJECT
|
|
79
|
+
return assertProjectName(normalised)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Walk from `start` up to the filesystem root, returning the first hit. */
|
|
83
|
+
const walkUp = (start, probe) => {
|
|
84
|
+
let dir = resolve(start)
|
|
85
|
+
for (;;) {
|
|
86
|
+
const hit = probe(dir)
|
|
87
|
+
if (hit !== null) return hit
|
|
88
|
+
const parent = dirname(dir)
|
|
89
|
+
if (parent === dir) return null
|
|
90
|
+
dir = parent
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Read the project name out of an anchor file. Only the `name = "…"` key is
|
|
96
|
+
* consulted — S2 needs the anchor *read*, not a general TOML implementation
|
|
97
|
+
* (the manifest parser lands with the template-authoring slice). An anchor
|
|
98
|
+
* without a usable name still anchors: the directory holding it wins.
|
|
99
|
+
*/
|
|
100
|
+
const anchorName = (file) => {
|
|
101
|
+
let text
|
|
102
|
+
try {
|
|
103
|
+
text = readFileSync(file, 'utf8')
|
|
104
|
+
} catch (cause) {
|
|
105
|
+
throw new KsbError({
|
|
106
|
+
code: CODES.ARTIFACT_UNREADABLE,
|
|
107
|
+
message: `could not read project anchor ${file}: ${cause.message}`,
|
|
108
|
+
exitCode: EXIT.VALIDATION,
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
const match = /^\s*name\s*=\s*["']([^"'\n]+)["']/m.exec(text)
|
|
112
|
+
return match ? match[1].trim() : basename(dirname(file))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const fromAnchor = (cwd) =>
|
|
116
|
+
walkUp(cwd, (dir) => {
|
|
117
|
+
const candidate = join(dir, ANCHOR_FILENAME)
|
|
118
|
+
return existsSync(candidate) ? candidate : null
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const fromGitRoot = (cwd) => walkUp(cwd, (dir) => (existsSync(join(dir, GIT_MARKER)) ? dir : null))
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @param {{project?: string, cwd?: string}} input
|
|
125
|
+
* @returns {{name: string, source: string}} resolved project and which layer won
|
|
126
|
+
*/
|
|
127
|
+
export function resolveProject({ project, cwd = process.cwd() } = {}) {
|
|
128
|
+
if (project !== undefined && project !== null && String(project).length > 0) {
|
|
129
|
+
return { name: assertProjectName(project), source: '--project' }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const anchor = fromAnchor(cwd)
|
|
133
|
+
if (anchor !== null) {
|
|
134
|
+
return { name: normaliseProjectName(anchorName(anchor)), source: ANCHOR_FILENAME }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const gitRoot = fromGitRoot(cwd)
|
|
138
|
+
if (gitRoot !== null) return { name: normaliseProjectName(basename(gitRoot)), source: 'git-root' }
|
|
139
|
+
|
|
140
|
+
return { name: normaliseProjectName(basename(resolve(cwd))), source: 'cwd' }
|
|
141
|
+
}
|