@erclx/canon 4.112.0 → 4.114.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-feedback/SKILL.md +3 -3
- package/claude/skills/codebase-layout/SKILL.md +1 -1
- package/claude/skills/target-setup/SKILL.md +2 -0
- package/claude/skills/target-setup/references/detect.md +2 -2
- package/claude/skills/target-setup/references/verify.md +26 -10
- package/claude/skills/test-craft/SKILL.md +1 -1
- package/docs/agents/commands.md +1 -1
- package/docs/agents/counts.md +3 -3
- package/docs/agents/install-and-sync.md +2 -0
- package/docs/agents/records.md +22 -15
- package/docs/agents/teach.md +8 -4
- package/docs/target-projects.md +9 -2
- package/docs/workflow/ai-workflow.md +1 -1
- package/governance/rules/lang/130-go.md +40 -0
- package/governance/rules/lang/140-php.md +31 -0
- package/governance/rules/lib/335-testing-go.md +35 -0
- package/governance/rules/lib/336-testing-php.md +30 -0
- package/governance/rules/lib/360-security-server.md +2 -0
- package/governance/rules/lib/370-database.md +2 -0
- package/governance/rules/ui/410-a11y.md +1 -0
- package/governance/rules/ui/440-surface-capture.md +2 -0
- package/governance/stacks/nextjs.toml +2 -0
- package/governance/stacks/react.toml +1 -1
- package/package.json +3 -1
- package/src/claude/skills-audit.ts +2 -19
- package/src/commands/feedback-format.ts +24 -0
- package/src/commands/feedback.ts +2 -1
- package/src/commands/records.ts +18 -1
- package/src/commands/teach.ts +41 -1
- package/src/commands/tooling.ts +25 -2
- package/src/design/components.ts +17 -0
- package/src/design/tokens.ts +1 -1
- package/src/gate/measures.ts +13 -2
- package/src/indexes/frontmatter.ts +10 -1
- package/src/pr/evidence.ts +3 -1
- package/src/records/backup.ts +197 -27
- package/src/records/push-guard.ts +71 -0
- package/src/teach/nav.ts +75 -25
- package/src/teach/workspace.ts +14 -6
- package/src/tooling/inject.ts +43 -4
- package/src/tooling/scan.ts +35 -0
- package/src/tooling/subfolder.ts +110 -0
- package/tooling/base/configs/.husky/post-merge +5 -1
- package/tooling/base/seeds/cspell.json +1 -1
- package/tooling/claude/seeds/.claude/hooks/silent-turn.sh +7 -8
- package/tooling/nextjs/configs/vitest.config.ts +1 -1
- package/tooling/python/configs/scripts/verify.sh +32 -15
- package/tooling/python/manifest.toml +1 -1
- package/tooling/python/reference.md +3 -4
- package/tooling/vite-react/configs/vite.config.ts +1 -1
- package/tooling/web/configs/scripts/verify.sh +32 -15
package/src/records/backup.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { existsSync, readdirSync } from 'node:fs'
|
|
1
|
+
import { existsSync, lstatSync, readdirSync } from 'node:fs'
|
|
2
2
|
import { basename, join, relative, resolve } from 'node:path'
|
|
3
3
|
import { $ } from 'bun'
|
|
4
4
|
import { gitEnv } from '@/git-env'
|
|
5
|
-
import { RECORD_ROOTS, recordRoot } from '@/record-root'
|
|
5
|
+
import { RECORD_ROOTS, recordRoot, SCRATCH } from '@/record-root'
|
|
6
|
+
import { type BlockedPath, guardPayload } from '@/records/push-guard'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* The legacy `.claude`-root allowlist, read only when a project has not moved
|
|
@@ -81,6 +82,11 @@ const RECORDS_GIT_NAME = '.records.git'
|
|
|
81
82
|
* every entry already keeps a pull's `README.md` out, so this is the one name
|
|
82
83
|
* that still has to be said: without it a listing would stage the history
|
|
83
84
|
* into itself.
|
|
85
|
+
*
|
|
86
|
+
* The list bounds the records index as well as the disk. `scopedFolders`
|
|
87
|
+
* filters the names the index tracks through `isExcluded`, so a name an older
|
|
88
|
+
* binary committed is removed from the history once rather than re-staged on
|
|
89
|
+
* every push by the `-f` that carries the payload.
|
|
84
90
|
*/
|
|
85
91
|
export const EXCLUDED_ENTRIES = [
|
|
86
92
|
'tmp',
|
|
@@ -88,6 +94,20 @@ export const EXCLUDED_ENTRIES = [
|
|
|
88
94
|
RECORDS_GIT_NAME,
|
|
89
95
|
] as const
|
|
90
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Whether a top-level name stays out of every push, at either root.
|
|
99
|
+
*
|
|
100
|
+
* `SCRATCH` is the legacy `.tmp` spelling, which a records index opened before
|
|
101
|
+
* the move can still carry and which `EXCLUDED_ENTRIES` names at its `.canon`
|
|
102
|
+
* spelling only.
|
|
103
|
+
*/
|
|
104
|
+
function isExcluded(name: string): boolean {
|
|
105
|
+
return (
|
|
106
|
+
name === SCRATCH ||
|
|
107
|
+
EXCLUDED_ENTRIES.includes(name as (typeof EXCLUDED_ENTRIES)[number])
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
91
111
|
/**
|
|
92
112
|
* The record folders sitting at `dir` right now, at either root spelling.
|
|
93
113
|
*
|
|
@@ -112,10 +132,7 @@ function foldersAt(dir: string): string[] {
|
|
|
112
132
|
return entries
|
|
113
133
|
.filter((entry) => entry.isDirectory())
|
|
114
134
|
.map((entry) => entry.name)
|
|
115
|
-
.filter(
|
|
116
|
-
(name) =>
|
|
117
|
-
!EXCLUDED_ENTRIES.includes(name as (typeof EXCLUDED_ENTRIES)[number]),
|
|
118
|
-
)
|
|
135
|
+
.filter((name) => !isExcluded(name))
|
|
119
136
|
}
|
|
120
137
|
|
|
121
138
|
/**
|
|
@@ -197,6 +214,7 @@ export const BACKUP_REFUSALS = [
|
|
|
197
214
|
'no-remote-records',
|
|
198
215
|
'local-changes',
|
|
199
216
|
'local-ahead',
|
|
217
|
+
'unsafe-payload',
|
|
200
218
|
'git-failed',
|
|
201
219
|
] as const
|
|
202
220
|
|
|
@@ -206,6 +224,8 @@ export interface BackupRefused {
|
|
|
206
224
|
readonly ok: false
|
|
207
225
|
readonly reason: BackupRefusal
|
|
208
226
|
readonly message: string
|
|
227
|
+
/** Every pending path the payload guard refused, carried on `unsafe-payload` only. */
|
|
228
|
+
readonly blocked?: readonly BlockedPath[]
|
|
209
229
|
}
|
|
210
230
|
|
|
211
231
|
export interface PushReport {
|
|
@@ -214,6 +234,10 @@ export interface PushReport {
|
|
|
214
234
|
readonly folders: readonly string[]
|
|
215
235
|
/** A folder in scope this push found on disk but the records index had never tracked. */
|
|
216
236
|
readonly firstSeen: readonly string[]
|
|
237
|
+
/** Every file this push stages that the records history has never carried. */
|
|
238
|
+
readonly added: readonly string[]
|
|
239
|
+
/** An excluded name the records index still tracked, removed from the history going forward. */
|
|
240
|
+
readonly dropped: readonly string[]
|
|
217
241
|
readonly changed: number
|
|
218
242
|
readonly commit?: string
|
|
219
243
|
readonly pushed: boolean
|
|
@@ -450,10 +474,14 @@ function refuseSplitRoots(root: string): BackupRefused | undefined {
|
|
|
450
474
|
)
|
|
451
475
|
}
|
|
452
476
|
|
|
453
|
-
/**
|
|
477
|
+
/**
|
|
478
|
+
* A pathspec-safe subset, which of it never entered the records index before,
|
|
479
|
+
* and which excluded names the index still tracks.
|
|
480
|
+
*/
|
|
454
481
|
interface FolderScope {
|
|
455
482
|
readonly scope: readonly string[]
|
|
456
483
|
readonly firstSeen: readonly string[]
|
|
484
|
+
readonly dropped: readonly string[]
|
|
457
485
|
}
|
|
458
486
|
|
|
459
487
|
/**
|
|
@@ -476,12 +504,19 @@ interface FolderScope {
|
|
|
476
504
|
* tracked, which is the read a caller reports rather than acts on: a folder
|
|
477
505
|
* `.canon` picked up that should have been excluded is visible in the push
|
|
478
506
|
* report instead of entering the payload silently.
|
|
507
|
+
*
|
|
508
|
+
* The index half passes through `isExcluded` before the union, since the index
|
|
509
|
+
* is the one side an excluded name can still reach the pathspec through. A
|
|
510
|
+
* name it drops is returned as `dropped` rather than silently forgotten, so the
|
|
511
|
+
* push can remove it from the history once and say so.
|
|
479
512
|
*/
|
|
480
513
|
async function scopedFolders(root: string): Promise<FolderScope> {
|
|
481
514
|
const tracked = await records(root, ['ls-files'])
|
|
482
|
-
const
|
|
515
|
+
const everIndexed = new Set(
|
|
483
516
|
tracked.ok ? tracked.text.split('\n').filter(Boolean).map(topSegment) : [],
|
|
484
517
|
)
|
|
518
|
+
const indexed = new Set([...everIndexed].filter((name) => !isExcluded(name)))
|
|
519
|
+
const dropped = [...everIndexed].filter(isExcluded).sort()
|
|
485
520
|
|
|
486
521
|
const present = new Set(foldersAt(workTree(root)))
|
|
487
522
|
const retired = RETIRED_FOLDERS.filter(
|
|
@@ -492,7 +527,7 @@ async function scopedFolders(root: string): Promise<FolderScope> {
|
|
|
492
527
|
(folder) => present.has(folder) && !indexed.has(folder),
|
|
493
528
|
)
|
|
494
529
|
|
|
495
|
-
return { scope, firstSeen }
|
|
530
|
+
return { scope, firstSeen, dropped }
|
|
496
531
|
}
|
|
497
532
|
|
|
498
533
|
function topSegment(path: string): string {
|
|
@@ -531,12 +566,105 @@ function countLines(text: string): number {
|
|
|
531
566
|
return text.split('\n').filter(Boolean).length
|
|
532
567
|
}
|
|
533
568
|
|
|
569
|
+
function splitNul(text: string): string[] {
|
|
570
|
+
return text.split('\0').filter(Boolean)
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* The files `add -A -f` is about to stage from `scope`, split into those the
|
|
575
|
+
* history has never carried and every pending one.
|
|
576
|
+
*
|
|
577
|
+
* `ls-files -o` without `--exclude-standard` lists ignored files too, which is
|
|
578
|
+
* what `-f` stages. `-m` also lists a deleted file and `-o` lists an embedded
|
|
579
|
+
* repository as a `dir/` entry, so both are filtered down to regular files,
|
|
580
|
+
* which is all a payload check has bytes to read.
|
|
581
|
+
*/
|
|
582
|
+
async function pendingFiles(
|
|
583
|
+
root: string,
|
|
584
|
+
scope: readonly string[],
|
|
585
|
+
): Promise<{ added: string[]; pending: string[] } | BackupRefused> {
|
|
586
|
+
if (scope.length === 0) return { added: [], pending: [] }
|
|
587
|
+
|
|
588
|
+
const [others, modified] = await Promise.all([
|
|
589
|
+
records(root, ['ls-files', '-z', '-o', '--', ...scope]),
|
|
590
|
+
records(root, ['ls-files', '-z', '-m', '--', ...scope]),
|
|
591
|
+
])
|
|
592
|
+
if (!others.ok) return failed('ls-files', others)
|
|
593
|
+
if (!modified.ok) return failed('ls-files', modified)
|
|
594
|
+
|
|
595
|
+
const tree = workTree(root)
|
|
596
|
+
const isRegularFile = (path: string): boolean => {
|
|
597
|
+
try {
|
|
598
|
+
return lstatSync(join(tree, path)).isFile()
|
|
599
|
+
} catch {
|
|
600
|
+
return false
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const added = splitNul(others.text).filter(isRegularFile).sort()
|
|
605
|
+
const pending = [
|
|
606
|
+
...new Set([...added, ...splitNul(modified.text).filter(isRegularFile)]),
|
|
607
|
+
].sort()
|
|
608
|
+
|
|
609
|
+
return { added, pending }
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function refuseBlocked(blocked: readonly BlockedPath[]): BackupRefused {
|
|
613
|
+
return {
|
|
614
|
+
...refuse(
|
|
615
|
+
'unsafe-payload',
|
|
616
|
+
[
|
|
617
|
+
`${blocked.length} record(s) cannot be backed up, so nothing was staged or pushed:`,
|
|
618
|
+
...blocked.map(
|
|
619
|
+
(finding) =>
|
|
620
|
+
` ${finding.path} (${finding.cause}: ${finding.detail})`,
|
|
621
|
+
),
|
|
622
|
+
'Move each out of the record folders, such as into .canon/tmp/, or remove the credential, then push again.',
|
|
623
|
+
].join('\n'),
|
|
624
|
+
),
|
|
625
|
+
blocked,
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Puts HEAD and the index back where they stood before this run committed.
|
|
631
|
+
*
|
|
632
|
+
* A mixed reset to the recorded HEAD rather than `reset --soft HEAD~1`. A soft
|
|
633
|
+
* reset leaves a dropped name's deletion staged while the next run's index read
|
|
634
|
+
* no longer lists the name, so that run's diff never sees the deletion and it
|
|
635
|
+
* is never committed. The recorded HEAD also covers the root commit, where
|
|
636
|
+
* `HEAD~1` does not exist and only removing the ref restores an unborn branch.
|
|
637
|
+
*
|
|
638
|
+
* Returns an error sentence when the undo itself failed, so the caller appends
|
|
639
|
+
* it rather than reporting a clean history that is not.
|
|
640
|
+
*/
|
|
641
|
+
async function undoCommit(
|
|
642
|
+
root: string,
|
|
643
|
+
priorHead: string,
|
|
644
|
+
): Promise<string | undefined> {
|
|
645
|
+
if (priorHead.length > 0) {
|
|
646
|
+
const reset = await records(root, ['reset', '--quiet', priorHead])
|
|
647
|
+
return reset.ok ? undefined : `Undoing it failed: ${reset.stderr}.`
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const unref = await records(root, ['update-ref', '-d', 'HEAD'])
|
|
651
|
+
if (!unref.ok) return `Undoing it failed: ${unref.stderr}.`
|
|
652
|
+
const reset = await records(root, ['reset', '--quiet'])
|
|
653
|
+
return reset.ok ? undefined : `Undoing it failed: ${reset.stderr}.`
|
|
654
|
+
}
|
|
655
|
+
|
|
534
656
|
/**
|
|
535
657
|
* Stages the backed folders, commits when any of them changed, and pushes.
|
|
536
658
|
*
|
|
537
|
-
* The
|
|
538
|
-
*
|
|
539
|
-
*
|
|
659
|
+
* The payload guard reads every pending file ahead of the stage, so a refusal
|
|
660
|
+
* leaves the index and the object store exactly as it found them. It reads what
|
|
661
|
+
* this run would add, never a commit already in the history.
|
|
662
|
+
*
|
|
663
|
+
* The push runs whether or not this call committed, because an older binary
|
|
664
|
+
* can have committed and then failed to reach the network. Skipping it would
|
|
665
|
+
* leave that commit on one disk, which is the state the whole verb exists to
|
|
666
|
+
* end. A commit this call made is undone when the push fails, so a remote that
|
|
667
|
+
* rejects the payload is not handed the same commit again on every later run.
|
|
540
668
|
*/
|
|
541
669
|
export async function pushRecords(root: string): Promise<PushOutcome> {
|
|
542
670
|
const split = refuseSplitRoots(root)
|
|
@@ -546,27 +674,56 @@ export async function pushRecords(root: string): Promise<PushOutcome> {
|
|
|
546
674
|
const remote = await resolveRemote(root, enclosing)
|
|
547
675
|
if (typeof remote !== 'string') return remote
|
|
548
676
|
|
|
549
|
-
const { scope, firstSeen } = await scopedFolders(root)
|
|
677
|
+
const { scope, firstSeen, dropped } = await scopedFolders(root)
|
|
678
|
+
|
|
679
|
+
const files = await pendingFiles(root, scope)
|
|
680
|
+
if ('ok' in files) return files
|
|
681
|
+
|
|
682
|
+
const blocked = await guardPayload(workTree(root), files.pending)
|
|
683
|
+
if (blocked.length > 0) return refuseBlocked(blocked)
|
|
550
684
|
|
|
551
685
|
if (scope.length > 0) {
|
|
552
686
|
// `-f` is what carries the payload: every backed folder is ignored by the
|
|
553
687
|
// enclosing repository, and the pathspecs are the whole list, so nothing
|
|
554
|
-
// outside them can enter the index however the ignore rules read.
|
|
688
|
+
// outside them can enter the index however the ignore rules read. An
|
|
689
|
+
// excluded name never reaches the list, whatever the records index holds.
|
|
555
690
|
const staged = await records(root, ['add', '-A', '-f', '--', ...scope])
|
|
556
691
|
if (!staged.ok) return failed('add', staged)
|
|
557
692
|
}
|
|
558
693
|
|
|
694
|
+
if (dropped.length > 0) {
|
|
695
|
+
const removed = await records(root, [
|
|
696
|
+
'rm',
|
|
697
|
+
'-r',
|
|
698
|
+
'--cached',
|
|
699
|
+
'--quiet',
|
|
700
|
+
'--',
|
|
701
|
+
...dropped,
|
|
702
|
+
])
|
|
703
|
+
if (!removed.ok) return failed('rm', removed)
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Scope alone would miss the dropped deletions, which sit outside it.
|
|
559
707
|
const diff = await records(root, [
|
|
560
708
|
'diff',
|
|
561
709
|
'--cached',
|
|
562
710
|
'--name-only',
|
|
563
711
|
'--',
|
|
564
712
|
...scope,
|
|
713
|
+
...dropped,
|
|
565
714
|
])
|
|
566
715
|
if (!diff.ok) return failed('diff', diff)
|
|
567
716
|
|
|
568
717
|
const changed = countLines(diff.text)
|
|
569
718
|
|
|
719
|
+
const prior = await records(root, [
|
|
720
|
+
'rev-parse',
|
|
721
|
+
'--verify',
|
|
722
|
+
'--quiet',
|
|
723
|
+
'HEAD',
|
|
724
|
+
])
|
|
725
|
+
const priorHead = prior.ok ? prior.text : ''
|
|
726
|
+
|
|
570
727
|
if (changed > 0) {
|
|
571
728
|
const stamp = new Date().toISOString().replace('T', ' ').slice(0, 16)
|
|
572
729
|
const commit = await records(root, [
|
|
@@ -579,11 +736,17 @@ export async function pushRecords(root: string): Promise<PushOutcome> {
|
|
|
579
736
|
if (!commit.ok) return failed('commit', commit)
|
|
580
737
|
}
|
|
581
738
|
|
|
582
|
-
const
|
|
739
|
+
const report = {
|
|
740
|
+
ok: true,
|
|
741
|
+
root,
|
|
742
|
+
folders: presentFolders(root),
|
|
743
|
+
firstSeen,
|
|
744
|
+
added: files.added,
|
|
745
|
+
dropped,
|
|
746
|
+
changed,
|
|
747
|
+
} as const
|
|
583
748
|
const head = await records(root, ['rev-parse', '--short', 'HEAD'])
|
|
584
|
-
if (!head.ok) {
|
|
585
|
-
return { ok: true, root, folders, firstSeen, changed, pushed: false }
|
|
586
|
-
}
|
|
749
|
+
if (!head.ok) return { ...report, pushed: false }
|
|
587
750
|
|
|
588
751
|
const branch = await projectBranch(root, enclosing)
|
|
589
752
|
const pushed = await records(root, [
|
|
@@ -591,17 +754,24 @@ export async function pushRecords(root: string): Promise<PushOutcome> {
|
|
|
591
754
|
'origin',
|
|
592
755
|
`HEAD:refs/heads/${branch}`,
|
|
593
756
|
])
|
|
594
|
-
if (!pushed.ok)
|
|
757
|
+
if (!pushed.ok) {
|
|
758
|
+
const refused = failed('push', pushed)
|
|
759
|
+
if (changed === 0) return refused
|
|
595
760
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
761
|
+
const undoError = await undoCommit(root, priorHead)
|
|
762
|
+
return refuse(
|
|
763
|
+
'git-failed',
|
|
764
|
+
[
|
|
765
|
+
refused.message,
|
|
766
|
+
'The commit this run made was undone, and the records are still on disk.',
|
|
767
|
+
undoError,
|
|
768
|
+
]
|
|
769
|
+
.filter(Boolean)
|
|
770
|
+
.join(' '),
|
|
771
|
+
)
|
|
604
772
|
}
|
|
773
|
+
|
|
774
|
+
return { ...report, commit: head.text, pushed: true }
|
|
605
775
|
}
|
|
606
776
|
|
|
607
777
|
/**
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { lstat, readFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { isBinary } from '@/binary'
|
|
4
|
+
import { scanText } from '@/secrets/scan'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The largest single record a push carries, in bytes.
|
|
8
|
+
*
|
|
9
|
+
* A constant rather than a flag. The largest record measured in this
|
|
10
|
+
* repository is a 6.7 MB git pack under a groundwork fixture and GitHub
|
|
11
|
+
* rejects a blob at 100 MB, so this sits well clear of both, and an override
|
|
12
|
+
* is one nobody remembers to pass the first time a backup tarball lands in a
|
|
13
|
+
* record folder.
|
|
14
|
+
*/
|
|
15
|
+
export const MAX_RECORD_BYTES = 25 * 1024 * 1024
|
|
16
|
+
|
|
17
|
+
export interface BlockedPath {
|
|
18
|
+
readonly path: string
|
|
19
|
+
readonly cause: 'oversized' | 'credential'
|
|
20
|
+
/** A size or `<label> at line <n>`, never the matched value or a preview of it. */
|
|
21
|
+
readonly detail: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function megabytes(bytes: number): string {
|
|
25
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Every pending path a push must not carry, in the order given.
|
|
30
|
+
*
|
|
31
|
+
* Reads the size off `lstat` first so an oversized file is never opened, and
|
|
32
|
+
* skips anything that is not a regular file, since `add` stages a symlink as
|
|
33
|
+
* its target path rather than the bytes it points at. The credential half is
|
|
34
|
+
* `scanText` unchanged, so the patterns and the `canon-allow-secret` marker
|
|
35
|
+
* answer here exactly as they do in `canon secrets scan`.
|
|
36
|
+
*/
|
|
37
|
+
export async function guardPayload(
|
|
38
|
+
tree: string,
|
|
39
|
+
paths: readonly string[],
|
|
40
|
+
): Promise<BlockedPath[]> {
|
|
41
|
+
const blocked: BlockedPath[] = []
|
|
42
|
+
|
|
43
|
+
for (const path of paths) {
|
|
44
|
+
const full = join(tree, path)
|
|
45
|
+
const stats = await lstat(full).catch(() => undefined)
|
|
46
|
+
if (!stats?.isFile()) continue
|
|
47
|
+
|
|
48
|
+
if (stats.size > MAX_RECORD_BYTES) {
|
|
49
|
+
blocked.push({
|
|
50
|
+
path,
|
|
51
|
+
cause: 'oversized',
|
|
52
|
+
detail: `${megabytes(stats.size)}, over the ${megabytes(MAX_RECORD_BYTES)} ceiling`,
|
|
53
|
+
})
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const text = await readFile(full, 'utf8').catch(() => undefined)
|
|
58
|
+
if (text === undefined || isBinary(text)) continue
|
|
59
|
+
|
|
60
|
+
const [first] = scanText(path, text)
|
|
61
|
+
if (first) {
|
|
62
|
+
blocked.push({
|
|
63
|
+
path,
|
|
64
|
+
cause: 'credential',
|
|
65
|
+
detail: `${first.label} at line ${first.line}`,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return blocked
|
|
71
|
+
}
|
package/src/teach/nav.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
TEACH_MISSION,
|
|
19
19
|
TEACH_REFERENCE,
|
|
20
20
|
TEACH_STYLESHEET,
|
|
21
|
+
TEACH_STYLESHEET_BASE,
|
|
21
22
|
teachDir,
|
|
22
23
|
type TeachRefused,
|
|
23
24
|
type WorkspaceDetail,
|
|
@@ -35,12 +36,23 @@ const BRAND_MARK = 'assets/brand/mark.svg'
|
|
|
35
36
|
*/
|
|
36
37
|
const HAND_WRITTEN_ICON = `<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='10 10 80 80'%3E%3Cpath d='M34,20 L15,28 L15,72 L34,80 Z M66,20 L85,28 L85,72 L66,80 Z' fill='rgb(224,114,75)' /%3E%3Crect x='44' y='15' width='12' height='70' rx='2' fill='rgb(224,114,75)' /%3E%3C/svg%3E" />`
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
/**
|
|
40
|
+
* The stylesheet link lessons used to carry by hand outside every region. The
|
|
41
|
+
* `style` region now links the base sheet itself, so keeping this one would
|
|
42
|
+
* load the base twice.
|
|
43
|
+
*/
|
|
44
|
+
const HAND_WRITTEN_STYLESHEET = `<link rel="stylesheet" href="../${TEACH_ASSETS}/${TEACH_STYLESHEET}" />`
|
|
45
|
+
|
|
46
|
+
const HAND_WRITTEN_HEAD = [HAND_WRITTEN_ICON, HAND_WRITTEN_STYLESHEET]
|
|
47
|
+
|
|
48
|
+
function dropHandWrittenHead(html: string): string {
|
|
49
|
+
return HAND_WRITTEN_HEAD.reduce(
|
|
50
|
+
(text, link) => text.replaceAll(link, ''),
|
|
51
|
+
html
|
|
52
|
+
.split('\n')
|
|
53
|
+
.filter((line) => !HAND_WRITTEN_HEAD.includes(line.trim()))
|
|
54
|
+
.join('\n'),
|
|
55
|
+
)
|
|
44
56
|
}
|
|
45
57
|
|
|
46
58
|
/**
|
|
@@ -504,6 +516,11 @@ function titleCase(slug: string): string {
|
|
|
504
516
|
].join(' ')
|
|
505
517
|
}
|
|
506
518
|
|
|
519
|
+
/** The mission's own title, falling back to the slug where it names none. */
|
|
520
|
+
function workspaceLabel(workspace: WorkspaceSummary): string {
|
|
521
|
+
return workspace.title ?? titleCase(workspace.topic)
|
|
522
|
+
}
|
|
523
|
+
|
|
507
524
|
interface JumpEntry {
|
|
508
525
|
readonly ordinal: string
|
|
509
526
|
readonly label: string
|
|
@@ -629,7 +646,7 @@ function renderSidebar(sidebar: Sidebar): string {
|
|
|
629
646
|
|
|
630
647
|
return `<aside class="sb">
|
|
631
648
|
<div class="sb-top">
|
|
632
|
-
<details class="sb-ws" name="mast"><summary><span class="ws-name">${escapeHtml(sidebar.heading)}</span><span class="car">${CARET}</span></summary><div class="sb-wl"><ul class="jump-list">${renderJumpList(sidebar.switcher)}</ul></div></details>
|
|
649
|
+
<details class="sb-ws" name="mast"><summary><span class="ws-name" title="${escapeHtml(sidebar.heading)}">${escapeHtml(sidebar.heading)}</span><span class="car">${CARET}</span></summary><div class="sb-wl"><ul class="jump-list">${renderJumpList(sidebar.switcher)}</ul></div></details>
|
|
633
650
|
</div>
|
|
634
651
|
<div class="sb-scroll">
|
|
635
652
|
<div class="sb-meta">${escapeHtml(sidebar.meta)}</div>
|
|
@@ -724,7 +741,7 @@ function workspaceJumpEntries(
|
|
|
724
741
|
): JumpEntry[] {
|
|
725
742
|
return workspaces.map((workspace) => ({
|
|
726
743
|
ordinal: ordinalOf(workspace),
|
|
727
|
-
label:
|
|
744
|
+
label: workspaceLabel(workspace),
|
|
728
745
|
href: hrefForWorkspace(workspace, prefix),
|
|
729
746
|
at: workspace.slug === currentSlug,
|
|
730
747
|
trailing: String(workspace.lessons),
|
|
@@ -935,7 +952,7 @@ function renderRootPage(workspaces: readonly WorkspaceSummary[]): string {
|
|
|
935
952
|
meta: `${workspaces.length} workspace${workspaces.length === 1 ? '' : 's'}`,
|
|
936
953
|
items: workspaces.map((workspace) => ({
|
|
937
954
|
ordinal: ordinalOf(workspace),
|
|
938
|
-
label:
|
|
955
|
+
label: workspaceLabel(workspace),
|
|
939
956
|
href: hrefForWorkspace(workspace, '') ?? '#',
|
|
940
957
|
at: false,
|
|
941
958
|
})),
|
|
@@ -951,7 +968,7 @@ function renderRootPage(workspaces: readonly WorkspaceSummary[]): string {
|
|
|
951
968
|
? 'Live'
|
|
952
969
|
: 'Open'
|
|
953
970
|
const blurb = `${workspace.lessons} lesson(s) · ${workspace.reference} reference page(s) · ${workspace.terms} term(s)`
|
|
954
|
-
const inner = `<span class="num">${ordinalOf(workspace)}</span><b>${escapeHtml(
|
|
971
|
+
const inner = `<span class="num">${ordinalOf(workspace)}</span><b>${escapeHtml(workspaceLabel(workspace))}</b><span class="state">${state}</span><span class="blurb">${blurb}</span>`
|
|
955
972
|
|
|
956
973
|
return href === undefined
|
|
957
974
|
? `<li class="soon"><a href="#" aria-disabled="true" tabindex="-1">${inner}</a></li>`
|
|
@@ -1014,7 +1031,7 @@ async function renderContentsPage(
|
|
|
1014
1031
|
)
|
|
1015
1032
|
: undefined
|
|
1016
1033
|
|
|
1017
|
-
const title =
|
|
1034
|
+
const title = workspaceLabel(detail)
|
|
1018
1035
|
|
|
1019
1036
|
const segments: CrumbSegment[] = [
|
|
1020
1037
|
{
|
|
@@ -1026,7 +1043,7 @@ async function renderContentsPage(
|
|
|
1026
1043
|
},
|
|
1027
1044
|
},
|
|
1028
1045
|
{
|
|
1029
|
-
label:
|
|
1046
|
+
label: workspaceLabel(detail),
|
|
1030
1047
|
jump: {
|
|
1031
1048
|
ariaLabel: 'Jump to a lesson',
|
|
1032
1049
|
entries: lessonJumpEntries(metas, '', undefined),
|
|
@@ -1035,7 +1052,7 @@ async function renderContentsPage(
|
|
|
1035
1052
|
]
|
|
1036
1053
|
|
|
1037
1054
|
const sidebar: Sidebar = {
|
|
1038
|
-
heading:
|
|
1055
|
+
heading: workspaceLabel(detail),
|
|
1039
1056
|
switcher: workspaceJumpEntries(workspaces, '../', detail.slug),
|
|
1040
1057
|
meta: `${metas.length} lesson${metas.length === 1 ? '' : 's'}`,
|
|
1041
1058
|
items: metas.map((meta, index) => ({
|
|
@@ -1139,21 +1156,44 @@ interface LessonRefused {
|
|
|
1139
1156
|
readonly missing: Region
|
|
1140
1157
|
}
|
|
1141
1158
|
|
|
1142
|
-
/**
|
|
1143
|
-
* Splices the four chrome regions into one lesson file, in a fixed order so a
|
|
1144
|
-
* missing marker is always reported against the same region a session can
|
|
1145
|
-
* check first. Nothing is written when any region is missing, which is what
|
|
1146
|
-
* keeps a partially-spliced file off disk.
|
|
1147
|
-
*/
|
|
1148
1159
|
/**
|
|
1149
1160
|
* A lesson embeds the workspace stylesheet, and an `@import` resolves against
|
|
1150
1161
|
* the lesson's own folder there rather than the assets folder, so it never
|
|
1151
|
-
* loaded anything. The base sheet reaches the page through the `<link
|
|
1162
|
+
* loaded anything. The base sheet reaches the page through the `<link>` the
|
|
1163
|
+
* `style` region carries ahead of the embedded rules.
|
|
1152
1164
|
*/
|
|
1153
1165
|
function stripImports(css: string): string {
|
|
1154
1166
|
return css.replace(/^@import[^;]*;[ \t]*\n?/gm, '')
|
|
1155
1167
|
}
|
|
1156
1168
|
|
|
1169
|
+
const HEADER_CLOSE = '<!-- /canon:teach:header -->'
|
|
1170
|
+
const FOOTNAV_OPEN = '<!-- canon:teach:footnav -->'
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* The authored body between the header and footnav regions, wrapped in
|
|
1174
|
+
* `<main>` when it holds none. The skill has a session write the body bare,
|
|
1175
|
+
* and the column width hangs off `main`. A body already holding one is left
|
|
1176
|
+
* alone, which keeps a second run byte-identical.
|
|
1177
|
+
*/
|
|
1178
|
+
function wrapMain(html: string): string {
|
|
1179
|
+
const start = html.indexOf(HEADER_CLOSE)
|
|
1180
|
+
const end = html.indexOf(FOOTNAV_OPEN, start)
|
|
1181
|
+
if (start === -1 || end === -1) return html
|
|
1182
|
+
|
|
1183
|
+
const bodyStart = start + HEADER_CLOSE.length
|
|
1184
|
+
const body = html.slice(bodyStart, end)
|
|
1185
|
+
if (/<main[\s>]/.test(body)) return html
|
|
1186
|
+
|
|
1187
|
+
return `${html.slice(0, bodyStart)}\n<main>\n${body.trim()}\n</main>\n${html.slice(end)}`
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* Splices the four chrome regions into one lesson file, in a fixed order so a
|
|
1192
|
+
* missing marker is always reported against the same region a session can
|
|
1193
|
+
* check first, then wraps the authored body in `<main>` where it holds none.
|
|
1194
|
+
* Nothing is written when any region is missing, which is what keeps a
|
|
1195
|
+
* partially-spliced file off disk.
|
|
1196
|
+
*/
|
|
1157
1197
|
async function rewriteLesson(
|
|
1158
1198
|
root: string,
|
|
1159
1199
|
detail: WorkspaceDetail,
|
|
@@ -1164,7 +1204,7 @@ async function rewriteLesson(
|
|
|
1164
1204
|
): Promise<LessonRewritten | LessonRefused> {
|
|
1165
1205
|
const file = metas[index].file
|
|
1166
1206
|
const path = join(root, detail.path, TEACH_LESSONS, file)
|
|
1167
|
-
let html =
|
|
1207
|
+
let html = dropHandWrittenHead(await readFile(path, 'utf8'))
|
|
1168
1208
|
|
|
1169
1209
|
const teachPrefix = '../../'
|
|
1170
1210
|
const workspacePrefix = '../'
|
|
@@ -1180,7 +1220,7 @@ async function rewriteLesson(
|
|
|
1180
1220
|
},
|
|
1181
1221
|
},
|
|
1182
1222
|
{
|
|
1183
|
-
label:
|
|
1223
|
+
label: workspaceLabel(detail),
|
|
1184
1224
|
href: `${workspacePrefix}index.html`,
|
|
1185
1225
|
jump: {
|
|
1186
1226
|
ariaLabel: 'Jump to a lesson',
|
|
@@ -1190,7 +1230,7 @@ async function rewriteLesson(
|
|
|
1190
1230
|
{ label: `Lesson ${index + 1} of ${metas.length}` },
|
|
1191
1231
|
],
|
|
1192
1232
|
{
|
|
1193
|
-
heading:
|
|
1233
|
+
heading: workspaceLabel(detail),
|
|
1194
1234
|
switcher: workspaceJumpEntries(workspaces, teachPrefix, detail.slug),
|
|
1195
1235
|
meta: `${metas.length} lesson${metas.length === 1 ? '' : 's'}`,
|
|
1196
1236
|
items: metas.map((meta, i) => ({
|
|
@@ -1210,6 +1250,16 @@ async function rewriteLesson(
|
|
|
1210
1250
|
// the class the script reveals feedback with.
|
|
1211
1251
|
const legacy = html.includes(LEGACY_OPTION)
|
|
1212
1252
|
|
|
1253
|
+
// Linked ahead of the embedded rules so those still win the cascade.
|
|
1254
|
+
// Embedding it instead repeats a sheet carrying its fonts inline in every
|
|
1255
|
+
// lesson. A workspace seeded before the pair has no base sheet to link.
|
|
1256
|
+
const hasBase = existsSync(
|
|
1257
|
+
join(root, detail.path, TEACH_ASSETS, TEACH_STYLESHEET_BASE),
|
|
1258
|
+
)
|
|
1259
|
+
const baseLink = hasBase
|
|
1260
|
+
? `<link rel="stylesheet" href="../${TEACH_ASSETS}/${TEACH_STYLESHEET_BASE}">\n`
|
|
1261
|
+
: ''
|
|
1262
|
+
|
|
1213
1263
|
const regions: ReadonlyArray<readonly [Region, string]> = [
|
|
1214
1264
|
// The stepper follows the workspace stylesheet so it wins the cascade at
|
|
1215
1265
|
// equal specificity, which is what reaches a workspace seeded before it.
|
|
@@ -1218,7 +1268,7 @@ async function rewriteLesson(
|
|
|
1218
1268
|
// lesson would lack.
|
|
1219
1269
|
[
|
|
1220
1270
|
'style',
|
|
1221
|
-
`${teachFavicon()}\n<style>\n${legacy ? css : `${css}\n${QUIZ_CSS}`}\n</style>\n${headScript('lesson')}`,
|
|
1271
|
+
`${teachFavicon()}\n${baseLink}<style>\n${legacy ? css : `${css}\n${QUIZ_CSS}`}\n</style>\n${headScript('lesson')}`,
|
|
1222
1272
|
],
|
|
1223
1273
|
['header', header],
|
|
1224
1274
|
['footnav', `${renderFootNav(metas, index)}\n${PANE_CLOSE}`],
|
|
@@ -1231,7 +1281,7 @@ async function rewriteLesson(
|
|
|
1231
1281
|
html = spliced
|
|
1232
1282
|
}
|
|
1233
1283
|
|
|
1234
|
-
await writeFile(path, html)
|
|
1284
|
+
await writeFile(path, wrapMain(html))
|
|
1235
1285
|
return { ok: true, file }
|
|
1236
1286
|
}
|
|
1237
1287
|
|
package/src/teach/workspace.ts
CHANGED
|
@@ -579,8 +579,8 @@ function missionText(
|
|
|
579
579
|
|
|
580
580
|
return [
|
|
581
581
|
'---',
|
|
582
|
-
`title: ${title}`,
|
|
583
|
-
`description: ${request.subject}`,
|
|
582
|
+
`title: ${yamlValue(title)}`,
|
|
583
|
+
`description: ${yamlValue(request.subject)}`,
|
|
584
584
|
`date: ${date}`,
|
|
585
585
|
'---',
|
|
586
586
|
'',
|
|
@@ -606,8 +606,8 @@ function missionText(
|
|
|
606
606
|
function resourcesText(title: string): string {
|
|
607
607
|
return [
|
|
608
608
|
'---',
|
|
609
|
-
`title: Sources for ${title}`,
|
|
610
|
-
`description: Sources read for ${title}, and leads found but not opened`,
|
|
609
|
+
`title: ${yamlValue(`Sources for ${title}`)}`,
|
|
610
|
+
`description: ${yamlValue(`Sources read for ${title}, and leads found but not opened`)}`,
|
|
611
611
|
'---',
|
|
612
612
|
'',
|
|
613
613
|
`# Sources for ${title}`,
|
|
@@ -628,8 +628,8 @@ function resourcesText(title: string): string {
|
|
|
628
628
|
function glossaryText(title: string): string {
|
|
629
629
|
return [
|
|
630
630
|
'---',
|
|
631
|
-
`title: Glossary for ${title}`,
|
|
632
|
-
`description: Terms ${title} defines, one entry each`,
|
|
631
|
+
`title: ${yamlValue(`Glossary for ${title}`)}`,
|
|
632
|
+
`description: ${yamlValue(`Terms ${title} defines, one entry each`)}`,
|
|
633
633
|
'---',
|
|
634
634
|
'',
|
|
635
635
|
`# Glossary for ${title}`,
|
|
@@ -641,6 +641,14 @@ function glossaryText(title: string): string {
|
|
|
641
641
|
].join('\n')
|
|
642
642
|
}
|
|
643
643
|
|
|
644
|
+
/**
|
|
645
|
+
* Serializes a frontmatter value, quoting only what YAML would otherwise read
|
|
646
|
+
* as something other than the string, so a plain title stays byte-identical.
|
|
647
|
+
*/
|
|
648
|
+
function yamlValue(value: string): string {
|
|
649
|
+
return Bun.YAML.stringify(value)
|
|
650
|
+
}
|
|
651
|
+
|
|
644
652
|
/** Today as `YYYY-MM-DD`, which is the one field the mission dates. */
|
|
645
653
|
function today(): string {
|
|
646
654
|
return new Date().toISOString().slice(0, DATE_LENGTH)
|