@sableui/react 0.7.0 → 0.8.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/AGENTS.md +2 -2
- package/CHANGELOG.md +115 -0
- package/README.md +2 -0
- package/bin/sable.mjs +34 -0
- package/dist/blocks/traffic-sources-block.js +1 -1
- package/dist/blocks/traffic-trend-block.js +3 -3
- package/dist/components/breadcrumbs/breadcrumbs.js +10 -9
- package/dist/components/breadcrumbs/breadcrumbs.meta.js +2 -2
- package/dist/components/button/button.meta.js +4 -0
- package/dist/components/card/card.js +7 -4
- package/dist/components/chat-composer/chat-composer.js +4 -2
- package/dist/components/chat-composer/chat-composer.meta.js +8 -2
- package/dist/components/command-menu/command-menu.js +9 -2
- package/dist/components/command-menu/command-menu.meta.js +3 -3
- package/dist/components/data-table/data-table.js +117 -64
- package/dist/components/data-table/data-table.meta.js +27 -1
- package/dist/components/form/form.js +30 -9
- package/dist/components/form/form.meta.js +17 -3
- package/dist/components/message-scroller/message-scroller.meta.js +1 -1
- package/dist/components/page-header/page-header.js +1 -0
- package/dist/components/page-header/page-header.meta.js +1 -1
- package/dist/components/pie-chart/pie-chart.js +3 -3
- package/dist/components/pie-chart/pie-chart.meta.js +3 -3
- package/dist/components/radial-chart/radial-chart.js +3 -3
- package/dist/components/radial-chart/radial-chart.meta.js +2 -2
- package/dist/components/settings-menu/settings-menu.js +10 -1
- package/dist/components/settings-menu/settings-menu.meta.js +1 -1
- package/dist/components/side-nav/side-nav.variants.js +3 -2
- package/dist/components/sidebar/sidebar.js +1 -1
- package/dist/components/sidebar/sidebar.meta.js +5 -0
- package/dist/components/switch/switch.js +1 -1
- package/dist/components/switch/switch.meta.js +2 -2
- package/dist/components/table/table.js +1 -1
- package/dist/components/toggle/toggle.js +2 -1
- package/dist/file-hashes.json +603 -0
- package/dist/icon-sets/reicon.js +13 -1
- package/dist/index.d.ts +88 -15
- package/dist/index.js +1 -1
- package/dist/layout/index.d.ts +5 -2
- package/dist/lib/chart/format.js +5 -0
- package/dist/lib/component-guidance.js +7 -2
- package/dist/lib/heading-row.js +3 -2
- package/dist/lib/trim-label.js +17 -4
- package/dist/presets/index.d.ts +7 -1
- package/dist/presets/index.js +10 -0
- package/dist/styles.css +27 -2
- package/llms/blocks/traffic-sources-block.md +1 -1
- package/llms/blocks/traffic-trend-block.md +5 -3
- package/llms/components/breadcrumbs.md +1 -1
- package/llms/components/button.md +1 -0
- package/llms/components/chat-composer.md +3 -2
- package/llms/components/command-menu.md +2 -2
- package/llms/components/data-table.md +33 -1
- package/llms/components/form.md +19 -2
- package/llms/components/message-scroller.md +1 -1
- package/llms/components/page-header.md +1 -1
- package/llms/components/pie-chart.md +2 -2
- package/llms/components/radial-chart.md +1 -1
- package/llms/components/settings-menu.md +1 -1
- package/llms/components/sidebar.md +1 -0
- package/llms/components/switch.md +2 -2
- package/llms/layout/region.md +16 -3
- package/llms/tasks/building-a-page.md +34 -3
- package/llms/tasks/creating-a-preset.md +4 -1
- package/llms/tasks/patching-a-component.md +48 -0
- package/llms/tasks/reviewing-a-design.md +9 -0
- package/llms/tasks/using-presets.md +19 -0
- package/llms-full.txt +207 -27
- package/llms.txt +1 -1
- package/package.json +4 -2
- package/registry.json +132 -30
- package/scripts/design-memory.mjs +7 -2
- package/scripts/design-presets.mjs +15 -4
- package/scripts/lint-rules.mjs +32 -1
- package/scripts/patches.mjs +219 -0
- package/scripts/sources.mjs +247 -0
- package/skills/sable/SKILL.md +16 -4
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sable patch — fix a Sable component in place while the library fix ships, and
|
|
3
|
+
* hear about it on every upgrade.
|
|
4
|
+
*
|
|
5
|
+
* A defect in a component left an app two choices: restyle it with className,
|
|
6
|
+
* which `component-restyle` rightly flags, or wait for a release. A patch is the
|
|
7
|
+
* third: the project's own package manager patches the installed module
|
|
8
|
+
* (`pnpm patch`, `yarn patch`, or patch-package for npm), and this records why,
|
|
9
|
+
* against which version, and how to tell whether it is still needed:
|
|
10
|
+
*
|
|
11
|
+
* .sable/patches.json { patches: [{ component, files, version, base, reason, repro, issue? }] }
|
|
12
|
+
*
|
|
13
|
+
* `base` holds the hash of each file as Sable shipped it at that version
|
|
14
|
+
* (dist/file-hashes.json), so `sable doctor` can tell three things on a later
|
|
15
|
+
* install: whether the patch is applied, whether Sable has changed the file
|
|
16
|
+
* since, and what the changelog says about the component in between. A patch
|
|
17
|
+
* whose file Sable changed fails doctor (and so `sable check`) until someone
|
|
18
|
+
* re-runs the repro and keeps the patch or drops it. Nothing changes silently,
|
|
19
|
+
* in either direction.
|
|
20
|
+
*/
|
|
21
|
+
import { createHash } from "node:crypto"
|
|
22
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
23
|
+
import { dirname, join, parse, relative, resolve } from "node:path"
|
|
24
|
+
import { fileURLToPath } from "node:url"
|
|
25
|
+
import { packageManager } from "./agent-setup.mjs"
|
|
26
|
+
import { parseFlags } from "./design-preferences.mjs"
|
|
27
|
+
|
|
28
|
+
export const PATCHES_FILE = join(".sable", "patches.json")
|
|
29
|
+
const PACKAGE = "@sableui/react"
|
|
30
|
+
|
|
31
|
+
const hash = (path) => createHash("sha256").update(readFileSync(path)).digest("hex").slice(0, 16)
|
|
32
|
+
|
|
33
|
+
/** Hashes of every dist file as published, written at build time. */
|
|
34
|
+
function shippedHashes(pkgRoot) {
|
|
35
|
+
const path = join(pkgRoot, "dist", "file-hashes.json")
|
|
36
|
+
return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function readPatches(root) {
|
|
40
|
+
const path = join(root, PATCHES_FILE)
|
|
41
|
+
if (!existsSync(path)) return []
|
|
42
|
+
const data = JSON.parse(readFileSync(path, "utf8"))
|
|
43
|
+
if (!Array.isArray(data?.patches)) throw Object.assign(new Error(`${PATCHES_FILE} has no patches array.`), { code: "ERR_PATCHES_INVALID" })
|
|
44
|
+
return data.patches
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function writePatches(root, patches) {
|
|
48
|
+
const path = join(root, PATCHES_FILE)
|
|
49
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
50
|
+
writeFileSync(path, `${JSON.stringify({ patches }, null, 2)}\n`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The dist modules a component's patch covers: its own folder's modules, not
|
|
55
|
+
* the barrel or the docs metadata. A part (CardList) resolves to its component.
|
|
56
|
+
*/
|
|
57
|
+
export function componentFiles(pkgRoot, name) {
|
|
58
|
+
const registry = JSON.parse(readFileSync(join(pkgRoot, "registry.json"), "utf8"))
|
|
59
|
+
const entries = [...(registry.components ?? []), ...(registry.layoutPrimitives?.primitives ?? [])]
|
|
60
|
+
const entry =
|
|
61
|
+
entries.find((c) => c.name === name) ??
|
|
62
|
+
entries.find((c) => (c.subcomponents ?? []).some((s) => s.name === name))
|
|
63
|
+
if (!entry?.path) return null
|
|
64
|
+
const dir = join(pkgRoot, "dist", entry.path)
|
|
65
|
+
if (!existsSync(dir)) return null
|
|
66
|
+
return {
|
|
67
|
+
component: entry.name,
|
|
68
|
+
files: readdirSync(dir)
|
|
69
|
+
.filter((f) => f.endsWith(".js") && f !== "index.js" && !f.endsWith(".meta.js"))
|
|
70
|
+
.map((f) => `dist/${entry.path}/${f}`),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Changelog bullets between two versions (exclusive, inclusive) that name the component. */
|
|
75
|
+
export function changelogMentions(pkgRoot, component, from, to) {
|
|
76
|
+
const path = join(pkgRoot, "CHANGELOG.md")
|
|
77
|
+
if (!existsSync(path)) return []
|
|
78
|
+
const text = readFileSync(path, "utf8")
|
|
79
|
+
const out = []
|
|
80
|
+
for (const section of text.split(/^## /m).slice(1)) {
|
|
81
|
+
const version = section.split(/\s/, 1)[0]
|
|
82
|
+
if (compareVersions(version, from) <= 0 || compareVersions(version, to) > 0) continue
|
|
83
|
+
for (const bullet of section.split(/\n(?=- )/)) {
|
|
84
|
+
if (!bullet.startsWith("- ")) continue
|
|
85
|
+
if (!new RegExp(`\\b${component}\\b`).test(bullet)) continue
|
|
86
|
+
const lead = /^- \*\*(.+?)\*\*/.exec(bullet)?.[1] ?? bullet.slice(2, 120)
|
|
87
|
+
out.push(`${version}: ${lead.replace(/\s+/g, " ").replace(/\.$/, "")}`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function compareVersions(a, b) {
|
|
94
|
+
const x = String(a).split(/[.-]/).map((n) => Number(n) || 0)
|
|
95
|
+
const y = String(b).split(/[.-]/).map((n) => Number(n) || 0)
|
|
96
|
+
for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) < (y[i] ?? 0) ? -1 : 1
|
|
97
|
+
return 0
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** How to make, commit and remove a patch with this project's package manager. */
|
|
101
|
+
export function patchSteps(pm, version, files) {
|
|
102
|
+
const list = files.join(", ")
|
|
103
|
+
if (pm === "pnpm")
|
|
104
|
+
return {
|
|
105
|
+
make: [`pnpm patch ${PACKAGE}@${version}`, `edit ${list} in the folder it prints`, "pnpm patch-commit <that folder>"],
|
|
106
|
+
remove: [`pnpm patch-remove ${PACKAGE}@${version}`],
|
|
107
|
+
}
|
|
108
|
+
if (pm === "yarn")
|
|
109
|
+
return {
|
|
110
|
+
make: [`yarn patch ${PACKAGE}`, `edit ${list} in the folder it prints`, "yarn patch-commit -s <that folder>", "(Yarn 1: use the npm steps with patch-package)"],
|
|
111
|
+
remove: [`remove the patch: entry for ${PACKAGE} from package.json resolutions, then yarn install`],
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
make: [`edit ${files.map((f) => `node_modules/${PACKAGE}/${f}`).join(", ")}`, `npx patch-package ${PACKAGE}`, 'add "postinstall": "patch-package" to package.json scripts'],
|
|
115
|
+
remove: [`delete patches/${PACKAGE.replace("/", "+")}+${version}.patch, then reinstall`],
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* One doctor check per recorded patch. `fail` when someone has to decide —
|
|
121
|
+
* Sable changed a patched file, or a patch is missing from the installed copy —
|
|
122
|
+
* so the agent is told on the upgrade itself, not when a user hits the bug.
|
|
123
|
+
*/
|
|
124
|
+
export function patchChecks(pkgRoot, root, installed) {
|
|
125
|
+
let patches
|
|
126
|
+
try {
|
|
127
|
+
patches = readPatches(root)
|
|
128
|
+
} catch (error) {
|
|
129
|
+
return [{ status: "fail", name: "patches", detail: error.message, fix: `Fix or delete ${PATCHES_FILE}.` }]
|
|
130
|
+
}
|
|
131
|
+
if (!patches.length) return []
|
|
132
|
+
const shipped = shippedHashes(pkgRoot)
|
|
133
|
+
if (!shipped) return [{ status: "warn", name: "patches", detail: `${patches.length} recorded, but this Sable build ships no file hashes to check them against.` }]
|
|
134
|
+
const pm = packageManager(parse(root).root, root)
|
|
135
|
+
return patches.map((p) => {
|
|
136
|
+
const name = `patch:${p.component}`
|
|
137
|
+
const why = `"${p.reason}"`
|
|
138
|
+
const missing = p.files.filter((f) => !shipped[f])
|
|
139
|
+
if (missing.length)
|
|
140
|
+
return { status: "fail", name, detail: `${p.version} → ${installed}: Sable no longer ships ${missing.join(", ")} (${why}). The component was restructured.`, fix: `Re-run the repro without the patch: ${p.repro}. Fixed → sable patch ${p.component} --drop. Still broken → patch the new files, then sable patch ${p.component} --keep.` }
|
|
141
|
+
const applied = p.files.some((f) => existsSync(join(pkgRoot, f)) && hash(join(pkgRoot, f)) !== shipped[f])
|
|
142
|
+
const changed = p.files.filter((f) => p.base?.[f] !== shipped[f])
|
|
143
|
+
const steps = patchSteps(pm, installed, p.files)
|
|
144
|
+
if (p.version === installed) {
|
|
145
|
+
if (applied) return { status: "pass", name, detail: `patched for ${why}${p.issue ? ` (${p.issue})` : ""}.` }
|
|
146
|
+
return { status: "warn", name, detail: `recorded for ${why} but not applied: the installed files are Sable's own.`, fix: steps.make.join(" → ") }
|
|
147
|
+
}
|
|
148
|
+
if (!changed.length && applied) return { status: "pass", name, detail: `patched for ${why}; Sable has not changed these files since ${p.version}.` }
|
|
149
|
+
const notes = changelogMentions(pkgRoot, p.component, p.version, installed)
|
|
150
|
+
const news = notes.length ? ` Changelog: ${notes.join(" | ")}.` : ""
|
|
151
|
+
if (!changed.length)
|
|
152
|
+
return { status: "fail", name, detail: `${p.version} → ${installed}: Sable did not change ${p.component}, and your patch (${why}) is not applied to ${installed}.`, fix: `Re-apply it: ${steps.make.join(" → ")}. Then sable patch ${p.component} --keep.` }
|
|
153
|
+
return {
|
|
154
|
+
status: "fail",
|
|
155
|
+
name,
|
|
156
|
+
detail: `${p.version} → ${installed}: Sable changed ${changed.join(", ")} since your patch (${why}).${news}`,
|
|
157
|
+
fix: `Re-run the repro on Sable's own files${applied ? " (remove the patch first)" : ""}: ${p.repro}. Fixed → sable patch ${p.component} --drop. Still broken → re-patch (${steps.make.join(" → ")}), then sable patch ${p.component} --keep, and report it.`,
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/* ── CLI ────────────────────────────────────────────────────────────────────── */
|
|
163
|
+
|
|
164
|
+
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
165
|
+
if (isMain) {
|
|
166
|
+
const args = process.argv.slice(2)
|
|
167
|
+
const json = args.includes("--json")
|
|
168
|
+
const emit = (type, data, text) => process.stdout.write(json ? `${JSON.stringify({ type, data })}\n` : `${text}\n`)
|
|
169
|
+
try {
|
|
170
|
+
const pkgRoot = fileURLToPath(new URL("../", import.meta.url))
|
|
171
|
+
const root = process.cwd()
|
|
172
|
+
const installed = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8")).version
|
|
173
|
+
const name = args[0] && !args[0].startsWith("--") ? args[0] : null
|
|
174
|
+
const flags = parseFlags(name ? args.slice(1) : args, ["--reason", "--repro", "--issue", "--file"], ["--list", "--keep", "--drop", "--json"])
|
|
175
|
+
const patches = readPatches(root)
|
|
176
|
+
const pm = packageManager(parse(root).root, root)
|
|
177
|
+
if (flags.has("--list") || !name) {
|
|
178
|
+
const checks = patchChecks(pkgRoot, root, installed)
|
|
179
|
+
emit("patch.list", { patches, checks }, checks.length ? checks.map((c) => `${c.status === "pass" ? "✓" : c.status === "warn" ? "!" : "✗"} ${c.name} ${c.detail}${c.fix ? `\n → ${c.fix}` : ""}`).join("\n") : "No Sable patches recorded.")
|
|
180
|
+
} else if (flags.has("--drop")) {
|
|
181
|
+
const p = patches.find((x) => x.component === name)
|
|
182
|
+
if (!p) throw Object.assign(new Error(`No recorded patch for ${name}.`), { code: "ERR_NO_PATCH" })
|
|
183
|
+
writePatches(root, patches.filter((x) => x !== p))
|
|
184
|
+
const steps = patchSteps(pm, p.version, p.files)
|
|
185
|
+
emit("patch.dropped", { component: name, remove: steps.remove }, `Dropped the ${name} patch record. Remove the patch itself too: ${steps.remove.join(" → ")}`)
|
|
186
|
+
} else if (flags.has("--keep")) {
|
|
187
|
+
const p = patches.find((x) => x.component === name)
|
|
188
|
+
if (!p) throw Object.assign(new Error(`No recorded patch for ${name}.`), { code: "ERR_NO_PATCH" })
|
|
189
|
+
const shipped = shippedHashes(pkgRoot) ?? {}
|
|
190
|
+
Object.assign(p, { version: installed, base: Object.fromEntries(p.files.map((f) => [f, shipped[f]])) })
|
|
191
|
+
writePatches(root, patches)
|
|
192
|
+
emit("patch.kept", p, `Kept the ${name} patch against ${installed}. Report it upstream if you haven't, so the library fix can replace it.`)
|
|
193
|
+
} else {
|
|
194
|
+
const reason = flags.get("--reason")
|
|
195
|
+
const repro = flags.get("--repro")
|
|
196
|
+
if (!reason || !repro)
|
|
197
|
+
throw Object.assign(new Error("A patch records why and how to check it: pass --reason \"<what is wrong>\" and --repro \"<how to see it>\"."), { code: "ERR_BAD_ARGUMENT" })
|
|
198
|
+
if (patches.some((x) => x.component === name)) throw Object.assign(new Error(`${name} already has a patch record. Use --keep or --drop.`), { code: "ERR_PATCH_EXISTS" })
|
|
199
|
+
const found = componentFiles(pkgRoot, name)
|
|
200
|
+
const extra = flags.get("--file")
|
|
201
|
+
if (!found && !extra) throw Object.assign(new Error(`No Sable component named ${name}. For a shared module, pass --file dist/lib/<module>.js.`), { code: "ERR_UNKNOWN_COMPONENT" })
|
|
202
|
+
const shipped = shippedHashes(pkgRoot) ?? {}
|
|
203
|
+
const files = [...(found?.files ?? []), ...(extra ? [extra.replace(/\\/g, "/")] : [])]
|
|
204
|
+
const unknown = files.filter((f) => !shipped[f])
|
|
205
|
+
if (unknown.length) throw Object.assign(new Error(`Not a published Sable file: ${unknown.join(", ")}.`), { code: "ERR_UNKNOWN_FILE" })
|
|
206
|
+
const record = { component: found?.component ?? name, files, version: installed, base: Object.fromEntries(files.map((f) => [f, shipped[f]])), reason, repro, ...(flags.get("--issue") ? { issue: flags.get("--issue") } : {}) }
|
|
207
|
+
writePatches(root, [...patches, record])
|
|
208
|
+
const steps = patchSteps(pm, installed, files)
|
|
209
|
+
emit(
|
|
210
|
+
"patch.recorded",
|
|
211
|
+
{ ...record, steps: steps.make },
|
|
212
|
+
`Recorded a patch for ${record.component} against ${installed} in ${relative(root, join(root, PATCHES_FILE))}.\nNow make it with ${pm}:\n${steps.make.map((s) => ` - ${s}`).join("\n")}\nOn every upgrade, sable doctor checks it: it fails when Sable changes these files, until the repro says keep (--keep) or drop (--drop).\nReport the defect upstream so the library fix can replace the patch.`,
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
} catch (error) {
|
|
216
|
+
process.stderr.write(`${JSON.stringify({ type: "error", code: error.code ?? "ERR_PATCH", message: error.message, suggestions: ["sable patch --list", "sable docs patching-a-component"] })}\n`)
|
|
217
|
+
process.exitCode = 1
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sable sources — point Tailwind at the Sable modules an app actually imports.
|
|
3
|
+
*
|
|
4
|
+
* The install recipe's `@source` covers the whole published `dist`, so Tailwind
|
|
5
|
+
* generates every component's classes whether the app renders it or not:
|
|
6
|
+
* Sable's own stylesheet is about 6 KB gzipped, and the whole-dist scan takes an
|
|
7
|
+
* app to about 32 KB. A support app measured that, a third more CSS than the
|
|
8
|
+
* same app on shadcn.
|
|
9
|
+
*
|
|
10
|
+
* This follows the app's imports from `@sableui/react` (and its subpaths)
|
|
11
|
+
* through the published module graph — each name to the module that defines it,
|
|
12
|
+
* then every module that one imports, static and lazy — and writes one
|
|
13
|
+
* `@source` per module. The app's own files are Tailwind's to find as before.
|
|
14
|
+
*
|
|
15
|
+
* The cost is that the list goes stale when the app imports something new, and
|
|
16
|
+
* a component missing from it renders unstyled without an error. So the file
|
|
17
|
+
* says where it came from, and `sable doctor` (and `sable check`) recompute it
|
|
18
|
+
* and fail when a module the app needs is not covered.
|
|
19
|
+
*/
|
|
20
|
+
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
|
|
21
|
+
import { dirname, join, relative, resolve } from "node:path"
|
|
22
|
+
import { fileURLToPath } from "node:url"
|
|
23
|
+
|
|
24
|
+
export const SOURCES_MARKER = "Generated by sable sources"
|
|
25
|
+
const SKIP = new Set(["node_modules", "dist", "build", "out", ".next", ".git", "coverage", ".turbo"])
|
|
26
|
+
|
|
27
|
+
/** `@sableui/react[/sub]` specifier → the published module, from the package's exports map. */
|
|
28
|
+
function entryFor(pkgRoot, specifier) {
|
|
29
|
+
const pkg = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8"))
|
|
30
|
+
const exports = pkg.publishConfig?.exports ?? pkg.exports ?? {}
|
|
31
|
+
const key = specifier === "@sableui/react" ? "." : `.${specifier.slice("@sableui/react".length)}`
|
|
32
|
+
const target = exports[key]
|
|
33
|
+
const path = typeof target === "string" ? target : target?.default ?? target?.import
|
|
34
|
+
if (!path || !path.endsWith(".js")) return null
|
|
35
|
+
const file = join(pkgRoot, path)
|
|
36
|
+
return existsSync(file) ? file : null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const moduleCache = new Map()
|
|
40
|
+
/** A module's imports and exports, read lexically from the bundler's plain ESM output. */
|
|
41
|
+
function parseModule(file) {
|
|
42
|
+
if (moduleCache.has(file)) return moduleCache.get(file)
|
|
43
|
+
const text = readFileSync(file, "utf8")
|
|
44
|
+
const rel = (spec) => resolve(dirname(file), spec)
|
|
45
|
+
const names = (list) =>
|
|
46
|
+
list
|
|
47
|
+
.split(",")
|
|
48
|
+
.map((s) => s.trim())
|
|
49
|
+
.filter(Boolean)
|
|
50
|
+
.map((s) => {
|
|
51
|
+
const [from, as] = s.split(/\s+as\s+/)
|
|
52
|
+
return { from: from.trim(), as: (as ?? from).trim() }
|
|
53
|
+
})
|
|
54
|
+
const imports = new Map() // local → { file, name }
|
|
55
|
+
for (const m of text.matchAll(/import\s*\{([^}]*)\}\s*from\s*"(\.[^"]+)"/g))
|
|
56
|
+
for (const n of names(m[1])) imports.set(n.as, { file: rel(m[2]), name: n.from })
|
|
57
|
+
const reexports = new Map() // exported → { file, name }
|
|
58
|
+
const local = new Set()
|
|
59
|
+
const stars = []
|
|
60
|
+
for (const m of text.matchAll(/export\s*\{([^}]*)\}\s*from\s*"(\.[^"]+)"/g))
|
|
61
|
+
for (const n of names(m[1])) reexports.set(n.as, { file: rel(m[2]), name: n.from })
|
|
62
|
+
for (const m of text.matchAll(/export\s*\*\s*from\s*"(\.[^"]+)"/g)) stars.push(rel(m[1]))
|
|
63
|
+
for (const m of text.matchAll(/export\s*\{([^}]*)\}\s*(?:;|\n|$)/g))
|
|
64
|
+
for (const n of names(m[1])) {
|
|
65
|
+
if (imports.has(n.from)) reexports.set(n.as, imports.get(n.from))
|
|
66
|
+
else local.add(n.as)
|
|
67
|
+
}
|
|
68
|
+
for (const m of text.matchAll(/export\s+(?:async\s+)?(?:function\*?|const|let|var|class)\s+([\w$]+)/g)) local.add(m[1])
|
|
69
|
+
const deps = [
|
|
70
|
+
...[...text.matchAll(/(?:import|export)\s[^"']*?from\s*"(\.[^"]+)"/g)].map((m) => rel(m[1])),
|
|
71
|
+
...[...text.matchAll(/import\s*"(\.[^"]+)"/g)].map((m) => rel(m[1])),
|
|
72
|
+
...[...text.matchAll(/import\(\s*"(\.[^"]+)"\s*\)/g)].map((m) => rel(m[1])),
|
|
73
|
+
]
|
|
74
|
+
const parsed = { local, reexports, stars, deps }
|
|
75
|
+
moduleCache.set(file, parsed)
|
|
76
|
+
return parsed
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The module that defines `name`, following re-exports and `export *`. */
|
|
80
|
+
function definingModule(file, name, seen = new Set()) {
|
|
81
|
+
if (seen.has(file) || !existsSync(file)) return null
|
|
82
|
+
seen.add(file)
|
|
83
|
+
const m = parseModule(file)
|
|
84
|
+
if (m.local.has(name)) return file
|
|
85
|
+
const re = m.reexports.get(name)
|
|
86
|
+
if (re) return definingModule(re.file, re.name, seen)
|
|
87
|
+
for (const star of m.stars) {
|
|
88
|
+
const hit = definingModule(star, name, seen)
|
|
89
|
+
if (hit) return hit
|
|
90
|
+
}
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Every module reachable from `roots` by relative import. */
|
|
95
|
+
function closure(roots) {
|
|
96
|
+
const out = new Set()
|
|
97
|
+
const stack = [...roots]
|
|
98
|
+
while (stack.length) {
|
|
99
|
+
const file = stack.pop()
|
|
100
|
+
if (out.has(file) || !existsSync(file)) continue
|
|
101
|
+
out.add(file)
|
|
102
|
+
stack.push(...parseModule(file).deps)
|
|
103
|
+
}
|
|
104
|
+
return out
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The app's source files, outside dependencies and build output. */
|
|
108
|
+
export function appSources(root) {
|
|
109
|
+
const out = []
|
|
110
|
+
const walk = (dir) => {
|
|
111
|
+
let entries
|
|
112
|
+
try {
|
|
113
|
+
entries = readdirSync(dir)
|
|
114
|
+
} catch {
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
for (const name of entries) {
|
|
118
|
+
if (SKIP.has(name) || name.startsWith(".")) continue
|
|
119
|
+
const full = join(dir, name)
|
|
120
|
+
const st = statSync(full)
|
|
121
|
+
if (st.isDirectory()) walk(full)
|
|
122
|
+
else if (/\.(?:[cm]?[jt]sx?)$/.test(name) && !/\.d\.ts$/.test(name)) out.push(full)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
walk(root)
|
|
126
|
+
return out
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The published modules an app's imports reach. `unresolved` lists what could
|
|
131
|
+
* not be traced (a namespace import, a name the package does not export); any
|
|
132
|
+
* of those means the whole package is needed.
|
|
133
|
+
*/
|
|
134
|
+
export function sableModules(pkgRoot, files) {
|
|
135
|
+
const roots = new Set()
|
|
136
|
+
const unresolved = []
|
|
137
|
+
const spec = /(?:import|export)\s+(type\s+)?([^;"'`]*?)\s*from\s*["'](@sableui\/react(?:\/[\w./-]+)?)["']|import\(\s*["'](@sableui\/react(?:\/[\w./-]+)?)["']\s*\)/g
|
|
138
|
+
for (const path of files) {
|
|
139
|
+
const text = readFileSync(path, "utf8")
|
|
140
|
+
for (const m of text.matchAll(spec)) {
|
|
141
|
+
if (m[1]) continue // `import type` brings no runtime module
|
|
142
|
+
const specifier = m[3] ?? m[4]
|
|
143
|
+
if (/\.css$/.test(specifier)) continue
|
|
144
|
+
const entry = entryFor(pkgRoot, specifier)
|
|
145
|
+
if (!entry) {
|
|
146
|
+
unresolved.push(specifier)
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
const clause = m[2] ?? ""
|
|
150
|
+
const braces = /\{([^}]*)\}/.exec(clause)?.[1]
|
|
151
|
+
// A namespace import, a default import, `export *` or a dynamic import
|
|
152
|
+
// can reach anything the entry exports.
|
|
153
|
+
if (m[4] || /\*/.test(clause) || !braces || /^\s*[\w$]+\s*(?:,|$)/.test(clause)) {
|
|
154
|
+
roots.add(entry)
|
|
155
|
+
if (!braces) continue
|
|
156
|
+
}
|
|
157
|
+
for (const part of braces.split(",")) {
|
|
158
|
+
const name = part.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim()
|
|
159
|
+
if (!name || /^type\s/.test(part.trim())) continue
|
|
160
|
+
const file = definingModule(entry, name)
|
|
161
|
+
if (file) roots.add(file)
|
|
162
|
+
else unresolved.push(`${name} from ${specifier}`)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// A component's .meta.js is its docs: class names there are example strings,
|
|
167
|
+
// never rendered, and a barrel import would otherwise ship them all.
|
|
168
|
+
return { modules: [...closure(roots)].filter((f) => !f.endsWith(".meta.js")).sort(), unresolved }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Where the app reaches the package: its own node_modules link when there is one. */
|
|
172
|
+
function packageBase(pkgRoot, root) {
|
|
173
|
+
const linked = join(root, "node_modules", "@sableui", "react")
|
|
174
|
+
return existsSync(join(linked, "package.json")) ? linked : pkgRoot
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function sourcesCss(pkgRoot, root, outFile) {
|
|
178
|
+
const { modules, unresolved } = sableModules(pkgRoot, appSources(root))
|
|
179
|
+
const base = packageBase(pkgRoot, root)
|
|
180
|
+
const from = dirname(resolve(root, outFile))
|
|
181
|
+
const line = (file) => `@source "${relative(from, join(base, relative(pkgRoot, file))).split("\\").join("/")}";`
|
|
182
|
+
const body = unresolved.length ? [line(join(pkgRoot, "dist")).replace(/";$/, '/**/*.{js,mjs}";')] : modules.map(line)
|
|
183
|
+
const css = [
|
|
184
|
+
`/* ${SOURCES_MARKER}: the @sableui/react modules this app imports, so Tailwind`,
|
|
185
|
+
" generates their classes and no others. Re-run `sable sources --write` after",
|
|
186
|
+
" importing something new from Sable; `sable check` fails while it is stale. */",
|
|
187
|
+
...body,
|
|
188
|
+
"",
|
|
189
|
+
].join("\n")
|
|
190
|
+
return { css, modules, unresolved }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Doctor: a sources file that is missing a module the app now imports renders
|
|
195
|
+
* that component unstyled, silently — so it fails. A whole-package @source is
|
|
196
|
+
* correct, just heavy, so it only gets a note.
|
|
197
|
+
*/
|
|
198
|
+
export function sourcesCheck(pkgRoot, root, cssFiles) {
|
|
199
|
+
const generated = cssFiles.find((f) => f.text.includes(SOURCES_MARKER))
|
|
200
|
+
if (!generated) {
|
|
201
|
+
const whole = cssFiles.some((f) => /@source[^;]*(?:@sableui\/react|packages\/sable)\/dist(?:\/\*\*)?[^;]*;/.test(f.text))
|
|
202
|
+
return whole ? [{ status: "info", name: "css:size", detail: "@source scans all of Sable's dist, so every component's classes ship (about 32 KB gzipped).", fix: "sable sources --write src/sable-sources.css, then import it in place of that @source." }] : []
|
|
203
|
+
}
|
|
204
|
+
const rel = relative(root, generated.path)
|
|
205
|
+
const { modules, unresolved } = sableModules(pkgRoot, appSources(root))
|
|
206
|
+
if (unresolved.length && !/\/dist\/\*\*\//.test(generated.text))
|
|
207
|
+
return [{ status: "fail", name: "css:sources", detail: `${rel} lists modules, but these imports could not be traced: ${unresolved.slice(0, 4).join(", ")}.`, fix: `sable sources --write ${rel}` }]
|
|
208
|
+
const base = packageBase(pkgRoot, root)
|
|
209
|
+
const covered = new Set(
|
|
210
|
+
[...generated.text.matchAll(/@source\s+"([^"]+)"/g)].map((m) => resolve(dirname(generated.path), m[1])),
|
|
211
|
+
)
|
|
212
|
+
const wholeDist = [...covered].some((p) => /[\\/]dist[\\/]\*\*/.test(p))
|
|
213
|
+
if (wholeDist) return [{ status: "pass", name: "css:sources", detail: `${rel} covers all of Sable.` }]
|
|
214
|
+
const missing = modules.filter((file) => !covered.has(join(base, relative(pkgRoot, file))))
|
|
215
|
+
if (missing.length)
|
|
216
|
+
return [{ status: "fail", name: "css:sources", detail: `${rel} is stale: ${missing.length} module(s) the app now imports are not in it (${missing.slice(0, 3).map((f) => relative(join(pkgRoot, "dist"), f).split("\\").join("/")).join(", ")}${missing.length > 3 ? ", …" : ""}). Their components render unstyled.`, fix: `sable sources --write ${rel}` }]
|
|
217
|
+
return [{ status: "pass", name: "css:sources", detail: `${rel} covers the ${modules.length} Sable modules the app imports.` }]
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/* ── CLI ────────────────────────────────────────────────────────────────────── */
|
|
221
|
+
|
|
222
|
+
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
223
|
+
if (isMain) {
|
|
224
|
+
const args = process.argv.slice(2)
|
|
225
|
+
const json = args.includes("--json")
|
|
226
|
+
try {
|
|
227
|
+
const pkgRoot = fileURLToPath(new URL("../", import.meta.url))
|
|
228
|
+
const root = process.cwd()
|
|
229
|
+
const at = args.indexOf("--write")
|
|
230
|
+
const out = at === -1 ? null : args[at + 1]
|
|
231
|
+
if (at !== -1 && (!out || out.startsWith("--"))) throw Object.assign(new Error("--write needs a stylesheet path, e.g. src/sable-sources.css."), { code: "ERR_BAD_ARGUMENT" })
|
|
232
|
+
const unknown = args.filter((a, i) => a.startsWith("--") && !["--write", "--json"].includes(a) && args[i - 1] !== "--write")
|
|
233
|
+
if (unknown.length) throw Object.assign(new Error(`Unknown flag: ${unknown[0]}`), { code: "ERR_BAD_FLAG" })
|
|
234
|
+
const target = out ?? "src/sable-sources.css"
|
|
235
|
+
const result = sourcesCss(pkgRoot, root, target)
|
|
236
|
+
if (out) writeFileSync(resolve(root, out), result.css)
|
|
237
|
+
const note = result.unresolved.length
|
|
238
|
+
? `Could not trace ${result.unresolved.slice(0, 4).join(", ")}, so it covers all of Sable.`
|
|
239
|
+
: `${result.modules.length} modules.`
|
|
240
|
+
if (json) process.stdout.write(`${JSON.stringify({ type: "sources", data: { file: out, modules: result.modules.length, unresolved: result.unresolved, css: result.css } })}\n`)
|
|
241
|
+
else if (out) process.stdout.write(`Wrote ${out}: ${note}\nImport it in your stylesheet in place of the whole-package @source:\n @import "./${relative(dirname(resolve(root, "src/app.css")), resolve(root, out)).split("\\").join("/")}";\n`)
|
|
242
|
+
else process.stdout.write(result.css)
|
|
243
|
+
} catch (error) {
|
|
244
|
+
process.stderr.write(`${JSON.stringify({ type: "error", code: error.code ?? "ERR_SOURCES", message: error.message, suggestions: ["sable sources --write src/sable-sources.css"] })}\n`)
|
|
245
|
+
process.exitCode = 1
|
|
246
|
+
}
|
|
247
|
+
}
|
package/skills/sable/SKILL.md
CHANGED
|
@@ -157,12 +157,21 @@ The installed catalog and guides are authoritative for API and token names.
|
|
|
157
157
|
## Develop or use a preset
|
|
158
158
|
|
|
159
159
|
Save a short brief in the project's existing design location: audience, primary
|
|
160
|
-
task, content, constraints, selected identity, and meaningful states
|
|
161
|
-
|
|
160
|
+
task, content, constraints, selected identity, and meaningful states, plus each
|
|
161
|
+
route's width (a Region `max`, or fluid) and why, chosen by what the person does
|
|
162
|
+
there (`sable docs building-a-page`). Label invented sample content. A supplied preset is an explicit input: read its exact record and
|
|
162
163
|
revision, validate it through `sable preset --check <path> --json`, and read
|
|
163
164
|
`sable docs using-presets`. Preserve its tokens and guidance unless the user requests
|
|
164
165
|
a change or the new task requires a documented adaptation.
|
|
165
166
|
|
|
167
|
+
A preset pasted or supplied for a project that already has an app (for example
|
|
168
|
+
from Canvas's “Copy for agent”) is a request to restyle that app, so it is
|
|
169
|
+
`polish`: save and check it, then apply it. Generate its CSS, load its fonts,
|
|
170
|
+
make it SableThemeProvider's default theme and mode in place of the old one, run
|
|
171
|
+
the build and the finish checklist, and look at it rendered. Don't stop at a saved
|
|
172
|
+
file plus a list of wiring steps for the user. Stop after saving only when the
|
|
173
|
+
user says so or the project has no app yet.
|
|
174
|
+
|
|
166
175
|
Without a preset, develop an appropriate direction from the brief. Use the bundled
|
|
167
176
|
presets as starting points or create original app-owned tokens. When the project has
|
|
168
177
|
its own visual atlas, retrieve references with `sable atlas --search "<problem>"
|
|
@@ -218,6 +227,9 @@ Every build ends with the finish checklist at the top of this skill. Notes on it
|
|
|
218
227
|
the smallest necessary addition, and include the inventory with the build's
|
|
219
228
|
evidence so conformance is reviewable.
|
|
220
229
|
|
|
221
|
-
Iterate on observed defects and recapture changed states.
|
|
222
|
-
|
|
230
|
+
Iterate on observed defects and recapture changed states. A defect inside a Sable
|
|
231
|
+
component is patched, not restyled: `sable patch <Component> --reason … --repro …`,
|
|
232
|
+
then the package manager's patch (`sable docs patching-a-component`). When `sable
|
|
233
|
+
doctor` fails on a patch after an upgrade, re-run its repro on Sable's own files
|
|
234
|
+
and keep or drop it. Contract checks cannot judge beauty. Deliver the result, the preset/revision used, the checks performed,
|
|
223
235
|
and material limitations.
|