themelia-ui 3.1.0 → 3.2.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/skills/themelia-ui/SKILL.md +38 -9
- package/.agents/skills/themelia-ui/manifest.json +23 -0
- package/.agents/skills/themelia-ui/references/api-compatibility.md +3 -2
- package/.agents/skills/themelia-ui/references/blocks.md +123 -0
- package/.agents/skills/themelia-ui/references/composition-ladder.md +8 -8
- package/.agents/skills/themelia-ui/references/composition.md +98 -1
- package/.agents/skills/themelia-ui/references/forms.md +23 -3
- package/.agents/skills/themelia-ui/references/framework-wiring.md +34 -6
- package/.agents/skills/themelia-ui/references/i18n.md +4 -2
- package/.agents/skills/themelia-ui/references/imports.md +1 -1
- package/.agents/skills/themelia-ui/references/installation.md +5 -4
- package/.agents/skills/themelia-ui/references/migration.md +22 -2
- package/.agents/skills/themelia-ui/references/provider-and-scoping.md +27 -9
- package/.agents/skills/themelia-ui/references/reference-workflows.md +43 -0
- package/.agents/skills/themelia-ui/references/structure-and-spacing.md +147 -0
- package/.agents/skills/themelia-ui/references/theming.md +6 -2
- package/.agents/skills/themelia-ui/references/troubleshooting.md +28 -5
- package/.agents/skills/themelia-ui/references/verification.md +58 -2
- package/CHANGELOG.md +25 -0
- package/README.md +3 -0
- package/dist/{alert-dialog-Bmo1YrTm.js → alert-dialog-Cg2HhuBe.js} +1 -1
- package/dist/base/alert-dialog.js +1 -1
- package/dist/base/cards.js +1 -1
- package/dist/base/choice-inputs.js +1 -1
- package/dist/base/combobox.js +1 -1
- package/dist/base/command.js +1 -1
- package/dist/base/date-pickers.js +1 -1
- package/dist/base/dialog.js +1 -1
- package/dist/base/display.js +1 -1
- package/dist/base/forms-numeric.js +1 -1
- package/dist/base/forms.js +1 -1
- package/dist/base/item.js +1 -1
- package/dist/base/navigation.js +1 -1
- package/dist/base/otp-input.js +1 -1
- package/dist/base/overlay.js +1 -1
- package/dist/base/popover-menu.js +1 -1
- package/dist/base/repeaters.js +1 -1
- package/dist/base/sheet.js +1 -1
- package/dist/base/sidebar.js +1 -1
- package/dist/base/skeleton.js +1 -1
- package/dist/base/structure.js +1 -1
- package/dist/base/timeline.js +1 -1
- package/dist/base/upload.js +1 -1
- package/dist/base/value-inputs.js +1 -1
- package/dist/blocks/admin/access.js +2 -2
- package/dist/blocks/admin/commerce.js +9 -9
- package/dist/blocks/analytics.js +3 -3
- package/dist/blocks/onboarding.js +1 -1
- package/dist/blocks/timelines.js +1 -1
- package/dist/{cards-DdiICdMM.js → cards-Ddhz43Bz.js} +2 -2
- package/dist/{choice-inputs-CCw-0gD6.js → choice-inputs-NwRxvmZN.js} +2 -2
- package/dist/combobox-519ubijc.js +292 -0
- package/dist/{command-CnB_IZSo.js → command-JjApYFds.js} +2 -2
- package/dist/{comments-C6Y_ddMt.js → comments-CCQ2QRaU.js} +4 -4
- package/dist/components/base/display/icon-badge.d.ts +2 -2
- package/dist/components/base/forms/form-field.d.ts +2 -2
- package/dist/components/base/structure/bleed.d.ts +1 -0
- package/dist/components/base/structure/grid.d.ts +8 -2
- package/dist/components/base/structure/split.d.ts +1 -0
- package/dist/components/features/combobox/fetching-combobox.d.ts +2 -1
- package/dist/components/features/combobox/resource-combobox.d.ts +3 -0
- package/dist/components/features/combobox/use-suggestions.d.ts +7 -0
- package/dist/css/structure.css +1 -1
- package/dist/{date-pickers-C2HgjpFU.js → date-pickers-BwCwKIVM.js} +1 -1
- package/dist/{dialog-0nRFg5Zs.js → dialog-CAnwRNrm.js} +1 -1
- package/dist/{display-BGN834xV.js → display-a7AoX_u7.js} +1 -1
- package/dist/features/actions.js +1 -1
- package/dist/features/activities.js +3 -3
- package/dist/features/ai-chat.js +2 -2
- package/dist/features/combobox.js +285 -273
- package/dist/features/comments.js +1 -1
- package/dist/features/data-view.js +5 -5
- package/dist/features/event-calendar.js +3 -3
- package/dist/features/filters.js +1 -1
- package/dist/features/global-search.js +4 -4
- package/dist/features/map.js +2 -2
- package/dist/features/media-library.js +8 -8
- package/dist/features/mentions.js +1 -1
- package/dist/features/overlays.js +1 -1
- package/dist/features/products.js +9 -9
- package/dist/features/resource-assignment.js +3 -3
- package/dist/features/resource.js +3 -3
- package/dist/features/schema-form.js +6 -6
- package/dist/features/sync.js +2 -2
- package/dist/features/table.js +1 -1
- package/dist/features/theme-tweaker.js +10 -10
- package/dist/{filters-geLuSlFL.js → filters-BhsTkWpZ.js} +9 -9
- package/dist/{forms-NxbJK4Mh.js → forms-C0WeNPFF.js} +1 -1
- package/dist/{forms-numeric-C6NMdj6G.js → forms-numeric-IwQ1ltNf.js} +1 -1
- package/dist/hooks/use-native-dialog.d.ts +1 -1
- package/dist/{item-CuUJyETl.js → item-M2pmpWXh.js} +1 -1
- package/dist/layout/app-shell.js +1 -1
- package/dist/layout/auth.js +1 -1
- package/dist/layout/header.js +3 -3
- package/dist/layout/navigation.js +1 -1
- package/dist/layout/page.js +2 -2
- package/dist/layout/settings.js +2 -2
- package/dist/layout/sidebar.js +10 -6
- package/dist/layout/workspace.js +3 -3
- package/dist/lib/ui-provider/ui-scope.d.ts +1 -1
- package/dist/{mentions-oeYNv_j1.js → mentions-BOxbigwp.js} +2 -2
- package/dist/{navigation-BTX5Ti0Z.js → navigation-Bgu7Vc0M.js} +2 -2
- package/dist/{navigation-WYKjDhZq.js → navigation-DiYACqpE.js} +2 -2
- package/dist/{overlay-C8RAgi2_.js → overlay-BuQOl8aP.js} +5 -5
- package/dist/{overlays-9BxwFTou.js → overlays-1sULyytH.js} +5 -5
- package/dist/{popover-menu-DcLeiw9r.js → popover-menu-QXgQeWeG.js} +1 -1
- package/dist/{repeaters-DfptvbGA.js → repeaters-CeH20dvr.js} +4 -4
- package/dist/{sheet-D-DVZH_b.js → sheet-CJ1-OAla.js} +1 -1
- package/dist/{sidebar-DXQOy_Cb.js → sidebar-D8uGqxio.js} +4 -4
- package/dist/{skeleton-CubvHel9.js → skeleton-BTKNKTdj.js} +1 -1
- package/dist/{structure-B9Y5NkP_.js → structure-CynEZ-OY.js} +1 -2
- package/dist/style.css +1 -1
- package/dist/styles/TOKENS.md +4 -3
- package/dist/{table-DT_20ity.js → table-B7dtIFUx.js} +4 -4
- package/dist/{timeline-BRZbSdhf.js → timeline-BR_jNgvz.js} +1 -1
- package/dist/{timelines-DczZKW5p.js → timelines-DXG1BfIN.js} +1 -1
- package/dist/{upload-C_6mlbfl.js → upload-CYuoJykZ.js} +1 -1
- package/dist/{value-inputs-CnO6RZeT.js → value-inputs-ng8aVJJZ.js} +4 -4
- package/docs/README.md +11 -1
- package/docs/build/browser-composition-fixture.ts +28 -0
- package/docs/build/recipes.md +398 -1
- package/docs/generated/component-index.json +1993 -862
- package/docs/generated/components/base--display.md +2 -2
- package/docs/generated/components/base--forms.md +72 -1
- package/docs/generated/components/base--structure.md +56 -5
- package/docs/generated/components/base--typography.md +38 -0
- package/docs/generated/components/blocks--admin--access.md +10 -0
- package/docs/generated/components/blocks--analytics.md +44 -0
- package/docs/generated/components/features--combobox.md +67 -0
- package/docs/generated/components/features--data-view.md +59 -3
- package/docs/generated/components/features--filters.md +10 -0
- package/docs/generated/components/features--media-library.md +68 -0
- package/docs/generated/components/features--overlays.md +54 -0
- package/docs/generated/components/features--rich-text-editor.md +38 -0
- package/docs/generated/components/layout--app-shell.md +31 -0
- package/docs/generated/components/layout--containers.md +40 -0
- package/docs/generated/components/layout--page.md +30 -1
- package/docs/generated/components/layout--sidebar.md +41 -0
- package/docs/generated/components/ui-provider.md +1 -1
- package/docs/generated/composition-ladder.md +8 -8
- package/docs/generated/consumer-contract.json +5736 -0
- package/docs/generated/imports.md +1 -1
- package/docs/generated/recipes.json +171 -1
- package/docs/generated/workflows/communications-overview/analytics/communications-reference.tsx +19 -0
- package/docs/generated/workflows/index.json +691 -0
- package/docs/generated/workflows/media-picking/media-library/media-picking-reference.tsx +63 -0
- package/docs/generated/workflows/media-picking/media-library/reference-data.ts +3 -0
- package/docs/generated/workflows/multi-section-form/form-workflow/multi-section-reference.tsx +46 -0
- package/docs/generated/workflows/nested-action-flow/action-overlays/nested-action-reference.tsx +39 -0
- package/docs/generated/workflows/record-detail/page-layout/record-detail-reference.tsx +24 -0
- package/docs/generated/workflows/resource-index/data-view/resource-index-reference.tsx +51 -0
- package/docs/generated/workflows/scoped-resource-selection/combobox/scoped-resource-reference.tsx +50 -0
- package/docs/generated/workflows/website-content-editor/media-library/reference-data.ts +3 -0
- package/docs/generated/workflows/website-content-editor/rich-text-editor/website-content-reference.tsx +33 -0
- package/docs/generated/workflows/workspace-navigation/app-shell/workspace-navigation-reference.tsx +26 -0
- package/docs/learn/api-compatibility.md +3 -2
- package/docs/learn/blocks.md +123 -0
- package/docs/learn/composition.md +98 -1
- package/docs/learn/forms.md +23 -3
- package/docs/learn/framework-wiring.md +34 -6
- package/docs/learn/i18n.md +4 -2
- package/docs/learn/installation.md +5 -4
- package/docs/learn/migration.md +22 -2
- package/docs/learn/provider-and-scoping.md +27 -9
- package/docs/learn/reference-workflows.md +43 -0
- package/docs/learn/structure-and-spacing.md +147 -0
- package/docs/learn/theming.md +6 -2
- package/docs/learn/troubleshooting.md +28 -5
- package/docs/learn/verification.md +58 -2
- package/package.json +4 -1
- package/scripts/consumer/codemod.mjs +3 -24
- package/scripts/consumer/contract-reader.mjs +42 -0
- package/scripts/consumer/css-scan.mjs +51 -0
- package/scripts/consumer/diagnose.mjs +136 -0
- package/scripts/consumer/find-component.mjs +28 -13
- package/scripts/consumer/install-skill.mjs +93 -11
- package/src/styles/TOKENS.md +4 -3
- package/dist/combobox-Byu8N-Oe.js +0 -274
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "themelia-ui",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Themelia UI is a production-ready, composable React component system with CSS-first theming.",
|
|
6
6
|
"keywords": [
|
|
@@ -38,6 +38,9 @@
|
|
|
38
38
|
"scripts/consumer/install-skill.mjs",
|
|
39
39
|
"scripts/consumer/find-component.mjs",
|
|
40
40
|
"scripts/consumer/codemod.mjs",
|
|
41
|
+
"scripts/consumer/diagnose.mjs",
|
|
42
|
+
"scripts/consumer/contract-reader.mjs",
|
|
43
|
+
"scripts/consumer/css-scan.mjs",
|
|
41
44
|
"docs/README.md",
|
|
42
45
|
"docs/learn",
|
|
43
46
|
"docs/generated",
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* stylesheet references requiring manual review.
|
|
11
11
|
*/
|
|
12
12
|
import { existsSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from 'node:fs'
|
|
13
|
-
import { dirname, join,
|
|
13
|
+
import { dirname, join, resolve } from 'node:path'
|
|
14
14
|
import { fileURLToPath } from 'node:url'
|
|
15
|
+
import { loadMigrationMaps } from './contract-reader.mjs'
|
|
15
16
|
|
|
16
17
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
17
18
|
const GENERATED = resolve(HERE, '..', '..', 'docs', 'generated')
|
|
@@ -23,29 +24,7 @@ const MARKUP = /\.(?:html|vue|svelte|astro|mdx)$/
|
|
|
23
24
|
const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
24
25
|
|
|
25
26
|
/** The two published maps, indexed for the rewrites below. */
|
|
26
|
-
export
|
|
27
|
-
const read = (file) => {
|
|
28
|
-
const path = resolve(dir, file)
|
|
29
|
-
if (!existsSync(path)) throw new Error(`the packaged migration map is missing at ${path}`)
|
|
30
|
-
return JSON.parse(readFileSync(path, 'utf8'))
|
|
31
|
-
}
|
|
32
|
-
const codemod = read('migration-codemod.json')
|
|
33
|
-
const broad = read('migration-broad-imports.json')
|
|
34
|
-
const root = resolve(dir, '..', '..')
|
|
35
|
-
const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8'))
|
|
36
|
-
return {
|
|
37
|
-
/* Where a reader finds the rest, as a path from wherever the codemod was run. */
|
|
38
|
-
doc: relative(process.cwd(), resolve(dir, 'migration.md')),
|
|
39
|
-
name: pkg.name,
|
|
40
|
-
moves: new Map(Object.entries(codemod.moves ?? {})),
|
|
41
|
-
tokens: new Map(Object.entries(codemod.tokens ?? {})),
|
|
42
|
-
classes: new Map(Object.entries(codemod.classes ?? {})),
|
|
43
|
-
reviewClasses: new Map(Object.entries(codemod.reviewClasses ?? {})),
|
|
44
|
-
broad: new Set([...(broad.removedSubpaths ?? [])]),
|
|
45
|
-
owner: broad.symbols ?? {},
|
|
46
|
-
ambiguous: new Set(Object.keys(broad.ambiguous ?? {})),
|
|
47
|
-
}
|
|
48
|
-
}
|
|
27
|
+
export const loadMaps = (dir = GENERATED) => loadMigrationMaps(dir)
|
|
49
28
|
|
|
50
29
|
/*
|
|
51
30
|
* A whole property name: the lookbehind keeps `i--` and `a--b` out; greedy, so a name
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { relative, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
export function loadMigrationMaps(dir) {
|
|
5
|
+
const read = (file) => {
|
|
6
|
+
const path = resolve(dir, file)
|
|
7
|
+
if (!existsSync(path)) throw new Error(`the packaged migration map is missing at ${path}`)
|
|
8
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
9
|
+
}
|
|
10
|
+
const codemod = read('migration-codemod.json')
|
|
11
|
+
const broad = read('migration-broad-imports.json')
|
|
12
|
+
const root = resolve(dir, '..', '..')
|
|
13
|
+
const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8'))
|
|
14
|
+
return {
|
|
15
|
+
/* Where a reader finds the rest, as a path from wherever the codemod was run. */
|
|
16
|
+
doc: relative(process.cwd(), resolve(dir, 'migration.md')),
|
|
17
|
+
name: pkg.name,
|
|
18
|
+
moves: new Map(Object.entries(codemod.moves ?? {})),
|
|
19
|
+
tokens: new Map(Object.entries(codemod.tokens ?? {})),
|
|
20
|
+
classes: new Map(Object.entries(codemod.classes ?? {})),
|
|
21
|
+
reviewClasses: new Map(Object.entries(codemod.reviewClasses ?? {})),
|
|
22
|
+
broad: new Set([...(broad.removedSubpaths ?? [])]),
|
|
23
|
+
owner: broad.symbols ?? {},
|
|
24
|
+
ambiguous: new Set(Object.keys(broad.ambiguous ?? {})),
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
/** Read generated package facts; absent or incompatible metadata is an incomplete analysis. */
|
|
30
|
+
export function loadConsumerContract(packageRoot) {
|
|
31
|
+
const pkg=JSON.parse(readFileSync(resolve(packageRoot,'package.json'),'utf8'))
|
|
32
|
+
const metadata=resolve(packageRoot,'docs/generated/consumer-contract.json')
|
|
33
|
+
if(!existsSync(metadata)) throw new Error(`consumer contract is missing at ${metadata}`)
|
|
34
|
+
const contract=JSON.parse(readFileSync(metadata,'utf8'))
|
|
35
|
+
if(contract.schemaVersion!==1 || !contract.publicExports || !contract.privateStyleNames || !Array.isArray(contract.privateStyleNames.variables) || !Array.isArray(contract.privateStyleNames.classes)) throw new Error('unsupported consumer contract schema')
|
|
36
|
+
if(contract.packageVersion!==pkg.version) throw new Error('consumer contract version differs from installed package')
|
|
37
|
+
for(const [specifier,entry] of Object.entries(contract.publicExports)) {
|
|
38
|
+
if(!(specifier===pkg.name||specifier.startsWith(`${pkg.name}/`)) || !['javascript','stylesheet','json'].includes(entry.kind) || !Array.isArray(entry.symbols)) throw new Error('invalid public export metadata')
|
|
39
|
+
if(entry.targets?.some(target=>!target.startsWith('./')||target.split('/').includes('..')||!existsSync(resolve(packageRoot,target)))) throw new Error(`installed package export target is missing: ${specifier}`)
|
|
40
|
+
}
|
|
41
|
+
return {...contract,name:pkg.name,packageRoot,migration:loadMigrationMaps(resolve(packageRoot,'docs/generated'))}
|
|
42
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Bounded plain-CSS observations. Unsupported input is explicit, never a regex pass. */
|
|
2
|
+
const decodeEscapes = value => value
|
|
3
|
+
.replace(/\\(?:\r\n|[\n\r\f])/g, '')
|
|
4
|
+
.replace(/\\([0-9a-fA-F]{1,6})(?:\r\n|[ \n\r\t\f])?|\\(.)/g, (_, hex, char) => {
|
|
5
|
+
if (!hex) return char
|
|
6
|
+
const point = parseInt(hex, 16)
|
|
7
|
+
return point === 0 || point > 0x10ffff || (point >= 0xd800 && point <= 0xdfff) ? '\uFFFD' : String.fromCodePoint(point)
|
|
8
|
+
})
|
|
9
|
+
export function scanCss(source, file = 'input.css') {
|
|
10
|
+
const classes = [], variables = [], imports = [], unsupported = []
|
|
11
|
+
const locate = at => { const before=source.slice(0,at); return { line:before.split('\n').length,column:at-(before.lastIndexOf('\n')+1)+1 } }
|
|
12
|
+
if (!file.endsWith('.css')) return { classes, variables, imports, unsupported:[{...locate(0),reason:'Only plain CSS is supported'}] }
|
|
13
|
+
const chars=source.split('')
|
|
14
|
+
for(let i=0;i<source.length;i++) {
|
|
15
|
+
if(source.slice(i,i+2)==='/*') {
|
|
16
|
+
const end=source.indexOf('*/',i+2)
|
|
17
|
+
if(end<0) { unsupported.push({...locate(i),reason:'Unclosed comment'}); break }
|
|
18
|
+
for(let j=i;j<end+2;j++) if(chars[j]!=='\n') chars[j]=' '
|
|
19
|
+
i=end+1
|
|
20
|
+
} else if(source[i]==='"'||source[i]==="'") {
|
|
21
|
+
const start=i, quote=source[i]; let closed=false
|
|
22
|
+
for(i++;i<source.length;i++) { if(source[i]==='\\') {i++;continue} if(source[i]===quote) {closed=true;break} }
|
|
23
|
+
if(!closed) unsupported.push({...locate(start),reason:'Unclosed string'})
|
|
24
|
+
const value=source.slice(start+1,i)
|
|
25
|
+
if(/@import\s+(?:url\(\s*)?$/i.test(chars.slice(0,start).join(''))) imports.push({specifier:decodeEscapes(value),...locate(start)})
|
|
26
|
+
for(let j=start;j<=i && j<chars.length;j++) if(chars[j]!=='\n') chars[j]=' '
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const clean=chars.join(''); let from=0, depth=0
|
|
30
|
+
for (const hit of clean.matchAll(/@import\s+url\(\s*([^\s)][^)]*?)\s*\)/gi)) {
|
|
31
|
+
const value=decodeEscapes(hit[1])
|
|
32
|
+
imports.push({specifier:value,...locate(hit.index)})
|
|
33
|
+
}
|
|
34
|
+
for(let i=0;i<clean.length;i++) {
|
|
35
|
+
if(clean[i]==='{') {
|
|
36
|
+
const prelude=clean.slice(from,i)
|
|
37
|
+
for(const hit of prelude.matchAll(/\.((?:\\[0-9a-fA-F]{1,6}\s?|\\.|[\w-])+)/g)) {
|
|
38
|
+
const name=decodeEscapes(hit[1])
|
|
39
|
+
classes.push({name,...locate(from+hit.index)})
|
|
40
|
+
}
|
|
41
|
+
depth++;from=i+1
|
|
42
|
+
} else if(clean[i]===';'||clean[i]==='}') { if(clean[i]==='}') depth--;from=i+1 }
|
|
43
|
+
}
|
|
44
|
+
if(depth!==0) unsupported.push({...locate(source.length),reason:'Unbalanced CSS blocks'})
|
|
45
|
+
for(const hit of clean.matchAll(/--[A-Za-z_][\w-]*/g)) {
|
|
46
|
+
const after=clean.slice(hit.index+hit[0].length), before=clean.slice(0,hit.index)
|
|
47
|
+
if(/^\s*:/.test(after)||/var\(\s*$/.test(before)) variables.push({name:hit[0],kind:/^\s*:/.test(after)?'declaration':'read',...locate(hit.index)})
|
|
48
|
+
}
|
|
49
|
+
if(/(?:^|[;{}])\s*\$|@(?:mixin|include|extend)\b/.test(clean)) unsupported.push({...locate(0),reason:'Preprocessor syntax is unsupported'})
|
|
50
|
+
return { classes, variables, imports, unsupported }
|
|
51
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Read-only observations of explicit consumer files against the installed package. */
|
|
3
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs'
|
|
4
|
+
import { createRequire } from 'node:module'
|
|
5
|
+
import { dirname, relative, resolve } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { loadConsumerContract } from './contract-reader.mjs'
|
|
8
|
+
import { scanCss } from './css-scan.mjs'
|
|
9
|
+
import { checkSkill } from './install-skill.mjs'
|
|
10
|
+
|
|
11
|
+
const DOC='node_modules/themelia-ui/docs/learn/'
|
|
12
|
+
const SCRIPT=/\.(?:[cm]?[jt]sx?)$/
|
|
13
|
+
const EXCLUDED=new Set(['node_modules','.git','dist','build'])
|
|
14
|
+
const inside=(root,path)=>{const rel=relative(root,path);return rel!== '..'&&!rel.startsWith('../')&&!rel.startsWith('..\\')}
|
|
15
|
+
export async function diagnoseProject({project='.',paths=[],contract,checkSkills=true}={}) {
|
|
16
|
+
const root=realpathSync(resolve(project)), require=createRequire(resolve(root,'package.json'))
|
|
17
|
+
const report={schemaVersion:1,packageVersion:null,checkedRules:[],skippedChecks:[],checkedFiles:[],skippedFiles:[],findings:[]}
|
|
18
|
+
const skip=(ruleId,reason)=>report.skippedChecks.push({ruleId,reason})
|
|
19
|
+
if(!contract) {
|
|
20
|
+
try {contract=loadConsumerContract(dirname(require.resolve('themelia-ui/package.json')))}
|
|
21
|
+
catch(error) {skip('package-contract',error.message);return {...report,exitCode:2}}
|
|
22
|
+
}
|
|
23
|
+
report.packageVersion=contract.packageVersion
|
|
24
|
+
const files=new Set()
|
|
25
|
+
const skipped=(file,reason)=>report.skippedFiles.push({file:relative(root,file),reason})
|
|
26
|
+
const walk=path=>{
|
|
27
|
+
if(!inside(root,path)||relative(root,path).split(/[\\/]/).some(part=>EXCLUDED.has(part))) {skipped(path,'Outside explicit project source boundary');return}
|
|
28
|
+
if(!existsSync(path)) {skipped(path,'Missing path');return}
|
|
29
|
+
if(!inside(root,realpathSync(path))) {skipped(path,'Resolved path escapes the project through a symlink');return}
|
|
30
|
+
const stat=lstatSync(path)
|
|
31
|
+
if(stat.isSymbolicLink()) {skipped(path,'Symlinks are not followed');return}
|
|
32
|
+
if(stat.isDirectory()) {for(const entry of readdirSync(path).sort()) if(!EXCLUDED.has(entry)) walk(resolve(path,entry));return}
|
|
33
|
+
if(SCRIPT.test(path)||/\.(?:css|scss|sass|less|vue|svelte|astro|mdx)$/.test(path)) files.add(path)
|
|
34
|
+
else skipped(path,'Unsupported source format')
|
|
35
|
+
}
|
|
36
|
+
if(!paths.length) skip('explicit-paths','Supply explicit source paths to analyze')
|
|
37
|
+
for(const path of paths) walk(resolve(root,path))
|
|
38
|
+
const scripts=[...files].filter(path=>SCRIPT.test(path))
|
|
39
|
+
let ts, program, checker
|
|
40
|
+
if(scripts.length) {
|
|
41
|
+
try {ts=require('typescript');program=ts.createProgram(scripts,{noResolve:true,noLib:true,types:[],target:ts.ScriptTarget.Latest,jsx:ts.JsxEmit.Preserve});checker=program.getTypeChecker()}
|
|
42
|
+
catch(error) {skip('script-parser',`Install TypeScript in this consumer project: ${error.message}`)}
|
|
43
|
+
}
|
|
44
|
+
const finding=(ruleId,severity,file,location,evidence,explanation,documentation='troubleshooting.md')=>report.findings.push({ruleId,severity,file:relative(root,file),line:location.line,column:location.column,evidence,explanation,documentation:DOC+documentation})
|
|
45
|
+
const exportEntry=specifier=>contract.publicExports[specifier]
|
|
46
|
+
const imports=(specifier,names,file,loc)=>{
|
|
47
|
+
if(!(specifier===contract.name||specifier.startsWith(`${contract.name}/`))) return
|
|
48
|
+
const entry=exportEntry(specifier)
|
|
49
|
+
if(!entry) {
|
|
50
|
+
const moved=contract.migration?.moves?.get(specifier)
|
|
51
|
+
const alternatives=names.map(name=>contract.migration?.owner?.[name]).filter(Boolean)
|
|
52
|
+
finding('unpublished-import','error',file,loc,specifier,`This exact import is not published.${moved?` Use ${moved}.`:alternatives.length?` Use ${[...new Set(alternatives)].join(', ')}.`:/\.css$/.test(specifier)?' Components bring their own CSS; remove obsolete module stylesheet imports.':' Consult the exact import catalogue.'}`,'composition.md');return
|
|
53
|
+
}
|
|
54
|
+
if(entry.kind==='javascript') for(const name of names) if(!entry.symbols.includes(name)) finding('missing-symbol','error',file,loc,`${specifier}: ${name}`,'This symbol is absent from the installed public declaration; consult the module API.','composition.md')
|
|
55
|
+
}
|
|
56
|
+
if(ts) report.checkedRules.push('unpublished-import','missing-symbol','duplicate-owner','nested-surface')
|
|
57
|
+
for(const file of scripts) {
|
|
58
|
+
if(!ts) {skipped(file,'TypeScript parser unavailable');continue}
|
|
59
|
+
const source=program.getSourceFile(file)
|
|
60
|
+
if(!source || source.parseDiagnostics.length) {skipped(file,'Source parse failed');continue}
|
|
61
|
+
report.checkedFiles.push(relative(root,file));const bindings=new Map(), namespaces=new Map()
|
|
62
|
+
const loc=node=>{const p=source.getLineAndCharacterOfPosition(node.getStart(source));return {line:p.line+1,column:p.character+1}}
|
|
63
|
+
const bind=(node,symbol,specifier)=>{const identity=checker.getSymbolAtLocation(node);if(identity) bindings.set(identity,{symbol,specifier})}
|
|
64
|
+
for(const node of source.statements) {
|
|
65
|
+
if(ts.isImportDeclaration(node)&&ts.isStringLiteral(node.moduleSpecifier)) {
|
|
66
|
+
const specifier=node.moduleSpecifier.text, clause=node.importClause, names=[]
|
|
67
|
+
if(clause?.name) {names.push('default');bind(clause.name,'default',specifier)}
|
|
68
|
+
const named=clause?.namedBindings
|
|
69
|
+
if(named&&ts.isNamedImports(named)) for(const element of named.elements) {const name=element.propertyName?.text??element.name.text;names.push(name);bind(element.name,name,specifier)}
|
|
70
|
+
if(named&&ts.isNamespaceImport(named)) namespaces.set(checker.getSymbolAtLocation(named.name),specifier)
|
|
71
|
+
imports(specifier,names,file,loc(node.moduleSpecifier))
|
|
72
|
+
} else if(ts.isExportDeclaration(node)&&node.moduleSpecifier&&ts.isStringLiteral(node.moduleSpecifier)) imports(node.moduleSpecifier.text,node.exportClause&&ts.isNamedExports(node.exportClause)?node.exportClause.elements.map(item=>item.propertyName?.text??item.name.text):[],file,loc(node.moduleSpecifier))
|
|
73
|
+
}
|
|
74
|
+
const owner=tag=>{
|
|
75
|
+
if(ts.isIdentifier(tag)) return bindings.get(checker.getSymbolAtLocation(tag))
|
|
76
|
+
if(ts.isPropertyAccessExpression(tag)) {const specifier=namespaces.get(checker.getSymbolAtLocation(tag.expression));if(specifier) return {specifier,symbol:tag.name.text}}
|
|
77
|
+
}
|
|
78
|
+
const native=tag=>{const value=owner(tag.tagName);return value&&exportEntry(value.specifier)?.symbols.includes(value.symbol)?value.symbol:null}
|
|
79
|
+
const title=tag=>{const attr=tag.attributes.properties.find(item=>ts.isJsxAttribute(item)&&['title','label'].includes(item.name.getText(source)));return attr?.initializer&&ts.isStringLiteral(attr.initializer)?attr.initializer.text:undefined}
|
|
80
|
+
const visit=node=>{
|
|
81
|
+
if(ts.isCallExpression(node)&&node.arguments[0]&&ts.isStringLiteral(node.arguments[0])&&(node.expression.kind===ts.SyntaxKind.ImportKeyword||(ts.isIdentifier(node.expression)&&node.expression.text==='require'&&!checker.getSymbolAtLocation(node.expression)))) imports(node.arguments[0].text,[],file,loc(node.arguments[0]))
|
|
82
|
+
const member=ts.isPropertyAccessExpression(node)?{base:node.expression,name:node.name.text}
|
|
83
|
+
:ts.isQualifiedName(node)?{base:node.left,name:node.right.text}
|
|
84
|
+
:ts.isElementAccessExpression(node)&&node.argumentExpression&&(ts.isStringLiteral(node.argumentExpression)||ts.isNoSubstitutionTemplateLiteral(node.argumentExpression))?{base:node.expression,name:node.argumentExpression.text}:null
|
|
85
|
+
if(member) {
|
|
86
|
+
const specifier=namespaces.get(checker.getSymbolAtLocation(member.base))
|
|
87
|
+
if(specifier) imports(specifier,[member.name],file,loc(node))
|
|
88
|
+
}
|
|
89
|
+
const tag=ts.isJsxElement(node)?node.openingElement:ts.isJsxSelfClosingElement(node)?node:null
|
|
90
|
+
if(tag) {
|
|
91
|
+
const symbol=native(tag)
|
|
92
|
+
if(symbol) for(let ancestor=node.parent;ancestor;ancestor=ancestor.parent) {
|
|
93
|
+
if(!ts.isJsxElement(ancestor)) continue
|
|
94
|
+
const parent=native(ancestor.openingElement);if(!parent) continue
|
|
95
|
+
if(title(tag)&&title(tag)===title(ancestor.openingElement)) finding('duplicate-owner','warning',file,loc(tag),`${parent} / ${symbol}: ${title(tag)}`,'Repeated literal names may duplicate a subject or field owner. Confirm distinct subjects and accessible names.','composition.md')
|
|
96
|
+
if(symbol==='Card'&&parent==='Card') finding('nested-surface','warning',file,loc(tag),'Card inside Card','Confirm the child is an independent subject. ContentBlock or flat FormSection may express an existing-surface region.','composition.md')
|
|
97
|
+
break
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
ts.forEachChild(node,visit)
|
|
101
|
+
};visit(source)
|
|
102
|
+
}
|
|
103
|
+
report.checkedRules.push('private-variable','private-selector','migration-token','stylesheet-import')
|
|
104
|
+
for(const file of [...files].filter(path=>!SCRIPT.test(path))) {
|
|
105
|
+
const observations=scanCss(readFileSync(file,'utf8'),file)
|
|
106
|
+
if(observations.unsupported.length) {skipped(file,observations.unsupported.map(item=>item.reason).join('; '));continue}
|
|
107
|
+
report.checkedFiles.push(relative(root,file))
|
|
108
|
+
for(const item of observations.imports) imports(item.specifier,[],file,item)
|
|
109
|
+
for(const item of observations.classes) if(contract.privateStyleNames.classes.includes(item.name)) finding('private-selector','warning',file,item,item.name,'This selector matches a verified CSS Module hash in this package build. Use documented hooks or public tokens.')
|
|
110
|
+
for(const item of observations.variables) {
|
|
111
|
+
if(contract.privateStyleNames.variables.includes(item.name)) finding('private-variable','warning',file,item,item.name,'This package-private variable is not a supported override. Use a documented public token.')
|
|
112
|
+
if(contract.migration?.tokens?.has(item.name)) finding('migration-token','warning',file,item,item.name,`Historical token; ${contract.migration.tokens.get(item.name)?`review ${contract.migration.tokens.get(item.name)}`:'no successor is declared'}.`,'migration.md')
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if(checkSkills) {
|
|
116
|
+
if(!contract.packageRoot) skip('skill-freshness','Installed package root unavailable')
|
|
117
|
+
else {
|
|
118
|
+
const freshness=checkSkill({project:root,source:resolve(contract.packageRoot,'.agents/skills/themelia-ui')})
|
|
119
|
+
if(freshness.exitCode===2) skip('skill-freshness',freshness.error)
|
|
120
|
+
else {report.checkedRules.push('skill-freshness');for(const target of freshness.targets) if(target.status!=='current') finding('skill-freshness','warning',resolve(root,`.${target.target==='agents'?'agents':'claude'}/skills/themelia-ui/SKILL.md`),{line:1,column:1},`${target.target}: ${target.status}; installed ${target.installedVersion??'unknown'}, expected ${freshness.packageVersion}`,`Review modified content and preserve application policy. Run install-skill.mjs --check --json, then reinstall only the generated copy you intend to replace. Unmanaged skills are never overwritten.`,'verification.md')}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const incomplete=report.skippedChecks.length||report.skippedFiles.length
|
|
124
|
+
return {...report,exitCode:incomplete?2:report.findings.some(item=>item.severity==='error')?1:0}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function runDiagnoseCli(args=process.argv.slice(2),io=console) {
|
|
128
|
+
let project='.',json=false;const paths=[]
|
|
129
|
+
for(const arg of args) {if(arg==='--json') json=true;else if(arg.startsWith('--project=')) project=arg.slice(10);else if(arg.startsWith('--')) {io.error('usage: diagnose.mjs --project=<directory> [--json] <paths...>');return 2}else paths.push(arg)}
|
|
130
|
+
let result
|
|
131
|
+
try {result=await diagnoseProject({project,paths})} catch(error) {io.error(`Incomplete diagnosis: ${error.message}`);return 2}
|
|
132
|
+
if(json) io.log(JSON.stringify(result,null,2))
|
|
133
|
+
else {io.log(`themelia-ui ${result.packageVersion??'unknown'}: ${result.checkedFiles.length} files, ${result.findings.length} findings`);for(const item of result.findings) io.log(`${item.severity} ${item.file}:${item.line}:${item.column} ${item.ruleId}: ${item.explanation}`);for(const item of [...result.skippedChecks,...result.skippedFiles]) io.log(`incomplete ${item.ruleId??item.file}: ${item.reason}`)}
|
|
134
|
+
return result.exitCode
|
|
135
|
+
}
|
|
136
|
+
if(process.argv[1]&&realpathSync(process.argv[1])===realpathSync(fileURLToPath(import.meta.url))) process.exitCode=await runDiagnoseCli()
|
|
@@ -11,6 +11,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
11
11
|
|
|
12
12
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
13
13
|
export const INDEX_PATH = resolve(HERE, '..', '..', 'docs/generated/component-index.json')
|
|
14
|
+
export const CONTEXTS = ['existing-surface', 'editable', 'read-only', 'server-collection', 'nested-overlay', 'changing-scope']
|
|
14
15
|
|
|
15
16
|
/** One catalogue entry in the shape the finder searches and `--json` prints. */
|
|
16
17
|
const toRecord = (entry) => ({
|
|
@@ -36,7 +37,9 @@ const toRecord = (entry) => ({
|
|
|
36
37
|
|
|
37
38
|
export function loadIndex(path = INDEX_PATH) {
|
|
38
39
|
if (!existsSync(path)) throw new Error(`the packaged component index is missing at ${path}`)
|
|
39
|
-
|
|
40
|
+
const index = JSON.parse(readFileSync(path, 'utf8'))
|
|
41
|
+
if (!Number.isInteger(index.schemaVersion) || index.schemaVersion < 1 || index.schemaVersion > 4 || !index.modules) throw new Error('unsupported component index schema; use the finder shipped with the installed package')
|
|
42
|
+
return Object.values(index.modules).map(toRecord)
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
const text = (value) => (Array.isArray(value) ? value.join(' ') : String(value ?? ''))
|
|
@@ -87,9 +90,11 @@ const STOPWORDS = new Set([
|
|
|
87
90
|
* which beats positive guidance and recipes. `avoidWhen` text never counts as a match.
|
|
88
91
|
*/
|
|
89
92
|
export function findComponents(modules, query, filters = {}) {
|
|
93
|
+
const contexts = filters.context ?? []
|
|
94
|
+
if (!Array.isArray(contexts) || contexts.some(value => !CONTEXTS.includes(value))) throw new Error(`unknown context; expected ${CONTEXTS.join(', ')}`)
|
|
90
95
|
const needle = query.trim().toLowerCase()
|
|
91
96
|
const hasFilter = ['tier', 'profile', 'status', 'module', 'peer', 'route', 'symbol']
|
|
92
|
-
.some((name) => Boolean(filters[name]))
|
|
97
|
+
.some((name) => Boolean(filters[name])) || contexts.length > 0
|
|
93
98
|
if (!needle && !hasFilter) return []
|
|
94
99
|
|
|
95
100
|
const scored = []
|
|
@@ -104,7 +109,7 @@ export function findComponents(modules, query, filters = {}) {
|
|
|
104
109
|
const symbols = (record.publicSymbols ?? []).map((s) => String(s).toLowerCase())
|
|
105
110
|
if (filters.symbol && !symbols.includes(filters.symbol.toLowerCase())) continue
|
|
106
111
|
|
|
107
|
-
if (!needle) {
|
|
112
|
+
if (!needle && !contexts.length) {
|
|
108
113
|
scored.push({ record, score: 0 })
|
|
109
114
|
continue
|
|
110
115
|
}
|
|
@@ -123,8 +128,8 @@ export function findComponents(modules, query, filters = {}) {
|
|
|
123
128
|
)
|
|
124
129
|
if (normalizedSymbols.includes(phrase)) score = 100
|
|
125
130
|
else if (normalizedModule === phrase) score = 90
|
|
126
|
-
else if (normalizedSymbols.some((symbol) => symbol.includes(phrase))) score = 75
|
|
127
|
-
else if (normalizedModule.includes(phrase)) score = 70
|
|
131
|
+
else if (phrase && normalizedSymbols.some((symbol) => symbol.includes(phrase))) score = 75
|
|
132
|
+
else if (phrase && normalizedModule.includes(phrase)) score = 70
|
|
128
133
|
else if (queryTerms.length > 1 && identityWholeMatch) score = 65
|
|
129
134
|
else {
|
|
130
135
|
const positive = [
|
|
@@ -151,16 +156,17 @@ export function findComponents(modules, query, filters = {}) {
|
|
|
151
156
|
}
|
|
152
157
|
for (const entry of record.componentGuidance ?? []) {
|
|
153
158
|
const curated = entry.guidanceSource === 'component'
|
|
154
|
-
const
|
|
159
|
+
const matchedContexts = contexts.filter(value => entry.contexts?.includes(value))
|
|
160
|
+
const corpus = [entry.symbol, entry.description, curated ? entry.chooseWhen : '', ...(entry.capabilities ?? []), ...matchedContexts].join(' ')
|
|
155
161
|
const hits = termHits(queryTerms, corpus)
|
|
156
162
|
const exact = normalizedWords(entry.symbol).join(' ') === phrase
|
|
157
|
-
if (exact || (queryTerms.length && hits * 2 >= queryTerms.length)) {
|
|
158
|
-
const componentScore = exact ? 100 : Math.min(curated ?
|
|
159
|
-
componentMatches.push({ ...entry, score: componentScore, matchedTerms: queryTerms.filter((term) => termHits([term], corpus)) })
|
|
163
|
+
if (exact || (queryTerms.length && hits * 2 >= queryTerms.length) || (!needle && matchedContexts.length)) {
|
|
164
|
+
const componentScore = exact ? 100 : Math.min(curated ? 74 : 34, (curated ? 39 : 10) + (queryTerms.length ? Math.round(25 * hits / queryTerms.length) : 0) + matchedContexts.length * 5)
|
|
165
|
+
componentMatches.push({ ...entry, score: componentScore, matchedContexts, contextCoverage: entry.contexts ? 'curated' : 'unknown', matchedTerms: queryTerms.filter((term) => termHits([term], corpus)) })
|
|
160
166
|
score = Math.max(score, componentScore)
|
|
161
167
|
}
|
|
162
168
|
}
|
|
163
|
-
if (score > 0) scored.push({ record: filters.explain ? { ...record, match: { score, components: componentMatches.sort((a, b) => b.score - a.score), reason: componentMatches.length ? 'Public component name, description
|
|
169
|
+
if (score > 0 || !needle) scored.push({ record: filters.explain ? { ...record, match: { score, contexts, contextCoverage: (record.componentGuidance ?? []).some(entry => entry.contexts) ? 'curated' : 'unknown', components: componentMatches.sort((a, b) => b.score - a.score), reason: componentMatches.length ? 'Public component name, description, positive usage guidance or applicable context' : 'Module identity, positive guidance or attributed recipe; uncurated contexts remain unknown' } } : record, score })
|
|
164
170
|
}
|
|
165
171
|
|
|
166
172
|
return scored
|
|
@@ -180,6 +186,7 @@ Options:
|
|
|
180
186
|
--symbol=<Name> exact public export name
|
|
181
187
|
--peer=<package> modules that require this optional peer
|
|
182
188
|
--route=</path> modules represented on this live preview route
|
|
189
|
+
--context=<context> repeatable: ${CONTEXTS.join(', ')}
|
|
183
190
|
--limit=<N> return at most N matches (default 8)
|
|
184
191
|
--explain include matched components, reasons and API anchors
|
|
185
192
|
--json emit complete machine-readable records
|
|
@@ -187,7 +194,7 @@ Options:
|
|
|
187
194
|
|
|
188
195
|
A text query is optional when at least one catalogue filter is present.`
|
|
189
196
|
|
|
190
|
-
const VALUE_FLAGS = new Set(['tier', 'profile', 'status', 'module', 'symbol', 'peer', 'route', 'limit'])
|
|
197
|
+
const VALUE_FLAGS = new Set(['tier', 'profile', 'status', 'module', 'symbol', 'peer', 'route', 'limit', 'context'])
|
|
191
198
|
const BOOLEAN_FLAGS = new Set(['json', 'help', 'explain'])
|
|
192
199
|
|
|
193
200
|
export function parseFinderArgs(args) {
|
|
@@ -207,7 +214,10 @@ export function parseFinderArgs(args) {
|
|
|
207
214
|
if (!VALUE_FLAGS.has(rawName)) throw new Error(`unknown option --${rawName}`)
|
|
208
215
|
const value = rest.join('=')
|
|
209
216
|
if (!value) throw new Error(`--${rawName} requires a value written as --${rawName}=...`)
|
|
210
|
-
|
|
217
|
+
if (rawName === 'context') {
|
|
218
|
+
if (!CONTEXTS.includes(value)) throw new Error(`unknown context ${value}; expected ${CONTEXTS.join(', ')}`)
|
|
219
|
+
options.context = [...(options.context ?? []), value]
|
|
220
|
+
} else options[rawName] = value
|
|
211
221
|
}
|
|
212
222
|
const limit = options.limit === undefined ? 8 : Number(options.limit)
|
|
213
223
|
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
@@ -229,7 +239,7 @@ export function runFinderCli(args = process.argv.slice(2), io = console) {
|
|
|
229
239
|
return 0
|
|
230
240
|
}
|
|
231
241
|
const filters = Object.fromEntries(
|
|
232
|
-
['tier', 'profile', 'status', 'module', 'symbol', 'peer', 'route']
|
|
242
|
+
['tier', 'profile', 'status', 'module', 'symbol', 'peer', 'route', 'context']
|
|
233
243
|
.filter((name) => parsed.options[name])
|
|
234
244
|
.map((name) => [name, parsed.options[name]]),
|
|
235
245
|
)
|
|
@@ -265,6 +275,11 @@ export function renderMatch(record) {
|
|
|
265
275
|
lines.push(` match ${record.match.reason}`)
|
|
266
276
|
for (const entry of record.match.components.slice(0, 3)) {
|
|
267
277
|
lines.push(` ${entry.symbol} — ${entry.chooseWhen}`, ` api ${entry.apiDoc}`, ` compose ${entry.composition}`)
|
|
278
|
+
lines.push(` guidance ${entry.guidanceSource === 'component' ? 'component' : 'module fallback'}`)
|
|
279
|
+
lines.push(` context ${entry.contextCoverage ?? 'unknown'}${entry.matchedContexts?.length ? `: ${entry.matchedContexts.join(', ')}` : ''}`)
|
|
280
|
+
const unmatched = (record.match.contexts ?? []).filter(context => !entry.matchedContexts?.includes(context))
|
|
281
|
+
if (unmatched.length) lines.push(` unmatched ${unmatched.join(', ')}`)
|
|
282
|
+
for (const workflow of entry.workflows ?? []) lines.push(` source node_modules/themelia-ui/docs/generated/${workflow.source}`)
|
|
268
283
|
}
|
|
269
284
|
}
|
|
270
285
|
return lines.join('\n')
|
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
/*
|
|
3
3
|
* Installs the themelia-ui agent skill (which component to use, its exact import, what to
|
|
4
4
|
* avoid) into a project's .agents/ and/or .claude/ skills. Offline: reads the copy shipped
|
|
5
|
-
* in this package.
|
|
5
|
+
* in this package. Stages replacements and rolls back caught write failures; never replaces a hand-written skill.
|
|
6
6
|
* node node_modules/themelia-ui/scripts/consumer/install-skill.mjs --project=. [--target=agents|claude|both]
|
|
7
7
|
*/
|
|
8
8
|
import {
|
|
9
9
|
cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync,
|
|
10
|
-
|
|
10
|
+
lstatSync,
|
|
11
11
|
} from 'node:fs'
|
|
12
|
-
import {
|
|
12
|
+
import { createHash } from 'node:crypto'
|
|
13
|
+
import { dirname, join, relative, resolve, sep } from 'node:path'
|
|
13
14
|
import { fileURLToPath } from 'node:url'
|
|
14
15
|
|
|
15
16
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
@@ -30,17 +31,78 @@ export const TARGET_DIRECTORIES = {
|
|
|
30
31
|
|
|
31
32
|
/** Whether a destination may be replaced: absent, or carrying our marker. */
|
|
32
33
|
export function mayOverwrite(destination) {
|
|
34
|
+
if (existsSync(destination) && lstatSync(destination).isSymbolicLink()) return false
|
|
33
35
|
const skill = join(destination, 'SKILL.md')
|
|
34
|
-
if (!existsSync(skill)) return
|
|
36
|
+
if (!existsSync(skill)) return !existsSync(destination) || readdirSync(destination).length === 0
|
|
35
37
|
return readFileSync(skill, 'utf8').includes(GENERATED_MARKER)
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
|
|
40
|
+
const hashFile = (file) => createHash('sha256').update(readFileSync(file)).digest('hex')
|
|
41
|
+
const inside = (root, file) => {
|
|
42
|
+
const path = relative(realpathSync(root), realpathSync(file))
|
|
43
|
+
return path !== '..' && !path.startsWith('../') && !path.startsWith('..\\')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A manifest is data, never permission to read paths outside the skill directory. */
|
|
47
|
+
export function readSkillManifest(root) {
|
|
48
|
+
const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8'))
|
|
49
|
+
if (manifest.schemaVersion !== 1 || typeof manifest.packageVersion !== 'string' || !manifest.files || typeof manifest.files !== 'object' || Array.isArray(manifest.files)) throw new Error('unsupported skill manifest')
|
|
50
|
+
if (!manifest.files['SKILL.md']) throw new Error('skill manifest has no SKILL.md')
|
|
51
|
+
for (const [path, hash] of Object.entries(manifest.files)) {
|
|
52
|
+
if (!path || path === 'manifest.json' || path.startsWith('/') || path.includes('\\') || path.split('/').some((part) => !part || part === '.' || part === '..') || !/^[a-f0-9]{64}$/.test(hash)) throw new Error(`invalid skill manifest path or hash: ${path}`)
|
|
53
|
+
}
|
|
54
|
+
return manifest
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Inspect installed copies without changing application policy or generated bytes. */
|
|
58
|
+
export function checkSkill({ project = '.', target = 'both', source = SOURCE } = {}) {
|
|
59
|
+
const names = target === 'both' ? ['agents', 'claude'] : [target]
|
|
60
|
+
if (names.some((name) => !(name in TARGET_DIRECTORIES))) throw new Error(`unknown --target=${target}; expected agents, claude or both`)
|
|
61
|
+
let expected
|
|
62
|
+
try {
|
|
63
|
+
expected = readSkillManifest(source)
|
|
64
|
+
const recorded = Object.keys(expected.files).sort()
|
|
65
|
+
const actual = listFiles(source).filter(file => file !== 'manifest.json')
|
|
66
|
+
if (JSON.stringify(recorded) !== JSON.stringify(actual)) throw new Error('packaged skill inventory differs from its manifest')
|
|
67
|
+
for (const [file, hash] of Object.entries(expected.files)) {
|
|
68
|
+
const path = join(source, file)
|
|
69
|
+
if (!inside(source, path) || hashFile(path) !== hash) throw new Error(`packaged skill differs from its manifest: ${file}`)
|
|
70
|
+
}
|
|
71
|
+
} catch (error) { return { packageVersion: null, targets: [], error: error.message, exitCode: 2 } }
|
|
72
|
+
const targets = names.map((name) => {
|
|
73
|
+
const destination = resolve(project, TARGET_DIRECTORIES[name])
|
|
74
|
+
const item = { target: name, status: 'missing', installedVersion: null, changedFiles: [], locallyModifiedFiles: [] }
|
|
75
|
+
if (!existsSync(destination)) return item
|
|
76
|
+
if (!mayOverwrite(destination)) return { ...item, status: 'unmanaged' }
|
|
77
|
+
let installed
|
|
78
|
+
try { installed = readSkillManifest(destination) } catch {
|
|
79
|
+
item.locallyModifiedFiles = listFiles(destination).filter(file => file !== 'manifest.json').filter(file => {
|
|
80
|
+
try { return !expected.files[file] || !inside(destination, join(destination, file)) || hashFile(join(destination, file)) !== expected.files[file] } catch { return true }
|
|
81
|
+
})
|
|
82
|
+
return { ...item, status: 'stale', changedFiles: ['manifest.json', ...item.locallyModifiedFiles] }
|
|
83
|
+
}
|
|
84
|
+
item.installedVersion = installed.packageVersion
|
|
85
|
+
item.locallyModifiedFiles = [...new Set([...Object.keys(installed.files), ...listFiles(destination).filter(file => file !== 'manifest.json')])].filter(file => {
|
|
86
|
+
try { return !installed.files[file] || !inside(destination, join(destination, file)) || hashFile(join(destination, file)) !== installed.files[file] } catch { return true }
|
|
87
|
+
}).sort()
|
|
88
|
+
const files = new Set([...Object.keys(expected.files), ...listFiles(destination).filter(file => file !== 'manifest.json')])
|
|
89
|
+
item.changedFiles = [...files].filter((file) => {
|
|
90
|
+
try { return !expected.files[file] || !inside(destination, join(destination, file)) || hashFile(join(destination, file)) !== expected.files[file] } catch { return true }
|
|
91
|
+
}).sort()
|
|
92
|
+
if (JSON.stringify(installed.files) !== JSON.stringify(expected.files)) item.changedFiles.push('manifest.json')
|
|
93
|
+
item.status = installed.packageVersion !== expected.packageVersion ? 'stale' : item.changedFiles.length ? 'modified' : 'current'
|
|
94
|
+
return item
|
|
95
|
+
})
|
|
96
|
+
return { packageVersion: expected.packageVersion, targets, exitCode: targets.every((item) => item.status === 'current') ? 0 : 1 }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function installSkill({ project = '.', target = 'both', source = SOURCE, overwriteModified = false } = {}) {
|
|
39
100
|
const targets = target === 'both' ? ['agents', 'claude'] : [target]
|
|
40
101
|
if (targets.some((name) => !(name in TARGET_DIRECTORIES))) {
|
|
41
102
|
throw new Error(`unknown --target=${target}; expected agents, claude or both`)
|
|
42
103
|
}
|
|
43
104
|
if (!existsSync(source)) throw new Error(`the packaged skill is missing at ${source}`)
|
|
105
|
+
for (const file of listFiles(source)) if (lstatSync(join(source, file)).isSymbolicLink()) throw new Error(`skill source contains a symlink: ${file}`)
|
|
44
106
|
|
|
45
107
|
const nonce = `${process.pid}-${Date.now()}`
|
|
46
108
|
const destinations = targets.map((name, index) => {
|
|
@@ -67,6 +129,10 @@ export function installSkill({ project = '.', target = 'both', source = SOURCE }
|
|
|
67
129
|
)
|
|
68
130
|
}
|
|
69
131
|
}
|
|
132
|
+
const freshness = checkSkill({ project, target, source })
|
|
133
|
+
if (freshness.exitCode === 2) throw new Error(`invalid packaged skill metadata: ${freshness.error}`)
|
|
134
|
+
const modified = freshness.targets.filter(item => item.locallyModifiedFiles?.length)
|
|
135
|
+
if (modified.length && !overwriteModified) throw new Error(`modified generated files: ${modified.map(item => `${item.target}: ${item.locallyModifiedFiles.join(', ')}`).join('; ')}. Review these changes and preserve application policy separately; --overwrite-modified explicitly replaces them.`)
|
|
70
136
|
|
|
71
137
|
try {
|
|
72
138
|
for (const item of destinations) {
|
|
@@ -78,8 +144,6 @@ export function installSkill({ project = '.', target = 'both', source = SOURCE }
|
|
|
78
144
|
renameSync(item.stage, item.destination)
|
|
79
145
|
item.committed = true
|
|
80
146
|
}
|
|
81
|
-
for (const item of destinations) rmSync(item.backup, { recursive: true, force: true })
|
|
82
|
-
return destinations.map(({ destination }) => destination)
|
|
83
147
|
} catch (error) {
|
|
84
148
|
for (const item of [...destinations].reverse()) {
|
|
85
149
|
rmSync(item.stage, { recursive: true, force: true })
|
|
@@ -88,6 +152,12 @@ export function installSkill({ project = '.', target = 'both', source = SOURCE }
|
|
|
88
152
|
}
|
|
89
153
|
throw error
|
|
90
154
|
}
|
|
155
|
+
// Commit is complete; discarded backups cannot support a later rollback.
|
|
156
|
+
for (const item of destinations) {
|
|
157
|
+
try { rmSync(item.backup, { recursive: true, force: true }) }
|
|
158
|
+
catch (error) { throw new Error(`Skill copies installed; backup cleanup failed at ${item.backup}: ${error.message}`) }
|
|
159
|
+
}
|
|
160
|
+
return destinations.map(({ destination }) => destination)
|
|
91
161
|
}
|
|
92
162
|
|
|
93
163
|
/** Every file under a directory, relative and sorted — for tests and for the summary. */
|
|
@@ -96,8 +166,8 @@ export function listFiles(root) {
|
|
|
96
166
|
const walk = (dir) => {
|
|
97
167
|
for (const entry of readdirSync(dir)) {
|
|
98
168
|
const full = join(dir, entry)
|
|
99
|
-
if (
|
|
100
|
-
else out.push(relative(root, full))
|
|
169
|
+
if (lstatSync(full).isDirectory()) walk(full)
|
|
170
|
+
else out.push(relative(root, full).split(sep).join('/'))
|
|
101
171
|
}
|
|
102
172
|
}
|
|
103
173
|
if (existsSync(root)) walk(root)
|
|
@@ -122,10 +192,13 @@ function runFromCommandLine(moduleUrl) {
|
|
|
122
192
|
|
|
123
193
|
const invokedDirectly = runFromCommandLine(import.meta.url)
|
|
124
194
|
if (invokedDirectly) {
|
|
125
|
-
const USAGE = `usage: install-skill.mjs [--project=<directory>] [--target=agents|claude|both]
|
|
195
|
+
const USAGE = `usage: install-skill.mjs [--project=<directory>] [--target=agents|claude|both] [--check] [--json] [--overwrite-modified]
|
|
126
196
|
|
|
127
197
|
Copies the packaged themelia-ui consumer skill into a project. Existing generated
|
|
128
|
-
copies are
|
|
198
|
+
copies are staged before replacement; caught write failures restore their backups.
|
|
199
|
+
A same-named hand-written skill is never overwritten.
|
|
200
|
+
--check is read-only: 0 current, 1 missing/drift/unmanaged, 2 invalid package metadata.
|
|
201
|
+
--overwrite-modified replaces reviewed local changes in a generated copy; unmanaged skills remain protected.`
|
|
129
202
|
try {
|
|
130
203
|
const options = { project: '.', target: 'both' }
|
|
131
204
|
for (const argument of process.argv.slice(2)) {
|
|
@@ -133,10 +206,19 @@ copies are replaced atomically; a same-named hand-written skill is never overwri
|
|
|
133
206
|
console.log(USAGE)
|
|
134
207
|
process.exit(0)
|
|
135
208
|
}
|
|
209
|
+
if (argument === '--check' || argument === '--json') { options[argument.slice(2)] = true; continue }
|
|
210
|
+
if (argument === '--overwrite-modified') { options.overwriteModified = true; continue }
|
|
136
211
|
const match = argument.match(/^--(project|target)=(.+)$/)
|
|
137
212
|
if (!match) throw new Error(`unknown argument ${argument}\n\n${USAGE}`)
|
|
138
213
|
options[match[1]] = match[2]
|
|
139
214
|
}
|
|
215
|
+
if (options.json && !options.check) throw new Error('--json requires --check')
|
|
216
|
+
if (options.check) {
|
|
217
|
+
const report = checkSkill(options)
|
|
218
|
+
console.log(options.json ? JSON.stringify(report, null, 2) : report.error ?? report.targets.map(item => `${item.target}: ${item.status} (installed ${item.installedVersion ?? 'unknown'}, package ${report.packageVersion})${item.changedFiles.length ? ` — ${item.changedFiles.join(', ')}` : ''}`).join('\n'))
|
|
219
|
+
if (report.exitCode === 1 && !options.json) console.log('Review modified/unmanaged copies; reinstall generated copies with install-skill.mjs --project=<directory>. Application policy stays in a separate skill.')
|
|
220
|
+
process.exit(report.exitCode)
|
|
221
|
+
}
|
|
140
222
|
const installed = installSkill(options)
|
|
141
223
|
for (const destination of installed) {
|
|
142
224
|
console.log(`installed ${listFiles(destination).length} file(s) into ${relative(process.cwd(), destination) || '.'}`)
|
package/src/styles/TOKENS.md
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
The theme is `src/styles/theme/*.css`: about eighty variables, declared once at `:root`. Every
|
|
4
4
|
colour is `light-dark(light, dark)`, and the element's `color-scheme` (set by `.light`,
|
|
5
|
-
`.dark`, `data-theme` or the operating system) picks the half.
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
`.dark`, `data-theme` or the operating system) picks the half. Component styles inherit
|
|
6
|
+
theme defaults rather than redeclaring them. Explicit scope overrides and density presets
|
|
7
|
+
replace the corresponding inherited values on their regions. A dark region inside a light
|
|
8
|
+
page needs no second set of colour declarations.
|
|
8
9
|
|
|
9
10
|
Components read the theme and compute the rest in the rule that uses it. The package's checks
|
|
10
11
|
fail on a `var()` nothing defines, a literal colour, radius, spacing or hairline in a
|