@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,265 @@
|
|
|
1
|
+
import { el } from './element.js'
|
|
2
|
+
import { str } from './schema-parts.js'
|
|
3
|
+
|
|
4
|
+
/** The one `kind` v1 implements. The 18-diagram-type taxonomy is later work. */
|
|
5
|
+
export const DIAGRAM_KINDS = Object.freeze(['graph'])
|
|
6
|
+
|
|
7
|
+
const diagramNode = Object.freeze({
|
|
8
|
+
type: 'object',
|
|
9
|
+
required: ['id', 'label'],
|
|
10
|
+
properties: { id: { ...str, minLength: 1 }, label: str },
|
|
11
|
+
additionalProperties: false,
|
|
12
|
+
})
|
|
13
|
+
const diagramEdge = Object.freeze({
|
|
14
|
+
type: 'object',
|
|
15
|
+
required: ['from', 'to'],
|
|
16
|
+
properties: { from: { ...str, minLength: 1 }, to: { ...str, minLength: 1 }, label: str },
|
|
17
|
+
additionalProperties: false,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const isNonEmptyString = (value) => typeof value === 'string' && value.length > 0
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Every problem with one diagram block, as human-readable messages.
|
|
24
|
+
* Empty array means the block is drawable.
|
|
25
|
+
*
|
|
26
|
+
* Validation happens before drawing, always: a diagram whose spec is broken
|
|
27
|
+
* (unknown kind, dangling edge, duplicate id) must fail with a `KSB_` code and
|
|
28
|
+
* a block path. Drawing "whatever survives" would produce a picture that
|
|
29
|
+
* silently disagrees with the spec it came from — the worst possible outcome
|
|
30
|
+
* for a document whose whole job is to be looked at.
|
|
31
|
+
*/
|
|
32
|
+
export function diagramProblems(block) {
|
|
33
|
+
if (isNonEmptyString(block?.parseError)) return [block.parseError]
|
|
34
|
+
|
|
35
|
+
const problems = []
|
|
36
|
+
if (!DIAGRAM_KINDS.includes(block?.kind)) {
|
|
37
|
+
problems.push(
|
|
38
|
+
`未知的 diagram kind ${JSON.stringify(block?.kind ?? null)};v1 僅支援:${DIAGRAM_KINDS.join(', ')}`,
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const nodes = block?.nodes
|
|
43
|
+
if (!Array.isArray(nodes) || nodes.length === 0) {
|
|
44
|
+
problems.push('diagram.nodes 必須是至少一個節點的陣列')
|
|
45
|
+
return problems
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const ids = new Set()
|
|
49
|
+
for (const [index, node] of nodes.entries()) {
|
|
50
|
+
if (!isNonEmptyString(node?.id)) {
|
|
51
|
+
problems.push(`nodes[${index}].id 必須是非空字串`)
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
if (ids.has(node.id)) {
|
|
55
|
+
problems.push(`nodes[${index}].id "${node.id}" 重複;節點 id 必須唯一`)
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
ids.add(node.id)
|
|
59
|
+
if (typeof node.label !== 'string') {
|
|
60
|
+
problems.push(`nodes[${index}].label(id "${node.id}")必須是字串`)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const edges = block?.edges
|
|
65
|
+
if (!Array.isArray(edges)) {
|
|
66
|
+
problems.push('diagram.edges 必須是陣列(沒有邊就給空陣列)')
|
|
67
|
+
return problems
|
|
68
|
+
}
|
|
69
|
+
for (const [index, edge] of edges.entries()) {
|
|
70
|
+
for (const end of ['from', 'to']) {
|
|
71
|
+
if (!isNonEmptyString(edge?.[end])) {
|
|
72
|
+
problems.push(`edges[${index}].${end} 必須是非空字串`)
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
if (!ids.has(edge[end])) {
|
|
76
|
+
problems.push(
|
|
77
|
+
`edges[${index}].${end} 指向不存在的節點 "${edge[end]}";` +
|
|
78
|
+
`已宣告的節點:${[...ids].join(', ')}`,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (edge?.label !== undefined && typeof edge.label !== 'string') {
|
|
83
|
+
problems.push(`edges[${index}].label 若存在必須是字串`)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return problems
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A `diagram` fence whose body could not be read at all.
|
|
91
|
+
*
|
|
92
|
+
* `nodes`/`edges` are deliberately absent rather than empty: the block has to
|
|
93
|
+
* be *unrenderable* by construction, so that even if the parse-error branch of
|
|
94
|
+
* validation were removed the shape check would still reject it. An empty-but-
|
|
95
|
+
* valid-looking diagram would draw a blank SVG and hide the authoring mistake.
|
|
96
|
+
*/
|
|
97
|
+
export const diagramParseError = (parseError) =>
|
|
98
|
+
Object.freeze({ type: 'diagram', kind: 'graph', parseError })
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* `diagram` → inline SVG, drawn from a layout this module does not compute.
|
|
102
|
+
*
|
|
103
|
+
* The geometry deliberately lives outside the block: both Kami templates must
|
|
104
|
+
* draw the *same* picture from the same spec (CONTRACT D1), and a layout
|
|
105
|
+
* duplicated per template is a guarantee that they will eventually differ. It
|
|
106
|
+
* now also lives outside the *renderer* — layout needs to know the available
|
|
107
|
+
* width, which is a page-layout fact this module cannot see (wayfinder issue 13
|
|
108
|
+
* 試金石). The render layer measures and feeds `ctx.layoutOf(block)` in.
|
|
109
|
+
* What a template may still own is the paint — the classes below are styled by
|
|
110
|
+
* each stylesheet's own palette variables.
|
|
111
|
+
*
|
|
112
|
+
* The arrowhead marker id is derived from the block id, which is assigned in
|
|
113
|
+
* document order: unique within an artifact (two diagrams cannot capture each
|
|
114
|
+
* other's marker) and stable across renders (so the bytes stay identical).
|
|
115
|
+
*/
|
|
116
|
+
export default Object.freeze({
|
|
117
|
+
type: 'diagram',
|
|
118
|
+
|
|
119
|
+
schema: Object.freeze({
|
|
120
|
+
required: ['kind', 'nodes', 'edges'],
|
|
121
|
+
properties: {
|
|
122
|
+
kind: { enum: [...DIAGRAM_KINDS] },
|
|
123
|
+
nodes: { type: 'array', minItems: 1, items: diagramNode },
|
|
124
|
+
edges: { type: 'array', items: diagramEdge },
|
|
125
|
+
},
|
|
126
|
+
}),
|
|
127
|
+
|
|
128
|
+
example: Object.freeze({
|
|
129
|
+
block: {
|
|
130
|
+
id: 'b18',
|
|
131
|
+
type: 'diagram',
|
|
132
|
+
kind: 'graph',
|
|
133
|
+
nodes: [
|
|
134
|
+
{ id: 'input', label: '來源語料' },
|
|
135
|
+
{ id: 'ir', label: 'block tree' },
|
|
136
|
+
{ id: 'artifact', label: '離線產物' },
|
|
137
|
+
],
|
|
138
|
+
edges: [
|
|
139
|
+
{ from: 'input', to: 'ir', label: 'parse' },
|
|
140
|
+
{ from: 'ir', to: 'artifact', label: 'render' },
|
|
141
|
+
{ from: 'artifact', to: 'ir', label: 'replay' },
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
|
|
146
|
+
styleHooks: Object.freeze([
|
|
147
|
+
'diagram',
|
|
148
|
+
'diagram-svg',
|
|
149
|
+
'diagram-arrow',
|
|
150
|
+
'diagram-edge',
|
|
151
|
+
'diagram-edge-line',
|
|
152
|
+
'diagram-edge-label',
|
|
153
|
+
'diagram-node',
|
|
154
|
+
'diagram-node-box',
|
|
155
|
+
'diagram-node-label',
|
|
156
|
+
]),
|
|
157
|
+
|
|
158
|
+
create: ({ kind, nodes, edges }) => Object.freeze({ type: 'diagram', kind, nodes, edges }),
|
|
159
|
+
|
|
160
|
+
validate: diagramProblems,
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* How a failed `validate` is reported. Stated as plain data because this
|
|
164
|
+
* layer imports nothing — the core turns it into a `KsbError` with the block
|
|
165
|
+
* path attached. `test_f1_validate_codes_match_core` pins the code against
|
|
166
|
+
* `CODES.DIAGRAM_INVALID` so the two spellings cannot drift.
|
|
167
|
+
*/
|
|
168
|
+
invalid: Object.freeze({
|
|
169
|
+
code: 'KSB_DIAGRAM_INVALID',
|
|
170
|
+
message: (problems) =>
|
|
171
|
+
`diagram block 無法繪製:${problems.join(';')}。` +
|
|
172
|
+
'規格錯了就報錯,不畫半張圖——空 SVG 會讓錯誤在視覺上消失。',
|
|
173
|
+
}),
|
|
174
|
+
|
|
175
|
+
render: (block, ctx) => {
|
|
176
|
+
const layout = ctx.layoutOf(block)
|
|
177
|
+
const arrowId = `ksb-arrow-${block.id}`
|
|
178
|
+
return el('figure', { class: 'diagram', 'data-diagram-kind': block.kind }, [
|
|
179
|
+
el(
|
|
180
|
+
'svg',
|
|
181
|
+
{
|
|
182
|
+
class: 'diagram-svg',
|
|
183
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
184
|
+
viewBox: `0 0 ${layout.width} ${layout.height}`,
|
|
185
|
+
width: layout.width,
|
|
186
|
+
height: layout.height,
|
|
187
|
+
role: 'img',
|
|
188
|
+
},
|
|
189
|
+
[
|
|
190
|
+
el('defs', null, [
|
|
191
|
+
el(
|
|
192
|
+
'marker',
|
|
193
|
+
{
|
|
194
|
+
id: arrowId,
|
|
195
|
+
viewBox: '0 0 10 10',
|
|
196
|
+
refX: 9,
|
|
197
|
+
refY: 5,
|
|
198
|
+
markerWidth: 7,
|
|
199
|
+
markerHeight: 7,
|
|
200
|
+
orient: 'auto-start-reverse',
|
|
201
|
+
},
|
|
202
|
+
[el('path', { class: 'diagram-arrow', d: 'M 0 0 L 10 5 L 0 10 z' })],
|
|
203
|
+
),
|
|
204
|
+
]),
|
|
205
|
+
...layout.edges.map((edge) =>
|
|
206
|
+
el(
|
|
207
|
+
'g',
|
|
208
|
+
{
|
|
209
|
+
class: 'diagram-edge',
|
|
210
|
+
'data-edge-from': edge.from,
|
|
211
|
+
'data-edge-to': edge.to,
|
|
212
|
+
},
|
|
213
|
+
[
|
|
214
|
+
el('path', {
|
|
215
|
+
class: 'diagram-edge-line',
|
|
216
|
+
d: edge.d,
|
|
217
|
+
'marker-end': `url(#${arrowId})`,
|
|
218
|
+
}),
|
|
219
|
+
// Filtered rather than left as `null`: a null child SSRs to an
|
|
220
|
+
// empty comment node, so an unlabelled edge would differ from a
|
|
221
|
+
// labelled one by a stray `<!---->` in the bytes.
|
|
222
|
+
...(edge.label
|
|
223
|
+
? [
|
|
224
|
+
el(
|
|
225
|
+
'text',
|
|
226
|
+
{
|
|
227
|
+
class: 'diagram-edge-label',
|
|
228
|
+
x: edge.labelX,
|
|
229
|
+
y: edge.labelY,
|
|
230
|
+
'text-anchor': edge.labelAnchor,
|
|
231
|
+
},
|
|
232
|
+
[edge.label],
|
|
233
|
+
),
|
|
234
|
+
]
|
|
235
|
+
: []),
|
|
236
|
+
],
|
|
237
|
+
),
|
|
238
|
+
),
|
|
239
|
+
...layout.nodes.map((node) =>
|
|
240
|
+
el('g', { class: 'diagram-node', 'data-node-id': node.id }, [
|
|
241
|
+
el('rect', {
|
|
242
|
+
class: 'diagram-node-box',
|
|
243
|
+
x: node.x,
|
|
244
|
+
y: node.y,
|
|
245
|
+
width: node.w,
|
|
246
|
+
height: node.h,
|
|
247
|
+
rx: 6,
|
|
248
|
+
}),
|
|
249
|
+
el(
|
|
250
|
+
'text',
|
|
251
|
+
{
|
|
252
|
+
class: 'diagram-node-label',
|
|
253
|
+
x: node.cx,
|
|
254
|
+
y: node.cy + 5,
|
|
255
|
+
'text-anchor': 'middle',
|
|
256
|
+
},
|
|
257
|
+
[node.label],
|
|
258
|
+
),
|
|
259
|
+
]),
|
|
260
|
+
),
|
|
261
|
+
],
|
|
262
|
+
),
|
|
263
|
+
])
|
|
264
|
+
},
|
|
265
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The neutral element tree — the whole conversion contract of the SDK.
|
|
3
|
+
*
|
|
4
|
+
* A block module's `render` returns *data*, never a component. That single
|
|
5
|
+
* choice is what makes a third-party block possible at all (wayfinder issue 13):
|
|
6
|
+
* if `render` returned Vue vnodes, every plugin would hard-depend on Vue 3, a
|
|
7
|
+
* template could never be pure data, and loading a plugin would be arbitrary
|
|
8
|
+
* code execution. Returning `{tag, attrs, children}` keeps Vue an implementation
|
|
9
|
+
* detail of `src/render/**` and leaves the public contract JSON-serializable.
|
|
10
|
+
*
|
|
11
|
+
* A node is one of:
|
|
12
|
+
*
|
|
13
|
+
* - a string — text content, escaped by the backend
|
|
14
|
+
* - `null` — a deliberately empty slot (the backend emits the same
|
|
15
|
+
* empty comment node Vue does, so its presence or absence
|
|
16
|
+
* is a byte-level decision the author controls)
|
|
17
|
+
* - `{tag, attrs?, children?}` — an element with child nodes
|
|
18
|
+
* - `{tag, attrs?, html}` — an element carrying raw inner HTML
|
|
19
|
+
*
|
|
20
|
+
* `children` and `html` are mutually exclusive: raw content is an island, and an
|
|
21
|
+
* island has no children the tree can reason about.
|
|
22
|
+
*
|
|
23
|
+
* Attribute values pass through verbatim, `null` included — a `null` attribute
|
|
24
|
+
* is omitted by the backend, which is how an optional attribute is expressed
|
|
25
|
+
* without a second node shape. Key order is preserved, and is part of the
|
|
26
|
+
* artifact's byte identity.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** An element with child nodes. `attrs` and `children` are both optional. */
|
|
30
|
+
export function el(tag, attrs = null, children = null) {
|
|
31
|
+
const node = { tag }
|
|
32
|
+
if (attrs !== null && attrs !== undefined) node.attrs = attrs
|
|
33
|
+
if (children !== null && children !== undefined) node.children = children
|
|
34
|
+
return Object.freeze(node)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* An element whose content is raw HTML — the escape hatch a `raw` island, a
|
|
39
|
+
* compiled `prose` run, or a table cell needs. The string is trusted: it has
|
|
40
|
+
* already been through the parser's inline compiler or is an explicit island.
|
|
41
|
+
*/
|
|
42
|
+
export function rawEl(tag, attrs, html) {
|
|
43
|
+
const node = { tag }
|
|
44
|
+
if (attrs !== null && attrs !== undefined) node.attrs = attrs
|
|
45
|
+
node.html = String(html ?? '')
|
|
46
|
+
return Object.freeze(node)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Whether a value is an element node (as opposed to text, null, or garbage). */
|
|
50
|
+
export const isElement = (node) =>
|
|
51
|
+
node !== null && typeof node === 'object' && typeof node.tag === 'string'
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { el } from './element.js'
|
|
2
|
+
import { str } from './schema-parts.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `graph` — 節點邊圖 (wayfinder issue 11 P1「資料」; hover 高亮 issue 11 P5).
|
|
6
|
+
*
|
|
7
|
+
* ## The schema is the whole of 13 號票 裁決 5, verbatim
|
|
8
|
+
*
|
|
9
|
+
* A node is `{id, label}`. An edge is `{from, to, label}`. Nothing else, on
|
|
10
|
+
* either, ever: 「diagram 的語意欄位(副標/variant/群組)屬 plugin kind,
|
|
11
|
+
* 核心 `graph` 保持窄」. The battle-map vocabulary that most wants in here —
|
|
12
|
+
* 狀態色, effort, 群組, 節點型別 — is named by that ruling as the thing that
|
|
13
|
+
* belongs to a plugin kind, so `additionalProperties: false` on both shapes is
|
|
14
|
+
* the ruling made mechanical rather than a matter of restraint.
|
|
15
|
+
*
|
|
16
|
+
* That leaves `graph` narrower than `diagram`, which is the point of having
|
|
17
|
+
* both: `diagram` carries a `kind` and is the entry point for the 18-type
|
|
18
|
+
* taxonomy a plugin will grow (SPEC §3.1), while `graph` is the one shape the
|
|
19
|
+
* core promises to keep drawable forever.
|
|
20
|
+
*
|
|
21
|
+
* ## Why the SVG assembly is not shared with `diagram`
|
|
22
|
+
*
|
|
23
|
+
* It could be — the two pictures are drawn from the same layout engine. It is
|
|
24
|
+
* not, and that is a byte decision rather than a taste one: every existing
|
|
25
|
+
* `kami/long-form` artifact embeds `diagram`'s markup verbatim, and a shared
|
|
26
|
+
* helper that produced so much as one different byte would move artifacts this
|
|
27
|
+
* slice is not allowed to move (CONTRACT F6a H6, and `f2b-baseline` would say
|
|
28
|
+
* so immediately). The duplication buys the guarantee that `diagram` is not
|
|
29
|
+
* touched at all; the day both are allowed to move together, they merge.
|
|
30
|
+
*
|
|
31
|
+
* Geometry comes from `ctx.layoutOf(block)` and is never computed here, for the
|
|
32
|
+
* reason issue 13 gives as its 試金石: layout needs the width the page gives the
|
|
33
|
+
* block, which is a page-layout fact no block can see. The render layer measures
|
|
34
|
+
* and feeds it in (`measureLaidOutGraphs` in `src/render/index.js`).
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const graphNode = Object.freeze({
|
|
38
|
+
type: 'object',
|
|
39
|
+
required: ['id', 'label'],
|
|
40
|
+
properties: { id: { ...str, minLength: 1 }, label: str },
|
|
41
|
+
additionalProperties: false,
|
|
42
|
+
})
|
|
43
|
+
const graphEdge = Object.freeze({
|
|
44
|
+
type: 'object',
|
|
45
|
+
required: ['from', 'to'],
|
|
46
|
+
properties: { from: { ...str, minLength: 1 }, to: { ...str, minLength: 1 }, label: str },
|
|
47
|
+
additionalProperties: false,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const isNonEmptyString = (value) => typeof value === 'string' && value.length > 0
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Every problem with one graph block, as human-readable messages.
|
|
54
|
+
*
|
|
55
|
+
* A dangling edge is the failure no schema keyword can state, and drawing it
|
|
56
|
+
* anyway would crash the layout (it dereferences both endpoints) or, worse,
|
|
57
|
+
* quietly drop the edge — a dependency picture that omits a dependency is the
|
|
58
|
+
* one error a reader cannot detect by looking.
|
|
59
|
+
*/
|
|
60
|
+
export function graphProblems(block) {
|
|
61
|
+
const problems = []
|
|
62
|
+
|
|
63
|
+
const nodes = block?.nodes
|
|
64
|
+
if (!Array.isArray(nodes) || nodes.length === 0) {
|
|
65
|
+
problems.push('graph.nodes 必須是至少一個節點的陣列')
|
|
66
|
+
return problems
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const ids = new Set()
|
|
70
|
+
for (const [index, node] of nodes.entries()) {
|
|
71
|
+
if (!isNonEmptyString(node?.id)) {
|
|
72
|
+
problems.push(`nodes[${index}].id 必須是非空字串`)
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
if (ids.has(node.id)) {
|
|
76
|
+
problems.push(`nodes[${index}].id "${node.id}" 重複;節點 id 必須唯一`)
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
ids.add(node.id)
|
|
80
|
+
if (typeof node.label !== 'string') {
|
|
81
|
+
problems.push(`nodes[${index}].label(id "${node.id}")必須是字串`)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const edges = block?.edges
|
|
86
|
+
if (!Array.isArray(edges)) {
|
|
87
|
+
problems.push('graph.edges 必須是陣列(沒有邊就給空陣列)')
|
|
88
|
+
return problems
|
|
89
|
+
}
|
|
90
|
+
for (const [index, edge] of edges.entries()) {
|
|
91
|
+
for (const end of ['from', 'to']) {
|
|
92
|
+
if (!isNonEmptyString(edge?.[end])) {
|
|
93
|
+
problems.push(`edges[${index}].${end} 必須是非空字串`)
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
if (!ids.has(edge[end])) {
|
|
97
|
+
problems.push(
|
|
98
|
+
`edges[${index}].${end} 指向不存在的節點 "${edge[end]}";` +
|
|
99
|
+
`已宣告的節點:${[...ids].join(', ')}`,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (edge?.label !== undefined && typeof edge.label !== 'string') {
|
|
104
|
+
problems.push(`edges[${index}].label 若存在必須是字串`)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return problems
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export default Object.freeze({
|
|
111
|
+
type: 'graph',
|
|
112
|
+
|
|
113
|
+
schema: Object.freeze({
|
|
114
|
+
required: ['nodes', 'edges'],
|
|
115
|
+
properties: {
|
|
116
|
+
nodes: { type: 'array', minItems: 1, items: graphNode },
|
|
117
|
+
edges: { type: 'array', items: graphEdge },
|
|
118
|
+
},
|
|
119
|
+
}),
|
|
120
|
+
|
|
121
|
+
example: Object.freeze({
|
|
122
|
+
block: {
|
|
123
|
+
id: 'b25',
|
|
124
|
+
type: 'graph',
|
|
125
|
+
nodes: [
|
|
126
|
+
{ id: 'blocks', label: 'block 模組' },
|
|
127
|
+
{ id: 'schema', label: 'IR schema' },
|
|
128
|
+
{ id: 'render', label: '渲染層' },
|
|
129
|
+
{ id: 'artifact', label: '離線產物' },
|
|
130
|
+
],
|
|
131
|
+
edges: [
|
|
132
|
+
{ from: 'blocks', to: 'schema', label: '推導' },
|
|
133
|
+
{ from: 'blocks', to: 'render', label: '中性樹' },
|
|
134
|
+
{ from: 'schema', to: 'artifact', label: '內嵌 IR' },
|
|
135
|
+
{ from: 'render', to: 'artifact', label: '組裝' },
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
}),
|
|
139
|
+
|
|
140
|
+
styleHooks: Object.freeze([
|
|
141
|
+
'graph',
|
|
142
|
+
'graph-svg',
|
|
143
|
+
'graph-arrow',
|
|
144
|
+
'graph-edge',
|
|
145
|
+
'graph-edge-line',
|
|
146
|
+
'graph-edge-label',
|
|
147
|
+
'graph-node',
|
|
148
|
+
'graph-node-box',
|
|
149
|
+
'graph-node-label',
|
|
150
|
+
]),
|
|
151
|
+
|
|
152
|
+
create: ({ nodes, edges }) => Object.freeze({ type: 'graph', nodes, edges }),
|
|
153
|
+
|
|
154
|
+
validate: graphProblems,
|
|
155
|
+
|
|
156
|
+
invalid: Object.freeze({
|
|
157
|
+
code: 'KSB_GRAPH_INVALID',
|
|
158
|
+
message: (problems) =>
|
|
159
|
+
`graph block 無法繪製:${problems.join(';')}。` +
|
|
160
|
+
'少畫一條邊的依賴圖,看起來與畫完的那一張一模一樣——所以規格錯了就報錯。',
|
|
161
|
+
}),
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The hover contract, stated here because the client asset reads it:
|
|
165
|
+
* `data-graph` scopes one graph against the others on the page,
|
|
166
|
+
* `data-node-id` names a node, and `data-edge-from` / `data-edge-to` name an
|
|
167
|
+
* edge's endpoints. The script needs nothing else to know what is adjacent to
|
|
168
|
+
* what — the adjacency is already in the markup.
|
|
169
|
+
*
|
|
170
|
+
* The arrowhead marker id is derived from the block id, assigned in document
|
|
171
|
+
* order: unique within an artifact (two graphs cannot capture each other's
|
|
172
|
+
* marker) and stable across renders, so the bytes stay identical.
|
|
173
|
+
*/
|
|
174
|
+
render: (block, ctx) => {
|
|
175
|
+
const layout = ctx.layoutOf(block)
|
|
176
|
+
const arrowId = `ksb-graph-arrow-${block.id}`
|
|
177
|
+
return el('figure', { class: 'graph', 'data-graph': block.id }, [
|
|
178
|
+
el(
|
|
179
|
+
'svg',
|
|
180
|
+
{
|
|
181
|
+
class: 'graph-svg',
|
|
182
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
183
|
+
viewBox: `0 0 ${layout.width} ${layout.height}`,
|
|
184
|
+
width: layout.width,
|
|
185
|
+
height: layout.height,
|
|
186
|
+
role: 'img',
|
|
187
|
+
},
|
|
188
|
+
[
|
|
189
|
+
el('defs', null, [
|
|
190
|
+
el(
|
|
191
|
+
'marker',
|
|
192
|
+
{
|
|
193
|
+
id: arrowId,
|
|
194
|
+
viewBox: '0 0 10 10',
|
|
195
|
+
refX: 9,
|
|
196
|
+
refY: 5,
|
|
197
|
+
markerWidth: 7,
|
|
198
|
+
markerHeight: 7,
|
|
199
|
+
orient: 'auto-start-reverse',
|
|
200
|
+
},
|
|
201
|
+
[el('path', { class: 'graph-arrow', d: 'M 0 0 L 10 5 L 0 10 z' })],
|
|
202
|
+
),
|
|
203
|
+
]),
|
|
204
|
+
...layout.edges.map((edge) =>
|
|
205
|
+
el(
|
|
206
|
+
'g',
|
|
207
|
+
{
|
|
208
|
+
class: 'graph-edge',
|
|
209
|
+
'data-edge-from': edge.from,
|
|
210
|
+
'data-edge-to': edge.to,
|
|
211
|
+
},
|
|
212
|
+
[
|
|
213
|
+
el('path', {
|
|
214
|
+
class: 'graph-edge-line',
|
|
215
|
+
d: edge.d,
|
|
216
|
+
'marker-end': `url(#${arrowId})`,
|
|
217
|
+
}),
|
|
218
|
+
// Filtered rather than left as `null`: a null child SSRs to an
|
|
219
|
+
// empty comment node, so an unlabelled edge would differ from a
|
|
220
|
+
// labelled one by a stray comment node in the bytes.
|
|
221
|
+
...(edge.label
|
|
222
|
+
? [
|
|
223
|
+
el(
|
|
224
|
+
'text',
|
|
225
|
+
{
|
|
226
|
+
class: 'graph-edge-label',
|
|
227
|
+
x: edge.labelX,
|
|
228
|
+
y: edge.labelY,
|
|
229
|
+
'text-anchor': edge.labelAnchor,
|
|
230
|
+
},
|
|
231
|
+
[edge.label],
|
|
232
|
+
),
|
|
233
|
+
]
|
|
234
|
+
: []),
|
|
235
|
+
],
|
|
236
|
+
),
|
|
237
|
+
),
|
|
238
|
+
...layout.nodes.map((node) =>
|
|
239
|
+
el('g', { class: 'graph-node', 'data-node-id': node.id, tabindex: '0' }, [
|
|
240
|
+
el('rect', {
|
|
241
|
+
class: 'graph-node-box',
|
|
242
|
+
x: node.x,
|
|
243
|
+
y: node.y,
|
|
244
|
+
width: node.w,
|
|
245
|
+
height: node.h,
|
|
246
|
+
rx: 6,
|
|
247
|
+
}),
|
|
248
|
+
el(
|
|
249
|
+
'text',
|
|
250
|
+
{
|
|
251
|
+
class: 'graph-node-label',
|
|
252
|
+
x: node.cx,
|
|
253
|
+
y: node.cy + 5,
|
|
254
|
+
'text-anchor': 'middle',
|
|
255
|
+
},
|
|
256
|
+
[node.label],
|
|
257
|
+
),
|
|
258
|
+
]),
|
|
259
|
+
),
|
|
260
|
+
],
|
|
261
|
+
),
|
|
262
|
+
])
|
|
263
|
+
},
|
|
264
|
+
})
|