@erclx/canon 5.26.0 → 5.27.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 +2 -2
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/SKILL.md +2 -0
- package/claude/skills/auto-ship/SKILL.md +3 -11
- package/claude/skills/auto-ship/references/review-findings.md +20 -0
- package/claude/skills/backlog-triage/SKILL.md +2 -0
- package/claude/skills/bash-cli-script/SKILL.md +2 -0
- package/claude/skills/build-in-slices/SKILL.md +2 -0
- package/claude/skills/canon-cli/SKILL.md +2 -0
- package/claude/skills/canon-feedback/SKILL.md +2 -0
- package/claude/skills/canon-feedback-triage/SKILL.md +2 -0
- package/claude/skills/canon-operator/SKILL.md +2 -0
- package/claude/skills/canon-rollout/SKILL.md +3 -11
- package/claude/skills/canon-rollout/references/failure-recovery.md +20 -0
- package/claude/skills/canvas/SKILL.md +13 -0
- package/claude/skills/ci-workflow/SKILL.md +2 -0
- package/claude/skills/code-craft/SKILL.md +2 -0
- package/claude/skills/codebase-layout/SKILL.md +2 -0
- package/claude/skills/context-fold/SKILL.md +3 -17
- package/claude/skills/context-fold/references/completion-report.md +26 -0
- package/claude/skills/create-rule/SKILL.md +2 -0
- package/claude/skills/create-skill/SKILL.md +2 -0
- package/claude/skills/create-standard/SKILL.md +2 -0
- package/claude/skills/decision-escalate/SKILL.md +2 -0
- package/claude/skills/deploy-app/SKILL.md +2 -0
- package/claude/skills/deprecation-migration/SKILL.md +2 -0
- package/claude/skills/design-extract/SKILL.md +2 -0
- package/claude/skills/design-taste/SKILL.md +2 -0
- package/claude/skills/docs-sync/SKILL.md +2 -0
- package/claude/skills/document-health/SKILL.md +2 -0
- package/claude/skills/draft-and-pick/SKILL.md +2 -0
- package/claude/skills/draft-doc/SKILL.md +2 -0
- package/claude/skills/draft-doc/references/wiki.md +1 -1
- package/claude/skills/draft-figure/SKILL.md +2 -0
- package/claude/skills/draft-identity/SKILL.md +2 -0
- package/claude/skills/draft-ready/SKILL.md +2 -0
- package/claude/skills/draft-screencast/SKILL.md +2 -0
- package/claude/skills/draft-slides/SKILL.md +2 -0
- package/claude/skills/git-branch/SKILL.md +2 -0
- package/claude/skills/git-commit/SKILL.md +2 -0
- package/claude/skills/git-followup/SKILL.md +2 -0
- package/claude/skills/git-issue/SKILL.md +2 -0
- package/claude/skills/git-pr/SKILL.md +2 -0
- package/claude/skills/git-pr/references/evidence.md +6 -6
- package/claude/skills/git-ship/SKILL.md +2 -0
- package/claude/skills/git-split/SKILL.md +2 -0
- package/claude/skills/git-stage/SKILL.md +2 -0
- package/claude/skills/git-worktree/SKILL.md +2 -0
- package/claude/skills/index-lookup/SKILL.md +2 -0
- package/claude/skills/markdown-propose/SKILL.md +2 -0
- package/claude/skills/memory-capture/SKILL.md +2 -0
- package/claude/skills/memory-review/SKILL.md +2 -0
- package/claude/skills/plan-feature/SKILL.md +2 -0
- package/claude/skills/plan-groundwork/SKILL.md +2 -0
- package/claude/skills/plan-intake/SKILL.md +2 -0
- package/claude/skills/plan-intake-answer/SKILL.md +2 -0
- package/claude/skills/project-commands/SKILL.md +2 -0
- package/claude/skills/read-frames/SKILL.md +2 -0
- package/claude/skills/record-screencast/SKILL.md +2 -0
- package/claude/skills/repo-metadata/SKILL.md +2 -0
- package/claude/skills/restate-plainly/SKILL.md +2 -0
- package/claude/skills/review-address/SKILL.md +3 -7
- package/claude/skills/review-address/references/post-review-findings.md +16 -0
- package/claude/skills/review-branch/SKILL.md +2 -0
- package/claude/skills/review-craft/SKILL.md +2 -0
- package/claude/skills/review-craft/references/ui.md +2 -2
- package/claude/skills/review-pr/SKILL.md +4 -8
- package/claude/skills/review-pr/references/body-variants.md +52 -0
- package/claude/skills/review-pr/references/key-changes.md +16 -0
- package/claude/skills/review-pr/references/post.md +15 -43
- package/claude/skills/review-ui/SKILL.md +3 -1
- package/claude/skills/role-orchestrator/SKILL.md +5 -14
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +23 -0
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +5 -33
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -21
- package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +38 -0
- package/claude/skills/role-orchestrator/references/orchestrator-watch.md +26 -0
- package/claude/skills/role-planner/SKILL.md +2 -0
- package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
- package/claude/skills/role-reviewer/SKILL.md +2 -0
- package/claude/skills/role-worker/SKILL.md +2 -0
- package/claude/skills/search-craft/SKILL.md +2 -0
- package/claude/skills/seed-sync/SKILL.md +2 -0
- package/claude/skills/session-compact/SKILL.md +2 -0
- package/claude/skills/session-map/SKILL.md +2 -0
- package/claude/skills/session-relay/SKILL.md +2 -0
- package/claude/skills/session-resume/SKILL.md +2 -0
- package/claude/skills/session-worktree/SKILL.md +3 -11
- package/claude/skills/session-worktree/references/install-command.md +20 -0
- package/claude/skills/sketch-design/SKILL.md +2 -0
- package/claude/skills/systematic-debugging/SKILL.md +2 -0
- package/claude/skills/target-check/SKILL.md +2 -0
- package/claude/skills/target-setup/SKILL.md +2 -0
- package/claude/skills/task-board/SKILL.md +2 -0
- package/claude/skills/teach-workspace/SKILL.md +5 -15
- package/claude/skills/teach-workspace/references/lesson-build.md +75 -0
- package/claude/skills/teach-workspace/references/lesson-craft.md +13 -69
- package/claude/skills/teach-workspace/references/revisit.md +22 -0
- package/claude/skills/test-craft/SKILL.md +2 -0
- package/claude/skills/test-first/SKILL.md +2 -0
- package/claude/skills/ui-checklist/SKILL.md +2 -0
- package/claude/skills/ux-audit/SKILL.md +2 -0
- package/claude/skills/ux-measure/SKILL.md +2 -0
- package/claude/skills/ux-walkthrough/SKILL.md +2 -0
- package/claude/skills/video-craft/SKILL.md +2 -0
- package/claude/skills/write-human/SKILL.md +2 -0
- package/claude/skills/youtube-transcripts/SKILL.md +2 -0
- package/docs/agents/canvas.md +23 -7
- package/docs/agents/capture.md +5 -5
- package/docs/agents/commands.md +2 -2
- package/docs/agents/index.md +3 -3
- package/docs/agents/install-and-sync.md +7 -0
- package/docs/agents/key-changes.md +1 -1
- package/docs/agents/pr-evidence.md +38 -15
- package/docs/agents/pr-local.md +19 -13
- package/docs/agents/pr-preview.md +44 -21
- package/docs/agents/sandbox.md +28 -4
- package/docs/agents/scripting.md +8 -1
- package/docs/agents/skills-audit.md +2 -1
- package/docs/workflow/operating-model.md +5 -6
- package/docs/workflow/parallel-features.md +22 -1
- package/docs/workflow/skill-map.md +3 -1
- package/docs/workflow/zshrc-aliases.md +1 -1
- package/package.json +1 -1
- package/scripts/core/regen-hero.ts +1 -1
- package/scripts/lib/sandbox-path.sh +6 -162
- package/src/audits/run.ts +1 -1
- package/src/canvas/address.ts +12 -3
- package/src/canvas/client/inspector/align-grid.tsx +162 -0
- package/src/canvas/client/inspector/color-field.tsx +201 -171
- package/src/canvas/client/inspector/color-inputs.tsx +139 -0
- package/src/canvas/client/inspector/color-picker.tsx +668 -0
- package/src/canvas/client/inspector/color-space.ts +240 -0
- package/src/canvas/client/inspector/field.tsx +5 -1
- package/src/canvas/client/inspector/fields.css +395 -6
- package/src/canvas/client/inspector/size-field.tsx +69 -0
- package/src/canvas/client/inspector/values.ts +43 -1
- package/src/canvas/client/inspector.tsx +190 -4
- package/src/canvas/client/selection.tsx +197 -0
- package/src/canvas/client/shell.css +104 -12
- package/src/canvas/client/state.ts +69 -9
- package/src/canvas/client/surface.tsx +147 -22
- package/src/canvas/content.ts +50 -1
- package/src/canvas/edit.ts +37 -4
- package/src/canvas/editing.ts +203 -0
- package/src/canvas/global.d.ts +15 -0
- package/src/canvas/server.ts +149 -12
- package/src/canvas/tokens.ts +59 -17
- package/src/census/count.ts +1 -1
- package/src/claude/seeds.ts +1 -1
- package/src/claude/skills-audit.ts +28 -0
- package/src/claude/skills-drift.ts +1 -1
- package/src/claude/skills-families.ts +36 -0
- package/src/claude/skills-list.ts +76 -15
- package/src/commands/audits.ts +2 -2
- package/src/commands/canvas.ts +179 -2
- package/src/commands/claude.ts +67 -9
- package/src/commands/design.ts +142 -2
- package/src/commands/gov.ts +1 -1
- package/src/commands/hooks.ts +1 -1
- package/src/commands/indexes.ts +1 -1
- package/src/commands/init.ts +1 -1
- package/src/commands/intake.ts +1 -1
- package/src/commands/migrate.ts +1 -1
- package/src/commands/pr.ts +105 -58
- package/src/commands/records.ts +1 -1
- package/src/commands/repo.ts +1 -1
- package/src/commands/sandbox.ts +54 -2
- package/src/commands/sessions.ts +1 -1
- package/src/commands/sync.ts +1 -1
- package/src/commands/tasks.ts +2 -2
- package/src/commands/teach.ts +1 -1
- package/src/commands/transcripts.ts +1 -1
- package/src/commands/worktrees.ts +1 -1
- package/src/comments/scan.ts +1 -1
- package/src/comments/trend.ts +1 -1
- package/src/context/citations.ts +1 -1
- package/src/context/classify/extract.ts +2 -2
- package/src/design/project-fonts.ts +392 -0
- package/src/feedback/github.ts +1 -1
- package/src/gate/measures.ts +47 -9
- package/src/gate/sequencer.ts +1 -1
- package/src/gate/stages.ts +9 -0
- package/src/{git-files.ts → git/files.ts} +2 -2
- package/src/{git-ignore.ts → git/ignore.ts} +1 -1
- package/src/{worktree.ts → git/worktree.ts} +1 -1
- package/src/gov/citations.ts +1 -1
- package/src/gov/install.ts +1 -1
- package/src/gov/superseded.ts +1 -1
- package/src/gov/test-order.ts +2 -2
- package/src/help.ts +1 -1
- package/src/hooks/post-merge.ts +1 -1
- package/src/indexes/walk.ts +1 -1
- package/src/labels/audit.ts +1 -1
- package/src/markdown/files.ts +1 -1
- package/src/migrate/apply.ts +1 -1
- package/src/pr/bijection.ts +1 -1
- package/src/pr/evidence.ts +96 -43
- package/src/pr/local.ts +16 -10
- package/src/pr/preview.ts +78 -2
- package/src/records/backup.ts +1 -1
- package/src/records/stale-canonical.ts +1 -1
- package/src/sandbox/headless/dispatch.ts +264 -0
- package/src/sandbox/headless/run.ts +653 -0
- package/src/sandbox/headless/watch.ts +145 -0
- package/src/sandbox/provision.ts +12 -1
- package/src/sandbox/tree.ts +4 -8
- package/src/secrets/scan.ts +1 -1
- package/src/sessions/claim.ts +1 -1
- package/src/sessions/resolve.ts +1 -1
- package/src/sessions/transfer/bundle.ts +1 -1
- package/src/slides/package/fonts.ts +6 -2
- package/src/standards/audit.ts +2 -2
- package/src/sync/check.ts +2 -2
- package/src/sync/engine.ts +2 -2
- package/src/sync/git.ts +1 -1
- package/src/sync/history.ts +1 -1
- package/src/sync/reverse.ts +1 -1
- package/src/sync/target.ts +1 -1
- package/src/sync/workflow.ts +1 -1
- package/src/targets/pulls.ts +2 -2
- package/src/targets/sweep.ts +2 -2
- package/src/tasks/reach.ts +1 -1
- package/src/tasks/trunk.ts +1 -1
- package/src/tooling/inject.ts +1 -1
- package/src/tooling/subfolder.ts +1 -1
- package/src/web/readme-citations.ts +1 -1
- package/src/worktrees/bare-flag.ts +1 -1
- package/src/worktrees/reclaim.ts +2 -2
- package/src/worktrees/remove.ts +1 -1
- package/standards/canvas.md +2 -1
- package/standards/skill.md +1 -0
- package/standards/wiki.md +12 -3
- package/tooling/base/configs/.github/workflows/pr-local-preview.yml +2 -2
- package/scripts/lib/sandbox-dispatch.sh +0 -190
- /package/src/{client-commands.ts → gate/client-commands.ts} +0 -0
- /package/src/{git-base.ts → git/base.ts} +0 -0
- /package/src/{git-env.ts → git/env.ts} +0 -0
- /package/src/{copy.ts → sync/copy.ts} +0 -0
- /package/src/{target.ts → targets/validate.ts} +0 -0
package/src/commands/sandbox.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
type RunEnvelope,
|
|
26
26
|
type Verdict,
|
|
27
27
|
} from '@/sandbox/expect'
|
|
28
|
+
import { runHeadless } from '@/sandbox/headless/run'
|
|
28
29
|
import { runSandbox } from '@/sandbox/provision'
|
|
29
30
|
import { sandboxTree } from '@/sandbox/tree'
|
|
30
31
|
import {
|
|
@@ -510,7 +511,7 @@ export function register(program: Command): void {
|
|
|
510
511
|
if (mismatch !== undefined) logWarn(mismatch)
|
|
511
512
|
|
|
512
513
|
// `--no-header` is the frame opt-out for a caller that already opened
|
|
513
|
-
// one, which `sandbox
|
|
514
|
+
// one, which `canon sandbox run` and the picker below both do.
|
|
514
515
|
const isHeader = cmd.args[0] !== '--no-header'
|
|
515
516
|
const args = isHeader ? cmd.args : cmd.args.slice(1)
|
|
516
517
|
|
|
@@ -527,6 +528,57 @@ export function register(program: Command): void {
|
|
|
527
528
|
})
|
|
528
529
|
})
|
|
529
530
|
|
|
531
|
+
sandbox
|
|
532
|
+
.command('run')
|
|
533
|
+
.description(
|
|
534
|
+
'Provision a scenario, drive one claude -p session over it, and print the envelope with its verdict',
|
|
535
|
+
)
|
|
536
|
+
.argument('[target]', 'Scenario as <category>:<command>, e.g. git:commit')
|
|
537
|
+
.argument('[prompt]', 'Skill invocation, e.g. "/canon:git-commit"')
|
|
538
|
+
.argument('[arm]', 'Named scenario arm, e.g. small')
|
|
539
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
540
|
+
.addHelpText(
|
|
541
|
+
'after',
|
|
542
|
+
[
|
|
543
|
+
'',
|
|
544
|
+
'Env overrides:',
|
|
545
|
+
' CANON_SKILL_TEST_MODEL default sonnet',
|
|
546
|
+
' CANON_SKILL_TEST_TOOLS default Bash,Read,Glob,Grep,Edit,Write',
|
|
547
|
+
' CANON_SKILL_TEST_MAX_TURNS default 30',
|
|
548
|
+
' CANON_SKILL_TEST_PERMISSION_MODE default bypassPermissions',
|
|
549
|
+
'',
|
|
550
|
+
'Examples:',
|
|
551
|
+
' canon sandbox run git:commit "/canon:git-commit"',
|
|
552
|
+
' canon sandbox run claude:plan-feature "/canon:plan-feature add a widget" small',
|
|
553
|
+
'',
|
|
554
|
+
'Bills a real session through the claude binary on PATH.',
|
|
555
|
+
'Exit codes: the verdict, 1 when the envelope does not parse, or the',
|
|
556
|
+
'provisioning or session code when either stops the run first.',
|
|
557
|
+
].join('\n'),
|
|
558
|
+
)
|
|
559
|
+
.action(
|
|
560
|
+
async (
|
|
561
|
+
target: string | undefined,
|
|
562
|
+
prompt: string | undefined,
|
|
563
|
+
arm: string | undefined,
|
|
564
|
+
_opts: unknown,
|
|
565
|
+
cmd: Command,
|
|
566
|
+
) => {
|
|
567
|
+
if (target === undefined) {
|
|
568
|
+
cmd.help()
|
|
569
|
+
return
|
|
570
|
+
}
|
|
571
|
+
if (reportAbsentScenarioTree()) return
|
|
572
|
+
|
|
573
|
+
process.exitCode = await runHeadless({
|
|
574
|
+
projectRoot: PROJECT_ROOT,
|
|
575
|
+
target,
|
|
576
|
+
prompt,
|
|
577
|
+
arm,
|
|
578
|
+
})
|
|
579
|
+
},
|
|
580
|
+
)
|
|
581
|
+
|
|
530
582
|
sandbox
|
|
531
583
|
.command('check')
|
|
532
584
|
.description('Check a provisioned sandbox against a scenario expectation')
|
|
@@ -628,7 +680,7 @@ export function register(program: Command): void {
|
|
|
628
680
|
' canon sandbox equivalence claude --json',
|
|
629
681
|
' canon sandbox equivalence claude:plan-feature --base origin/main',
|
|
630
682
|
'',
|
|
631
|
-
'Provisions offline only and never runs sandbox
|
|
683
|
+
'Provisions offline only and never runs canon sandbox run or a claude binary.',
|
|
632
684
|
'--stub-remote also runs the anchor arms, and wins over --include-anchor.',
|
|
633
685
|
'Exit codes: 0 when every compared arm is identical or red the same way',
|
|
634
686
|
'on both sides, 1 on any difference, exit mismatch, or enumeration error.',
|
package/src/commands/sessions.ts
CHANGED
package/src/commands/sync.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
} from '@/sync/target'
|
|
21
21
|
import type { UnclaimedFolder } from '@/sync/reverse'
|
|
22
22
|
import { runGitWorkflow } from '@/sync/workflow'
|
|
23
|
-
import { resolveTarget } from '@/
|
|
23
|
+
import { resolveTarget } from '@/targets/validate'
|
|
24
24
|
import {
|
|
25
25
|
intro,
|
|
26
26
|
isNonInteractive,
|
package/src/commands/tasks.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { relative } from 'node:path'
|
|
2
2
|
import type { Command } from 'commander'
|
|
3
3
|
import { execa } from 'execa'
|
|
4
|
-
import { gitEnv } from '@/git
|
|
4
|
+
import { gitEnv } from '@/git/env'
|
|
5
5
|
import { type AnswersOutcome, planAnswers } from '@/tasks/answers'
|
|
6
6
|
import { type BranchOutcome, planBranch } from '@/tasks/branch'
|
|
7
7
|
import {
|
|
@@ -59,7 +59,7 @@ import {
|
|
|
59
59
|
outro,
|
|
60
60
|
pipeOutput,
|
|
61
61
|
} from '@/ui'
|
|
62
|
-
import { mainWorktreeRoot } from '@/worktree'
|
|
62
|
+
import { mainWorktreeRoot } from '@/git/worktree'
|
|
63
63
|
|
|
64
64
|
/** Returned when the board carries a finding, which is the gating result. */
|
|
65
65
|
const EXIT_FINDINGS = 2
|
package/src/commands/teach.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { Command } from 'commander'
|
|
|
3
3
|
import { recordDir } from '@/record-root'
|
|
4
4
|
import { ensureYtDlp, fetchOne } from '@/transcripts/fetch'
|
|
5
5
|
import { palette } from '@/ui'
|
|
6
|
-
import { mainWorktreeRoot } from '@/worktree'
|
|
6
|
+
import { mainWorktreeRoot } from '@/git/worktree'
|
|
7
7
|
|
|
8
8
|
interface TranscriptOptions {
|
|
9
9
|
out?: string
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
type Unreadable,
|
|
17
17
|
type WorktreeVerdict,
|
|
18
18
|
} from '@/worktrees/reclaim'
|
|
19
|
-
import { mainWorktreeRoot } from '@/worktree'
|
|
19
|
+
import { mainWorktreeRoot } from '@/git/worktree'
|
|
20
20
|
import { repairBareFlag } from '@/worktrees/bare-flag'
|
|
21
21
|
import {
|
|
22
22
|
removeReclaimable,
|
package/src/comments/scan.ts
CHANGED
package/src/comments/trend.ts
CHANGED
package/src/context/citations.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { readFile } from 'node:fs/promises'
|
|
3
3
|
import { resolve } from 'node:path'
|
|
4
|
-
import { listRepositoryFiles } from '@/git
|
|
4
|
+
import { listRepositoryFiles } from '@/git/files'
|
|
5
5
|
import { RECORD_ROOTS } from '@/record-root'
|
|
6
6
|
import { SURFACE_ROOTS } from '@/surface-root'
|
|
7
7
|
|
|
@@ -2,8 +2,8 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
2
2
|
import { basename, relative, resolve } from 'node:path'
|
|
3
3
|
import { $ } from 'bun'
|
|
4
4
|
import { resolveFolders } from '@/context/folders'
|
|
5
|
-
import { gitEnv } from '@/git
|
|
6
|
-
import { listChangedFiles, resolveBaseRef } from '@/git
|
|
5
|
+
import { gitEnv } from '@/git/env'
|
|
6
|
+
import { listChangedFiles, resolveBaseRef } from '@/git/files'
|
|
7
7
|
import { surfaceDir } from '@/surface-root'
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendFileSync,
|
|
3
|
+
copyFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
realpathSync,
|
|
9
|
+
statSync,
|
|
10
|
+
} from 'node:fs'
|
|
11
|
+
import {
|
|
12
|
+
basename,
|
|
13
|
+
dirname,
|
|
14
|
+
extname,
|
|
15
|
+
isAbsolute,
|
|
16
|
+
join,
|
|
17
|
+
relative,
|
|
18
|
+
resolve,
|
|
19
|
+
} from 'node:path'
|
|
20
|
+
import { primaryFontFamily } from '@/capture/sources'
|
|
21
|
+
import { DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR } from '@/design/adapter'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A project's own faces, declared as plain `@font-face` rules in its override
|
|
25
|
+
* sheets and stored beside them under `project/`, which sync treats as
|
|
26
|
+
* project-authored by location and never writes. CSS is the declaration
|
|
27
|
+
* format because every consumer already reads it, and reading descriptors
|
|
28
|
+
* out of the font's own tables cannot open WOFF2 without a Brotli parser.
|
|
29
|
+
*
|
|
30
|
+
* A rule's `url()` is read from inside `project/` only, after `realpath`, so
|
|
31
|
+
* an override cannot carry a file from elsewhere on the machine into a frame.
|
|
32
|
+
* A hand-edited sheet reaches this module as often as `addProjectFace` does,
|
|
33
|
+
* so the check sits on the read rather than on the write.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export const PROJECT_FONTS_DIR = 'fonts'
|
|
37
|
+
export const PROJECT_FONTS_SHEET = 'fonts.css'
|
|
38
|
+
|
|
39
|
+
export type FaceProblem = 'missing' | 'outside-folder'
|
|
40
|
+
|
|
41
|
+
export interface ProjectFace {
|
|
42
|
+
readonly family: string
|
|
43
|
+
/** The `font-weight` descriptor verbatim, `400` where the rule omits it. */
|
|
44
|
+
readonly weight: string
|
|
45
|
+
readonly style: string
|
|
46
|
+
/** Relative to the root. */
|
|
47
|
+
readonly sheet: string
|
|
48
|
+
/** The first local `url()` as the rule writes it. */
|
|
49
|
+
readonly file: string
|
|
50
|
+
readonly present: boolean
|
|
51
|
+
readonly problem?: FaceProblem
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface DroppedFace {
|
|
55
|
+
readonly sheet: string
|
|
56
|
+
readonly file: string
|
|
57
|
+
readonly reason: FaceProblem
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface InlinedSheet {
|
|
61
|
+
readonly css: string
|
|
62
|
+
readonly dropped: readonly DroppedFace[]
|
|
63
|
+
/** The families of the rules that survived, in sheet order. */
|
|
64
|
+
readonly declared: readonly string[]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const FORMATS = {
|
|
68
|
+
'.woff2': { mime: 'font/woff2', format: 'woff2', magic: ['wOF2'] },
|
|
69
|
+
'.woff': { mime: 'font/woff', format: 'woff', magic: ['wOFF'] },
|
|
70
|
+
'.ttf': {
|
|
71
|
+
mime: 'font/ttf',
|
|
72
|
+
format: 'truetype',
|
|
73
|
+
magic: ['\u0000\u0001\u0000\u0000', 'true'],
|
|
74
|
+
},
|
|
75
|
+
'.otf': { mime: 'font/otf', format: 'opentype', magic: ['OTTO'] },
|
|
76
|
+
} as const
|
|
77
|
+
|
|
78
|
+
type FontExtension = keyof typeof FORMATS
|
|
79
|
+
|
|
80
|
+
function isFontExtension(ext: string): ext is FontExtension {
|
|
81
|
+
return Object.hasOwn(FORMATS, ext)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const FACE_RULE = /@font-face\s*\{[^}]*\}/g
|
|
85
|
+
const URL = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g
|
|
86
|
+
const SCHEME = /^[a-z][a-z0-9+.-]*:/i
|
|
87
|
+
const DECLARATION = /([A-Za-z-]+)\s*:\s*([^;}]*)/g
|
|
88
|
+
|
|
89
|
+
/** Comments blanked to equal length, so an index into the result fits the source. */
|
|
90
|
+
function masked(css: string): string {
|
|
91
|
+
return css.replace(/\/\*[\s\S]*?\*\//g, (comment) =>
|
|
92
|
+
' '.repeat(comment.length),
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function descriptor(rule: string, name: string): string | undefined {
|
|
97
|
+
const match = rule.match(new RegExp(`(?:^|[;{\\s])${name}\\s*:\\s*([^;}]*)`))
|
|
98
|
+
return match?.[1]?.trim()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function familyOf(rule: string): string {
|
|
102
|
+
return primaryFontFamily(descriptor(rule, 'font-family') ?? '')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Every family a declaration value lists, quoted or not, across the sheet
|
|
107
|
+
* outside its `@font-face` rules, which declare a face rather than use one.
|
|
108
|
+
* Reads every property rather than only `font-family`, since a token such as
|
|
109
|
+
* `--figure-hand` names its stack under a name that ends in neither.
|
|
110
|
+
*/
|
|
111
|
+
export function namedFamilies(css: string): Set<string> {
|
|
112
|
+
const uses = masked(css).replace(FACE_RULE, '')
|
|
113
|
+
const named = new Set<string>()
|
|
114
|
+
for (const [, , value] of uses.matchAll(DECLARATION)) {
|
|
115
|
+
for (const item of (value ?? '').split(',')) {
|
|
116
|
+
const family = primaryFontFamily(item)
|
|
117
|
+
if (family) named.add(family)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return named
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function projectDir(root: string): string {
|
|
124
|
+
return join(root, DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isWithin(folder: string, path: string): boolean {
|
|
128
|
+
const inside = relative(folder, path)
|
|
129
|
+
return inside !== '' && !inside.startsWith('..') && !isAbsolute(inside)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
type Located =
|
|
133
|
+
| { readonly ok: true; readonly path: string }
|
|
134
|
+
| { readonly ok: false; readonly reason: FaceProblem }
|
|
135
|
+
|
|
136
|
+
/** A URL path as the name on disk, or undefined for a malformed escape. */
|
|
137
|
+
function decoded(url: string): string | undefined {
|
|
138
|
+
try {
|
|
139
|
+
return decodeURIComponent(url.replace(/[?#].*$/, ''))
|
|
140
|
+
} catch {
|
|
141
|
+
return undefined
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Confined lexically before any read, then again after resolving links. The
|
|
147
|
+
* check runs on the decoded path, so an escaped `..` cannot pass it.
|
|
148
|
+
*/
|
|
149
|
+
function locate(root: string, sheet: string, url: string): Located {
|
|
150
|
+
const folder = projectDir(root)
|
|
151
|
+
const clean = decoded(url)
|
|
152
|
+
if (clean === undefined) return { ok: false, reason: 'missing' }
|
|
153
|
+
if (isAbsolute(clean)) return { ok: false, reason: 'outside-folder' }
|
|
154
|
+
const path = resolve(join(root, dirname(sheet)), clean)
|
|
155
|
+
if (!isWithin(folder, path)) return { ok: false, reason: 'outside-folder' }
|
|
156
|
+
if (!existsSync(path) || !statSync(path).isFile()) {
|
|
157
|
+
return { ok: false, reason: 'missing' }
|
|
158
|
+
}
|
|
159
|
+
if (!isWithin(realpathSync(folder), realpathSync(path))) {
|
|
160
|
+
return { ok: false, reason: 'outside-folder' }
|
|
161
|
+
}
|
|
162
|
+
return { ok: true, path }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function localUrls(rule: string): string[] {
|
|
166
|
+
return [...rule.matchAll(URL)]
|
|
167
|
+
.map((match) => match[2]?.trim() ?? '')
|
|
168
|
+
.filter((url) => url !== '' && !SCHEME.test(url))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function mimeOf(path: string): string {
|
|
172
|
+
const ext = extname(path).toLowerCase()
|
|
173
|
+
return isFontExtension(ext) ? FORMATS[ext].mime : 'application/octet-stream'
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Rewrites each local `url()` in a sheet's `@font-face` rules to a `data:`
|
|
178
|
+
* URI, so a frame carries the face rather than a path the frame's origin
|
|
179
|
+
* cannot serve. A rule naming a file that is missing or leads out of
|
|
180
|
+
* `project/` is dropped whole and reported, and the sheet otherwise renders.
|
|
181
|
+
* A `data:` or remote URL is left as written.
|
|
182
|
+
*/
|
|
183
|
+
export function inlineProjectFaces(
|
|
184
|
+
root: string,
|
|
185
|
+
sheet: string,
|
|
186
|
+
css: string,
|
|
187
|
+
): InlinedSheet {
|
|
188
|
+
const dropped: DroppedFace[] = []
|
|
189
|
+
const declared: string[] = []
|
|
190
|
+
let out = ''
|
|
191
|
+
let cursor = 0
|
|
192
|
+
for (const match of masked(css).matchAll(FACE_RULE)) {
|
|
193
|
+
const start = match.index
|
|
194
|
+
const end = start + match[0].length
|
|
195
|
+
const rule = css.slice(start, end)
|
|
196
|
+
out += css.slice(cursor, start)
|
|
197
|
+
cursor = end
|
|
198
|
+
|
|
199
|
+
const urls = localUrls(match[0])
|
|
200
|
+
const located = urls.map((url) => ({ url, at: locate(root, sheet, url) }))
|
|
201
|
+
const failed = located.find(({ at }) => !at.ok)
|
|
202
|
+
if (failed && !failed.at.ok) {
|
|
203
|
+
dropped.push({ sheet, file: failed.url, reason: failed.at.reason })
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
let inlined = rule
|
|
207
|
+
for (const { url, at } of located) {
|
|
208
|
+
if (!at.ok) continue
|
|
209
|
+
const data = `data:${mimeOf(at.path)};base64,${readFileSync(at.path).toString('base64')}`
|
|
210
|
+
inlined = inlined.replace(url, data)
|
|
211
|
+
}
|
|
212
|
+
out += inlined
|
|
213
|
+
declared.push(familyOf(match[0]))
|
|
214
|
+
}
|
|
215
|
+
return { css: out + css.slice(cursor), dropped, declared }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The project's override sheets, root-relative, in the order a frame cascades them. */
|
|
219
|
+
export function projectSheets(root: string): string[] {
|
|
220
|
+
const folder = projectDir(root)
|
|
221
|
+
if (!existsSync(folder)) return []
|
|
222
|
+
return readdirSync(folder)
|
|
223
|
+
.filter((name) => name.endsWith('.css'))
|
|
224
|
+
.sort((a, b) => a.localeCompare(b))
|
|
225
|
+
.map((name) => join(DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR, name))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Every face the override sheets declare, without reading a font file. */
|
|
229
|
+
export function listProjectFaces(root: string): ProjectFace[] {
|
|
230
|
+
return projectSheets(root).flatMap((sheet) => {
|
|
231
|
+
const css = masked(readFileSync(join(root, sheet), 'utf8'))
|
|
232
|
+
return [...css.matchAll(FACE_RULE)].map((match): ProjectFace => {
|
|
233
|
+
const rule = match[0]
|
|
234
|
+
const local = localUrls(rule)[0]
|
|
235
|
+
const external = [...rule.matchAll(URL)][0]?.[2]?.trim() ?? ''
|
|
236
|
+
/*
|
|
237
|
+
* A rule carrying its face inline or remotely has no file to read, and
|
|
238
|
+
* the frame keeps it as written, so it lists as present. A `data:` URI
|
|
239
|
+
* is shortened to its scheme rather than echoing the whole face.
|
|
240
|
+
*/
|
|
241
|
+
const file = local ?? (external.startsWith('data:') ? 'data:' : external)
|
|
242
|
+
const at: Located = local
|
|
243
|
+
? locate(root, sheet, local)
|
|
244
|
+
: external
|
|
245
|
+
? { ok: true, path: external }
|
|
246
|
+
: { ok: false, reason: 'missing' }
|
|
247
|
+
return {
|
|
248
|
+
family: familyOf(rule),
|
|
249
|
+
weight: descriptor(rule, 'font-weight') ?? '400',
|
|
250
|
+
style: descriptor(rule, 'font-style') ?? 'normal',
|
|
251
|
+
sheet,
|
|
252
|
+
file,
|
|
253
|
+
present: at.ok,
|
|
254
|
+
...(!at.ok && { problem: at.reason }),
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface FaceSpec {
|
|
261
|
+
readonly family: string
|
|
262
|
+
readonly weight?: string
|
|
263
|
+
readonly style?: string
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export type AddOutcome =
|
|
267
|
+
| {
|
|
268
|
+
readonly ok: true
|
|
269
|
+
readonly family: string
|
|
270
|
+
readonly weight: string
|
|
271
|
+
readonly style: string
|
|
272
|
+
/** Relative to the sheet's folder, as the rule writes it. */
|
|
273
|
+
readonly file: string
|
|
274
|
+
readonly sheet: string
|
|
275
|
+
}
|
|
276
|
+
| {
|
|
277
|
+
readonly ok: false
|
|
278
|
+
readonly reason: 'invalid-font' | 'invalid-face' | 'exists'
|
|
279
|
+
readonly detail: string
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** A name CSS takes inside single quotes with no escaping. */
|
|
283
|
+
const SAFE_FAMILY = /^[A-Za-z0-9][A-Za-z0-9 _-]*$/
|
|
284
|
+
/** A file name a single-quoted `url()` takes with no escaping. */
|
|
285
|
+
const SAFE_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
|
|
286
|
+
const WEIGHT = /^\d{1,4}( \d{1,4})?$/
|
|
287
|
+
|
|
288
|
+
function weightInRange(weight: string): boolean {
|
|
289
|
+
return weight.split(' ').every((n) => Number(n) >= 1 && Number(n) <= 1000)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Copies a face into `project/fonts/` and appends one rule for it to
|
|
294
|
+
* `project/fonts.css`. Refuses before writing anything when the extension is
|
|
295
|
+
* not a font, the bytes disagree with it, a descriptor would need escaping,
|
|
296
|
+
* or the folder already holds a file of that name.
|
|
297
|
+
*/
|
|
298
|
+
export function addProjectFace(
|
|
299
|
+
root: string,
|
|
300
|
+
source: string,
|
|
301
|
+
spec: FaceSpec,
|
|
302
|
+
): AddOutcome {
|
|
303
|
+
const name = basename(source)
|
|
304
|
+
const ext = extname(name).toLowerCase()
|
|
305
|
+
if (!isFontExtension(ext)) {
|
|
306
|
+
return {
|
|
307
|
+
ok: false,
|
|
308
|
+
reason: 'invalid-font',
|
|
309
|
+
detail: `${name} is not a font. Use woff2, woff, ttf, or otf`,
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (!SAFE_FILE.test(name)) {
|
|
313
|
+
return {
|
|
314
|
+
ok: false,
|
|
315
|
+
reason: 'invalid-font',
|
|
316
|
+
detail: `${name} must be letters, digits, dots, dashes, or underscores to sit in a url(). Rename it`,
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (!existsSync(source) || !statSync(source).isFile()) {
|
|
320
|
+
return { ok: false, reason: 'invalid-font', detail: `${source} not found` }
|
|
321
|
+
}
|
|
322
|
+
const head = readFileSync(source).subarray(0, 4).toString('binary')
|
|
323
|
+
if (!FORMATS[ext].magic.some((magic) => head === magic)) {
|
|
324
|
+
return {
|
|
325
|
+
ok: false,
|
|
326
|
+
reason: 'invalid-font',
|
|
327
|
+
detail: `${name} does not read as ${ext.slice(1)} from its first bytes`,
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const weight = spec.weight ?? '400'
|
|
332
|
+
const style = spec.style ?? 'normal'
|
|
333
|
+
if (!SAFE_FAMILY.test(spec.family)) {
|
|
334
|
+
return {
|
|
335
|
+
ok: false,
|
|
336
|
+
reason: 'invalid-face',
|
|
337
|
+
detail: `family ${spec.family} must be letters, digits, spaces, dashes, or underscores`,
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (!WEIGHT.test(weight) || !weightInRange(weight)) {
|
|
341
|
+
return {
|
|
342
|
+
ok: false,
|
|
343
|
+
reason: 'invalid-face',
|
|
344
|
+
detail: `weight ${weight} must be one number or a range of two, from 1 to 1000`,
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (style !== 'normal' && style !== 'italic') {
|
|
348
|
+
return {
|
|
349
|
+
ok: false,
|
|
350
|
+
reason: 'invalid-face',
|
|
351
|
+
detail: `style ${style} must be normal or italic`,
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const folder = join(projectDir(root), PROJECT_FONTS_DIR)
|
|
356
|
+
const dest = join(folder, name)
|
|
357
|
+
const file = `${PROJECT_FONTS_DIR}/${name}`
|
|
358
|
+
if (existsSync(dest)) {
|
|
359
|
+
return {
|
|
360
|
+
ok: false,
|
|
361
|
+
reason: 'exists',
|
|
362
|
+
detail: `${relative(root, dest)} already exists. Remove it or rename the source`,
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
mkdirSync(folder, { recursive: true })
|
|
367
|
+
copyFileSync(source, dest)
|
|
368
|
+
const sheetPath = join(projectDir(root), PROJECT_FONTS_SHEET)
|
|
369
|
+
const lead =
|
|
370
|
+
existsSync(sheetPath) && readFileSync(sheetPath, 'utf8').trim() !== ''
|
|
371
|
+
? '\n'
|
|
372
|
+
: ''
|
|
373
|
+
appendFileSync(
|
|
374
|
+
sheetPath,
|
|
375
|
+
`${lead}@font-face {
|
|
376
|
+
font-family: '${spec.family}';
|
|
377
|
+
font-weight: ${weight};
|
|
378
|
+
font-style: ${style};
|
|
379
|
+
font-display: swap;
|
|
380
|
+
src: url('${file}') format('${FORMATS[ext].format}');
|
|
381
|
+
}
|
|
382
|
+
`,
|
|
383
|
+
)
|
|
384
|
+
return {
|
|
385
|
+
ok: true,
|
|
386
|
+
family: spec.family,
|
|
387
|
+
weight,
|
|
388
|
+
style,
|
|
389
|
+
file,
|
|
390
|
+
sheet: relative(root, sheetPath),
|
|
391
|
+
}
|
|
392
|
+
}
|
package/src/feedback/github.ts
CHANGED