hlmnhkl-ui 0.18.0 → 0.19.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/README.md +22 -7
- package/package.json +1 -1
- package/scripts/add.js +276 -75
- package/src/components/Toast/Toast.module.css +2 -0
package/README.md
CHANGED
|
@@ -11,18 +11,20 @@ React components that move with meaning. Black, white and grey, accessible, and
|
|
|
11
11
|
|
|
12
12
|
## Install
|
|
13
13
|
|
|
14
|
-
From your app's folder, copy the components you need:
|
|
14
|
+
From your app's folder, set it up once, then copy the components you need:
|
|
15
15
|
|
|
16
16
|
```sh
|
|
17
|
+
npx hlmnhkl-ui@latest init
|
|
17
18
|
npx hlmnhkl-ui@latest add button dialog
|
|
18
19
|
```
|
|
19
20
|
|
|
20
|
-
Also `pnpm dlx`, `yarn dlx` (Yarn 2 and up) or `bunx`.
|
|
21
|
+
Also `pnpm dlx`, `yarn dlx` (Yarn 2 and up) or `bunx`. `init` adds `tokens.css` and `base.css` to `src/ui` (or `--to <folder>`) and remembers the folder in `hlmnhkl-ui.json`. `add` also copies the components they use (Button needs Spinner) and the shared files.
|
|
21
22
|
|
|
22
|
-
Then import the
|
|
23
|
+
Then import the styles once at your app root, and use a component:
|
|
23
24
|
|
|
24
25
|
```tsx
|
|
25
26
|
import './ui/tokens.css'
|
|
27
|
+
import './ui/base.css'
|
|
26
28
|
import { Button } from './ui/components/Button'
|
|
27
29
|
|
|
28
30
|
<Button onClick={save}>Save</Button>
|
|
@@ -30,12 +32,25 @@ import { Button } from './ui/components/Button'
|
|
|
30
32
|
|
|
31
33
|
Using Next.js or React Router? The [installation guide](https://ui.hlmnhkl.my/installation) has the steps for each.
|
|
32
34
|
|
|
35
|
+
| Command | What it does |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| `init` | Set a project up: `tokens.css`, `base.css` and `hlmnhkl-ui.json` |
|
|
38
|
+
| `add <name>...` | Copy components, with what they import |
|
|
39
|
+
| `list` | Show everything there is, free and Pro. `--kind blocks` filters |
|
|
40
|
+
| `diff <name>...` | Show what changed between your copy and the latest version |
|
|
41
|
+
| `update <name>...` | Show the changes, then replace your copy after you say yes |
|
|
42
|
+
|
|
33
43
|
| Option | What it does |
|
|
34
44
|
| --- | --- |
|
|
35
|
-
| `--to <folder>` | Where the files go. Default: `src/ui` |
|
|
36
|
-
| `--dry-run` | Print what would
|
|
37
|
-
| `--overwrite` |
|
|
38
|
-
| `--with-base` |
|
|
45
|
+
| `--to <folder>` | Where the files go. Default: the folder in `hlmnhkl-ui.json`, else `src/ui` |
|
|
46
|
+
| `--dry-run` | Print what would happen, write nothing |
|
|
47
|
+
| `--overwrite` | `add`: replace files that are already there. Without it, they are skipped and listed |
|
|
48
|
+
| `--with-base` | `add`: also copy `base.css` (`init` always does) |
|
|
49
|
+
| `--yes` | `update`: replace without asking |
|
|
50
|
+
|
|
51
|
+
Every copied file starts with one line naming hlmnhkl/ui and the MIT licence, so the licence notice travels with the code. `diff` and `update` ignore that line.
|
|
52
|
+
|
|
53
|
+
**With an AI assistant:** point it at [llms.txt](https://ui.hlmnhkl.my/llms.txt) (every component and the rules) or [llms-full.txt](https://ui.hlmnhkl.my/llms-full.txt) (props, keyboard notes and an example for each free component). There is also a [registry.json](https://ui.hlmnhkl.my/registry.json).
|
|
39
54
|
|
|
40
55
|
## What you get
|
|
41
56
|
|
package/package.json
CHANGED
package/scripts/add.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
3
|
-
//
|
|
2
|
+
// The hlmnhkl/ui command line: sets a project up, lists what exists, and copies components, blocks,
|
|
3
|
+
// templates and themes (with everything they import) into it, then shows and takes later changes.
|
|
4
|
+
// Runs as `npx hlmnhkl-ui <command>` from the npm package, or `npm run add -- <command>` in this repo.
|
|
4
5
|
// Plain JavaScript on purpose: the project has no Node type package.
|
|
5
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
6
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
|
6
7
|
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
|
|
8
|
+
import { createInterface } from 'node:readline/promises'
|
|
7
9
|
import { fileURLToPath } from 'node:url'
|
|
8
10
|
|
|
9
11
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
@@ -11,8 +13,25 @@ const SRC = join(ROOT, 'src')
|
|
|
11
13
|
const INTERNAL = join(SRC, 'internal')
|
|
12
14
|
const EXCLUDED = /(\.(test|spec|stories|demo)\.[jt]sx?$)|(^|\/)(docs|demos|__tests__)\//
|
|
13
15
|
const DEFAULT_TO = 'src/ui'
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
+
const CONFIG = 'hlmnhkl-ui.json'
|
|
17
|
+
const SITE = 'https://ui.hlmnhkl.my'
|
|
18
|
+
const COMMANDS = ['init', 'add', 'list', 'diff', 'update']
|
|
19
|
+
const USAGE = `Usage: npx hlmnhkl-ui <command> [options]
|
|
20
|
+
|
|
21
|
+
init Set a project up: tokens.css, base.css and ${CONFIG}
|
|
22
|
+
add <name> [<name>...] Copy components, blocks, templates or themes, with what they import
|
|
23
|
+
list [--kind <kind>] Show everything there is (kind: components, blocks, templates, themes)
|
|
24
|
+
diff <name> [<name>...] Show what changed between your copy and the latest version
|
|
25
|
+
update <name> [...] Show the changes, then replace your copy after you say yes
|
|
26
|
+
|
|
27
|
+
Options:
|
|
28
|
+
--to <folder> Where files go. Defaults to the folder in ${CONFIG}, else ${DEFAULT_TO}
|
|
29
|
+
--overwrite add: replace files that are already there
|
|
30
|
+
--with-base add: also copy base.css (init always does)
|
|
31
|
+
--yes update: replace without asking
|
|
32
|
+
--dry-run Show what would happen, write nothing
|
|
33
|
+
|
|
34
|
+
In this repo, run: npm run add -- <command> ...`
|
|
16
35
|
|
|
17
36
|
// A theme is one CSS file; every other kind is a folder per name. Each kind keeps its folder name in the target.
|
|
18
37
|
const KINDS = [
|
|
@@ -22,35 +41,41 @@ const KINDS = [
|
|
|
22
41
|
{ key: 'theme', plural: 'Themes', folder: 'themes', pro: true, single: true },
|
|
23
42
|
].map((k) => ({ ...k, dir: join(SRC, k.folder) }))
|
|
24
43
|
|
|
25
|
-
function fail(message) {
|
|
44
|
+
function fail(message, usage = true) {
|
|
26
45
|
console.error(`Error: ${message}`)
|
|
27
|
-
console.error(USAGE)
|
|
46
|
+
if (usage) console.error(`\n${USAGE}`)
|
|
28
47
|
process.exit(1)
|
|
29
48
|
}
|
|
30
49
|
|
|
31
50
|
function parseArgs(argv) {
|
|
32
|
-
const opts = { names: [], to: '', overwrite: false, dryRun: false, withBase: false }
|
|
51
|
+
const opts = { command: 'add', names: [], to: '', kind: '', overwrite: false, dryRun: false, withBase: false, yes: false }
|
|
52
|
+
const rest = []
|
|
33
53
|
for (let i = 0; i < argv.length; i++) {
|
|
34
54
|
const arg = argv[i]
|
|
35
|
-
if (arg === '--help' || arg === '-h') {
|
|
55
|
+
if (arg === '--help' || arg === '-h' || arg === 'help') {
|
|
36
56
|
console.log(USAGE)
|
|
37
|
-
console.log('
|
|
57
|
+
console.log('\nA name can be a component (Button), a block (Dashboard), a template or a theme (forest).')
|
|
38
58
|
console.log('When two kinds share a name, add the kind: blocks/Settings, templates/Settings.')
|
|
39
59
|
process.exit(0)
|
|
40
|
-
} else if (arg === '--to') {
|
|
41
|
-
|
|
42
|
-
if (!
|
|
60
|
+
} else if (arg === '--to' || arg === '--kind') {
|
|
61
|
+
const value = argv[++i] ?? ''
|
|
62
|
+
if (!value || value.startsWith('--')) fail(arg === '--to' ? '--to needs a folder, for example --to src/ui' : '--kind needs a kind, for example --kind blocks')
|
|
63
|
+
opts[arg.slice(2)] = value
|
|
43
64
|
} else if (arg.startsWith('--to=')) opts.to = arg.slice(5)
|
|
65
|
+
else if (arg.startsWith('--kind=')) opts.kind = arg.slice(7)
|
|
44
66
|
else if (arg === '--overwrite') opts.overwrite = true
|
|
45
67
|
else if (arg === '--dry-run') opts.dryRun = true
|
|
46
68
|
else if (arg === '--with-base') opts.withBase = true
|
|
69
|
+
else if (arg === '--yes' || arg === '-y') opts.yes = true
|
|
47
70
|
else if (arg.startsWith('-')) fail(`Unknown option ${arg}.`)
|
|
48
|
-
else
|
|
71
|
+
else rest.push(arg)
|
|
72
|
+
}
|
|
73
|
+
// The command comes first, like shadcn. A bare name still means add, as before.
|
|
74
|
+
if (COMMANDS.includes(rest[0])) opts.command = rest.shift()
|
|
75
|
+
opts.names = rest
|
|
76
|
+
if (['add', 'diff', 'update'].includes(opts.command) && !opts.names.length) {
|
|
77
|
+
fail(`Name at least one component, block, template or theme, for example: npx hlmnhkl-ui ${opts.command} button`)
|
|
49
78
|
}
|
|
50
|
-
// `npx hlmnhkl-ui add button` passes "add" first, like shadcn.
|
|
51
|
-
if (opts.names[0] === 'add') opts.names.shift()
|
|
52
|
-
if (!opts.names.length) fail('Name at least one component, block, template or theme, for example button.')
|
|
53
|
-
if (!opts.to) opts.to = DEFAULT_TO
|
|
54
79
|
return opts
|
|
55
80
|
}
|
|
56
81
|
|
|
@@ -88,6 +113,7 @@ const rel = (p) => relative(SRC, p).split(sep).join('/')
|
|
|
88
113
|
const kindOf = (key) => KINDS.find((k) => k.key === key)
|
|
89
114
|
const keyOf = (item) => `${item.kind}:${item.name}`
|
|
90
115
|
const label = (item) => (item.kind === 'component' ? item.name : `${item.name} (${item.kind})`)
|
|
116
|
+
const kebab = (name) => name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
|
|
91
117
|
|
|
92
118
|
// Every name that can be copied, in every kind.
|
|
93
119
|
function listItems() {
|
|
@@ -114,9 +140,9 @@ function lookup(raw, all) {
|
|
|
114
140
|
fail(`"${raw}" is more than one thing: ${hits.map(label).join(', ')}. Add the kind, for example ${kindOf(hits[1].kind).folder}/${hits[1].name}.`)
|
|
115
141
|
}
|
|
116
142
|
const pro = proComponents().find((p) => p.toLowerCase() === name.toLowerCase())
|
|
117
|
-
if (pro) fail(`${pro} is in hlmnhkl/ui Pro, so it is not in this package. See
|
|
143
|
+
if (pro) fail(`${pro} is in hlmnhkl/ui Pro, so it is not in this package. See ${SITE}/${raw.toLowerCase()}`, false)
|
|
118
144
|
const close = suggest(name, all)
|
|
119
|
-
fail(`There is no component, block, template or theme called "${raw}".${close.length ? ` Did you mean ${close.join(', ')}?` : ''}
|
|
145
|
+
fail(`There is no component, block, template or theme called "${raw}".${close.length ? ` Did you mean ${close.join(', ')}?` : ''}`, false)
|
|
120
146
|
}
|
|
121
147
|
|
|
122
148
|
// Only in the public package: the names of Pro components, so asking for one gets a clear answer.
|
|
@@ -125,6 +151,19 @@ function proComponents() {
|
|
|
125
151
|
return isFile(file) ? JSON.parse(readFileSync(file, 'utf8')) : []
|
|
126
152
|
}
|
|
127
153
|
|
|
154
|
+
// Only in this repo: the Pro component ids from src/docs/tiers.ts, so a Pro file gets the Pro credit line.
|
|
155
|
+
let proIds
|
|
156
|
+
function isPro(item) {
|
|
157
|
+
if (kindOf(item.kind).pro) return true
|
|
158
|
+
if (proIds === undefined) {
|
|
159
|
+
const tiers = join(SRC, 'docs', 'tiers.ts')
|
|
160
|
+
const src = isFile(tiers) ? readFileSync(tiers, 'utf8') : ''
|
|
161
|
+
const set = src.match(/proComponentIds = new Set\(\[([\s\S]*?)\]\)/)?.[1] ?? ''
|
|
162
|
+
proIds = new Set([...set.matchAll(/'([a-z0-9-]+)'/g)].map((m) => m[1]))
|
|
163
|
+
}
|
|
164
|
+
return proIds.has(kebab(item.name))
|
|
165
|
+
}
|
|
166
|
+
|
|
128
167
|
function listFiles(dir) {
|
|
129
168
|
return readdirSync(dir, { withFileTypes: true }).flatMap((d) => {
|
|
130
169
|
const p = join(dir, d.name)
|
|
@@ -151,7 +190,7 @@ function resolveImport(from, spec) {
|
|
|
151
190
|
const base = resolve(dirname(from), spec)
|
|
152
191
|
const tries = [base, `${base}.ts`, `${base}.tsx`, join(base, 'index.ts'), join(base, 'index.tsx')]
|
|
153
192
|
const hit = tries.find(isFile)
|
|
154
|
-
if (!hit) fail(`${rel(from)} imports "${spec}", but no such file exists
|
|
193
|
+
if (!hit) fail(`${rel(from)} imports "${spec}", but no such file exists.`, false)
|
|
155
194
|
return hit
|
|
156
195
|
}
|
|
157
196
|
|
|
@@ -163,7 +202,7 @@ function ownerOf(file) {
|
|
|
163
202
|
if (k.single) return { kind: k.key, name: basename(file, '.css') }
|
|
164
203
|
return { kind: k.key, name: relative(k.dir, file).split(sep)[0] }
|
|
165
204
|
}
|
|
166
|
-
fail(`${rel(file)} is outside src/components, src/blocks, src/templates, src/themes and src/internal, so it cannot be copied
|
|
205
|
+
fail(`${rel(file)} is outside src/components, src/blocks, src/templates, src/themes and src/internal, so it cannot be copied.`, false)
|
|
167
206
|
}
|
|
168
207
|
|
|
169
208
|
function plan(items) {
|
|
@@ -198,74 +237,236 @@ function plan(items) {
|
|
|
198
237
|
for (let i = 0; i < seenInternal.length; i++) {
|
|
199
238
|
for (const spec of importsOf(seenInternal[i])) {
|
|
200
239
|
const target = resolveImport(seenInternal[i], spec)
|
|
201
|
-
if (ownerOf(target) !== null) fail(`${rel(seenInternal[i])} imports from outside src/internal, which internal files must not do
|
|
240
|
+
if (ownerOf(target) !== null) fail(`${rel(seenInternal[i])} imports from outside src/internal, which internal files must not do.`, false)
|
|
202
241
|
visitInternal(target)
|
|
203
242
|
}
|
|
204
243
|
}
|
|
205
244
|
return { items: [...picked.values()], internal: [...internal].sort(), needs }
|
|
206
245
|
}
|
|
207
246
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
247
|
+
// Every copied file starts with one credit line, so the MIT licence's notice travels with the code.
|
|
248
|
+
// It is left out when comparing, so it never counts as a change.
|
|
249
|
+
function credit(file, pro) {
|
|
250
|
+
const text = pro ? `hlmnhkl/ui Pro, see LICENSE-PRO.md. ${SITE}` : `hlmnhkl/ui, MIT licence. ${SITE}`
|
|
251
|
+
return file.endsWith('.css') ? `/* ${text} */\n` : `// ${text}\n`
|
|
252
|
+
}
|
|
253
|
+
const withoutCredit = (text) => text.replace(/^(\/\/ hlmnhkl\/ui[^\n]*|\/\* hlmnhkl\/ui[^\n]*\*\/)\n/, '')
|
|
211
254
|
|
|
212
|
-
// npm runs scripts from the repo root; INIT_CWD is
|
|
213
|
-
const
|
|
214
|
-
if (existsSync(to) && !isDir(to)) fail(`--to points at a file, not a folder: ${opts.to}`)
|
|
255
|
+
// The project folder (npm runs scripts from the repo root; INIT_CWD is where the command was typed).
|
|
256
|
+
const CWD = process.env.INIT_CWD ?? process.cwd()
|
|
215
257
|
|
|
216
|
-
|
|
258
|
+
function readConfig() {
|
|
259
|
+
const file = join(CWD, CONFIG)
|
|
260
|
+
if (!isFile(file)) return null
|
|
261
|
+
try {
|
|
262
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
263
|
+
} catch {
|
|
264
|
+
fail(`${CONFIG} is not valid JSON. Fix it or delete it, then run the command again.`, false)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
217
267
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const
|
|
221
|
-
|
|
268
|
+
function targetFolder(opts) {
|
|
269
|
+
const folder = opts.to || readConfig()?.to || DEFAULT_TO
|
|
270
|
+
const to = resolve(CWD, folder)
|
|
271
|
+
if (existsSync(to) && !isDir(to)) fail(`--to points at a file, not a folder: ${folder}`, false)
|
|
272
|
+
return { folder, to }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Every file a set of items needs, with where it goes and which credit it gets.
|
|
276
|
+
function copiesFor(items, internal, to) {
|
|
277
|
+
const copies = []
|
|
278
|
+
for (const item of items) {
|
|
279
|
+
const k = kindOf(item.kind)
|
|
280
|
+
const pro = isPro(item)
|
|
281
|
+
for (const file of filesOf(item)) copies.push({ from: file, to: join(to, k.folder, relative(k.dir, file)), pro })
|
|
282
|
+
}
|
|
283
|
+
for (const file of internal) copies.push({ from: file, to: join(to, 'internal', relative(INTERNAL, file)), pro: false })
|
|
284
|
+
return copies
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const fresh = (c) => credit(c.to, c.pro) + readFileSync(c.from, 'utf8')
|
|
288
|
+
const state = (c) => (!existsSync(c.to) ? 'missing' : withoutCredit(readFileSync(c.to, 'utf8')) === readFileSync(c.from, 'utf8') ? 'same' : 'changed')
|
|
289
|
+
|
|
290
|
+
function writeCopy(c, dryRun) {
|
|
291
|
+
if (dryRun) return
|
|
292
|
+
mkdirSync(dirname(c.to), { recursive: true })
|
|
293
|
+
writeFileSync(c.to, fresh(c))
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
|
|
297
|
+
const shown = (to, file) => relative(to, file).split(sep).join('/')
|
|
298
|
+
|
|
299
|
+
// The smallest set of changed lines, with two lines around each, like `git diff`.
|
|
300
|
+
function lineDiff(oldText, newText) {
|
|
301
|
+
const a = oldText.split('\n')
|
|
302
|
+
const b = newText.split('\n')
|
|
303
|
+
const n = a.length
|
|
304
|
+
const m = b.length
|
|
305
|
+
const lcs = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1))
|
|
306
|
+
for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1])
|
|
307
|
+
const ops = []
|
|
308
|
+
let i = 0
|
|
309
|
+
let j = 0
|
|
310
|
+
while (i < n || j < m) {
|
|
311
|
+
if (i < n && j < m && a[i] === b[j]) ops.push([' ', a[i++], j++])
|
|
312
|
+
else if (j < m && (i === n || lcs[i][j + 1] >= lcs[i + 1][j])) ops.push(['+', b[j++]])
|
|
313
|
+
else ops.push(['-', a[i++]])
|
|
314
|
+
}
|
|
315
|
+
const keep = ops.map((op, k) => op[0] !== ' ' || ops.slice(Math.max(0, k - 2), k + 3).some((o) => o[0] !== ' '))
|
|
316
|
+
const out = []
|
|
317
|
+
ops.forEach((op, k) => {
|
|
318
|
+
if (keep[k]) out.push(`${op[0]} ${op[1]}`)
|
|
319
|
+
else if (keep[k - 1]) out.push(' ...')
|
|
320
|
+
})
|
|
321
|
+
return out
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// --- Commands -------------------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
function runInit(opts) {
|
|
327
|
+
const { folder, to } = targetFolder(opts)
|
|
328
|
+
const sheets = ['tokens.css', 'base.css'].map((name) => ({ from: join(SRC, name), to: join(to, name), pro: false }))
|
|
329
|
+
console.log(`${opts.dryRun ? 'Dry run, nothing written. ' : ''}Setting up hlmnhkl/ui in ${folder}`)
|
|
330
|
+
for (const s of sheets) {
|
|
331
|
+
if (existsSync(s.to)) console.log(` ${basename(s.to)}: already there, kept (your theme edits stay)`)
|
|
332
|
+
else {
|
|
333
|
+
writeCopy(s, opts.dryRun)
|
|
334
|
+
console.log(` ${basename(s.to)}: added`)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const config = join(CWD, CONFIG)
|
|
338
|
+
if (isFile(config)) console.log(` ${CONFIG}: already there, kept`)
|
|
339
|
+
else {
|
|
340
|
+
if (!opts.dryRun) writeFileSync(config, `${JSON.stringify({ to: folder }, null, 2)}\n`)
|
|
341
|
+
console.log(` ${CONFIG}: added, so later commands put files in ${folder}`)
|
|
342
|
+
}
|
|
343
|
+
console.log(`\nNext:\n 1. Import both once at your app root, tokens first:\n import './${folder.replace(/^\.\//, '')}/tokens.css'\n import './${folder.replace(/^\.\//, '')}/base.css'`)
|
|
344
|
+
console.log(` 2. Add your first component: npx hlmnhkl-ui add button`)
|
|
345
|
+
console.log(` See everything there is: npx hlmnhkl-ui list, or ${SITE}`)
|
|
222
346
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
347
|
+
|
|
348
|
+
function runList(opts) {
|
|
349
|
+
const all = listItems()
|
|
350
|
+
const wanted = opts.kind ? KINDS.find((k) => k.folder === opts.kind.toLowerCase() || k.key === opts.kind.toLowerCase()) : null
|
|
351
|
+
if (opts.kind && !wanted) fail(`"${opts.kind}" is not a kind. Use components, blocks, templates or themes.`)
|
|
352
|
+
for (const k of KINDS) {
|
|
353
|
+
if (wanted && wanted !== k) continue
|
|
354
|
+
const names = all.filter((i) => i.kind === k.key)
|
|
355
|
+
const free = names.filter((i) => !isPro(i)).map((i) => i.name)
|
|
356
|
+
const pro = [...names.filter((i) => isPro(i)).map((i) => i.name), ...(k.key === 'component' ? proComponents() : [])]
|
|
357
|
+
if (!free.length && !pro.length) continue
|
|
358
|
+
console.log(`${k.plural} (${free.length + pro.length})`)
|
|
359
|
+
if (free.length) console.log(` ${free.join(', ')}`)
|
|
360
|
+
if (pro.length) console.log(` Pro: ${pro.join(', ')}`)
|
|
236
361
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
362
|
+
console.log(`\nAdd one: npx hlmnhkl-ui add <name>. Every page: ${SITE}`)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function runAdd(opts) {
|
|
366
|
+
const all = listItems()
|
|
367
|
+
const { to } = targetFolder(opts)
|
|
368
|
+
const { items, internal, needs } = plan(opts.names.map((n) => lookup(n, all)))
|
|
369
|
+
const copies = copiesFor(items, internal, to)
|
|
370
|
+
const sheets = [{ from: join(SRC, 'tokens.css'), to: join(to, 'tokens.css'), pro: false }]
|
|
371
|
+
if (opts.withBase) sheets.push({ from: join(SRC, 'base.css'), to: join(to, 'base.css'), pro: false })
|
|
372
|
+
|
|
373
|
+
const written = []
|
|
374
|
+
const skipped = []
|
|
375
|
+
const put = (c, allowOverwrite) => {
|
|
376
|
+
const s = state(c)
|
|
377
|
+
if (s !== 'missing' && !allowOverwrite) {
|
|
378
|
+
skipped.push(`${shown(to, c.to)} (already there, ${s === 'same' ? 'same' : 'different'})`)
|
|
379
|
+
return false
|
|
380
|
+
}
|
|
381
|
+
writeCopy(c, opts.dryRun)
|
|
382
|
+
written.push(shown(to, c.to))
|
|
383
|
+
return true
|
|
384
|
+
}
|
|
385
|
+
for (const c of copies) put(c, opts.overwrite)
|
|
386
|
+
// Stylesheets hold the app's own theme edits, so they are never replaced.
|
|
387
|
+
const tokensNew = put(sheets[0], false)
|
|
388
|
+
if (sheets[1]) put(sheets[1], false)
|
|
389
|
+
|
|
390
|
+
console.log(`${opts.dryRun ? 'Dry run, nothing written. ' : ''}Target: ${to}`)
|
|
391
|
+
console.log(`${opts.dryRun ? 'Would copy' : 'Copied'} ${plural(written.length, 'file')}.`)
|
|
392
|
+
for (const k of KINDS) {
|
|
393
|
+
const names = items.filter((i) => i.kind === k.key).map((i) => i.name)
|
|
394
|
+
if (names.length || k.key === 'component') console.log(`${k.plural}: ${names.length ? names.join(', ') : 'none'}`)
|
|
240
395
|
}
|
|
241
|
-
|
|
242
|
-
|
|
396
|
+
console.log(`Internal files: ${internal.length ? internal.map((f) => relative(INTERNAL, f)).join(', ') : 'none'}`)
|
|
397
|
+
if (needs.size) {
|
|
398
|
+
console.log('Dependencies:')
|
|
399
|
+
for (const { item, deps } of needs.values()) console.log(` ${item.name} needs ${[...deps.values()].map(label).join(', ')}`)
|
|
400
|
+
}
|
|
401
|
+
if (items.some(isPro)) console.log('Pro: part of hlmnhkl/ui Pro.')
|
|
402
|
+
if (skipped.length) {
|
|
403
|
+
console.log(`Skipped ${plural(skipped.length, 'existing file')}${opts.overwrite ? '' : ' (see what differs with diff, take the new version with update)'}:`)
|
|
404
|
+
for (const s of skipped) console.log(` ${s}`)
|
|
405
|
+
if (skipped.some((s) => /^(tokens|base)\.css /.test(s))) console.log(' tokens.css and base.css are never replaced, so your theme edits stay.')
|
|
406
|
+
}
|
|
407
|
+
if (tokensNew) console.log(`Next: import ${join(opts.to || readConfig()?.to || DEFAULT_TO, 'tokens.css')} once at your app root (or run init).`)
|
|
408
|
+
if (items.some((i) => i.kind === 'theme')) console.log('Themes: import each one after tokens.css, then set data-theme-brand or data-density on <html>.')
|
|
243
409
|
}
|
|
244
410
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
console.log(`${verb} ${written.length} file${written.length === 1 ? '' : 's'}.`)
|
|
253
|
-
for (const k of KINDS) {
|
|
254
|
-
const names = items.filter((i) => i.kind === k.key).map((i) => i.name)
|
|
255
|
-
if (names.length || k.key === 'component') console.log(`${k.plural}: ${names.length ? names.join(', ') : 'none'}`)
|
|
411
|
+
// Compares the asked items (and what they import) with the copies in the project.
|
|
412
|
+
function compare(opts) {
|
|
413
|
+
const all = listItems()
|
|
414
|
+
const { to } = targetFolder(opts)
|
|
415
|
+
const { items, internal } = plan(opts.names.map((n) => lookup(n, all)))
|
|
416
|
+
const copies = copiesFor(items, internal, to).map((c) => ({ ...c, state: state(c) }))
|
|
417
|
+
return { to, items, copies }
|
|
256
418
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
419
|
+
|
|
420
|
+
function report(to, copies, withLines) {
|
|
421
|
+
const changed = copies.filter((c) => c.state === 'changed')
|
|
422
|
+
const missing = copies.filter((c) => c.state === 'missing')
|
|
423
|
+
for (const c of changed) {
|
|
424
|
+
console.log(`\nChanged: ${shown(to, c.to)}`)
|
|
425
|
+
if (withLines) for (const line of lineDiff(withoutCredit(readFileSync(c.to, 'utf8')), readFileSync(c.from, 'utf8'))) console.log(` ${line}`)
|
|
426
|
+
}
|
|
427
|
+
for (const c of missing) console.log(`New: ${shown(to, c.to)}`)
|
|
428
|
+
const same = copies.length - changed.length - missing.length
|
|
429
|
+
console.log(`\n${plural(changed.length, 'file')} changed, ${missing.length} new, ${same} up to date.`)
|
|
430
|
+
if (withLines && changed.length) console.log('Lines with "-" are in your copy, lines with "+" are in the latest version.')
|
|
431
|
+
return changed.length + missing.length
|
|
261
432
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
if (
|
|
433
|
+
|
|
434
|
+
function runDiff(opts) {
|
|
435
|
+
const { to, copies } = compare(opts)
|
|
436
|
+
const count = report(to, copies, true)
|
|
437
|
+
if (count) console.log(`Take the latest version: npx hlmnhkl-ui update ${opts.names.join(' ')}`)
|
|
267
438
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
439
|
+
|
|
440
|
+
async function runUpdate(opts) {
|
|
441
|
+
const { to, copies } = compare(opts)
|
|
442
|
+
const todo = copies.filter((c) => c.state !== 'same')
|
|
443
|
+
if (!todo.length) {
|
|
444
|
+
console.log(`Everything is up to date (${plural(copies.length, 'file')}).`)
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
report(to, copies, false)
|
|
448
|
+
if (opts.dryRun) {
|
|
449
|
+
console.log('Dry run, nothing written.')
|
|
450
|
+
return
|
|
451
|
+
}
|
|
452
|
+
if (!opts.yes) {
|
|
453
|
+
if (!process.stdin.isTTY) fail('update asks before replacing files. Run it in a terminal, or add --yes.', false)
|
|
454
|
+
console.log('Your own edits in the changed files will be replaced. See them first with diff.')
|
|
455
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
456
|
+
const answer = (await rl.question(`Replace ${plural(todo.length, 'file')}? (y/N) `)).trim().toLowerCase()
|
|
457
|
+
rl.close()
|
|
458
|
+
if (answer !== 'y' && answer !== 'yes') {
|
|
459
|
+
console.log('Nothing changed.')
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
for (const c of todo) writeCopy(c, false)
|
|
464
|
+
console.log(`Updated ${plural(todo.length, 'file')}.`)
|
|
271
465
|
}
|
|
466
|
+
|
|
467
|
+
const opts = parseArgs(process.argv.slice(2))
|
|
468
|
+
if (opts.command === 'init') runInit(opts)
|
|
469
|
+
else if (opts.command === 'list') runList(opts)
|
|
470
|
+
else if (opts.command === 'diff') runDiff(opts)
|
|
471
|
+
else if (opts.command === 'update') await runUpdate(opts)
|
|
472
|
+
else runAdd(opts)
|