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 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`. Files go to `src/ui`, or pass `--to <folder>`. The command also copies the components they use (Button needs Spinner), the shared files and `tokens.css`.
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 tokens once at your app root, and use a component:
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 be copied, write nothing |
37
- | `--overwrite` | Replace files that are already there. Without it, they are skipped and listed |
38
- | `--with-base` | Also copy `base.css`, the small reset |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hlmnhkl-ui",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "React components in a monochrome style. Copy them into your app with npx hlmnhkl-ui add <name>.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://ui.hlmnhkl.my",
package/scripts/add.js CHANGED
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
- // Copies components, blocks, templates and themes, and everything they import, into another project.
3
- // Runs as `npx hlmnhkl-ui add <name>` from the npm package, or `npm run add -- <name>` in this repo.
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 USAGE = `Usage: npx hlmnhkl-ui add <name> [<name>...] [--to <folder>] [--overwrite] [--dry-run] [--with-base]
15
- --to defaults to ${DEFAULT_TO}. In this repo, run: npm run add -- <name> --to <folder>`
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('A name can be a component (Button), a block (Dashboard), a template or a theme (forest).')
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
- opts.to = argv[++i] ?? ''
42
- if (!opts.to || opts.to.startsWith('--')) fail('--to needs a folder, for example --to ../my-app/src/ui')
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 opts.names.push(arg)
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 https://ui.hlmnhkl.my/${raw.toLowerCase()}`)
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
- const opts = parseArgs(process.argv.slice(2))
209
- const all = listItems()
210
- const asked = opts.names.map((n) => lookup(n, all))
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 the folder the command was typed in.
213
- const to = resolve(process.env.INIT_CWD ?? process.cwd(), opts.to)
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
- const { items, internal, needs } = plan(asked)
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
- const copies = []
219
- for (const item of items) {
220
- const k = kindOf(item.kind)
221
- for (const file of filesOf(item)) copies.push({ from: file, to: join(to, k.folder, relative(k.dir, file)) })
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
- for (const file of internal) copies.push({ from: file, to: join(to, 'internal', relative(INTERNAL, file)) })
224
-
225
- const sheets = [{ from: join(SRC, 'tokens.css'), to: join(to, 'tokens.css') }]
226
- if (opts.withBase) sheets.push({ from: join(SRC, 'base.css'), to: join(to, 'base.css') })
227
-
228
- const written = []
229
- const skipped = []
230
- const write = ({ from, to: dest }, allowOverwrite) => {
231
- const shown = relative(to, dest).split(sep).join('/')
232
- if (existsSync(dest) && !allowOverwrite) {
233
- const same = readFileSync(dest).equals(readFileSync(from))
234
- skipped.push(`${shown} (${same ? 'already there, same' : 'already there, different'})`)
235
- return false
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
- if (!opts.dryRun) {
238
- mkdirSync(dirname(dest), { recursive: true })
239
- copyFileSync(from, dest)
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
- written.push(shown)
242
- return true
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
- for (const c of copies) write(c, opts.overwrite)
246
- // Stylesheets hold the app's own theme edits, so they are never replaced.
247
- const tokensNew = write(sheets[0], false)
248
- if (sheets[1]) write(sheets[1], false)
249
-
250
- const verb = opts.dryRun ? 'Would copy' : 'Copied'
251
- console.log(`${opts.dryRun ? 'Dry run, nothing written. ' : ''}Target: ${to}`)
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
- console.log(`Internal files: ${internal.length ? internal.map((f) => relative(INTERNAL, f)).join(', ') : 'none'}`)
258
- if (needs.size) {
259
- console.log('Dependencies:')
260
- for (const { item, deps } of needs.values()) console.log(` ${item.name} needs ${[...deps.values()].map(label).join(', ')}`)
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
- if (items.some((i) => kindOf(i.kind).pro)) console.log('Pro: part of hlmnhkl/ui Pro.')
263
- if (skipped.length) {
264
- console.log(`Skipped ${skipped.length} existing file${skipped.length === 1 ? '' : 's'}${opts.overwrite ? '' : ' (use --overwrite to replace them)'}:`)
265
- for (const s of skipped) console.log(` ${s}`)
266
- if (skipped.some((s) => /^(tokens|base)\.css /.test(s))) console.log(' tokens.css and base.css are never replaced, so your theme edits stay.')
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
- if (tokensNew) console.log(`Next: import ${join(opts.to, 'tokens.css')} once at your app root.`)
269
- if (items.some((i) => i.kind === 'theme')) {
270
- console.log('Themes: import each one after tokens.css, then set data-theme-brand or data-density on <html>.')
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)
@@ -1,7 +1,9 @@
1
+ /* The negative margin keeps the announcers from adding a pixel of scroll under the page. */
1
2
  .srOnly {
2
3
  position: absolute;
3
4
  width: 1px;
4
5
  height: 1px;
6
+ margin: -1px;
5
7
  overflow: hidden;
6
8
  clip-path: inset(50%);
7
9
  white-space: nowrap;