@erclx/canon 4.8.1 → 4.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -139,6 +139,8 @@ export interface SyncAdapter {
139
139
  collectMissing?(target: string): RetiredSurface[]
140
140
  /** Dropped from the walk, so neither matching nor orphaned. */
141
141
  isExcluded?(file: InstalledFile): boolean
142
+ /** Glob the walk lists. Defaults to `DEFAULT_INSTALL_PATTERN`. */
143
+ readonly installPattern?: string
142
144
  /**
143
145
  * Top-level folder under `installedRoot` that is project-authored by
144
146
  * location rather than by the name inference `locateSource` runs.
@@ -158,15 +160,26 @@ export interface SyncAdapter {
158
160
  }
159
161
 
160
162
  /**
161
- * Lists installed markdown, dotfiles included. `Bun.Glob` skips entries
162
- * beginning with a dot unless `dot` is set, and every domain installs under
163
- * `.claude/`, so a nested dot-directory would silently drop out of the walk.
163
+ * What a domain installs, when it installs something other than markdown.
164
+ * Design ships a stylesheet, and every other domain ships prose.
164
165
  */
165
- export function listInstalled(root: string, target: string): InstalledFile[] {
166
+ export const DEFAULT_INSTALL_PATTERN = '**/*.md'
167
+
168
+ /**
169
+ * Lists installed files matching the domain's pattern, dotfiles included.
170
+ * `Bun.Glob` skips entries beginning with a dot unless `dot` is set, and every
171
+ * domain installs under `.claude/`, so a nested dot-directory would silently
172
+ * drop out of the walk.
173
+ */
174
+ export function listInstalled(
175
+ root: string,
176
+ target: string,
177
+ pattern: string = DEFAULT_INSTALL_PATTERN,
178
+ ): InstalledFile[] {
166
179
  if (!existsSync(root)) return []
167
180
 
168
181
  return [
169
- ...new Bun.Glob('**/*.md').scanSync({
182
+ ...new Bun.Glob(pattern).scanSync({
170
183
  cwd: root,
171
184
  onlyFiles: true,
172
185
  dot: true,
@@ -192,7 +205,11 @@ export function planSync(adapter: SyncAdapter, target: string): SyncPlan {
192
205
  const hashes = stampedHashes(readStamp(target), adapter.stamp?.domain)
193
206
  const walked = new Set<string>()
194
207
 
195
- for (const file of listInstalled(adapter.installedRoot(target), target)) {
208
+ for (const file of listInstalled(
209
+ adapter.installedRoot(target),
210
+ target,
211
+ adapter.installPattern,
212
+ )) {
196
213
  if (adapter.isExcluded?.(file) === true) continue
197
214
  walked.add(toStampKey(file.rel))
198
215
 
@@ -562,7 +579,11 @@ export async function recordStamp(
562
579
 
563
580
  const hashes: Record<string, string> = {}
564
581
 
565
- for (const file of listInstalled(adapter.installedRoot(target), target)) {
582
+ for (const file of listInstalled(
583
+ adapter.installedRoot(target),
584
+ target,
585
+ adapter.installPattern,
586
+ )) {
566
587
  if (adapter.isExcluded?.(file) === true) continue
567
588
  if (isProjectAuthored(adapter, file)) continue
568
589
 
package/src/sync/stamp.ts CHANGED
@@ -6,16 +6,17 @@ import { execa } from 'execa'
6
6
  import { recordTarget } from '@/targets/registry'
7
7
 
8
8
  /**
9
- * Domains the stamp can record. Governance attributes file by file through the
10
- * sync engine. Tooling runs its own inject and manifest machinery, so it
11
- * records the stack chain it resolved instead and carries no file hashes.
9
+ * Domains the stamp can record. Governance and design both attribute file by
10
+ * file through the sync engine. Tooling runs its own inject and manifest
11
+ * machinery, so it records the stack chain it resolved instead and carries no
12
+ * file hashes.
12
13
  *
13
14
  * A stamp written before the standards or snippets install channel closed
14
15
  * still carries a `standards` or `snippets` record. `isStamp` ignores the key
15
16
  * and `sortDomains` drops it on the next write, so the target loses a domain
16
17
  * nothing can refresh rather than losing the whole file.
17
18
  */
18
- export const STAMP_DOMAINS = ['governance', 'tooling'] as const
19
+ export const STAMP_DOMAINS = ['governance', 'design', 'tooling'] as const
19
20
 
20
21
  export type StampDomain = (typeof STAMP_DOMAINS)[number]
21
22
 
@@ -1,8 +1,9 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
+ import { DESIGN_INSTALL_DIR } from '@/design/adapter'
3
4
  import { isDirectory } from '@/target'
4
5
 
5
- export const SYNC_DOMAINS = ['governance', 'claude'] as const
6
+ export const SYNC_DOMAINS = ['governance', 'design', 'claude'] as const
6
7
 
7
8
  export type SyncDomain = (typeof SYNC_DOMAINS)[number]
8
9
 
@@ -13,6 +14,7 @@ export interface DomainState {
13
14
 
14
15
  const DOMAIN_MARKERS: Record<SyncDomain, string> = {
15
16
  governance: join('.claude', 'rules'),
17
+ design: DESIGN_INSTALL_DIR,
16
18
  claude: '.claude',
17
19
  }
18
20
 
@@ -24,6 +26,7 @@ const DOMAIN_MARKERS: Record<SyncDomain, string> = {
24
26
  */
25
27
  const DOMAIN_PATHS: Record<SyncDomain, readonly string[]> = {
26
28
  governance: ['.claude/rules/', '.claude/GOV.md'],
29
+ design: ['.claude/design/'],
27
30
  claude: ['.gitignore'],
28
31
  }
29
32
 
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
3
3
  import { join, relative } from 'node:path'
4
+ import { buildDesignCss } from '@/design/css'
4
5
  import { parseFrontmatter, readField } from '@/indexes/frontmatter'
5
6
  import { type BodyLine, bodyLines } from '@/markdown/scan'
6
7
  import { recordDir } from '@/record-root'
@@ -841,3 +842,59 @@ export async function defineTerms(
841
842
 
842
843
  return { ok: true, slug: found.slug, path, defined: terms }
843
844
  }
845
+
846
+ export interface StylesheetWritten {
847
+ readonly ok: true
848
+ readonly slug: string
849
+ /** Relative to the root, so a caller prints a path a reader can open. */
850
+ readonly path: string
851
+ /** False when the workspace already held one and this call left it alone. */
852
+ readonly written: boolean
853
+ }
854
+
855
+ export type StylesheetOutcome = StylesheetWritten | TeachRefused
856
+
857
+ const STYLESHEET_BANNER = [
858
+ 'Seeded by `canon teach stylesheet` from the design source in',
859
+ 'src/design/tokens.ts. The tokens and the two components below are the',
860
+ 'system this workspace renders in. Add lesson rules under them and read a',
861
+ 'value through its custom property rather than restating the hex, which is',
862
+ 'what let one workspace fork the palette from every other.',
863
+ ].join('\n ')
864
+
865
+ /**
866
+ * Writes a workspace's one stylesheet from the design source.
867
+ *
868
+ * Every workspace used to carry a hand-authored copy, which is how the course
869
+ * palette forked once per workspace. The name is fixed at `TEACH_STYLESHEET`
870
+ * and the folder at `TEACH_ASSETS` for the same reason a second lesson has to
871
+ * reach the file the first one wrote, and this is what puts the values in it.
872
+ *
873
+ * An existing stylesheet is left alone rather than replaced. A workspace adds
874
+ * lesson rules to this file as it goes, so overwriting would discard them, and
875
+ * `--force` is the caller saying it wants the seed back.
876
+ */
877
+ export async function writeStylesheet(
878
+ root: string,
879
+ selector: string,
880
+ force = false,
881
+ ): Promise<StylesheetOutcome> {
882
+ const found = await readWorkspace(root, selector)
883
+ if (!found.ok) return found
884
+
885
+ const workspace = found.workspace
886
+ const rel = join(workspace.path, TEACH_ASSETS, TEACH_STYLESHEET)
887
+ const path = join(root, rel)
888
+
889
+ if (existsSync(path) && !force) {
890
+ return { ok: true, slug: workspace.slug, path: rel, written: false }
891
+ }
892
+
893
+ await mkdir(join(root, workspace.path, TEACH_ASSETS), { recursive: true })
894
+ await writeFile(
895
+ path,
896
+ buildDesignCss(undefined, { banner: STYLESHEET_BANNER }),
897
+ )
898
+
899
+ return { ok: true, slug: workspace.slug, path: rel, written: true }
900
+ }