@erclx/canon 5.52.0 → 5.53.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.
Files changed (127) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +1 -4
  3. package/claude/.claude-plugin/plugin.json +1 -1
  4. package/claude/skills/canon-cli/SKILL.md +0 -3
  5. package/claude/skills/context-fold/REQUIREMENT.md +0 -1
  6. package/claude/skills/design-taste/references/kinds.md +0 -1
  7. package/claude/skills/draw-figure/REQUIREMENT.md +2 -3
  8. package/claude/skills/draw-figure/SKILL.md +4 -5
  9. package/claude/skills/draw-figure/references/figures.md +0 -1
  10. package/claude/skills/draw-figure/references/hand-drawn.md +1 -1
  11. package/claude/skills/pick-by-eye/REQUIREMENT.md +0 -1
  12. package/claude/skills/pick-by-eye/SKILL.md +1 -1
  13. package/claude/skills/teach-craft/REQUIREMENT.md +30 -0
  14. package/claude/skills/teach-craft/SKILL.md +39 -0
  15. package/claude/skills/teach-craft/references/lesson-craft.md +52 -0
  16. package/claude/skills/{teach-workspace → teach-craft}/references/pedagogy.md +12 -4
  17. package/docs/agents/audits.md +0 -1
  18. package/docs/target/migrations.md +10 -1
  19. package/docs/workflow/parallel-features.md +1 -1
  20. package/docs/workflow/skill-map-on-request.md +10 -22
  21. package/docs/workflow/skill-map.md +12 -14
  22. package/package.json +1 -4
  23. package/scripts/core/regen-web-previews.ts +2 -67
  24. package/src/audits/catalog.ts +0 -1
  25. package/src/browser/engine.ts +3 -6
  26. package/src/capture/render.ts +1 -1
  27. package/src/claude/cases/authoring.ts +0 -12
  28. package/src/claude/cases/misc.ts +0 -17
  29. package/src/claude/cases/workflow.ts +2 -38
  30. package/src/claude/skills-audit.ts +0 -1
  31. package/src/cli/help.ts +0 -7
  32. package/src/cli.ts +0 -6
  33. package/src/commands/records.ts +0 -1
  34. package/src/commands/serve.ts +2 -2
  35. package/src/design/components.ts +8 -1514
  36. package/src/design/css.ts +5 -6
  37. package/src/design/fonts.ts +3 -5
  38. package/src/driver/drive.ts +3 -4
  39. package/src/driver/steps.ts +2 -5
  40. package/src/inventory/walk.ts +1 -1
  41. package/src/markdown/scope.ts +4 -31
  42. package/src/records/validate.ts +4 -176
  43. package/src/sandbox/equivalence.ts +0 -5
  44. package/claude/skills/draft-and-pick/REQUIREMENT.md +0 -26
  45. package/claude/skills/draft-and-pick/SKILL.md +0 -11
  46. package/claude/skills/draft-doc/REQUIREMENT.md +0 -26
  47. package/claude/skills/draft-doc/SKILL.md +0 -11
  48. package/claude/skills/draft-figure/REQUIREMENT.md +0 -26
  49. package/claude/skills/draft-figure/SKILL.md +0 -11
  50. package/claude/skills/draft-identity/REQUIREMENT.md +0 -26
  51. package/claude/skills/draft-identity/SKILL.md +0 -11
  52. package/claude/skills/draft-ready/REQUIREMENT.md +0 -26
  53. package/claude/skills/draft-ready/SKILL.md +0 -11
  54. package/claude/skills/draft-screencast/REQUIREMENT.md +0 -26
  55. package/claude/skills/draft-screencast/SKILL.md +0 -11
  56. package/claude/skills/draft-slides/REQUIREMENT.md +0 -26
  57. package/claude/skills/draft-slides/SKILL.md +0 -11
  58. package/claude/skills/make-deck/REQUIREMENT.md +0 -36
  59. package/claude/skills/make-deck/SKILL.md +0 -80
  60. package/claude/skills/make-deck/references/deck.md +0 -183
  61. package/claude/skills/read-frames/REQUIREMENT.md +0 -30
  62. package/claude/skills/read-frames/SKILL.md +0 -55
  63. package/claude/skills/record-screencast/REQUIREMENT.md +0 -35
  64. package/claude/skills/record-screencast/SKILL.md +0 -99
  65. package/claude/skills/script-screencast/REQUIREMENT.md +0 -34
  66. package/claude/skills/script-screencast/SKILL.md +0 -70
  67. package/claude/skills/script-screencast/references/beats-template.md +0 -119
  68. package/claude/skills/teach-workspace/REQUIREMENT.md +0 -34
  69. package/claude/skills/teach-workspace/SKILL.md +0 -196
  70. package/claude/skills/teach-workspace/references/glossary.md +0 -90
  71. package/claude/skills/teach-workspace/references/lesson-build.md +0 -106
  72. package/claude/skills/teach-workspace/references/lesson-craft.md +0 -135
  73. package/claude/skills/teach-workspace/references/promotion-fold.md +0 -35
  74. package/claude/skills/teach-workspace/references/promotion.md +0 -116
  75. package/claude/skills/teach-workspace/references/revisit.md +0 -28
  76. package/claude/skills/teach-workspace/references/teach.md +0 -211
  77. package/claude/skills/video-craft/REQUIREMENT.md +0 -35
  78. package/claude/skills/video-craft/SKILL.md +0 -96
  79. package/claude/skills/video-craft/references/adopted.md +0 -110
  80. package/src/commands/demo.ts +0 -542
  81. package/src/commands/slides.ts +0 -184
  82. package/src/commands/teach.ts +0 -1401
  83. package/src/demo/beats.ts +0 -135
  84. package/src/demo/compile.ts +0 -350
  85. package/src/demo/container.ts +0 -219
  86. package/src/demo/cursors.ts +0 -55
  87. package/src/demo/drive.ts +0 -442
  88. package/src/demo/pointer.ts +0 -178
  89. package/src/demo/theme.ts +0 -112
  90. package/src/demo/timeline.ts +0 -64
  91. package/src/slides/convert/chart.ts +0 -165
  92. package/src/slides/convert/deck.ts +0 -244
  93. package/src/slides/convert/export.ts +0 -567
  94. package/src/slides/convert/master.ts +0 -198
  95. package/src/slides/convert/shapes.ts +0 -804
  96. package/src/slides/convert/svg.ts +0 -38
  97. package/src/slides/convert/walk.ts +0 -671
  98. package/src/slides/decks.ts +0 -110
  99. package/src/slides/open.ts +0 -18
  100. package/src/slides/package/fonts.ts +0 -301
  101. package/src/slides/package/motion.ts +0 -316
  102. package/src/slides/package/test-face.ts +0 -113
  103. package/src/teach/browser/compile.ts +0 -44
  104. package/src/teach/browser/dismiss.ts +0 -76
  105. package/src/teach/browser/focus-line.ts +0 -19
  106. package/src/teach/browser/glossary-filter.ts +0 -44
  107. package/src/teach/browser/head.ts +0 -30
  108. package/src/teach/browser/quiz.ts +0 -19
  109. package/src/teach/browser/reload.ts +0 -9
  110. package/src/teach/browser/sidebar.ts +0 -233
  111. package/src/teach/browser/theme.ts +0 -18
  112. package/src/teach/components/code.tsx +0 -24
  113. package/src/teach/components/heading.tsx +0 -11
  114. package/src/teach/components/list.tsx +0 -17
  115. package/src/teach/components/paragraph.tsx +0 -29
  116. package/src/teach/components/refs.tsx +0 -28
  117. package/src/teach/cross-references.ts +0 -78
  118. package/src/teach/fonts.ts +0 -16
  119. package/src/teach/highlight.ts +0 -71
  120. package/src/teach/html/jsx-dev-runtime.ts +0 -13
  121. package/src/teach/html/jsx-runtime.ts +0 -113
  122. package/src/teach/html/raw.ts +0 -28
  123. package/src/teach/lesson.ts +0 -178
  124. package/src/teach/nav.ts +0 -1337
  125. package/src/teach/render.ts +0 -240
  126. package/src/teach/workspace.ts +0 -1164
  127. package/src/teach/write-if-changed.ts +0 -17
@@ -1,1164 +0,0 @@
1
- import { existsSync } from 'node:fs'
2
- import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
3
- import { basename, join, relative } from 'node:path'
4
- import { TEACH_STYLESHEET_COMPONENTS } from '@/design/components'
5
- import { buildDesignCss } from '@/design/css'
6
- import { parseFrontmatter, readField } from '@/indexes/frontmatter'
7
- import { type BodyLine, bodyLines } from '@/markdown/scan'
8
- import { recordDir } from '@/roots/record'
9
- import { TEACH_FONT_FACES } from '@/teach/fonts'
10
- import { writeIfChanged } from '@/teach/write-if-changed'
11
-
12
- export const TEACH_REFUSALS = [
13
- 'no-teach',
14
- 'no-workspace',
15
- 'ambiguous',
16
- 'exists',
17
- 'no-file',
18
- 'no-section',
19
- 'listed',
20
- 'defined',
21
- 'bad-input',
22
- ] as const
23
-
24
- export type TeachRefusal = (typeof TEACH_REFUSALS)[number]
25
-
26
- export interface TeachRefused {
27
- readonly ok: false
28
- readonly reason: TeachRefusal
29
- readonly message: string
30
- readonly detail: readonly string[]
31
- }
32
-
33
- /** The files and folders `claude/skills/teach-workspace/references/teach.md` fixes for a workspace. */
34
- export const TEACH_MISSION = 'MISSION.md'
35
- export const TEACH_RESOURCES = 'RESOURCES.md'
36
- export const TEACH_GLOSSARY = 'GLOSSARY.md'
37
- export const TEACH_REFERENCE = 'reference'
38
- export const TEACH_RECORDS = 'learning-records'
39
- export const TEACH_LESSONS = 'lessons'
40
- export const TEACH_ASSETS = 'assets'
41
-
42
- /**
43
- * The one stylesheet every lesson in a workspace links. The name is fixed here
44
- * rather than chosen per lesson, because the second lesson has to reach the
45
- * file the first one wrote and a name composed twice is a name that can differ.
46
- * This file is workspace-owned: `writeStylesheet` writes it once and leaves it
47
- * alone, since a lesson adds its own rules here. It imports `TEACH_STYLESHEET_BASE`
48
- * for the tokens and components, which is the half a resync may always replace.
49
- */
50
- export const TEACH_STYLESHEET = 'course.css'
51
-
52
- /**
53
- * The seeded half of a workspace's styling, imported by `TEACH_STYLESHEET`.
54
- * `writeStylesheet` rewrites this file from the design source on every call,
55
- * since nothing workspace-authored lives in it, which is what lets a design
56
- * token move without a `--force` flag discarding a workspace's lesson rules.
57
- */
58
- export const TEACH_STYLESHEET_BASE = 'base.css'
59
-
60
- /**
61
- * The mission heading whose list a session reads as exit criteria. The writer,
62
- * the reader below, and the record validator all match this one spelling, so a
63
- * heading none of them can find fails the validator rather than reading as an
64
- * empty list.
65
- */
66
- export const TEACH_SUCCESS_HEADING = '## Success looks like'
67
-
68
- /**
69
- * A workspace folder as the standard names it, capturing the ordinal and the
70
- * topic separately so a listing sorts by the first and a selector matches the
71
- * second.
72
- */
73
- export const WORKSPACE_NAME = /^(\d{2})-([a-z0-9]+(?:-[a-z0-9]+)*)$/
74
-
75
- const TOPIC_SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/
76
-
77
- const READ_HEADING = '## Read'
78
- const LEADS_HEADING = '## Leads'
79
-
80
- /**
81
- * The line a scaffolded section carries until something real lands in it. An
82
- * empty section reads as one someone forgot to write, and the insert verbs drop
83
- * this line as the first real entry arrives.
84
- */
85
- const PLACEHOLDER = '- None yet.'
86
-
87
- /** Two digits on the folder, per the standard, because a person opens few. */
88
- const ORDINAL_WIDTH = 2
89
-
90
- const DATE_LENGTH = 'YYYY-MM-DD'.length
91
-
92
- const MS_PER_DAY = 24 * 60 * 60 * 1000
93
-
94
- /** The heading a learning record schedules its revisits under. */
95
- const REVISIT_HEADING = '## Revisit'
96
-
97
- /**
98
- * The gaps a revisit item moves through, in days. A rung is a one-based index
99
- * into this list, widening after a retrieval the learner passed unaided and
100
- * narrowing after a miss, which is the spacing rule the pedagogy reference
101
- * states. The values are here rather than in a skill body because a session
102
- * told to widen a gap still picks the number by judgment.
103
- */
104
- const REVISIT_LADDER = [1, 3, 7, 16, 35] as const
105
-
106
- /**
107
- * One scheduled entry, as a learning record spells it. The rung is carried in
108
- * the record rather than derived from how many records name the item, since a
109
- * miss drops it back and a count only ever climbs.
110
- */
111
- const REVISIT_ENTRY =
112
- /^-\s+\*\*(.+?)\*\*:\s*due\s+(\d{4}-\d{2}-\d{2}),\s*rung\s+([1-9]\d*)\s*$/
113
-
114
- export interface RevisitItem {
115
- /** What comes back up, as the record names it. */
116
- readonly item: string
117
- /** The day it comes back up, as `YYYY-MM-DD`. */
118
- readonly date: string
119
- readonly rung: number
120
- /** True once that day has arrived or passed. */
121
- readonly overdue: boolean
122
- /** The date to schedule when the learner retrieves it unaided. */
123
- readonly hit: string
124
- /** The date to schedule when they miss it. */
125
- readonly miss: string
126
- /** The record the surviving entry was written in. */
127
- readonly record: string
128
- }
129
-
130
- export interface WorkspaceSummary {
131
- readonly slug: string
132
- /** `NaN` when the folder name carries no ordinal, which a listing reports. */
133
- readonly ordinal: number
134
- readonly topic: string
135
- /** Relative to the root, so a caller prints a path a reader can open. */
136
- readonly path: string
137
- readonly title: string | undefined
138
- readonly opened: string | undefined
139
- readonly lessons: number
140
- readonly records: number
141
- readonly reference: number
142
- readonly terms: number
143
- /** Required files the workspace does not carry, by the standard's layout. */
144
- readonly missing: readonly string[]
145
- }
146
-
147
- export interface WorkspaceDetail extends WorkspaceSummary {
148
- readonly lessonFiles: readonly string[]
149
- readonly recordFiles: readonly string[]
150
- readonly referenceFiles: readonly string[]
151
- readonly glossary: readonly string[]
152
- /** The mission's success lines, which a session reports progress against. */
153
- readonly success: readonly string[]
154
- /**
155
- * What the learning records schedule, soonest first. A session reads this
156
- * before it has chosen what to teach, which is why it rides on the listing
157
- * rather than on the verb that resolves a lesson already picked.
158
- */
159
- readonly due: readonly RevisitItem[]
160
- }
161
-
162
- export interface WorkspacesListed {
163
- readonly ok: true
164
- readonly workspaces: readonly WorkspaceSummary[]
165
- /** The ordinal an open would take, so no caller derives one by hand. */
166
- readonly next: string
167
- }
168
-
169
- export interface WorkspaceRead {
170
- readonly ok: true
171
- readonly workspace: WorkspaceDetail
172
- }
173
-
174
- export interface WorkspaceOpened {
175
- readonly ok: true
176
- readonly slug: string
177
- readonly path: string
178
- readonly created: readonly string[]
179
- }
180
-
181
- export interface SourcesWritten {
182
- readonly ok: true
183
- readonly slug: string
184
- readonly path: string
185
- readonly read: readonly Source[]
186
- readonly leads: readonly Source[]
187
- }
188
-
189
- export interface TermsWritten {
190
- readonly ok: true
191
- readonly slug: string
192
- readonly path: string
193
- readonly defined: readonly Term[]
194
- }
195
-
196
- export type ListOutcome = WorkspacesListed | TeachRefused
197
- export type ReadOutcome = WorkspaceRead | TeachRefused
198
- export type OpenOutcome = WorkspaceOpened | TeachRefused
199
- export type SourceOutcome = SourcesWritten | TeachRefused
200
- export type TermOutcome = TermsWritten | TeachRefused
201
-
202
- export interface Source {
203
- readonly title: string
204
- readonly url: string
205
- }
206
-
207
- export interface Term {
208
- readonly term: string
209
- readonly definition: string
210
- }
211
-
212
- export interface OpenRequest {
213
- readonly topic: string
214
- readonly subject: string
215
- readonly startingPoint: string
216
- readonly success: readonly string[]
217
- readonly outOfScope: readonly string[]
218
- readonly title?: string
219
- readonly date?: string
220
- }
221
-
222
- export function refuse(
223
- reason: TeachRefusal,
224
- message: string,
225
- detail: readonly string[] = [],
226
- ): TeachRefused {
227
- return { ok: false, reason, message, detail }
228
- }
229
-
230
- /**
231
- * Every workspace sits under the main worktree root rather than under the
232
- * checkout the caller stands in. Resolving that root belongs to the caller, so
233
- * this takes one and never reads the working directory.
234
- *
235
- * A root already named `teach` is taken as the teach folder itself rather than
236
- * a project root to wrap, which is what lets a caller point straight at a
237
- * folder such as `examples/teach` that holds workspaces outside any `.canon/`
238
- * or `.claude/` record root.
239
- */
240
- export function teachDir(root: string): string {
241
- return basename(root) === 'teach' ? root : recordDir(root, 'teach')
242
- }
243
-
244
- async function listSlugs(dir: string): Promise<string[]> {
245
- const entries = await readdir(dir, { withFileTypes: true })
246
-
247
- return entries
248
- .filter((entry) => entry.isDirectory())
249
- .map((entry) => entry.name)
250
- .filter((name) => WORKSPACE_NAME.test(name))
251
- .sort()
252
- }
253
-
254
- async function filesIn(
255
- dir: string,
256
- folder: string,
257
- suffix?: string,
258
- ): Promise<string[]> {
259
- const path = join(dir, folder)
260
- if (!existsSync(path)) return []
261
-
262
- const entries = await readdir(path, { withFileTypes: true })
263
-
264
- return entries
265
- .filter(
266
- (entry) => entry.isFile() && (!suffix || entry.name.endsWith(suffix)),
267
- )
268
- .map((entry) => entry.name)
269
- .sort()
270
- }
271
-
272
- function isEntry(line: string): boolean {
273
- const trimmed = line.trim()
274
- return trimmed.startsWith('- ') && trimmed !== PLACEHOLDER
275
- }
276
-
277
- /** One bullet per term the subject defines, which is what a glossary holds. */
278
- function glossaryTerms(text: string): string[] {
279
- return unfenced(text)
280
- .map((line) => line.text)
281
- .filter(isEntry)
282
- .map((line) => line.trim().slice('- '.length))
283
- }
284
-
285
- /**
286
- * The mission's success lines, each one an observable thing the learner will be
287
- * able to do. A session reads them as exit criteria, so a wrapped entry is
288
- * joined back into one line rather than reported as two criteria.
289
- *
290
- * A mission carrying no such heading yields nothing rather than refusing. The
291
- * record validator is what reports the absent section, and a listing that
292
- * refused would take the whole workspace down with it.
293
- */
294
- function successLines(text: string): string[] {
295
- const lines = text.split('\n')
296
- const section = sectionRange(
297
- unfenced(text),
298
- TEACH_SUCCESS_HEADING,
299
- lines.length,
300
- )
301
-
302
- if (!section) return []
303
-
304
- return bulletBlocks(lines.slice(section.start, section.end)).map((block) =>
305
- block.join(' ').trim().slice('- '.length).replace(/\s+/g, ' ').trim(),
306
- )
307
- }
308
-
309
- /**
310
- * Whether a `YYYY-MM-DD` string names a real day.
311
- *
312
- * The entry pattern admits `2026-13-45`, since it counts digits rather than
313
- * reading a calendar, and `addDays` throws on a date that names no day, so a
314
- * single mistyped record would take down every listing that reads the
315
- * workspace. Every date reaching `addDays` passes here first.
316
- */
317
- function isDay(date: string): boolean {
318
- return !Number.isNaN(Date.parse(`${date}T00:00:00Z`))
319
- }
320
-
321
- /** A day offset from a real `YYYY-MM-DD` date, back out in the same form. */
322
- function addDays(date: string, days: number): string {
323
- const start = Date.parse(`${date}T00:00:00Z`)
324
- return new Date(start + days * MS_PER_DAY).toISOString().slice(0, DATE_LENGTH)
325
- }
326
-
327
- /** A rung moved by one step, held inside the ladder at both ends. */
328
- function stepRung(rung: number, step: number): number {
329
- return Math.min(REVISIT_LADDER.length, Math.max(1, rung + step))
330
- }
331
-
332
- function gapFor(rung: number, step: number): number {
333
- return REVISIT_LADDER[stepRung(rung, step) - 1]
334
- }
335
-
336
- export interface RecordText {
337
- readonly file: string
338
- readonly text: string
339
- }
340
-
341
- /**
342
- * Every revisit a workspace's learning records schedule, soonest first.
343
- *
344
- * Records arrive in read order and a later one supersedes an earlier entry for
345
- * the same item, since the schedule is the state of one topic rather than a log
346
- * of every time it was set. Matching is case-insensitive on the item, so a
347
- * session that capitalized differently in a later record still supersedes
348
- * rather than opening a second schedule beside the first.
349
- *
350
- * An entry not in the recorded shape is skipped rather than reported. A note
351
- * carrying no date schedules nothing, which is what the standard's anti-pattern
352
- * names, and this reader has no date to put on it. A date naming no real day is
353
- * skipped on the same footing, since a schedule nothing can order is a note.
354
- */
355
- export function readRevisits(
356
- records: readonly RecordText[],
357
- today: string,
358
- ): RevisitItem[] {
359
- if (!isDay(today)) return []
360
-
361
- const scheduled = new Map<
362
- string,
363
- { item: string; date: string; rung: number; record: string }
364
- >()
365
-
366
- for (const record of records) {
367
- const lines = record.text.split('\n')
368
- const section = sectionRange(
369
- unfenced(record.text),
370
- REVISIT_HEADING,
371
- lines.length,
372
- )
373
-
374
- if (!section) continue
375
-
376
- for (const block of bulletBlocks(lines.slice(section.start, section.end))) {
377
- const match = REVISIT_ENTRY.exec(block.join(' ').replace(/\s+/g, ' '))
378
- if (!match || !isDay(match[2])) continue
379
-
380
- scheduled.set(match[1].toLowerCase(), {
381
- item: match[1],
382
- date: match[2],
383
- rung: Number(match[3]),
384
- record: record.file,
385
- })
386
- }
387
- }
388
-
389
- return [...scheduled.values()]
390
- .map((entry) => ({
391
- item: entry.item,
392
- date: entry.date,
393
- rung: entry.rung,
394
- overdue: entry.date <= today,
395
- hit: addDays(today, gapFor(entry.rung, 1)),
396
- miss: addDays(today, gapFor(entry.rung, -1)),
397
- record: entry.record,
398
- }))
399
- .sort(
400
- (left, right) =>
401
- left.date.localeCompare(right.date) ||
402
- left.item.localeCompare(right.item),
403
- )
404
- }
405
-
406
- /** The ordinal a folder name carries, or `NaN` when it carries none. */
407
- function ordinalOf(slug: string): number {
408
- const match = WORKSPACE_NAME.exec(slug)
409
- return match ? Number(match[1]) : Number.NaN
410
- }
411
-
412
- async function summarize(
413
- root: string,
414
- dir: string,
415
- slug: string,
416
- ): Promise<WorkspaceDetail> {
417
- const match = WORKSPACE_NAME.exec(slug)
418
- const missionPath = join(dir, TEACH_MISSION)
419
- const mission = existsSync(missionPath)
420
- ? await readFile(missionPath, 'utf8')
421
- : undefined
422
- const frontmatter =
423
- mission === undefined ? undefined : parseFrontmatter(mission)
424
-
425
- const glossaryPath = join(dir, TEACH_GLOSSARY)
426
- const glossary = existsSync(glossaryPath)
427
- ? glossaryTerms(await readFile(glossaryPath, 'utf8'))
428
- : []
429
-
430
- const [lessonFiles, recordFiles, referenceFiles] = await Promise.all([
431
- filesIn(dir, TEACH_LESSONS),
432
- filesIn(dir, TEACH_RECORDS, '.md'),
433
- filesIn(dir, TEACH_REFERENCE, '.md'),
434
- ])
435
-
436
- // Read order is filename order, which is record order, so a later record
437
- // supersedes an earlier schedule for the same item.
438
- const records = await Promise.all(
439
- recordFiles.map(async (file) => ({
440
- file,
441
- text: await readFile(join(dir, TEACH_RECORDS, file), 'utf8'),
442
- })),
443
- )
444
-
445
- return {
446
- slug,
447
- ordinal: match ? Number(match[1]) : Number.NaN,
448
- topic: match ? match[2] : slug,
449
- path: relative(root, dir),
450
- title: readField(frontmatter, 'title'),
451
- opened: readField(frontmatter, 'date'),
452
- lessons: lessonFiles.length,
453
- records: recordFiles.length,
454
- reference: referenceFiles.length,
455
- terms: glossary.length,
456
- missing: [TEACH_MISSION, TEACH_RESOURCES, TEACH_GLOSSARY].filter(
457
- (file) => !existsSync(join(dir, file)),
458
- ),
459
- lessonFiles,
460
- recordFiles,
461
- referenceFiles,
462
- glossary,
463
- success: mission === undefined ? [] : successLines(mission),
464
- due: readRevisits(records, today()),
465
- }
466
- }
467
-
468
- function byOrdinal(left: WorkspaceSummary, right: WorkspaceSummary): number {
469
- if (Number.isNaN(left.ordinal)) return Number.isNaN(right.ordinal) ? 0 : 1
470
- if (Number.isNaN(right.ordinal)) return -1
471
- return left.ordinal - right.ordinal
472
- }
473
-
474
- /**
475
- * Reads the ordinals off the folder names rather than off summaries, so opening
476
- * a workspace costs one directory listing instead of a read of every file in
477
- * every workspace already there.
478
- */
479
- function nextOrdinal(slugs: readonly string[]): string {
480
- const highest = slugs
481
- .map(ordinalOf)
482
- .filter((ordinal) => !Number.isNaN(ordinal))
483
- .reduce((carry, ordinal) => Math.max(carry, ordinal), 0)
484
-
485
- return String(highest + 1).padStart(ORDINAL_WIDTH, '0')
486
- }
487
-
488
- /**
489
- * Every workspace under the root, with the ordinal a new one would take.
490
- *
491
- * A folder failing the name pattern is listed rather than dropped, since
492
- * dropping it hides the one workspace a session most needs to see. Its ordinal
493
- * reads as absent, it sorts last, and it never moves the next number.
494
- */
495
- export async function listWorkspaces(root: string): Promise<ListOutcome> {
496
- const dir = teachDir(root)
497
-
498
- if (!existsSync(dir)) {
499
- return refuse('no-teach', `No teach folder at ${relative(root, dir)}.`)
500
- }
501
-
502
- const slugs = await listSlugs(dir)
503
-
504
- const workspaces = await Promise.all(
505
- slugs.map((slug) => summarize(root, join(dir, slug), slug)),
506
- )
507
-
508
- return {
509
- ok: true,
510
- workspaces: [...workspaces].sort(byOrdinal),
511
- next: nextOrdinal(slugs),
512
- }
513
- }
514
-
515
- /**
516
- * The workspace a selector names, matched on the folder name or on the topic
517
- * behind the ordinal. Two topics matching is a refusal rather than a pick,
518
- * because the caller meant one of them and no verb here can say which.
519
- */
520
- async function findWorkspace(
521
- root: string,
522
- selector: string,
523
- ): Promise<{ slug: string; dir: string } | TeachRefused> {
524
- const dir = teachDir(root)
525
-
526
- if (!existsSync(dir)) {
527
- return refuse('no-teach', `No teach folder at ${relative(root, dir)}.`)
528
- }
529
-
530
- const slugs = await listSlugs(dir)
531
- const matched = slugs.filter(
532
- (slug) => slug === selector || WORKSPACE_NAME.exec(slug)?.[2] === selector,
533
- )
534
-
535
- if (matched.length === 0) {
536
- return refuse('no-workspace', `No workspace named ${selector}.`, slugs)
537
- }
538
-
539
- if (matched.length > 1) {
540
- return refuse(
541
- 'ambiguous',
542
- `${selector} names ${matched.length} workspaces.`,
543
- matched,
544
- )
545
- }
546
-
547
- return { slug: matched[0], dir: join(dir, matched[0]) }
548
- }
549
-
550
- /** One workspace with the filenames behind each count. */
551
- export async function readWorkspace(
552
- root: string,
553
- selector: string,
554
- ): Promise<ReadOutcome> {
555
- const found = await findWorkspace(root, selector)
556
- if ('ok' in found) return found
557
-
558
- return { ok: true, workspace: await summarize(root, found.dir, found.slug) }
559
- }
560
-
561
- function titleFor(topic: string, given: string | undefined): string {
562
- if (given) return given
563
-
564
- const words = topic.split('-')
565
- return [
566
- words[0].charAt(0).toUpperCase() + words[0].slice(1),
567
- ...words.slice(1),
568
- ].join(' ')
569
- }
570
-
571
- function missionText(
572
- request: OpenRequest,
573
- title: string,
574
- date: string,
575
- ): string {
576
- const outOfScope =
577
- request.outOfScope.length > 0
578
- ? request.outOfScope
579
- : ['Nothing has been ruled out yet.']
580
-
581
- return [
582
- '---',
583
- `title: ${yamlValue(title)}`,
584
- `description: ${yamlValue(request.subject)}`,
585
- `date: ${date}`,
586
- '---',
587
- '',
588
- `# ${title}`,
589
- '',
590
- request.subject,
591
- '',
592
- '## Starting point',
593
- '',
594
- request.startingPoint,
595
- '',
596
- '## Success looks like',
597
- '',
598
- ...request.success.map((line) => `- ${line}`),
599
- '',
600
- '## Out of scope',
601
- '',
602
- ...outOfScope.map((line) => `- ${line}`),
603
- '',
604
- ].join('\n')
605
- }
606
-
607
- function resourcesText(title: string): string {
608
- return [
609
- '---',
610
- `title: ${yamlValue(`Sources for ${title}`)}`,
611
- `description: ${yamlValue(`Sources read for ${title}, and leads found but not opened`)}`,
612
- '---',
613
- '',
614
- `# Sources for ${title}`,
615
- '',
616
- 'Sources that stand behind this workspace, kept apart from leads nobody opened.',
617
- '',
618
- READ_HEADING,
619
- '',
620
- PLACEHOLDER,
621
- '',
622
- LEADS_HEADING,
623
- '',
624
- PLACEHOLDER,
625
- '',
626
- ].join('\n')
627
- }
628
-
629
- function glossaryText(title: string): string {
630
- return [
631
- '---',
632
- `title: ${yamlValue(`Glossary for ${title}`)}`,
633
- `description: ${yamlValue(`Terms ${title} defines, one entry each`)}`,
634
- '---',
635
- '',
636
- `# Glossary for ${title}`,
637
- '',
638
- 'One entry per term the subject defines, sorted alphabetically.',
639
- '',
640
- PLACEHOLDER,
641
- '',
642
- ].join('\n')
643
- }
644
-
645
- /**
646
- * Serializes a frontmatter value, quoting only what YAML would otherwise read
647
- * as something other than the string, so a plain title stays byte-identical.
648
- */
649
- function yamlValue(value: string): string {
650
- return Bun.YAML.stringify(value)
651
- }
652
-
653
- /** Today as `YYYY-MM-DD`, which is the one field the mission dates. */
654
- function today(): string {
655
- return new Date().toISOString().slice(0, DATE_LENGTH)
656
- }
657
-
658
- /**
659
- * Creates a workspace at the next ordinal with all three required files.
660
- *
661
- * The ordinal, the folder name, and every path are derived here rather than by
662
- * the caller. A caller standing in a linked worktree cannot reach this root
663
- * through its file-editing tools, so a path it composed by hand is one nothing
664
- * checks before the write lands somewhere else.
665
- */
666
- export async function openWorkspace(
667
- root: string,
668
- request: OpenRequest,
669
- ): Promise<OpenOutcome> {
670
- if (!TOPIC_SLUG.test(request.topic)) {
671
- return refuse('bad-input', `Not a kebab-case topic: ${request.topic}.`, [
672
- request.topic,
673
- ])
674
- }
675
-
676
- if (request.success.length === 0) {
677
- return refuse(
678
- 'bad-input',
679
- 'A mission needs at least one success line. Pass --success <line>.',
680
- )
681
- }
682
-
683
- const dir = teachDir(root)
684
- await mkdir(dir, { recursive: true })
685
-
686
- const slugs = await listSlugs(dir)
687
- const existing = slugs.find(
688
- (slug) => WORKSPACE_NAME.exec(slug)?.[2] === request.topic,
689
- )
690
-
691
- if (existing) {
692
- return refuse(
693
- 'exists',
694
- `${existing} already covers ${request.topic}. Resume it rather than opening a second.`,
695
- [existing],
696
- )
697
- }
698
-
699
- const slug = `${nextOrdinal(slugs)}-${request.topic}`
700
- const folder = join(dir, slug)
701
- const title = titleFor(request.topic, request.title)
702
-
703
- await mkdir(folder, { recursive: true })
704
-
705
- const files: ReadonlyArray<readonly [string, string]> = [
706
- [TEACH_MISSION, missionText(request, title, request.date ?? today())],
707
- [TEACH_RESOURCES, resourcesText(title)],
708
- [TEACH_GLOSSARY, glossaryText(title)],
709
- ]
710
-
711
- for (const [name, text] of files) {
712
- await writeFile(join(folder, name), text)
713
- }
714
-
715
- return {
716
- ok: true,
717
- slug,
718
- path: relative(root, folder),
719
- created: files.map(([name]) => join(relative(root, folder), name)),
720
- }
721
- }
722
-
723
- interface Range {
724
- readonly start: number
725
- readonly end: number
726
- }
727
-
728
- /**
729
- * Body lines outside every fence, each still carrying the source line it came
730
- * from. `bodyLines` numbers from one past the frontmatter it drops, so `number
731
- * - 1` addresses the same line in the array a caller splits itself.
732
- */
733
- function unfenced(text: string): BodyLine[] {
734
- return bodyLines(text).filter((line) => !line.fenced)
735
- }
736
-
737
- /**
738
- * The half-open source range a heading owns, ending at the next heading of the
739
- * same level or above. A heading quoted inside a fenced example selects nothing,
740
- * since the scan never sees it.
741
- */
742
- function sectionRange(
743
- lines: readonly BodyLine[],
744
- heading: string,
745
- total: number,
746
- ): Range | undefined {
747
- let start: number | undefined
748
-
749
- for (const line of lines) {
750
- if (start === undefined) {
751
- if (line.text.trim() === heading) start = line.number
752
- continue
753
- }
754
-
755
- if (/^##?[ \t]/.test(line.text)) return { start, end: line.number - 1 }
756
- }
757
-
758
- return start === undefined ? undefined : { start, end: total }
759
- }
760
-
761
- /**
762
- * The half-open source range holding a bullet list, from its first entry to the
763
- * last line of its last entry. A list carrying only the placeholder yields that
764
- * line's range, so the first real entry replaces it rather than landing beside
765
- * it.
766
- *
767
- * An indented non-blank line extends the range because that is how markdown
768
- * wraps an entry too long for one line. An unindented one does not, so a
769
- * paragraph written under a list stays outside and is neither sorted nor moved.
770
- */
771
- function bulletRange(
772
- lines: readonly BodyLine[],
773
- within?: Range,
774
- ): Range | undefined {
775
- let first: number | undefined
776
- let last: number | undefined
777
-
778
- for (const line of lines) {
779
- const index = line.number - 1
780
-
781
- if (within && (index < within.start || index >= within.end)) continue
782
-
783
- if (line.text.trim().startsWith('- ')) {
784
- first ??= index
785
- last = index
786
- continue
787
- }
788
-
789
- const wraps = first !== undefined && /^\s+\S/.test(line.text)
790
- if (wraps) last = index
791
- }
792
-
793
- return first === undefined || last === undefined
794
- ? undefined
795
- : { start: first, end: last + 1 }
796
- }
797
-
798
- /**
799
- * The bullet blocks in a run of lines, each a bullet with the continuation lines
800
- * wrapped under it.
801
- *
802
- * Splitting on the bullet marker rather than keeping the lines that are bullets
803
- * is what holds a wrapped entry together. A filter over lines keeps the first
804
- * line of one and silently drops the rest, which loses half of every entry an
805
- * author wrapped at the margin.
806
- */
807
- function bulletBlocks(lines: readonly string[]): string[][] {
808
- const blocks: string[][] = []
809
-
810
- for (const line of lines) {
811
- if (line.trim().startsWith('- ')) {
812
- blocks.push([line])
813
- continue
814
- }
815
-
816
- blocks.at(-1)?.push(line)
817
- }
818
-
819
- return blocks.filter((block) => block[0].trim() !== PLACEHOLDER)
820
- }
821
-
822
- /**
823
- * Places entries in a line range, dropping the scaffolded placeholder as the
824
- * first real entry lands. `sorted` merges alphabetically and anything else
825
- * appends, which is the split between a glossary and a source list.
826
- */
827
- function placeEntries(
828
- lines: readonly string[],
829
- range: Range,
830
- entries: readonly string[],
831
- sorted: boolean,
832
- ): string {
833
- const kept = bulletBlocks(lines.slice(range.start, range.end))
834
- const added = entries.map((entry) => [entry])
835
-
836
- const placed = sorted
837
- ? [...kept, ...added].sort((left, right) =>
838
- left[0].toLowerCase().localeCompare(right[0].toLowerCase()),
839
- )
840
- : [...kept, ...added]
841
-
842
- return [
843
- ...lines.slice(0, range.start),
844
- ...placed.flat(),
845
- ...lines.slice(range.end),
846
- ].join('\n')
847
- }
848
-
849
- /** Appends entries under a heading, keeping the blank lines around the list. */
850
- function insertUnderHeading(
851
- text: string,
852
- heading: string,
853
- entries: readonly string[],
854
- ): string | undefined {
855
- const lines = text.split('\n')
856
- const body = unfenced(text)
857
- const section = sectionRange(body, heading, lines.length)
858
- if (!section) return undefined
859
-
860
- const bullets = bulletRange(body, section)
861
-
862
- if (!bullets) {
863
- return [
864
- ...lines.slice(0, section.start),
865
- '',
866
- ...entries,
867
- '',
868
- ...lines.slice(section.end),
869
- ].join('\n')
870
- }
871
-
872
- return placeEntries(lines, bullets, entries, false)
873
- }
874
-
875
- async function openFile(
876
- dir: string,
877
- slug: string,
878
- name: string,
879
- ): Promise<string | TeachRefused> {
880
- const path = join(dir, name)
881
-
882
- if (!existsSync(path)) {
883
- return refuse('no-file', `${slug} carries no ${name}.`)
884
- }
885
-
886
- return path
887
- }
888
-
889
- /** The entry lines of the file, or of one heading's section. */
890
- function listedEntries(text: string, heading?: string): string[] {
891
- const body = unfenced(text)
892
- const section = heading
893
- ? sectionRange(body, heading, text.split('\n').length)
894
- : undefined
895
- if (heading && !section) return []
896
-
897
- return body
898
- .filter(
899
- (line) =>
900
- isEntry(line.text) &&
901
- (!section ||
902
- (line.number - 1 >= section.start && line.number - 1 < section.end)),
903
- )
904
- .map((line) => line.text)
905
- }
906
-
907
- function lists(entries: readonly string[], source: Source): boolean {
908
- return entries.some((line) => line.includes(`(${source.url})`))
909
- }
910
-
911
- /**
912
- * Drops the lead entries naming the given URLs, wrapped continuation lines
913
- * included, and restores the placeholder when the section empties.
914
- */
915
- function withoutLeads(text: string, urls: ReadonlySet<string>): string {
916
- const lines = text.split('\n')
917
- const section = sectionRange(unfenced(text), LEADS_HEADING, lines.length)
918
- if (!section) return text
919
-
920
- const bullets = bulletRange(unfenced(text), section)
921
- if (!bullets) return text
922
-
923
- const kept = bulletBlocks(lines.slice(bullets.start, bullets.end)).filter(
924
- (block) => ![...urls].some((url) => block[0].includes(`(${url})`)),
925
- )
926
-
927
- return [
928
- ...lines.slice(0, bullets.start),
929
- ...(kept.length > 0 ? kept.flat() : [PLACEHOLDER]),
930
- ...lines.slice(bullets.end),
931
- ].join('\n')
932
- }
933
-
934
- /**
935
- * Appends sources to `RESOURCES.md`, keeping what was read apart from what was
936
- * only found.
937
- *
938
- * A URL already listed under either heading is refused rather than repeated,
939
- * since a second entry for one source splits what rests on it across two lines.
940
- * The one exception is a read source found under `## Leads`, which moves to
941
- * `## Read` under its new title, since opening a lead is how it becomes read.
942
- * Any refusal leaves the file untouched, so a batch lands whole or not at all.
943
- */
944
- export async function recordSources(
945
- root: string,
946
- selector: string,
947
- read: readonly Source[],
948
- leads: readonly Source[],
949
- ): Promise<SourceOutcome> {
950
- const found = await findWorkspace(root, selector)
951
- if ('ok' in found) return found
952
-
953
- const path = await openFile(found.dir, found.slug, TEACH_RESOURCES)
954
- if (typeof path !== 'string') return path
955
-
956
- let text = await readFile(path, 'utf8')
957
- const listed = listedEntries(text)
958
- const leadEntries = listedEntries(text, LEADS_HEADING)
959
-
960
- const isPromotion = (source: Source) => lists(leadEntries, source)
961
- const promoted = read.filter(isPromotion)
962
-
963
- const repeated = [
964
- ...read.filter((source) => !isPromotion(source)),
965
- ...leads,
966
- ].filter((source) => lists(listed, source))
967
-
968
- if (repeated.length > 0) {
969
- return refuse(
970
- 'listed',
971
- `${TEACH_RESOURCES} already lists ${repeated.map((source) => source.url).join(', ')}.`,
972
- repeated.map((source) => source.url),
973
- )
974
- }
975
-
976
- if (promoted.length > 0) {
977
- text = withoutLeads(text, new Set(promoted.map((source) => source.url)))
978
- }
979
-
980
- for (const [heading, sources] of [
981
- [READ_HEADING, read],
982
- [LEADS_HEADING, leads],
983
- ] as const) {
984
- if (sources.length === 0) continue
985
-
986
- const written = insertUnderHeading(
987
- text,
988
- heading,
989
- sources.map((source) => `- [${source.title}](${source.url})`),
990
- )
991
-
992
- if (written === undefined) {
993
- return refuse(
994
- 'no-section',
995
- `${TEACH_RESOURCES} carries no ${heading} section to write into.`,
996
- [heading],
997
- )
998
- }
999
-
1000
- text = written
1001
- }
1002
-
1003
- await writeFile(path, text)
1004
-
1005
- return { ok: true, slug: found.slug, path, read, leads }
1006
- }
1007
-
1008
- /**
1009
- * The entry shape the standard fixes, which leads with the bolded term.
1010
- *
1011
- * The definition is terminated before the citation is appended, since a caller
1012
- * passing a bare phrase would otherwise run it into the sentence naming where
1013
- * the term first appears.
1014
- */
1015
- function termEntry(term: Term, firstSeen: string | undefined): string {
1016
- const definition = /[.!?]$/.test(term.definition)
1017
- ? term.definition
1018
- : `${term.definition}.`
1019
-
1020
- const where = firstSeen ? ` First seen in ${firstSeen}.` : ''
1021
-
1022
- return `- **${term.term}**: ${definition}${where}`
1023
- }
1024
-
1025
- /** The term a glossary entry defines, read back out of its bolded span. */
1026
- function definedTerm(entry: string): string {
1027
- return entry.replace(/^\*\*(.+?)\*\*.*$/s, '$1').toLowerCase()
1028
- }
1029
-
1030
- /**
1031
- * Adds terms to `GLOSSARY.md`, alphabetically, in one read and one write.
1032
- *
1033
- * A term already defined is refused rather than replaced. A definition the
1034
- * subject has moved under is a revision of the entry rather than a second one,
1035
- * and no verb here can tell those two apart from the command line.
1036
- */
1037
- export async function defineTerms(
1038
- root: string,
1039
- selector: string,
1040
- terms: readonly Term[],
1041
- firstSeen: string | undefined,
1042
- ): Promise<TermOutcome> {
1043
- const found = await findWorkspace(root, selector)
1044
- if ('ok' in found) return found
1045
-
1046
- const path = await openFile(found.dir, found.slug, TEACH_GLOSSARY)
1047
- if (typeof path !== 'string') return path
1048
-
1049
- const text = await readFile(path, 'utf8')
1050
- const existing = glossaryTerms(text).map(definedTerm)
1051
-
1052
- const defined = terms.filter((term) =>
1053
- existing.includes(term.term.toLowerCase()),
1054
- )
1055
-
1056
- if (defined.length > 0) {
1057
- return refuse(
1058
- 'defined',
1059
- `${TEACH_GLOSSARY} already defines ${defined.map((term) => term.term).join(', ')}.`,
1060
- defined.map((term) => term.term),
1061
- )
1062
- }
1063
-
1064
- const entries = terms.map((term) => termEntry(term, firstSeen))
1065
- const lines = text.split('\n')
1066
- const bullets = bulletRange(unfenced(text))
1067
-
1068
- // A glossary carries no heading over its list, so the entries land in the
1069
- // bullet range itself. A file holding none yet is appended to, which covers a
1070
- // glossary written by hand rather than scaffolded here.
1071
- const written = bullets
1072
- ? placeEntries(lines, bullets, entries, true)
1073
- : `${text.trimEnd()}\n\n${entries.join('\n')}\n`
1074
-
1075
- await writeFile(path, written)
1076
-
1077
- return { ok: true, slug: found.slug, path, defined: terms }
1078
- }
1079
-
1080
- export interface StylesheetWritten {
1081
- readonly ok: true
1082
- readonly slug: string
1083
- /** Relative to the root, so a caller prints a path a reader can open. */
1084
- readonly path: string
1085
- /** Relative to the root, the re-synced seed `path` imports. */
1086
- readonly basePath: string
1087
- /** False when the workspace already held `path` and this call left it alone. */
1088
- readonly written: boolean
1089
- }
1090
-
1091
- export type StylesheetOutcome = StylesheetWritten | TeachRefused
1092
-
1093
- const STYLESHEET_BASE_BANNER = [
1094
- 'Seeded by `canon teach stylesheet` from the design source in',
1095
- 'src/design/tokens.ts, and rewritten on every call. The tokens and the two',
1096
- 'components below are the system this workspace renders in. Read a value',
1097
- 'through its custom property rather than restating the hex, which is what',
1098
- 'let one workspace fork the palette from every other. Add lesson rules to',
1099
- `${TEACH_STYLESHEET} instead, which imports this file and is never rewritten.`,
1100
- ].join('\n ')
1101
-
1102
- const stylesheetSeed = (): string =>
1103
- `@import url('./${TEACH_STYLESHEET_BASE}');\n`
1104
-
1105
- /**
1106
- * Writes a workspace's stylesheet pair from the design source.
1107
- *
1108
- * Every workspace used to carry one hand-authored file, which is how the
1109
- * course palette forked once per workspace. The names are fixed at
1110
- * `TEACH_STYLESHEET` and `TEACH_STYLESHEET_BASE`, and the folder at
1111
- * `TEACH_ASSETS`, for the same reason a second lesson has to reach the files
1112
- * the first one wrote and names composed twice can differ.
1113
- *
1114
- * `TEACH_STYLESHEET_BASE` carries the tokens and the components and is
1115
- * rewritten every call, since nothing workspace-authored lives in it.
1116
- * `TEACH_STYLESHEET` imports it, carries a workspace's own lesson rules, and
1117
- * is written once and left alone, since overwriting it would discard them.
1118
- * `--force` is the caller saying it wants that file's seed back too.
1119
- */
1120
- export async function writeStylesheet(
1121
- root: string,
1122
- selector: string,
1123
- force = false,
1124
- ): Promise<StylesheetOutcome> {
1125
- const found = await readWorkspace(root, selector)
1126
- if (!found.ok) return found
1127
-
1128
- const workspace = found.workspace
1129
- const assetsDir = join(workspace.path, TEACH_ASSETS)
1130
- const baseRel = join(assetsDir, TEACH_STYLESHEET_BASE)
1131
- const rel = join(assetsDir, TEACH_STYLESHEET)
1132
- const basePath = join(root, baseRel)
1133
- const path = join(root, rel)
1134
-
1135
- await mkdir(join(root, assetsDir), { recursive: true })
1136
- await writeIfChanged(
1137
- basePath,
1138
- buildDesignCss(undefined, {
1139
- banner: STYLESHEET_BASE_BANNER,
1140
- embedFonts: TEACH_FONT_FACES,
1141
- components: TEACH_STYLESHEET_COMPONENTS,
1142
- }),
1143
- )
1144
-
1145
- if (existsSync(path) && !force) {
1146
- return {
1147
- ok: true,
1148
- slug: workspace.slug,
1149
- path: rel,
1150
- basePath: baseRel,
1151
- written: false,
1152
- }
1153
- }
1154
-
1155
- await writeFile(path, stylesheetSeed())
1156
-
1157
- return {
1158
- ok: true,
1159
- slug: workspace.slug,
1160
- path: rel,
1161
- basePath: baseRel,
1162
- written: true,
1163
- }
1164
- }