@erclx/canon 5.17.0 → 5.19.0
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 +1 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/deploy-app/REQUIREMENT.md +44 -0
- package/claude/skills/deploy-app/SKILL.md +45 -0
- package/claude/skills/{deploy-cloudflare/SKILL.md → deploy-app/references/cloudflare.md} +5 -9
- package/claude/skills/deploy-app/references/vercel.md +72 -0
- package/docs/agents/architecture-record.md +12 -4
- package/docs/agents/canvas.md +13 -1
- package/docs/agents/commands.md +3 -2
- package/docs/agents/context-audit-checks.md +1 -1
- package/docs/agents/context-audit.md +4 -4
- package/docs/agents/index.md +2 -2
- package/docs/agents/teach.md +7 -1
- package/docs/workflow/skill-map.md +9 -9
- package/package.json +2 -1
- package/src/audits/catalog.ts +46 -1
- package/src/canvas/address.ts +73 -39
- package/src/canvas/client/inspector.tsx +241 -33
- package/src/canvas/client/pages-panel.tsx +51 -0
- package/src/canvas/client/shell.css +126 -1
- package/src/canvas/client/state.ts +120 -4
- package/src/canvas/client/theme-panel.tsx +55 -0
- package/src/canvas/content.ts +14 -3
- package/src/canvas/edit.ts +360 -0
- package/src/canvas/server.ts +50 -5
- package/src/canvas/tokens.ts +82 -0
- package/src/claude/cases/misc.ts +5 -1
- package/src/commands/canvas.ts +95 -1
- package/src/commands/context.ts +85 -5
- package/src/commands/slides.ts +73 -4
- package/src/commands/teach.ts +112 -4
- package/src/context/architecture.ts +98 -18
- package/src/context/gate.ts +30 -3
- package/src/context/requirements.ts +77 -0
- package/src/gate/measures.ts +69 -28
- package/src/gate/stages.ts +5 -5
- package/src/serve/live.ts +113 -0
- package/src/serve/static.ts +60 -2
- package/src/slides/convert/export.ts +323 -0
- package/src/slides/convert/shapes.ts +764 -0
- package/src/slides/convert/svg.ts +38 -0
- package/src/slides/convert/walk.ts +444 -0
- package/src/teach/browser/reload.ts +9 -0
- package/src/teach/nav.ts +7 -6
- package/src/teach/workspace.ts +2 -1
- package/src/teach/write-if-changed.ts +17 -0
- package/standards/architecture.md +13 -7
- package/standards/requirements.md +4 -0
- package/tooling/cloudflare/configs/.github/workflows/deploy.yml +2 -2
- package/tooling/cloudflare/reference.md +1 -1
- package/claude/skills/deploy-cloudflare/REQUIREMENT.md +0 -37
package/src/canvas/server.ts
CHANGED
|
@@ -15,7 +15,12 @@ import {
|
|
|
15
15
|
TEMP_SUFFIX,
|
|
16
16
|
writeSelection,
|
|
17
17
|
} from '@/canvas/content'
|
|
18
|
-
import {
|
|
18
|
+
import { type EditRefused, editFrame } from '@/canvas/edit'
|
|
19
|
+
import {
|
|
20
|
+
resolveFrameTokens,
|
|
21
|
+
type TokenOptions,
|
|
22
|
+
tokenGroups,
|
|
23
|
+
} from '@/canvas/tokens'
|
|
19
24
|
import { buildDesignCss } from '@/design/css'
|
|
20
25
|
import {
|
|
21
26
|
bindFirstFree,
|
|
@@ -140,7 +145,9 @@ function json(body: unknown, status = 200): Response {
|
|
|
140
145
|
})
|
|
141
146
|
}
|
|
142
147
|
|
|
143
|
-
const REFUSAL_STATUS: Record<
|
|
148
|
+
const REFUSAL_STATUS: Record<EditRefused['reason'], number> = {
|
|
149
|
+
'invalid-edit': 400,
|
|
150
|
+
'not-text-only': 409,
|
|
144
151
|
'invalid-name': 400,
|
|
145
152
|
'invalid-size': 400,
|
|
146
153
|
'invalid-position': 400,
|
|
@@ -154,7 +161,7 @@ const REFUSAL_STATUS: Record<ContentRefused['reason'], number> = {
|
|
|
154
161
|
'stale-address': 409,
|
|
155
162
|
}
|
|
156
163
|
|
|
157
|
-
function refusal(refused: ContentRefused): Response {
|
|
164
|
+
function refusal(refused: ContentRefused | EditRefused): Response {
|
|
158
165
|
return json(refused, REFUSAL_STATUS[refused.reason])
|
|
159
166
|
}
|
|
160
167
|
|
|
@@ -351,11 +358,11 @@ export function startCanvas(
|
|
|
351
358
|
'/': options.shell,
|
|
352
359
|
'/api/pages': guarded(() => {
|
|
353
360
|
const resolved = resolveFrameTokens(root, tokens)
|
|
354
|
-
const { css
|
|
361
|
+
const { css, ...source } = resolved
|
|
355
362
|
const selected = readSelection(root)
|
|
356
363
|
return json({
|
|
357
364
|
pages: listPages(root),
|
|
358
|
-
tokens: source,
|
|
365
|
+
tokens: { ...source, groups: tokenGroups(css) },
|
|
359
366
|
selection: selected && {
|
|
360
367
|
page: selected.page,
|
|
361
368
|
frame: selected.frame,
|
|
@@ -389,6 +396,44 @@ export function startCanvas(
|
|
|
389
396
|
}),
|
|
390
397
|
),
|
|
391
398
|
},
|
|
399
|
+
'/api/frames/edit': {
|
|
400
|
+
POST: guarded(
|
|
401
|
+
mutation((body) => {
|
|
402
|
+
if (
|
|
403
|
+
!isRecord(body) ||
|
|
404
|
+
typeof body.page !== 'string' ||
|
|
405
|
+
typeof body.frame !== 'string' ||
|
|
406
|
+
typeof body.property !== 'string' ||
|
|
407
|
+
typeof body.value !== 'string'
|
|
408
|
+
) {
|
|
409
|
+
return badBody(
|
|
410
|
+
'send page, frame, element, property, and value',
|
|
411
|
+
)
|
|
412
|
+
}
|
|
413
|
+
const { element } = body
|
|
414
|
+
/*
|
|
415
|
+
* The hash is what keeps an edit off an element the file has
|
|
416
|
+
* since moved, so the shell's edit never goes without it.
|
|
417
|
+
*/
|
|
418
|
+
if (!isAddressShape(element) || element.hash === undefined) {
|
|
419
|
+
return badBody(
|
|
420
|
+
'send an element as index, tag, count, and the hash it was served with',
|
|
421
|
+
)
|
|
422
|
+
}
|
|
423
|
+
const outcome = editFrame(
|
|
424
|
+
root,
|
|
425
|
+
body.page,
|
|
426
|
+
body.frame,
|
|
427
|
+
element,
|
|
428
|
+
{
|
|
429
|
+
property: body.property,
|
|
430
|
+
value: body.value,
|
|
431
|
+
},
|
|
432
|
+
)
|
|
433
|
+
return outcome.ok ? json(outcome) : refusal(outcome)
|
|
434
|
+
}),
|
|
435
|
+
),
|
|
436
|
+
},
|
|
392
437
|
'/api/selection': {
|
|
393
438
|
POST: guarded(
|
|
394
439
|
mutation((body) => {
|
package/src/canvas/tokens.ts
CHANGED
|
@@ -29,6 +29,88 @@ const BASE_FILE = 'base.css'
|
|
|
29
29
|
|
|
30
30
|
const NONE_NOTICE = `No token stylesheet, so frames render unstyled. Run canon design install to add ${join(DESIGN_INSTALL_DIR, BASE_FILE)}, put overrides under ${join(DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR)}/, or link a stylesheet from the frame.`
|
|
31
31
|
|
|
32
|
+
export type TokenKind =
|
|
33
|
+
| 'color'
|
|
34
|
+
| 'spacing'
|
|
35
|
+
| 'radius'
|
|
36
|
+
| 'font-family'
|
|
37
|
+
| 'font-size'
|
|
38
|
+
| 'other'
|
|
39
|
+
|
|
40
|
+
export interface Token {
|
|
41
|
+
readonly name: string
|
|
42
|
+
readonly value: string
|
|
43
|
+
/** The literal a `var()` value ends at, where the sheet defines it. */
|
|
44
|
+
readonly resolved?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface TokenGroup {
|
|
48
|
+
readonly kind: TokenKind
|
|
49
|
+
readonly tokens: readonly Token[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const KIND_ORDER: readonly TokenKind[] = [
|
|
53
|
+
'color',
|
|
54
|
+
'spacing',
|
|
55
|
+
'radius',
|
|
56
|
+
'font-family',
|
|
57
|
+
'font-size',
|
|
58
|
+
'other',
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
/** By the naming the toolkit's module and most token sheets share. */
|
|
62
|
+
function kindOf(name: string): TokenKind {
|
|
63
|
+
if (name.startsWith('--color-')) return 'color'
|
|
64
|
+
if (/^--(space|spacing)-/.test(name)) return 'spacing'
|
|
65
|
+
if (name.startsWith('--radius-')) return 'radius'
|
|
66
|
+
if (/-family$|^--font-family-/.test(name)) return 'font-family'
|
|
67
|
+
if (/-size$|^--font-size-|^--text-/.test(name)) return 'font-size'
|
|
68
|
+
return 'other'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const DECLARATION = /(--[A-Za-z0-9_-]+)\s*:\s*([^;}]*)/g
|
|
72
|
+
|
|
73
|
+
const REFERENCE = /^var\(\s*(--[A-Za-z0-9_-]+)\s*(?:,[^)]*)?\)$/
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The custom properties a token sheet defines, grouped by kind. A property
|
|
77
|
+
* defined twice keeps its first value, which is the dark theme's in this
|
|
78
|
+
* toolkit's module and the base's in an installed sheet. Empty groups drop.
|
|
79
|
+
*/
|
|
80
|
+
export function tokenGroups(css: string): TokenGroup[] {
|
|
81
|
+
const values = new Map<string, string>()
|
|
82
|
+
for (const [, name, value] of css
|
|
83
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
84
|
+
.matchAll(DECLARATION)) {
|
|
85
|
+
if (name && value !== undefined && !values.has(name)) {
|
|
86
|
+
values.set(name, value.trim())
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const resolve = (value: string, seen: Set<string>): string | undefined => {
|
|
91
|
+
const reference = value.match(REFERENCE)?.[1]
|
|
92
|
+
if (!reference) return value
|
|
93
|
+
const next = values.get(reference)
|
|
94
|
+
if (next === undefined || seen.has(reference)) return undefined
|
|
95
|
+
return resolve(next, new Set([...seen, reference]))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const grouped = new Map<TokenKind, Token[]>()
|
|
99
|
+
for (const [name, value] of values) {
|
|
100
|
+
const resolved = REFERENCE.test(value)
|
|
101
|
+
? resolve(value, new Set([name]))
|
|
102
|
+
: undefined
|
|
103
|
+
const token =
|
|
104
|
+
resolved === undefined ? { name, value } : { name, value, resolved }
|
|
105
|
+
const kind = kindOf(name)
|
|
106
|
+
grouped.set(kind, [...(grouped.get(kind) ?? []), token])
|
|
107
|
+
}
|
|
108
|
+
return KIND_ORDER.flatMap((kind) => {
|
|
109
|
+
const tokens = grouped.get(kind)
|
|
110
|
+
return tokens ? [{ kind, tokens }] : []
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
32
114
|
/** The installed files in cascade order: the base first, then each override. */
|
|
33
115
|
function installedFiles(root: string): string[] {
|
|
34
116
|
const files: string[] = []
|
package/src/claude/cases/misc.ts
CHANGED
|
@@ -104,6 +104,10 @@ export const MISC_CASES: readonly SkillCase[] = [
|
|
|
104
104
|
},
|
|
105
105
|
{
|
|
106
106
|
prompt: 'Connect this repo to Cloudflare Pages and set up the deploy.',
|
|
107
|
-
expect: 'deploy-
|
|
107
|
+
expect: 'deploy-app',
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
prompt: 'Set up a Vercel project for this app and wire up its secrets.',
|
|
111
|
+
expect: 'deploy-app',
|
|
108
112
|
},
|
|
109
113
|
]
|
package/src/commands/canvas.ts
CHANGED
|
@@ -14,6 +14,11 @@ import {
|
|
|
14
14
|
renamePage,
|
|
15
15
|
} from '@/canvas/content'
|
|
16
16
|
import { captureCanvas } from '@/canvas/capture'
|
|
17
|
+
import {
|
|
18
|
+
type EditRefused,
|
|
19
|
+
editFrameAtIndex,
|
|
20
|
+
STYLE_PROPERTIES,
|
|
21
|
+
} from '@/canvas/edit'
|
|
17
22
|
import { CANVAS_PORT, startCanvas } from '@/canvas/server'
|
|
18
23
|
import { resolveFrameTokens } from '@/canvas/tokens'
|
|
19
24
|
import { displayPath, parsePort, waitForInterrupt } from '@/serve/report'
|
|
@@ -40,6 +45,7 @@ interface Refused {
|
|
|
40
45
|
readonly ok: false
|
|
41
46
|
readonly reason:
|
|
42
47
|
| ContentRefused['reason']
|
|
48
|
+
| EditRefused['reason']
|
|
43
49
|
| 'no-root'
|
|
44
50
|
| 'no-server'
|
|
45
51
|
| 'capture-failed'
|
|
@@ -102,7 +108,7 @@ export function register(program: Command): void {
|
|
|
102
108
|
.command('canvas')
|
|
103
109
|
.helpOption('-h, --help', 'Show this help message')
|
|
104
110
|
.description(
|
|
105
|
-
'Local canvas of pages and HTML frames (serve, list, page, frame, selection, capture)',
|
|
111
|
+
'Local canvas of pages and HTML frames (serve, list, page, frame, selection, edit, capture)',
|
|
106
112
|
)
|
|
107
113
|
.addHelpText(
|
|
108
114
|
'after',
|
|
@@ -118,6 +124,7 @@ export function register(program: Command): void {
|
|
|
118
124
|
' canon canvas frame add drafts hero --width 1440 --height 900',
|
|
119
125
|
' canon canvas frame move drafts hero --x 200 --y 120',
|
|
120
126
|
' canon canvas selection --json',
|
|
127
|
+
" canon canvas edit drafts/hero --element 4 --set 'color=var(--color-accent)'",
|
|
121
128
|
' canon canvas capture drafts/hero',
|
|
122
129
|
' canon canvas list --json',
|
|
123
130
|
'',
|
|
@@ -366,6 +373,93 @@ export function register(program: Command): void {
|
|
|
366
373
|
outro()
|
|
367
374
|
})
|
|
368
375
|
|
|
376
|
+
withRoot(
|
|
377
|
+
canvas
|
|
378
|
+
.command('edit')
|
|
379
|
+
.description(
|
|
380
|
+
'Set one property of an element in a frame, the writer the inspector uses',
|
|
381
|
+
)
|
|
382
|
+
.argument('<target>', '<page>/<frame>')
|
|
383
|
+
.requiredOption(
|
|
384
|
+
'--element <index>',
|
|
385
|
+
'The element by its index in document order, as canvas selection reports it',
|
|
386
|
+
)
|
|
387
|
+
.requiredOption(
|
|
388
|
+
'--set <property=value>',
|
|
389
|
+
'The property and its value, where an empty value drops a style property',
|
|
390
|
+
),
|
|
391
|
+
)
|
|
392
|
+
.addHelpText(
|
|
393
|
+
'after',
|
|
394
|
+
[
|
|
395
|
+
'',
|
|
396
|
+
`Properties: text, ${STYLE_PROPERTIES.join(', ')}.`,
|
|
397
|
+
'A style property is written into the element inline style and',
|
|
398
|
+
'leaves its other properties as they were. text replaces the text of',
|
|
399
|
+
'an element holding text alone, and refuses one holding elements.',
|
|
400
|
+
'Every byte outside the element stays as it was.',
|
|
401
|
+
'',
|
|
402
|
+
].join('\n'),
|
|
403
|
+
)
|
|
404
|
+
.action(
|
|
405
|
+
async (
|
|
406
|
+
target: string,
|
|
407
|
+
opts: RootOptions & { element: string; set: string },
|
|
408
|
+
) => {
|
|
409
|
+
const emitJson = opts.json ?? false
|
|
410
|
+
const resolved = await resolveRoot(opts)
|
|
411
|
+
if (!resolved.ok) {
|
|
412
|
+
process.exitCode = refuse(resolved, emitJson)
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
const { root } = resolved
|
|
416
|
+
const [pageName, frameName, ...rest] = target.split('/')
|
|
417
|
+
if (!pageName || !frameName || rest.length > 0) {
|
|
418
|
+
process.exitCode = refuse(
|
|
419
|
+
{
|
|
420
|
+
ok: false,
|
|
421
|
+
reason: 'invalid-name',
|
|
422
|
+
detail: `${target} is not <page>/<frame>`,
|
|
423
|
+
},
|
|
424
|
+
emitJson,
|
|
425
|
+
)
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
const split = opts.set.indexOf('=')
|
|
429
|
+
if (split <= 0) {
|
|
430
|
+
process.exitCode = refuse(
|
|
431
|
+
{
|
|
432
|
+
ok: false,
|
|
433
|
+
reason: 'invalid-edit',
|
|
434
|
+
detail: `${opts.set} is not <property>=<value>`,
|
|
435
|
+
},
|
|
436
|
+
emitJson,
|
|
437
|
+
)
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
const property = opts.set.slice(0, split).trim()
|
|
441
|
+
const value = opts.set.slice(split + 1)
|
|
442
|
+
const index =
|
|
443
|
+
opts.element.trim() === '' ? Number.NaN : Number(opts.element)
|
|
444
|
+
const outcome = editFrameAtIndex(root, pageName, frameName, index, {
|
|
445
|
+
property,
|
|
446
|
+
value,
|
|
447
|
+
})
|
|
448
|
+
if (!outcome.ok) {
|
|
449
|
+
process.exitCode = refuse(outcome, emitJson)
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
const path = resolve(canvasDir(root), outcome.page, outcome.file)
|
|
453
|
+
if (emitJson) writeJson({ ...outcome, property, value, path })
|
|
454
|
+
intro(BANNER)
|
|
455
|
+
logAdd(`${outcome.page}/${outcome.file}`)
|
|
456
|
+
logInfo(
|
|
457
|
+
`element ${index}: ${property} ${value === '' ? 'dropped' : `= ${value}`}`,
|
|
458
|
+
)
|
|
459
|
+
outro()
|
|
460
|
+
},
|
|
461
|
+
)
|
|
462
|
+
|
|
369
463
|
withRoot(
|
|
370
464
|
canvas
|
|
371
465
|
.command('capture')
|
package/src/commands/context.ts
CHANGED
|
@@ -24,8 +24,10 @@ import {
|
|
|
24
24
|
coveredCount,
|
|
25
25
|
isOverCount,
|
|
26
26
|
isOverLength,
|
|
27
|
+
isOverRisks,
|
|
27
28
|
measureArchitecture,
|
|
28
29
|
missingRevisit,
|
|
30
|
+
overWords,
|
|
29
31
|
testableCount,
|
|
30
32
|
} from '@/context/architecture'
|
|
31
33
|
import { auditCitations, type CitationReport } from '@/context/citations'
|
|
@@ -56,6 +58,12 @@ import {
|
|
|
56
58
|
resolveFolders,
|
|
57
59
|
} from '@/context/folders'
|
|
58
60
|
import { isGating } from '@/context/gate'
|
|
61
|
+
import {
|
|
62
|
+
isOverWordCap,
|
|
63
|
+
measureRequirements,
|
|
64
|
+
requirementsRel,
|
|
65
|
+
type RequirementsReport,
|
|
66
|
+
} from '@/context/requirements'
|
|
59
67
|
import { auditIndexes, type FolderDrift } from '@/context/index-drift'
|
|
60
68
|
import {
|
|
61
69
|
measureWireframeFolder,
|
|
@@ -96,7 +104,7 @@ export function register(program: Command): void {
|
|
|
96
104
|
context
|
|
97
105
|
.command('audit')
|
|
98
106
|
.description(
|
|
99
|
-
'Report required sections, entry length, citations, reference form, catalog tables, provenance, index drift, the architecture record against its own ceiling,
|
|
107
|
+
'Report required sections, entry length, citations, reference form, catalog tables, provenance, index drift, the architecture record against its own ceiling, entry cap, word cap, and risk cap, the requirements record against its own word cap, and wireframe states against their evidence folders',
|
|
100
108
|
)
|
|
101
109
|
.argument('[path]', 'Project root, defaulting to the current directory')
|
|
102
110
|
.helpOption('-h, --help', 'Show this help message')
|
|
@@ -125,8 +133,10 @@ export function register(program: Command): void {
|
|
|
125
133
|
'measures it. A record stating an entry cap gates the same way when',
|
|
126
134
|
'it holds more decisions than the cap, and a record stating that',
|
|
127
135
|
'every decision closes with a revisit sentence gates when one lacks',
|
|
128
|
-
'it. A record stating
|
|
129
|
-
'
|
|
136
|
+
'it. A record stating a word cap a decision or a risk bullet cap',
|
|
137
|
+
'gates past either, and a requirements record stating a word cap',
|
|
138
|
+
'gates past it. A record stating none of these is reported and',
|
|
139
|
+
'never gated. --gate widens the gate to the other two',
|
|
130
140
|
'findings that are facts rather than judgments: a missing required',
|
|
131
141
|
'section and index drift. A context entry requires Overview and',
|
|
132
142
|
'Layout, and a wireframe requires Regions, States, Copy, and Not on',
|
|
@@ -658,6 +668,7 @@ async function runAudit(
|
|
|
658
668
|
// record. A run that never looked and a project with nothing to look at are
|
|
659
669
|
// different answers, and one value for both reports the second as the first.
|
|
660
670
|
const record = gateOnly ? undefined : await measureArchitecture(root)
|
|
671
|
+
const requirements = gateOnly ? undefined : await measureRequirements(root)
|
|
661
672
|
const wireframes = gateOnly
|
|
662
673
|
? []
|
|
663
674
|
: (
|
|
@@ -681,6 +692,7 @@ async function runAudit(
|
|
|
681
692
|
reportProvenance(entries, folders)
|
|
682
693
|
reportDrift(drift)
|
|
683
694
|
reportRecord(record, root)
|
|
695
|
+
reportRequirements(requirements, root)
|
|
684
696
|
reportWireframeStates(wireframes)
|
|
685
697
|
outro()
|
|
686
698
|
}
|
|
@@ -719,6 +731,8 @@ async function runAudit(
|
|
|
719
731
|
// target that never wrote one is entitled to. Absent says the run
|
|
720
732
|
// never looked, which is `--citations-only`.
|
|
721
733
|
architecture: gateOnly ? undefined : (record ?? null),
|
|
734
|
+
// The same three states for the requirements record.
|
|
735
|
+
requirements: gateOnly ? undefined : (requirements ?? null),
|
|
722
736
|
// Absent under `--citations-only`, for the same reason as above. An
|
|
723
737
|
// empty array under the ordinary run says the project carries no
|
|
724
738
|
// wireframes folder or no entry carrying a States table, which is a
|
|
@@ -744,6 +758,10 @@ async function runAudit(
|
|
|
744
758
|
recordOverCount: record !== undefined && isOverCount(record),
|
|
745
759
|
recordMissingRevisit:
|
|
746
760
|
record !== undefined && missingRevisit(record).length > 0,
|
|
761
|
+
recordOverWords: record !== undefined && overWords(record).length > 0,
|
|
762
|
+
recordOverRisks: record !== undefined && isOverRisks(record),
|
|
763
|
+
requirementsOverWords:
|
|
764
|
+
requirements !== undefined && isOverWordCap(requirements),
|
|
747
765
|
sections,
|
|
748
766
|
drift,
|
|
749
767
|
wireframes,
|
|
@@ -1152,11 +1170,44 @@ function reportRecord(
|
|
|
1152
1170
|
const { allowances } = report
|
|
1153
1171
|
|
|
1154
1172
|
logInfo(
|
|
1155
|
-
`${plural(report.words, 'word')} across ${plural(report.lines, 'line')}
|
|
1173
|
+
`${plural(report.words, 'word')} across ${plural(report.lines, 'line')}. The whole file's count never gates.`,
|
|
1156
1174
|
)
|
|
1157
1175
|
if (report.risksWords !== undefined) {
|
|
1158
1176
|
logInfo(
|
|
1159
|
-
`\`## Risks / open questions\` holds ${plural(report.risksWords, 'word')}
|
|
1177
|
+
`\`## Risks / open questions\` holds ${plural(report.risksWords, 'word')}.`,
|
|
1178
|
+
)
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
const longDecisions = overWords(report)
|
|
1182
|
+
if (report.wordCap === undefined) {
|
|
1183
|
+
logInfo(
|
|
1184
|
+
'No word cap a decision stated, so each decision word count is reported and never gated.',
|
|
1185
|
+
)
|
|
1186
|
+
} else if (longDecisions.length > 0) {
|
|
1187
|
+
for (const entry of longDecisions) {
|
|
1188
|
+
logError(
|
|
1189
|
+
`"${entry.heading}" holds ${entry.words} words against a cap of ${report.wordCap} a decision.`,
|
|
1190
|
+
)
|
|
1191
|
+
}
|
|
1192
|
+
} else {
|
|
1193
|
+
logInfo(
|
|
1194
|
+
`Every decision fits the cap of ${report.wordCap} words a decision.`,
|
|
1195
|
+
)
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
if (report.riskCap === undefined) {
|
|
1199
|
+
logInfo('No risk bullet cap stated, so the Risks section is never gated.')
|
|
1200
|
+
} else if (report.risksBullets === undefined) {
|
|
1201
|
+
logWarn(
|
|
1202
|
+
`The record states a cap of ${report.riskCap} risk bullets and carries no \`## Risks / open questions\` section to count.`,
|
|
1203
|
+
)
|
|
1204
|
+
} else if (isOverRisks(report)) {
|
|
1205
|
+
logError(
|
|
1206
|
+
`${plural(report.risksBullets, 'risk bullet')} against a cap of ${report.riskCap}, nested bullets counted.`,
|
|
1207
|
+
)
|
|
1208
|
+
} else {
|
|
1209
|
+
logInfo(
|
|
1210
|
+
`${plural(report.risksBullets, 'risk bullet')} against a cap of ${report.riskCap}, nested bullets counted.`,
|
|
1160
1211
|
)
|
|
1161
1212
|
}
|
|
1162
1213
|
|
|
@@ -1257,6 +1308,35 @@ function reportRecord(
|
|
|
1257
1308
|
)
|
|
1258
1309
|
}
|
|
1259
1310
|
|
|
1311
|
+
/** Reports the requirements record against the word cap it states, if any. */
|
|
1312
|
+
function reportRequirements(
|
|
1313
|
+
report: RequirementsReport | undefined,
|
|
1314
|
+
root: string,
|
|
1315
|
+
): void {
|
|
1316
|
+
logStep('Requirements record')
|
|
1317
|
+
|
|
1318
|
+
if (report === undefined) {
|
|
1319
|
+
logInfo(
|
|
1320
|
+
`Out of scope. The project carries no ${requirementsRel(root)}, so there was no record to measure.`,
|
|
1321
|
+
)
|
|
1322
|
+
return
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
if (report.wordCap === undefined) {
|
|
1326
|
+
logInfo(
|
|
1327
|
+
`${plural(report.words, 'word')} in ${report.rel} and no word cap stated, so the count is reported and never gated.`,
|
|
1328
|
+
)
|
|
1329
|
+
} else if (isOverWordCap(report)) {
|
|
1330
|
+
logError(
|
|
1331
|
+
`${plural(report.words, 'word')} against a cap of ${report.wordCap} in ${report.rel}.`,
|
|
1332
|
+
)
|
|
1333
|
+
} else {
|
|
1334
|
+
logInfo(
|
|
1335
|
+
`${plural(report.words, 'word')} against a cap of ${report.wordCap} in ${report.rel}.`,
|
|
1336
|
+
)
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1260
1340
|
function reportDrift(drift: readonly FolderDrift[]): void {
|
|
1261
1341
|
logStep('Index drift')
|
|
1262
1342
|
|
package/src/commands/slides.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs'
|
|
1
|
+
import { existsSync, statSync } from 'node:fs'
|
|
2
2
|
import { resolve } from 'node:path'
|
|
3
3
|
import type { Command } from 'commander'
|
|
4
|
+
import { INSTALL_BROWSER } from '@/browser/engine'
|
|
4
5
|
import { creationRel, SCRATCH } from '@/record-root'
|
|
5
6
|
import { LAYOUTS } from '@/slides/layouts'
|
|
6
7
|
import { openDeck } from '@/slides/open'
|
|
@@ -17,8 +18,14 @@ export function register(program: Command): void {
|
|
|
17
18
|
slides
|
|
18
19
|
.command('render')
|
|
19
20
|
.helpOption('-h, --help', 'Show this help message')
|
|
20
|
-
.description(
|
|
21
|
-
|
|
21
|
+
.description(
|
|
22
|
+
'Render a SLIDES.md source, or a folder of HTML slides, into a PowerPoint deck',
|
|
23
|
+
)
|
|
24
|
+
.option(
|
|
25
|
+
'-s, --source <path>',
|
|
26
|
+
'Source SLIDES.md file, or a folder of .html slides read in filename order',
|
|
27
|
+
'.claude/SLIDES.md',
|
|
28
|
+
)
|
|
22
29
|
.option(
|
|
23
30
|
'-o, --out <path>',
|
|
24
31
|
'Output directory',
|
|
@@ -44,8 +51,15 @@ export function register(program: Command): void {
|
|
|
44
51
|
if (!existsSync(sourcePath)) {
|
|
45
52
|
fail(`${opts.source} not found`)
|
|
46
53
|
}
|
|
47
|
-
const variant = parseVariant(opts.variant)
|
|
48
54
|
const mirror = resolveMirror(opts.mirror)
|
|
55
|
+
if (statSync(sourcePath).isDirectory()) {
|
|
56
|
+
if (opts.variant !== undefined) {
|
|
57
|
+
fail('--variant applies to a SLIDES.md source, not a folder')
|
|
58
|
+
}
|
|
59
|
+
await renderHtmlFolder(sourcePath, outDir, mirror, opts.open)
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
const variant = parseVariant(opts.variant)
|
|
49
63
|
const { GREEN, GREY, NC, RED } = palette(process.stderr)
|
|
50
64
|
intro('Render slides')
|
|
51
65
|
const result = await renderSlidesDoc(sourcePath, outDir, {
|
|
@@ -103,6 +117,61 @@ export function register(program: Command): void {
|
|
|
103
117
|
})
|
|
104
118
|
}
|
|
105
119
|
|
|
120
|
+
/**
|
|
121
|
+
* The converter loads on demand, so no other command resolves pptxgenjs,
|
|
122
|
+
* jszip, or the token module at startup. A fallback or a refused link reports
|
|
123
|
+
* one line each and keeps the exit at 0, as an unrecognized layout does on the
|
|
124
|
+
* markdown path, since the deck is written either way.
|
|
125
|
+
*/
|
|
126
|
+
async function renderHtmlFolder(
|
|
127
|
+
sourceDir: string,
|
|
128
|
+
outDir: string,
|
|
129
|
+
mirror: string | undefined,
|
|
130
|
+
shouldOpen: boolean | undefined,
|
|
131
|
+
): Promise<void> {
|
|
132
|
+
const { exportHtmlDeck } = await import('@/slides/convert/export')
|
|
133
|
+
const { GREEN, GREY, NC, RED } = palette(process.stderr)
|
|
134
|
+
const result = await exportHtmlDeck(sourceDir, outDir, {
|
|
135
|
+
root: process.cwd(),
|
|
136
|
+
mirror,
|
|
137
|
+
})
|
|
138
|
+
if (result.status === 'refused') {
|
|
139
|
+
fail(
|
|
140
|
+
result.reason === 'browser-missing'
|
|
141
|
+
? `Chromium is not installed. Run ${INSTALL_BROWSER}`
|
|
142
|
+
: result.message,
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
intro('Render slides')
|
|
146
|
+
process.stderr.write(
|
|
147
|
+
`${GREY}│${NC} ${GREEN}✓${NC} ${result.slideCount} slides\n${GREY}│${NC} ${GREEN}✓${NC} ${result.pptxPath}\n`,
|
|
148
|
+
)
|
|
149
|
+
for (const { slide, selector, properties } of result.fallbacks) {
|
|
150
|
+
process.stderr.write(
|
|
151
|
+
`${GREY}│${NC} ${RED}✗${NC} slide ${slide} ${selector} drawn as a picture for ${properties.join(', ')}\n`,
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
for (const { slide, selector, href, reason } of result.refusedLinks) {
|
|
155
|
+
process.stderr.write(
|
|
156
|
+
`${GREY}│${NC} ${RED}✗${NC} slide ${slide} ${selector} link "${href}" left out: ${reason}\n`,
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
if (result.mirrorPath) {
|
|
160
|
+
process.stderr.write(
|
|
161
|
+
`${GREY}│${NC} ${GREEN}✓${NC} mirrored to ${result.mirrorPath}\n`,
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
if (shouldOpen) {
|
|
165
|
+
const target = result.mirrorPath ?? result.pptxPath
|
|
166
|
+
const opened = await openDeck(target)
|
|
167
|
+
const mark = opened ? `${GREEN}✓${NC}` : `${RED}✗${NC}`
|
|
168
|
+
process.stderr.write(
|
|
169
|
+
`${GREY}│${NC} ${mark} ${opened ? 'opened' : 'could not open'} ${target}\n`,
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
outro()
|
|
173
|
+
}
|
|
174
|
+
|
|
106
175
|
function parseVariant(value: string | undefined): Variant | undefined {
|
|
107
176
|
if (value === undefined) return undefined
|
|
108
177
|
if (value === 'light' || value === 'dark') return value
|