@nanisoft/prism-ui 0.7.0 → 0.9.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 (47) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/catalog.d.ts.map +1 -1
  3. package/dist/catalog.js +30 -0
  4. package/dist/components/index.d.ts +6 -0
  5. package/dist/components/index.d.ts.map +1 -1
  6. package/dist/components/index.js +3 -0
  7. package/dist/components/ui/breadcrumb.d.ts +6 -2
  8. package/dist/components/ui/breadcrumb.d.ts.map +1 -1
  9. package/dist/components/ui/breadcrumb.js +5 -3
  10. package/dist/components/ui/dialog.d.ts +12 -1
  11. package/dist/components/ui/dialog.d.ts.map +1 -1
  12. package/dist/components/ui/dialog.js +2 -2
  13. package/dist/components/ui/live-region.d.ts +78 -0
  14. package/dist/components/ui/live-region.d.ts.map +1 -0
  15. package/dist/components/ui/live-region.js +48 -0
  16. package/dist/components/ui/mark.d.ts +44 -0
  17. package/dist/components/ui/mark.d.ts.map +1 -0
  18. package/dist/components/ui/mark.js +79 -0
  19. package/dist/components/ui/pagination.d.ts +24 -6
  20. package/dist/components/ui/pagination.d.ts.map +1 -1
  21. package/dist/components/ui/pagination.js +23 -9
  22. package/dist/components/ui/product-switcher.d.ts +7 -0
  23. package/dist/components/ui/product-switcher.d.ts.map +1 -1
  24. package/dist/components/ui/product-switcher.js +2 -2
  25. package/dist/components/ui/slider.d.ts +1 -1
  26. package/dist/components/ui/slider.d.ts.map +1 -1
  27. package/dist/components/ui/tree.d.ts +115 -0
  28. package/dist/components/ui/tree.d.ts.map +1 -0
  29. package/dist/components/ui/tree.js +137 -0
  30. package/dist/fonts/Inter-OFL.txt +93 -0
  31. package/dist/fonts/inter-latin-400.woff2 +0 -0
  32. package/dist/fonts/inter-latin-500.woff2 +0 -0
  33. package/dist/fonts/inter-latin-600.woff2 +0 -0
  34. package/dist/styles.css +52 -0
  35. package/gates/README.md +98 -0
  36. package/gates/cli.mjs +133 -0
  37. package/gates/hidden-state.mjs +243 -0
  38. package/gates/index.mjs +81 -0
  39. package/gates/laws.mjs +170 -0
  40. package/gates/links.mjs +187 -0
  41. package/gates/pack-boundary.mjs +421 -0
  42. package/gates/pin.mjs +130 -0
  43. package/gates/retired-line.mjs +314 -0
  44. package/gates/run.mjs +320 -0
  45. package/gates/runtime-token-read.mjs +129 -0
  46. package/gates/stylesheet-ownership.mjs +207 -0
  47. package/package.json +16 -3
package/gates/pin.mjs ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The pin law: the design system is a version, and the token package is not
3
+ * this repository's dependency.
4
+ *
5
+ * The contract these four repositories coordinated through had a version line,
6
+ * and it drifted. One site spent a release on a different component line from
7
+ * its three siblings and nothing failed, because the gate that read the pin only
8
+ * asked whether the pin was exact, which it was. Exactness was necessary and not
9
+ * sufficient, and a rule that checks the easy half and calls itself the rule is
10
+ * the failure this gate exists to end.
11
+ *
12
+ * So three things are asserted, and the third is the one this law is actually
13
+ * about:
14
+ *
15
+ * 1. the component package is pinned to an exact version;
16
+ * 2. the token package is not declared in any dependency block;
17
+ * 3. the token package's name appears nowhere in the workspace configuration.
18
+ *
19
+ * (2) and (3) are the same fact held twice in the four repositories, in a
20
+ * manifest and in a `minimumReleaseAgeExclude` list, and both copies were
21
+ * invisible to each other. The component package declares the token package at
22
+ * an exact version, so the consumer cannot be handed a mismatched pair and a
23
+ * second declaration of that number is a second fact to keep in step with a
24
+ * release. The token version is the component package's business. It reaches
25
+ * this repository because the component package requires it, not because this
26
+ * repository named it.
27
+ *
28
+ * Coverage is asserted: both files must resolve and the design system must
29
+ * actually load, so a run that read a manifest with nothing in it fails rather
30
+ * than reports a repository with no pin.
31
+ */
32
+ import { law } from './laws.mjs'
33
+ import { CoverageError, PACKAGE, TOKEN_PACKAGE, designSystem, finding, floor, read, requireRoot } from './run.mjs'
34
+
35
+ /** The blocks a manifest can declare a dependency in. `overrides` is where a line gets pinned back. */
36
+ const DEPENDENCY_BLOCKS = [
37
+ 'dependencies',
38
+ 'devDependencies',
39
+ 'peerDependencies',
40
+ 'optionalDependencies',
41
+ 'overrides',
42
+ 'resolutions',
43
+ 'pnpm.overrides',
44
+ 'pnpm.resolutions',
45
+ ]
46
+
47
+ const at = (object, dotted) =>
48
+ dotted.split('.').reduce((value, key) => (value == null ? undefined : value[key]), object)
49
+
50
+ /** An exact version, and nothing else. A range is not a version anybody chose. */
51
+ const EXACT = /^\d+\.\d+\.\d+$/
52
+
53
+ export function run({ root, config }) {
54
+ const l = law('pin')
55
+ const manifestPath = config.manifest ?? 'package.json'
56
+ const workspacePath = config.workspace ?? 'pnpm-workspace.yaml'
57
+ const findings = []
58
+ const notes = []
59
+
60
+ requireRoot(root, manifestPath)
61
+ const manifest = JSON.parse(read(root, manifestPath))
62
+
63
+ const pin = manifest.dependencies?.[PACKAGE] ?? manifest.devDependencies?.[PACKAGE]
64
+ if (typeof pin !== 'string' || !EXACT.test(pin)) {
65
+ findings.push(
66
+ finding(
67
+ manifestPath,
68
+ 'range-pin',
69
+ `${PACKAGE} is pinned as ${JSON.stringify(pin ?? null)}, and a range lets this repository move onto a\n` +
70
+ ' design-system line nobody chose for it.',
71
+ ),
72
+ )
73
+ }
74
+
75
+ for (const block of DEPENDENCY_BLOCKS) {
76
+ const declared = at(manifest, block)
77
+ if (!declared || typeof declared !== 'object') continue
78
+ for (const name of Object.keys(declared)) {
79
+ if (name !== TOKEN_PACKAGE) continue
80
+ findings.push(
81
+ finding(
82
+ manifestPath,
83
+ 'second-declaration',
84
+ `\`${block}\` declares ${TOKEN_PACKAGE}, and ${PACKAGE} declares it at an exact version itself.\n` +
85
+ ` Two repositories holding one number is two facts to keep in step, and this is the one that\n` +
86
+ ` already drifted once: the token package's version is the component package's decision.`,
87
+ ),
88
+ )
89
+ }
90
+ }
91
+
92
+ const workspace = read(root, workspacePath)
93
+ if (workspace === null) {
94
+ throw new CoverageError(
95
+ `${workspacePath} does not resolve, so the second copy of the token version cannot be read at\n` +
96
+ ' all. A workspace configuration that does not exist is the cleanest version of this defect.',
97
+ )
98
+ }
99
+ workspace.split('\n').forEach((line, index) => {
100
+ if (!line.includes(TOKEN_PACKAGE)) return
101
+ findings.push(
102
+ finding(
103
+ `${workspacePath}:${index + 1}`,
104
+ 'second-declaration',
105
+ 'the token package\'s name is in the workspace configuration. A release-age exclusion is a\n' +
106
+ ' second declaration of the version the component package already declares, and it is the\n' +
107
+ ' copy nothing else can see.',
108
+ ),
109
+ )
110
+ })
111
+
112
+ // Coverage: the installed design system has to load, because a pin nobody can
113
+ // resolve is a pin that states an intention.
114
+ const installed = designSystem(root)
115
+ floor('the design system resolved to a version', installed.version ? 1 : 0, 1)
116
+
117
+ notes.push(
118
+ `${l.id}: ${PACKAGE}@${pin ?? 'unpinned'} is pinned exactly, and ${TOKEN_PACKAGE} is declared by` +
119
+ ` ${PACKAGE}@${installed.version} rather than by this repository.`,
120
+ )
121
+ notes.push(
122
+ `${l.id}: read ${manifestPath} and ${workspacePath}; ${DEPENDENCY_BLOCKS.length} dependency blocks and both`,
123
+ )
124
+ notes.push(
125
+ `${l.id}: the installed package is read through its own export map, so a renamed subpath is an error`,
126
+ )
127
+ notes.push(`${l.id}: here rather than a run that reports every property in this repository as undeclared.`)
128
+
129
+ return { law: l, findings, notes }
130
+ }
@@ -0,0 +1,314 @@
1
+ /**
2
+ * The retired-line law: no trace of the old component library survives.
3
+ *
4
+ * Four repositories shipped the retired line together, each discovered it had to
5
+ * come off separately, and the rule that governed the removal lived in prose in
6
+ * four files. A rule in a document decays; this one already had. One repository's
7
+ * own instructions said never import the library directly while three files did,
8
+ * the build baked 126 KB of generated variables from it on every run, and that
9
+ * generated file was the only definition site for every custom property the
10
+ * repository's own stylesheet read.
11
+ *
12
+ * **The lockfile is read as a graph and never grepped.** Two failures make that
13
+ * non-negotiable. A base64 integrity hash contains the characters a
14
+ * package-specifier pattern admits, so a grep reports hits that are not packages.
15
+ * And deleting a dependency line does not empty a lockfile while another package
16
+ * declares the library, which is exactly what happened here: the retired line
17
+ * declared it, so ten packages stayed reachable after the line was dropped and
18
+ * only the move of the pin emptied the graph. The parser reads `importers:` and
19
+ * `snapshots:` and never reads a `resolution:` value, which is where the hashes
20
+ * are.
21
+ *
22
+ * **There is no carve-out for this gate's own prose.** The four copies each
23
+ * excluded one file by path, because each named the library it was keeping out.
24
+ * A rule that has to except itself is a rule whose exception is a file a later
25
+ * commit renames, and the file it excepted was inside a consumer's repository
26
+ * where a reader would come to change a gate. The gate now lives in the package,
27
+ * so the text that names the library is not in the tree being scanned.
28
+ *
29
+ * Coverage is asserted: the run reads the whole repository below the declared
30
+ * roots, every skipped directory is printed, and a run that read fewer files than
31
+ * its floor fails rather than reporting a clean tree.
32
+ */
33
+ import { existsSync } from 'node:fs'
34
+ import path from 'node:path'
35
+
36
+ import { law } from './laws.mjs'
37
+ import { SKIP_DIRECTORIES, blankComments, finding, floor, read, walk } from './run.mjs'
38
+
39
+ /** The old line was built on Ant Design. */
40
+ const isVendorPackage = (name) => name === 'antd' || name.startsWith('@ant-design/')
41
+
42
+ /** Text files a trace could be in. The lockfile is a graph input, not a text input. */
43
+ const TEXT = /\.(ts|tsx|js|jsx|mjs|cjs|css|json|md|mdx|svg|ya?ml|toml)$/
44
+
45
+ /**
46
+ * A module specifier naming the old line's packages, in every position a
47
+ * specifier can take. The bare root matters: `from 'antd'` is reachable with no
48
+ * subpath at all, and a pattern anchored on `from 'antd/` misses it.
49
+ */
50
+ const MODULE_SPECIFIER =
51
+ /(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+|\brequire\s*\(\s*)['"](?:antd(?:\/[^'"]*)?|@ant-design\/[^'"]*)['"]/
52
+
53
+ /** The files the old line shipped, by name. A `.gitignore` line naming one is a finding. */
54
+ const ARTEFACTS = ['antd-vars.css', 'bake-antd-css.mjs']
55
+
56
+ /** The old line's own theming symbols, which a half-finished removal leaves behind. */
57
+ const THEMING_SYMBOLS = /prismCssVarKey|prismBrandPacks|PrismThemeModeProvider/
58
+
59
+ /** The old line's custom-property namespace. The current contract is unprefixed. */
60
+ const OLD_NAMESPACE = /--prism-[a-z0-9-]*/
61
+
62
+ /** The old line's ruleset class. A pack and a mode are `[data-pack]` and `.dark` today. */
63
+ const OLD_RULESET = /\bprism-[a-z0-9]+(?:-[a-z0-9]+)*-(?:light|dark)\b/
64
+
65
+ export function run({ root, config }) {
66
+ const l = law('retired-line')
67
+ const findings = []
68
+ const notes = []
69
+ const manifestPath = config.manifest ?? 'package.json'
70
+ const lockfilePath = config.lockfile ?? 'pnpm-lock.yaml'
71
+ const documents = new Set(config.documents ?? ['README.md', 'AGENTS.md', 'CLAUDE.md'])
72
+ const minFiles = config.minFiles ?? 12
73
+
74
+ const files = walk(root, { skip: SKIP_DIRECTORIES, extensions: [TEXT] })
75
+ floor('this run read', files.length, minFiles)
76
+
77
+ let bytes = 0
78
+ let commentOnly = 0
79
+ for (const relative of files) {
80
+ const source = read(root, relative)
81
+ bytes += source.length
82
+ const lines = source.split('\n')
83
+ /*
84
+ * Comments are blanked before the two families that name things rather than
85
+ * use them: the custom-property namespace and the theming symbols. A comment
86
+ * that says which property the old sheet declared is a historical record, and
87
+ * the design system's own gate excludes its rule table for the same reason.
88
+ * A *declaration* or a *read* of one is not excused, and blanking rather than
89
+ * skipping the file is what keeps that distinction: a stylesheet whose rule is
90
+ * commented out stops being a read and starts being a note, which is correct.
91
+ *
92
+ * The module specifier, the artefact name and the ruleset class are NOT
93
+ * comment-blanked. A comment that tells a later implementer to import the old
94
+ * package is an instruction rather than a record, and that is the family the
95
+ * whole programme lost a reader to.
96
+ */
97
+ const codeLines = blankComments(source).split('\n')
98
+
99
+ lines.forEach((line, index) => {
100
+ const at = `${relative}:${index + 1}`
101
+ const code = codeLines[index] ?? ''
102
+ if (code.trim() === '' && line.trim() !== '') commentOnly += 1
103
+
104
+ const specifier = line.match(MODULE_SPECIFIER)
105
+ if (specifier) {
106
+ findings.push(
107
+ finding(at, 'module-imported', `"${specifier[0].trim()}" imports the retired line. A consumer installs ${'`@nanisoft/prism-ui`'}, which needs no part of it.`),
108
+ )
109
+ }
110
+ for (const artefact of ARTEFACTS) {
111
+ if (line.includes(artefact)) {
112
+ findings.push(
113
+ finding(at, 'generated-artefact', `this line names the retired line's artefact \`${artefact}\`, and the artefact and its generator are the same removal.`),
114
+ )
115
+ }
116
+ }
117
+ if (THEMING_SYMBOLS.test(code)) {
118
+ findings.push(
119
+ finding(at, 'theming-symbol', `this line names the retired line's theming symbol. The theme is two attributes on the document element and a blocking script the design system ships.`),
120
+ )
121
+ }
122
+ const namespace = code.match(OLD_NAMESPACE)
123
+ if (namespace) {
124
+ findings.push(
125
+ finding(at, 'custom-property', `\`${namespace[0]}\` is the retired line's custom-property namespace. The current contract is unprefixed (\`--background\`), so this read resolves to nothing.`),
126
+ )
127
+ }
128
+ const ruleset = code.match(OLD_RULESET)
129
+ if (ruleset) {
130
+ findings.push(
131
+ finding(at, 'ruleset-class', `\`${ruleset[0]}\` is the retired line's ruleset class. A pack and a mode are \`[data-pack]\` and \`.dark\` today.`),
132
+ )
133
+ }
134
+ // The lockfile is read as a graph below and never as text, because a base64
135
+ // integrity hash contains the characters every specifier pattern admits.
136
+ if (relative === lockfilePath) {
137
+ const parsed = parseLockfile(source)
138
+ for (const [name, chain] of reachableVendors(parsed)) {
139
+ findings.push(
140
+ finding(
141
+ lockfilePath,
142
+ 'reachable-package',
143
+ `\`${name}\` is still reachable from importer \`${chain[0]}\`: ${chain.join(' -> ')}. Deleting a dependency line does not empty a lockfile while another package declares it.`,
144
+ ),
145
+ )
146
+ }
147
+ return
148
+ }
149
+ if (relative === manifestPath) {
150
+ for (const name of vendorDependencies(JSON.parse(source))) {
151
+ findings.push(
152
+ finding(manifestPath, 'dependency-declared', `\`${name}\` is a live dependency. A consumer installs the design system, which brings its own foundation.`),
153
+ )
154
+ }
155
+ }
156
+ if (documents.has(relative) || relative.startsWith('docs/')) {
157
+ const prose = line.match(/\bant[\s-]design\b/i) ?? line.match(/\bantd\b/)
158
+ if (prose) {
159
+ findings.push(
160
+ finding(at, 'living-instruction', `"${prose[0]}" names the retired line in an instruction a later implementer will follow. A document that needs to say what the old line was belongs in a historical record.`),
161
+ )
162
+ }
163
+ }
164
+ })
165
+ }
166
+
167
+ if (!existsSync(path.join(root, lockfilePath))) {
168
+ findings.push(
169
+ finding(lockfilePath, 'missing', 'the lockfile does not resolve, so reachability cannot be read at all and the graph half of this law is unrun.'),
170
+ )
171
+ }
172
+
173
+ notes.push(`${l.id}: ${findings.length} finding(s) across ${files.length} file(s) read, ${bytes} byte(s)`)
174
+ notes.push(`${l.id}: ${commentOnly} comment-only line(s) were exempt from the namespace and theming-symbol families, and were not exempt from the specifier, artefact or ruleset families. A comment naming a property the old sheet declared is a record; a comment telling a later implementer which package to import is an instruction.`)
175
+ notes.push(`${l.id}: ${lockfilePath} was read as a dependency graph and never text-scanned, because a base64 integrity hash contains the characters a specifier pattern admits.`)
176
+ notes.push(`${l.id}: every directory this run did not read, printed so an exclusion is arguable:`)
177
+ for (const directory of SKIP_DIRECTORIES) notes.push(`${l.id}: ${directory}/`)
178
+ notes.push(
179
+ `${l.id}: this gate does not except itself, because it does not live in the tree it scans. The four copies each excluded one file by path for exactly that reason.`,
180
+ )
181
+ notes.push(
182
+ `${l.id}: the honest limit: this is a text and dependency-graph scan. It cannot see a competitor reached through a package that renames it, and it cannot see a runtime that resolves one by string.`,
183
+ )
184
+
185
+ return { law: l, findings, notes }
186
+ }
187
+
188
+ /** Every dependency key naming the retired line, in any block that declares one. */
189
+ export function vendorDependencies(manifest) {
190
+ const found = []
191
+ for (const block of [
192
+ 'dependencies',
193
+ 'devDependencies',
194
+ 'peerDependencies',
195
+ 'optionalDependencies',
196
+ 'overrides',
197
+ 'resolutions',
198
+ 'pnpm.overrides',
199
+ 'pnpm.resolutions',
200
+ ]) {
201
+ const declared = manifest?.[block]
202
+ if (!declared || typeof declared !== 'object') continue
203
+ for (const name of Object.keys(declared)) if (isVendorPackage(name)) found.push(name)
204
+ }
205
+ return found
206
+ }
207
+
208
+ const indentOf = (line) => line.length - line.trimStart().length
209
+ const unquote = (value) => value.trim().replace(/^'(.*)'$/, '$1').trim()
210
+
211
+ /**
212
+ * Parse the lockfile into the two things reachability needs and nothing else.
213
+ *
214
+ * The line ending is normalised first, because a CRLF checkout leaves a carriage
215
+ * return on the end of every line, so trimming a section name yields a name with
216
+ * a character at the end of it. No section then matches, the graph comes back
217
+ * empty, and an empty graph finds nothing and reports a clean pass. That is the
218
+ * difference between a gate and a gate that is switched off.
219
+ */
220
+ export function parseLockfile(text) {
221
+ const importers = new Map()
222
+ const snapshots = new Map()
223
+ let section = null
224
+ let importer = null
225
+ let snapshot = null
226
+ let depBlock = null
227
+ let pending = null
228
+
229
+ for (const line of text.replace(/\r\n/g, '\n').split('\n')) {
230
+ if (line.trim() === '') continue
231
+ const indent = indentOf(line)
232
+
233
+ if (indent === 0) {
234
+ section = line.replace(/:.*/, '')
235
+ importer = null
236
+ snapshot = null
237
+ depBlock = null
238
+ pending = null
239
+ continue
240
+ }
241
+
242
+ if (section === 'importers') {
243
+ if (indent === 2) {
244
+ importer = unquote(line.trim().replace(/:$/, ''))
245
+ importers.set(importer, new Map())
246
+ } else if (indent === 4) {
247
+ depBlock = line.trim().replace(/:$/, '')
248
+ } else if (indent === 6) {
249
+ pending = unquote(line.trim().replace(/:$/, ''))
250
+ } else if (indent === 8) {
251
+ // The resolved version is the seed of the walk. A `link:` version is a
252
+ // workspace edge with no snapshot, so it seeds nothing.
253
+ if (!line.trim().startsWith('version:') || pending == null) continue
254
+ const version = unquote(line.trim().slice('version:'.length))
255
+ if (version.startsWith('link:')) continue
256
+ importers.get(importer)?.set(`${depBlock ?? 'dependencies'}/${pending}`, `${pending}@${version}`)
257
+ }
258
+ continue
259
+ }
260
+
261
+ if (section === 'snapshots') {
262
+ if (indent === 2) {
263
+ snapshot = unquote(line.trim().replace(/:(\s*\{\})?$/, ''))
264
+ if (!snapshots.has(snapshot)) snapshots.set(snapshot, new Map())
265
+ } else if (indent === 4) {
266
+ depBlock = line.trim().replace(/:$/, '')
267
+ } else if (indent === 6) {
268
+ const colon = line.indexOf(':')
269
+ const name = unquote(line.slice(0, colon))
270
+ const version = unquote(line.slice(colon + 1))
271
+ snapshots.get(snapshot)?.set(`${depBlock ?? 'dependencies'}/${name}`, `${name}@${version}`)
272
+ }
273
+ }
274
+ }
275
+
276
+ return { importers, snapshots }
277
+ }
278
+
279
+ /**
280
+ * Split a `name@version(peer@1)` key at the `@` outside every parenthesised
281
+ * suffix. `lastIndexOf` does not work: the suffixes are full of `@`.
282
+ */
283
+ export function splitPackageKey(key) {
284
+ let depth = 0
285
+ for (let i = 1; i < key.length; i += 1) {
286
+ const char = key[i]
287
+ if (char === '(') depth += 1
288
+ else if (char === ')') depth -= 1
289
+ else if (char === '@' && depth === 0) return [key.slice(0, i), key.slice(i + 1)]
290
+ }
291
+ return [key, '']
292
+ }
293
+
294
+ /** Every retired-line package reachable from an importer, with the chain that reaches it. */
295
+ export function reachableVendors(lock) {
296
+ const found = new Map()
297
+ for (const [importer, direct] of lock.importers) {
298
+ const queue = [...direct.values()].map((key) => ({ key, parent: null }))
299
+ const seen = new Set()
300
+ while (queue.length > 0) {
301
+ const node = queue.shift()
302
+ if (seen.has(node.key)) continue
303
+ seen.add(node.key)
304
+ const [name] = splitPackageKey(node.key)
305
+ if (isVendorPackage(name) && !found.has(name)) {
306
+ const chain = [importer]
307
+ for (let cursor = node; cursor; cursor = cursor.parent) chain.push(splitPackageKey(cursor.key)[0])
308
+ found.set(name, chain.reverse())
309
+ }
310
+ for (const target of lock.snapshots.get(node.key)?.values() ?? []) queue.push({ key: target, parent: node })
311
+ }
312
+ }
313
+ return found
314
+ }