@weft-js/warp-icons 0.1.1 → 0.1.2

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/compile.mjs CHANGED
@@ -2,10 +2,20 @@
2
2
  // Names go through variables, conditionals, static lists and maps, imports and props handed down through components.
3
3
  // A name it can't follow is legal: that use gets the pack, lazily. An unknown literal is an error. No Rust: the pass is
4
4
  // a parse and a walk, so it runs the same in `vite build` and `weft check`.
5
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
6
- import { dirname, extname, join, relative, resolve as path } from 'node:path'
7
- import { brotliCompressSync, constants } from 'node:zlib'
8
- import { parseAst } from 'vite'
5
+ import { closest } from './src/compile-closest.mjs'
6
+ import { title, pascal } from './src/compile-pascal.mjs'
7
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
8
+ import { extname, join, relative } from 'node:path'
9
+ import { actionList, list, findings as catalogFindings } from './src/compile-catalog.mjs'
10
+ import { namesModule } from './src/compile-names.mjs'
11
+ import { rewritePack } from './src/compile-rewrite-pack.mjs'
12
+ import { resolver } from './src/compile-resolver.mjs'
13
+ import { read } from './src/compile-read.mjs'
14
+ import { unwrap, literal } from './src/compile-unwrap.mjs'
15
+ export { closest } from './src/compile-closest.mjs'
16
+ export { pascal } from './src/compile-pascal.mjs'
17
+ export { actionList, list, packCost, publish } from './src/compile-catalog.mjs'
18
+ export { namesModule, trimTiers } from './src/compile-names.mjs'
9
19
 
10
20
  /** warp's expression stops, in order. */
11
21
  export const STOPS = ['quiet', 'balanced', 'vivid']
@@ -13,50 +23,53 @@ export const STOPS = ['quiet', 'balanced', 'vivid']
13
23
  export const DEFAULT_STOP = 'balanced'
14
24
  const SKIP = new Set(['node_modules', 'dist', '.weft', '.git', 'target', 'coverage'])
15
25
  const CODE = /\.[cm]?[jt]sx?$/
16
- const WRAPPERS = new Set(['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'ParenthesizedExpression', 'TSTypeAssertion'])
17
- const ITERATORS = new Set(['map', 'flatMap', 'filter', 'forEach', 'find', 'some', 'every', 'sort', 'toSorted'])
18
- const ICON_SOURCES = new Set(['@weft-js/warp-icons/all', '@weft-js/warp-icons/icon', '@weft-js/warp'])
26
+ const ICON_SOURCES = new Set([
27
+ '@weft-js/warp-icons/all',
28
+ '@weft-js/warp-icons/icon',
29
+ '@weft-js/warp',
30
+ ])
19
31
 
20
- const title = name => name.replace(/(^|-)(.)/g, (_, __, c) => c.toUpperCase())
21
- /** The export a name and variant compile to: `check`, `solid` is `CheckIconSolid`. */
22
- export const pascal = (name, variant = '') => `${title(name)}Icon${variant ? variant[0].toUpperCase() + variant.slice(1) : ''}`
23
32
  /** The variant flags `<Icon>` takes. */
24
33
  export const VARIANTS = ['solid']
25
34
  /** Which variants an element asks for: '' is hollow, the default. A flag it can't read may be either. */
26
35
  function variants(element) {
27
- const out = new Set(), flags = element.attributes.filter(a => a.type === 'JSXAttribute' && (VARIANTS.includes(a.name.name) || a.name.name === 'action'))
36
+ const out = new Set(),
37
+ flags = element.attributes.filter(
38
+ (a) =>
39
+ a.type === 'JSXAttribute' && (VARIANTS.includes(a.name.name) || a.name.name === 'action'),
40
+ )
28
41
  for (const a of flags) {
29
42
  const v = a.value?.type === 'JSXExpressionContainer' ? unwrap(a.value.expression) : null
30
- if (!a.value || v?.value === true || typeof a.value.value === 'string' || (typeof v?.value === 'string' && v.value)) return { set: new Set([a.name.name]), fixed: flags.length === 1 }
43
+ if (
44
+ !a.value ||
45
+ v?.value === true ||
46
+ typeof a.value.value === 'string' ||
47
+ (typeof v?.value === 'string' && v.value)
48
+ )
49
+ return { set: new Set([a.name.name]), fixed: flags.length === 1 }
31
50
  if (v?.value === false) continue
32
- out.add(a.name.name); out.add('')
51
+ out.add(a.name.name)
52
+ out.add('')
33
53
  }
34
54
  if (!out.size) out.add('')
35
55
  return { set: out, fixed: out.size === 1 }
36
56
  }
37
57
 
38
- const line = (code, at) => { const before = code.slice(0, at).split('\n'); return { line: before.length, column: before.at(-1).length + 1 } }
39
-
40
- function distance(a, b) {
41
- let row = Array.from({ length: b.length + 1 }, (_, i) => i)
42
- for (let i = 1; i <= a.length; i++) {
43
- const next = [i]
44
- for (let j = 1; j <= b.length; j++) next[j] = Math.min(next[j - 1] + 1, row[j] + 1, row[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
45
- row = next
46
- }
47
- return row[b.length]
58
+ const line = (code, at) => {
59
+ const before = code.slice(0, at).split('\n')
60
+ return { line: before.length, column: before.at(-1).length + 1 }
48
61
  }
49
- /** The name nearest to a typo, for "did you mean": a prefix counts as one edit closer. */
50
- export const closest = (name, names) => names.reduce((best, n) => { const d = distance(name, n) - (n.startsWith(name) ? 1 : 0); return d < best.d ? { n, d } : best }, { n: null, d: Infinity }).n
51
62
 
52
63
  function sources(root) {
53
64
  const out = []
54
- const walk = dir => {
65
+ const walk = (dir) => {
55
66
  for (const entry of readdirSync(dir)) {
56
67
  if (SKIP.has(entry) || entry.startsWith('.')) continue
57
68
  const p = join(dir, entry)
58
- if (statSync(p).isDirectory()) { if (entry !== 'test' && entry !== 'tests' && entry !== '__tests__') walk(p) }
59
- else if (CODE.test(entry) && !/\.(test|spec|d)\./.test(entry) && !/\.config\./.test(entry)) out.push(p)
69
+ if (statSync(p).isDirectory()) {
70
+ if (entry !== 'test' && entry !== 'tests' && entry !== '__tests__') walk(p)
71
+ } else if (CODE.test(entry) && !/\.(test|spec|d)\./.test(entry) && !/\.config\./.test(entry))
72
+ out.push(p)
60
73
  else if (extname(entry) === '.html') out.push(p)
61
74
  }
62
75
  }
@@ -64,123 +77,26 @@ function sources(root) {
64
77
  return out
65
78
  }
66
79
 
67
- function moduleFile(from, source) {
68
- const base = path(dirname(from), source)
69
- for (const c of [base, ...['.tsx', '.ts', '.jsx', '.js', '.mjs'].map(e => base + e), ...['tsx', 'ts', 'jsx', 'js'].map(e => join(base, `index.${e}`)), base.replace(/\.js$/, '.ts'), base.replace(/\.js$/, '.tsx')])
70
- if (existsSync(c) && statSync(c).isFile()) return c
71
- return null
72
- }
73
-
74
- function parse(file, code) {
75
- try { return parseAst(code, { lang: file.endsWith('.tsx') ? 'tsx' : file.endsWith('.ts') ? 'ts' : 'jsx' }) } catch { return null }
76
- }
77
-
78
- /** One source: its imports, what its names are bound to, and every place an icon name, a stop or a pack is used. */
79
- function read(file, code) {
80
- const unit = { file, code, imports: new Map(), bindings: new Map(), exports: new Map(), uses: [], sites: [], stops: [], related: [], members: [], idents: [], warp: false }
81
- if (extname(file) === '.html') {
82
- for (const m of code.matchAll(/data-expression=["']?([a-z]+)/g)) unit.stops.push({ value: m[1] })
83
- return unit
84
- }
85
- for (const m of code.matchAll(/setAttribute\(\s*['"]data-expression['"]\s*,\s*(?:['"]([a-z]+)['"])?/g)) unit.stops.push(m[1] ? { value: m[1] } : { dynamic: true, at: m.index })
86
- if (/dataset\.expression\s*=/.test(code)) unit.stops.push({ dynamic: true, at: code.search(/dataset\.expression\s*=/) })
87
- const ast = parse(file, code)
88
- if (!ast) return unit
89
- unit.ast = ast
90
- const bind = (name, binding) => { if (!unit.bindings.has(name)) unit.bindings.set(name, []); unit.bindings.get(name).push(binding) }
91
- const pattern = (id, make) => {
92
- if (id?.type === 'Identifier') bind(id.name, make(null))
93
- else if (id?.type === 'AssignmentPattern') pattern(id.left, make)
94
- else if (id?.type === 'ObjectPattern') for (const p of id.properties) {
95
- const key = p.key?.name ?? p.key?.value
96
- if (key !== undefined) pattern(p.value, inner => make(inner ? [key, ...inner] : [key]))
97
- }
98
- }
99
- const component = (name, params) => {
100
- if (!name || !/^[A-Z]/.test(name)) return
101
- const first = params?.[0] ?? params?.items?.[0]
102
- if (first?.type === 'Identifier') bind(first.name, { kind: 'props', comp: name })
103
- else if (first) pattern(first, keys => ({ kind: 'prop', comp: name, key: keys?.[0] }))
104
- }
105
- const visit = node => {
106
- if (!node || typeof node !== 'object') return
107
- if (Array.isArray(node)) { for (const n of node) visit(n); return }
108
- if (typeof node.type !== 'string') return
109
- switch (node.type) {
110
- case 'ImportDeclaration': {
111
- const source = node.source.value
112
- if (source === '@weft-js/warp' || source.startsWith('@weft-js/warp/')) unit.warp = true
113
- const target = source.startsWith('.') ? moduleFile(file, source) : null
114
- if (target) unit.related.push(target)
115
- for (const s of node.specifiers ?? []) {
116
- const imported = s.type === 'ImportNamespaceSpecifier' ? '*' : s.type === 'ImportDefaultSpecifier' ? 'default' : s.imported.name ?? s.imported.value
117
- unit.imports.set(s.local.name, { source, imported, target })
118
- }
119
- return
120
- }
121
- case 'ExportNamedDeclaration':
122
- if (node.source && node.source.value.startsWith('.')) { const t = moduleFile(file, node.source.value); if (t) { unit.related.push(t); for (const s of node.specifiers ?? []) unit.exports.set(s.exported.name, { reexport: t, name: s.local.name ?? s.local.value }) } }
123
- break
124
- case 'ExportAllDeclaration':
125
- if (node.source.value.startsWith('.')) { const t = moduleFile(file, node.source.value); if (t) { unit.related.push(t); (unit.stars ??= []).push(t) } }
126
- break
127
- case 'VariableDeclarator':
128
- if (node.init) {
129
- pattern(node.id, keys => ({ kind: 'value', expr: node.init, keys }))
130
- const fn = unwrap(node.init)
131
- if (node.id.type === 'Identifier' && /Function/.test(fn.type)) component(node.id.name, fn.params)
132
- }
133
- break
134
- case 'FunctionDeclaration':
135
- component(node.id?.name, node.params)
136
- break
137
- case 'CallExpression': {
138
- const callee = node.callee
139
- if (callee.type === 'MemberExpression' && !callee.computed && ITERATORS.has(callee.property.name)) {
140
- const fn = node.arguments[0] && unwrap(node.arguments[0])
141
- if (fn && /Function/.test(fn.type)) pattern(fn.params?.[0] ?? fn.params?.items?.[0], keys => ({ kind: 'each', of: callee.object, keys }))
142
- }
143
- break
144
- }
145
- case 'MemberExpression':
146
- if (node.object.type === 'Identifier') unit.members.push({ local: node.object.name, node })
147
- break
148
- case 'JSXMemberExpression':
149
- if (node.object.type === 'JSXIdentifier') unit.members.push({ local: node.object.name, node, jsx: true })
150
- break
151
- case 'Identifier':
152
- case 'JSXIdentifier':
153
- unit.idents.push(node)
154
- break
155
- case 'JSXOpeningElement': {
156
- const comp = node.name.type === 'JSXIdentifier' ? node.name.name : node.name.type === 'JSXMemberExpression' ? `${node.name.object.name}.${node.name.property.name}` : null
157
- if (!comp) break
158
- for (const attr of node.attributes) {
159
- if (attr.type !== 'JSXAttribute' || attr.name.type !== 'JSXIdentifier') continue
160
- const value = attr.value?.type === 'JSXExpressionContainer' ? attr.value.expression : attr.value
161
- if (attr.name.name === 'data-expression' && value) unit.stops.push({ expr: value, at: attr.start })
162
- if (value && /^[A-Z]/.test(comp)) unit.uses.push({ comp, prop: attr.name.name, expr: value, at: attr.start, end: attr.end, element: node })
163
- }
164
- break
165
- }
166
- case 'Property':
167
- case 'ObjectProperty':
168
- if (!node.computed && (node.key.name ?? node.key.value) === 'icon' && !node.method) unit.sites.push({ soft: true, expr: node.value, at: node.start })
169
- break
170
- }
171
- for (const k of Object.keys(node)) { if (k !== 'type' && k !== 'start' && k !== 'end') visit(node[k]) }
172
- }
173
- visit(ast.body)
174
- return unit
175
- }
176
-
177
- const isSpecifier = (unit, id) => unit.ast.body.some(n => n.type === 'ImportDeclaration' && n.specifiers?.some(s => s.local === id || s.local.start === id.start))
178
- const packLocals = unit => [...unit.imports].filter(([, i]) => i.source === '@weft-js/warp-icons' && (i.imported === 'default' || i.imported === '*')).map(([l]) => l)
179
- const kebab = s => s.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`).replace(/^-/, '')
80
+ const isSpecifier = (unit, id) =>
81
+ unit.ast.body.some(
82
+ (n) =>
83
+ n.type === 'ImportDeclaration' &&
84
+ n.specifiers?.some((s) => s.local === id || s.local.start === id.start),
85
+ )
86
+ const packLocals = (unit) =>
87
+ [...unit.imports]
88
+ .filter(
89
+ ([, i]) =>
90
+ i.source === '@weft-js/warp-icons' && (i.imported === 'default' || i.imported === '*'),
91
+ )
92
+ .map(([l]) => l)
93
+ const kebab = (s) => s.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`).replace(/^-/, '')
180
94
  const TOOLS = new Set(['iconSet'])
181
95
  /** `X`, `XHollow`, `XIcon`, `XIconSolid`: a pack member as a name and a variant. */
182
96
  function member(id) {
183
- const [, base, variant = ''] = /^(.+?)(Hollow|Solid|Action)?$/.exec(id.replace(/Icon(?=$|Hollow$|Solid$|Action$)/, ''))
97
+ const [, base, variant = ''] = /^(.+?)(Hollow|Solid|Action)?$/.exec(
98
+ id.replace(/Icon(?=$|Hollow$|Solid$|Action$)/, ''),
99
+ )
184
100
  return { name: kebab(base), variant: variant === 'Hollow' ? '' : variant.toLowerCase() }
185
101
  }
186
102
  function packName(id, catalog, names, missing) {
@@ -189,234 +105,190 @@ function packName(id, catalog, names, missing) {
189
105
  if (catalog.includes(name)) names.add(variant ? `${name}:${variant}` : name)
190
106
  else missing()
191
107
  }
192
- const packIds = catalog => catalog.map(n => title(n))
193
-
194
- const unwrap = node => { while (node && WRAPPERS.has(node.type)) node = node.expression; return node }
108
+ const packIds = (catalog) => catalog.map((n) => title(n))
195
109
 
196
- /** Everything a build needs to know: the names, the stops, and where either escaped. */
197
- export function analyze(root, { catalog }) {
110
+ function readUnits(root) {
198
111
  const units = new Map()
199
112
  const queue = sources(root)
200
113
  while (queue.length) {
201
114
  const file = queue.pop()
202
115
  if (units.has(file)) continue
203
116
  let code
204
- try { code = readFileSync(file, 'utf8') } catch { continue }
117
+ try {
118
+ code = readFileSync(file, 'utf8')
119
+ } catch {
120
+ continue
121
+ }
205
122
  const unit = read(file, code)
206
123
  units.set(file, unit)
207
124
  queue.push(...unit.related)
208
125
  }
209
- const usesOf = new Map()
210
- for (const u of units.values()) for (const use of u.uses) {
211
- const key = `${use.comp}.${use.prop}`
212
- if (!usesOf.has(key)) usesOf.set(key, [])
213
- usesOf.get(key).push({ unit: u, use })
214
- }
215
-
216
- const empty = () => ({ values: new Set(), shapes: [], dynamic: null })
217
- const merge = (into, r) => { for (const v of r.values) into.values.add(v); into.shapes.push(...r.shapes); into.dynamic ??= r.dynamic; return into }
218
- const text = (unit, n) => unit.code.slice(n.start, n.end).slice(0, 60)
219
-
220
- function exported(file, name, seen) {
221
- const u = units.get(file)
222
- if (!u) return { ...empty(), dynamic: `export ${name}` }
223
- const re = u.exports.get(name)
224
- if (re?.reexport) return exported(re.reexport, re.name, seen)
225
- if (u.bindings.has(name)) return identifier(u, name, seen)
226
- for (const star of u.stars ?? []) { const r = exported(star, name, seen); if (!r.dynamic) return r }
227
- return { ...empty(), dynamic: `export ${name}` }
228
- }
126
+ return units
127
+ }
229
128
 
230
- function identifier(unit, name, seen) {
231
- if (name === 'undefined') return empty()
232
- const id = `${unit.file}#${name}`
233
- if (seen.has(id)) return empty()
234
- seen.add(id)
235
- const imp = unit.imports.get(name)
236
- if (imp) {
237
- if (imp.source === '@weft-js/warp-icons' || imp.source.startsWith('@weft-js/warp-icons/')) return empty()
238
- if (imp.target && imp.imported !== '*') return exported(imp.target, imp.imported, seen)
239
- return { ...empty(), dynamic: name }
129
+ /** Everything a build needs to know: the names, the stops, and where either escaped. */
130
+ export function analyze(root, { catalog }) {
131
+ const units = readUnits(root)
132
+ const usesOf = new Map()
133
+ for (const u of units.values())
134
+ for (const use of u.uses) {
135
+ const key = `${use.comp}.${use.prop}`
136
+ if (!usesOf.has(key)) usesOf.set(key, [])
137
+ usesOf.get(key).push({ unit: u, use })
240
138
  }
241
- const list = unit.bindings.get(name)
242
- if (!list) return { ...empty(), dynamic: name }
243
- const out = empty()
244
- for (const b of list) merge(out, binding(unit, b, seen))
245
- seen.delete(id)
246
- return out
247
- }
248
139
 
249
- function pick(unit, from, keys, seen) {
250
- let cur = from
251
- for (const key of keys ?? []) {
252
- const next = empty(); next.dynamic = cur.dynamic
253
- for (const { shape, unit: u } of cur.shapes) merge(next, property(u, shape, key, seen))
254
- cur = next
255
- }
256
- return cur
257
- }
140
+ const { expression } = resolver(units, usesOf)
258
141
 
259
- function binding(unit, b, seen) {
260
- if (b.kind === 'value') return pick(unit, expression(unit, b.expr, seen), b.keys, seen)
261
- if (b.kind === 'each') {
262
- const list = expression(unit, b.of, seen), items = empty()
263
- items.dynamic = list.dynamic
264
- for (const { shape, unit: u } of list.shapes) if (shape.type === 'ArrayExpression') for (const el of shape.elements) if (el) merge(items, expression(u, el, seen))
265
- return pick(unit, items, b.keys, seen)
266
- }
267
- if (b.kind === 'prop') return b.key ? forwarded(b.comp, b.key, seen) : { ...empty(), dynamic: `${b.comp} props` }
268
- return { ...empty(), dynamic: `${b.comp} props` }
142
+ const names = new Set(),
143
+ unknown = [],
144
+ dynamic = [],
145
+ escaped = [],
146
+ stops = new Set([DEFAULT_STOP]),
147
+ stopsDynamic = []
148
+ const known = new Set(catalog)
149
+ const where = (unit, at) => ({
150
+ file: relative(root, unit.file),
151
+ abs: unit.file,
152
+ ...line(unit.code, at),
153
+ })
154
+ const site = createSite({ expression, known, names, unknown, dynamic, where, catalog })
155
+ const iconLocal = (unit, comp) => {
156
+ const imp = unit.imports.get(comp)
157
+ return imp && imp.imported === 'Icon' && ICON_SOURCES.has(imp.source)
269
158
  }
270
-
271
- function forwarded(comp, prop, seen) {
272
- const out = empty()
273
- for (const { unit, use } of usesOf.get(`${comp}.${prop}`) ?? []) merge(out, expression(unit, use.expr, seen))
274
- return out
159
+ const warpLocal = (unit, comp) => {
160
+ const imp = unit.imports.get(comp.split('.')[0])
161
+ return imp && (imp.source === '@weft-js/warp' || imp.source.startsWith('@weft-js/warp/'))
275
162
  }
276
-
277
- function property(unit, shape, key, seen) {
278
- const out = empty()
279
- if (shape.type === 'ObjectExpression') {
280
- for (const p of shape.properties) {
281
- if (p.type === 'SpreadElement') { const s = expression(unit, p.argument, seen); for (const x of s.shapes) merge(out, property(x.unit, x.shape, key, seen)); out.dynamic ??= s.dynamic; continue }
282
- const k = p.key?.name ?? p.key?.value
283
- if (key === null || k === key) merge(out, expression(unit, p.value, seen))
284
- }
285
- } else if (shape.type === 'ArrayExpression') for (const el of shape.elements) if (el) merge(out, expression(unit, el, seen))
286
- return out
163
+ const ctx = {
164
+ catalog,
165
+ names,
166
+ unknown,
167
+ dynamic,
168
+ escaped,
169
+ stops,
170
+ stopsDynamic,
171
+ known,
172
+ where,
173
+ site,
174
+ expression,
175
+ iconLocal,
176
+ warpLocal,
287
177
  }
288
-
289
- function expression(unit, raw, seen = new Set()) {
290
- const node = unwrap(raw)
291
- if (!node) return empty()
292
- switch (node.type) {
293
- case 'Literal':
294
- case 'StringLiteral':
295
- return typeof node.value === 'string' ? { ...empty(), values: new Set([node.value]) } : empty()
296
- case 'TemplateLiteral':
297
- return node.expressions.length ? { ...empty(), dynamic: text(unit, node) } : { ...empty(), values: new Set([node.quasis[0].value.cooked]) }
298
- case 'ConditionalExpression':
299
- return merge(expression(unit, node.consequent, seen), expression(unit, node.alternate, seen))
300
- case 'LogicalExpression':
301
- return merge(expression(unit, node.left, seen), expression(unit, node.right, seen))
302
- case 'ArrayExpression':
303
- case 'ObjectExpression':
304
- return { ...empty(), shapes: [{ shape: node, unit }] }
305
- case 'Identifier':
306
- return identifier(unit, node.name, seen)
307
- case 'ArrowFunctionExpression':
308
- case 'FunctionExpression':
309
- case 'JSXElement':
310
- case 'JSXFragment':
311
- return empty()
312
- case 'MemberExpression': {
313
- const object = unwrap(node.object)
314
- const key = node.computed ? (unwrap(node.property)?.value ?? null) : node.property.name
315
- if (object.type === 'Identifier') {
316
- const props = unit.bindings.get(object.name)?.find(b => b.kind === 'props')
317
- if (props && key) return forwarded(props.comp, key, seen)
318
- const imp = unit.imports.get(object.name)
319
- if (imp?.imported === '*' && imp.target && key) return exported(imp.target, key, seen)
320
- }
321
- const base = expression(unit, object, seen), out = empty()
322
- out.dynamic = base.dynamic
323
- for (const { shape, unit: u } of base.shapes) merge(out, property(u, shape, key, seen))
324
- return out
325
- }
326
- case 'CallExpression': { // a list method hands back the list, or an item of it
327
- const c = node.callee
328
- if (c.type === 'MemberExpression' && !c.computed && ['filter', 'sort', 'toSorted', 'slice', 'reverse', 'concat'].includes(c.property.name)) return expression(unit, c.object, seen)
329
- if (c.type === 'MemberExpression' && !c.computed && c.property.name === 'find') {
330
- const base = expression(unit, c.object, seen), out = empty()
331
- out.dynamic = base.dynamic
332
- for (const { shape, unit: u } of base.shapes) merge(out, property(u, shape, null, seen))
333
- return out
334
- }
335
- return { ...empty(), dynamic: text(unit, node) }
336
- }
337
- default:
338
- return { ...empty(), dynamic: text(unit, node) }
339
- }
178
+ for (const unit of units.values()) scanUnit(unit, ctx)
179
+ return {
180
+ names,
181
+ unknown,
182
+ dynamic,
183
+ escaped,
184
+ stops: stopsDynamic.length ? new Set(STOPS) : stops,
185
+ stopsDynamic,
186
+ files: units.size,
340
187
  }
188
+ }
341
189
 
342
- const names = new Set(), unknown = [], dynamic = [], escaped = [], stops = new Set([DEFAULT_STOP]), stopsDynamic = []
343
- const known = new Set(catalog)
344
- const where = (unit, at) => ({ file: relative(root, unit.file), abs: unit.file, ...line(unit.code, at) })
345
- const site = (unit, expr, at, soft, element) => {
190
+ function createSite({ expression, known, names, unknown, dynamic, where, catalog }) {
191
+ return (unit, expr, at, soft, element) => {
346
192
  const r = expression(unit, expr)
347
193
  const kinds = element ? variants(element).set : new Set([''])
348
194
  for (const v of r.values) {
349
- if (known.has(v)) for (const k of kinds) {
350
- if (k === 'action' && !actionList().includes(v)) unknown.push({ name: v, why: actionMessage(v).slice(5), ...where(unit, unwrap(expr).start ?? at) })
351
- else names.add(k ? `${v}:${k}` : v)
352
- }
353
- else if (!soft) unknown.push({ name: v, suggest: closest(v, catalog), ...where(unit, unwrap(expr).start ?? at) })
195
+ if (known.has(v))
196
+ for (const k of kinds) {
197
+ if (k === 'action' && !actionList().includes(v))
198
+ unknown.push({
199
+ name: v,
200
+ why: actionMessage(v).slice(5),
201
+ ...where(unit, unwrap(expr).start ?? at),
202
+ })
203
+ else names.add(k ? `${v}:${k}` : v)
204
+ }
205
+ else if (!soft)
206
+ unknown.push({
207
+ name: v,
208
+ suggest: closest(v, catalog),
209
+ ...where(unit, unwrap(expr).start ?? at),
210
+ })
354
211
  }
355
212
  if (r.dynamic) dynamic.push({ why: r.dynamic, ...where(unit, at) })
356
213
  }
357
- const iconLocal = (unit, comp) => { const imp = unit.imports.get(comp); return imp && imp.imported === 'Icon' && ICON_SOURCES.has(imp.source) }
358
- const warpLocal = (unit, comp) => { const imp = unit.imports.get(comp.split('.')[0]); return imp && (imp.source === '@weft-js/warp' || imp.source.startsWith('@weft-js/warp/')) }
359
- for (const unit of units.values()) {
360
- for (const use of unit.uses) {
361
- if (use.prop === 'name' && iconLocal(unit, use.comp)) site(unit, use.expr, use.at, false, use.element)
362
- else if (use.prop === 'icon' && warpLocal(unit, use.comp)) {
363
- // a literal becomes the icon itself (`rewrite`), so it needs no entry in every page's table
364
- const v = literal(use.expr)
365
- if (v !== undefined && known.has(v)) continue
366
- site(unit, use.expr, use.at, false)
367
- }
368
- if (use.prop === 'tier') { for (const v of expression(unit, use.expr).values) if (STOPS.includes(v)) stops.add(v) }
214
+ }
215
+
216
+ function scanUnit(unit, ctx) {
217
+ const { stops, stopsDynamic, known, where, site, expression, iconLocal, warpLocal } = ctx
218
+ for (const use of unit.uses) {
219
+ if (use.prop === 'name' && iconLocal(unit, use.comp))
220
+ site(unit, use.expr, use.at, false, use.element)
221
+ else if (use.prop === 'icon' && warpLocal(unit, use.comp)) {
222
+ // a literal becomes the icon itself (`rewrite`), so it needs no entry in every page's table
223
+ const v = literal(use.expr)
224
+ if (v !== undefined && known.has(v)) continue
225
+ site(unit, use.expr, use.at, false)
369
226
  }
370
- if (unit.warp) for (const s of unit.sites) site(unit, s.expr, s.at, true)
371
- for (const local of packLocals(unit)) {
372
- const own = unit.members.filter(m => m.local === local), inner = new Set(own.map(m => m.node.object))
373
- for (const { node } of own) {
374
- if (node.computed) {
375
- const r = expression(unit, node.property)
376
- for (const v of r.values) packName(v, catalog, names, () => unknown.push({ name: v, suggest: closest(v, packIds(catalog)), ...where(unit, node.start) }))
377
- if (r.dynamic) dynamic.push({ why: r.dynamic, ...where(unit, node.start) })
378
- } else packName(node.property.name, catalog, names, () => unknown.push({ name: node.property.name, suggest: closest(node.property.name, packIds(catalog)), ...where(unit, node.property.start) }))
379
- }
380
- for (const id of unit.idents) if (id.name === local && !inner.has(id) && !isSpecifier(unit, id)) escaped.push({ why: `${local} is used as a value`, ...where(unit, id.start) })
227
+ if (use.prop === 'tier') {
228
+ for (const v of expression(unit, use.expr).values) if (STOPS.includes(v)) stops.add(v)
381
229
  }
382
- for (const s of unit.stops) {
383
- if (s.value) { if (STOPS.includes(s.value)) stops.add(s.value); continue }
384
- if (s.dynamic) { stopsDynamic.push(where(unit, s.at)); continue }
385
- const r = expression(unit, s.expr)
386
- for (const v of r.values) if (STOPS.includes(v)) stops.add(v)
387
- if (r.dynamic) stopsDynamic.push({ why: r.dynamic, ...where(unit, s.at) })
230
+ }
231
+ if (unit.warp) for (const s of unit.sites) site(unit, s.expr, s.at, true)
232
+ scanPack(unit, ctx)
233
+ for (const s of unit.stops) {
234
+ if (s.value) {
235
+ if (STOPS.includes(s.value)) stops.add(s.value)
236
+ continue
237
+ }
238
+ if (s.dynamic) {
239
+ stopsDynamic.push(where(unit, s.at))
240
+ continue
388
241
  }
242
+ const r = expression(unit, s.expr)
243
+ for (const v of r.values) if (STOPS.includes(v)) stops.add(v)
244
+ if (r.dynamic) stopsDynamic.push({ why: r.dynamic, ...where(unit, s.at) })
389
245
  }
390
- return { names, unknown, dynamic, escaped, stops: stopsDynamic.length ? new Set(STOPS) : stops, stopsDynamic, files: units.size }
391
- }
392
-
393
- /** The module warp's name lookup becomes in a build: these names and no others, and the pack only if a name escaped. */
394
- /** @param {Iterable<string>} names */
395
- export function namesModule(names, { lazy = false, source } = {}) {
396
- const list = [...names].sort()
397
- const id = key => pascal(...key.split(':'))
398
- const imports = list.length ? `import { ${list.map(id).join(', ')} } from '@weft-js/warp-icons'\n` : ''
399
- const table = `const names = { ${list.map(n => `${JSON.stringify(n)}: ${id(n)}`).join(', ')} }\nconst key = (name, variant) => (variant ? name + ':' + variant : name)\n`
400
- const packExport = `export const pack = id => { const [, b, v = ''] = /^(.+?)(Hollow|Solid|Action)?$/.exec(id.replace(/Icon(?=$|Hollow$|Solid$|Action$)/, '')); return resolve(b.replace(/[A-Z]/g, c => '-' + c.toLowerCase()).replace(/^-/, ''), v.toLowerCase()) }\n`
401
- if (source) return `${imports}import { remote } from '@weft-js/warp-icons/late'\n${table}const get = remote(${JSON.stringify(source)})\nexport const resolve = (name, variant) => names[key(name, variant)] ?? get(name, variant)\n${packExport}`
402
- if (lazy) return `${imports}import { late } from '@weft-js/warp-icons/late'\n${table}const load = () => import('@weft-js/warp-icons/all').then(m => m.lookup)\nexport const resolve = (name, variant) => names[key(name, variant)] ?? late(name, variant, load)\n${packExport}`
403
- return `${imports}${table}export const resolve = (name, variant) => names[key(name, variant)]\n${packExport}`
404
246
  }
405
247
 
406
- const VISIBLE = { quiet: 'oq', balanced: 'ob', vivid: 'v' }
407
- /** Drop the drawings no shipped stop can show. With one stop left there is nothing to switch, so the switch goes too. */
408
- export function trimTiers(source, stops) {
409
- // a build imports the pack as a namespace (rewrite), and this default would keep every icon alive
410
- const code = source
411
- if (stops.size === 3) return code
412
- const keep = [...stops].map(s => VISIBLE[s]).join('')
413
- const out = code.replace(/<path data-t="([oqbv])"[^>]*?\/>/g, (m, t) => (keep.includes(t) ? m : ''))
414
- return stops.size === 1 ? out.replaceAll(/ data-t="[oqbv]"/g, '').replace("import '../tiers.css'\n", '') : out
248
+ function scanPack(unit, ctx) {
249
+ const { catalog, names, unknown, dynamic, escaped, where, expression } = ctx
250
+ for (const local of packLocals(unit)) {
251
+ const own = unit.members.filter((m) => m.local === local),
252
+ inner = new Set(own.map((m) => m.node.object))
253
+ for (const { node } of own) {
254
+ if (node.computed) {
255
+ const r = expression(unit, node.property)
256
+ for (const v of r.values)
257
+ packName(v, catalog, names, () =>
258
+ unknown.push({
259
+ name: v,
260
+ suggest: closest(v, packIds(catalog)),
261
+ ...where(unit, node.start),
262
+ }),
263
+ )
264
+ if (r.dynamic) dynamic.push({ why: r.dynamic, ...where(unit, node.start) })
265
+ } else
266
+ packName(node.property.name, catalog, names, () =>
267
+ unknown.push({
268
+ name: node.property.name,
269
+ suggest: closest(node.property.name, packIds(catalog)),
270
+ ...where(unit, node.property.start),
271
+ }),
272
+ )
273
+ }
274
+ for (const id of unit.idents)
275
+ if (id.name === local && !inner.has(id) && !isSpecifier(unit, id))
276
+ escaped.push({ why: `${local} is used as a value`, ...where(unit, id.start) })
277
+ }
415
278
  }
416
279
 
417
- const literal = expr => {
418
- const v = unwrap(expr)
419
- return (v.type === 'Literal' || v.type === 'StringLiteral') && typeof v.value === 'string' ? v.value : undefined
280
+ // `Icon` alone is a small file over the names module, so this import doesn't bring the set
281
+ function iconOnlyImports(unit) {
282
+ const edits = []
283
+ for (const n of unit.ast.body)
284
+ if (
285
+ n.type === 'ImportDeclaration' &&
286
+ n.source.value === '@weft-js/warp-icons/all' &&
287
+ n.specifiers.length === 1 &&
288
+ n.specifiers[0].imported?.name === 'Icon'
289
+ )
290
+ edits.push({ start: n.source.start, end: n.source.end, text: "'@weft-js/warp-icons/icon'" })
291
+ return edits
420
292
  }
421
293
 
422
294
  /** `<Icon name="check">` becomes `<CheckIcon>`, the same output a named import gives, and `icon="check"` on a warp component takes `CheckIcon`. */
@@ -424,26 +296,38 @@ export function rewrite(code, file, catalog) {
424
296
  if (!/@weft-js\/(warp-icons|warp)/.test(code)) return null
425
297
  const unit = read(file, code)
426
298
  if (!unit.ast) return null
427
- const local = [...unit.imports].find(([, i]) => i.imported === 'Icon' && ICON_SOURCES.has(i.source))?.[0]
428
- const edits = [], added = new Set()
429
- // `Icon` alone is a small file over the names module, so this import doesn't bring the set
430
- for (const n of unit.ast.body) if (n.type === 'ImportDeclaration' && n.source.value === '@weft-js/warp-icons/all' && n.specifiers.length === 1 && n.specifiers[0].imported?.name === 'Icon') edits.push({ start: n.source.start, end: n.source.end, text: "'@weft-js/warp-icons/icon'" })
299
+ const local = [...unit.imports].find(
300
+ ([, i]) => i.imported === 'Icon' && ICON_SOURCES.has(i.source),
301
+ )?.[0]
302
+ const edits = iconOnlyImports(unit),
303
+ added = new Set()
431
304
  // the pack: a default import is the namespace, so a static member is an ordinary named import to the bundler
432
- let lookups = false
433
- for (const pack of packLocals(unit)) {
434
- for (const n of unit.ast.body) if (n.type === 'ImportDeclaration' && n.source.value === '@weft-js/warp-icons') for (const s of n.specifiers) if (s.local.name === pack && s.type === 'ImportDefaultSpecifier') edits.push({ start: s.start, end: s.end, text: `* as ${pack}` })
435
- for (const { local: l, node } of unit.members) {
436
- if (l !== pack) continue
437
- if (!node.computed) { if (catalog) packName(node.property.name, catalog, new Set(), () => { throw Object.assign(new Error(unknownMessage(node.property.name, packIds(catalog))), { loc: { file, ...line(code, node.property.start) } }) }); continue }
438
- edits.push({ start: node.start, end: node.property.start, text: '__icon(' }, { start: node.property.end, end: node.end, text: ')' })
439
- lookups = true
440
- }
441
- }
305
+ const lookups = rewritePack({
306
+ unit,
307
+ catalog,
308
+ code,
309
+ file,
310
+ edits,
311
+ packLocals,
312
+ packName,
313
+ unknownMessage,
314
+ packIds,
315
+ line,
316
+ })
442
317
  for (const use of unit.uses) {
443
318
  if (use.prop === 'icon') {
444
- const v = literal(use.expr), imp = unit.imports.get(use.comp.split('.')[0])
445
- if (v === undefined || !imp || !(imp.source === '@weft-js/warp' || imp.source.startsWith('@weft-js/warp/'))) continue
446
- if (catalog && !catalog.includes(v)) throw Object.assign(new Error(unknownMessage(v, catalog)), { loc: { file, ...line(code, unwrap(use.expr).start) } })
319
+ const v = literal(use.expr),
320
+ imp = unit.imports.get(use.comp.split('.')[0])
321
+ if (
322
+ v === undefined ||
323
+ !imp ||
324
+ !(imp.source === '@weft-js/warp' || imp.source.startsWith('@weft-js/warp/'))
325
+ )
326
+ continue
327
+ if (catalog && !catalog.includes(v))
328
+ throw Object.assign(new Error(unknownMessage(v, catalog)), {
329
+ loc: { file, ...line(code, unwrap(use.expr).start) },
330
+ })
447
331
  const name = pascal(v)
448
332
  added.add(name)
449
333
  edits.push({ start: use.at, end: use.end, text: `icon={${name}}` })
@@ -451,33 +335,45 @@ export function rewrite(code, file, catalog) {
451
335
  }
452
336
  if (!local || use.comp !== local || use.prop !== 'name') continue
453
337
  const v = unwrap(use.expr)
454
- if ((v.type !== 'Literal' && v.type !== 'StringLiteral') || typeof v.value !== 'string') continue
455
- if (catalog && !catalog.includes(v.value)) throw Object.assign(new Error(unknownMessage(v.value, catalog)), { loc: { file, ...line(code, v.start) } })
338
+ if ((v.type !== 'Literal' && v.type !== 'StringLiteral') || typeof v.value !== 'string')
339
+ continue
340
+ if (catalog && !catalog.includes(v.value))
341
+ throw Object.assign(new Error(unknownMessage(v.value, catalog)), {
342
+ loc: { file, ...line(code, v.start) },
343
+ })
456
344
  if (!use.element.selfClosing) continue
457
345
  const wanted = variants(use.element)
458
346
  if (!wanted.fixed) continue
459
- if (wanted.set.has('action') && !actionList().includes(v.value)) throw Object.assign(new Error(actionMessage(v.value)), { loc: { file, ...line(code, v.start) } })
347
+ if (wanted.set.has('action') && !actionList().includes(v.value))
348
+ throw Object.assign(new Error(actionMessage(v.value)), {
349
+ loc: { file, ...line(code, v.start) },
350
+ })
460
351
  const name = pascal(v.value, [...wanted.set][0])
461
352
  added.add(name)
462
- edits.push({ start: use.element.start, end: use.element.name.end, text: `<${name}` }, { start: use.at, end: use.end, text: '' })
463
- for (const a of use.element.attributes) if (a.type === 'JSXAttribute' && VARIANTS.includes(a.name.name)) edits.push({ start: a.start, end: a.end, text: '' })
353
+ edits.push(
354
+ { start: use.element.start, end: use.element.name.end, text: `<${name}` },
355
+ { start: use.at, end: use.end, text: '' },
356
+ )
357
+ for (const a of use.element.attributes)
358
+ if (a.type === 'JSXAttribute' && VARIANTS.includes(a.name.name))
359
+ edits.push({ start: a.start, end: a.end, text: '' })
464
360
  }
465
361
  if (!edits.length) return null
466
362
  let out = code
467
- for (const e of edits.sort((a, b) => b.start - a.start)) out = out.slice(0, e.start) + e.text + out.slice(e.end)
468
- const head = (added.size ? `import { ${[...added].join(', ')} } from '@weft-js/warp-icons'\n` : '') + (lookups ? "import { pack as __icon } from '@weft-js/warp-icons/names'\n" : '')
363
+ for (const e of edits.sort((a, b) => b.start - a.start))
364
+ out = out.slice(0, e.start) + e.text + out.slice(e.end)
365
+ const head =
366
+ (added.size ? `import { ${[...added].join(', ')} } from '@weft-js/warp-icons'\n` : '') +
367
+ (lookups ? "import { pack as __icon } from '@weft-js/warp-icons/names'\n" : '')
469
368
  return head + out
470
369
  }
471
370
 
472
371
  /** I001 for `action` on an icon that has none. */
473
- export const actionMessage = name => `I001 no action for icon '${name}', did you mean '${closest(name, actionList())}'? The action icons are ${actionList().join(', ')}.`
372
+ export const actionMessage = (name) =>
373
+ `I001 no action for icon '${name}', did you mean '${closest(name, actionList())}'? The action icons are ${actionList().join(', ')}.`
474
374
  /** I001 for a name that isn't in the catalog. */
475
- export const unknownMessage = (name, catalog) => `I001 no icon '${name}', did you mean '${closest(name, catalog)}'?`
476
-
477
- /** The icons that have an action, read from the generated names. */
478
- export const actionList = () => [...readFileSync(new URL('./src/gen/names.ts', import.meta.url), 'utf8').match(/const actions = (\[.*?\])/)[1].matchAll(/"([a-z0-9-]+)"/g)].map(m => m[1])
479
- /** Every icon name, read from the generated names. */
480
- export const list = () => [...new Set([...readFileSync(new URL('./src/gen/names.ts', import.meta.url), 'utf8').split('\n')[1].matchAll(/"([a-z0-9-]+)"/g)].map(m => m[1]))]
375
+ export const unknownMessage = (name, catalog) =>
376
+ `I001 no icon '${name}', did you mean '${closest(name, catalog)}'?`
481
377
 
482
378
  /** The vite side, loaded by @weft-js/core/vite when @weft-js/warp-icons is in the build. */
483
379
  export function pass({ root, command, tier, source }) {
@@ -485,40 +381,12 @@ export function pass({ root, command, tier, source }) {
485
381
  const catalog = list()
486
382
  const result = build ? analyze(root, { catalog }) : null
487
383
  const lazy = !!result?.dynamic.length
488
- const stops = tier && tier !== 'all' ? new Set([tier]) : result && !tier ? result.stops : new Set(STOPS)
384
+ const stops =
385
+ tier && tier !== 'all' ? new Set([tier]) : result && !tier ? result.stops : new Set(STOPS)
489
386
  return { catalog, result, lazy, stops, module: () => namesModule(result.names, { lazy, source }) }
490
387
  }
491
388
 
492
- /** What the pack costs if a page takes it: every icon in one tier, brotli. */
493
- export function packCost() {
494
- const code = readFileSync(new URL('./src/gen/all.tsx', import.meta.url), 'utf8')
495
- return brotliCompressSync(Buffer.from(code), { params: { [constants.BROTLI_PARAM_QUALITY]: 5 } }).length
496
- }
497
-
498
- /** `weft check`'s view: the same walk a build does, as findings. */
389
+ /** The icon findings for a build. */
499
390
  export function findings(root) {
500
- const catalog = list(), r = analyze(root, { catalog })
501
- const kb = `${(packCost() / 1024).toFixed(0)} KB`
502
- return [
503
- ...r.unknown.map(u => ({ code: 'I001', file: u.file, line: u.line, column: u.column, why: u.why ?? `no icon '${u.name}', did you mean '${u.suggest}'?` })),
504
- ...r.dynamic.map(d => ({ code: 'I002', file: d.file, line: d.line, column: d.column, why: `\`${d.why}\` isn't known until runtime, so this use loads the icon pack lazily (about ${kb} br, only on pages that reach it).` })),
505
- ...r.escaped.map(d => ({ code: 'I003', file: d.file, line: d.line, column: d.column, why: `${d.why}, so every icon ships with the page (about ${kb} br).` })),
506
- ]
507
- }
508
-
509
- /** Every icon and variant as a static .svg, for a CDN or bucket. Writes files; nothing is uploaded. */
510
- export async function publish(dir) {
511
- const { load } = await import('./scripts/build.mjs')
512
- const { forms } = await import('./scripts/forms.mjs')
513
- const icons = await load()
514
- mkdirSync(dir, { recursive: true })
515
- const svg = html => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">${html}</svg>\n`
516
- let files = 0
517
- for (const [name, entry] of Object.entries(icons)) {
518
- const f = forms(name, entry)
519
- const all = { '': f.hollow.line + f.hollow.pixels, '.solid': f.solid.line + f.solid.pixels }
520
- for (const [suffix, html] of Object.entries(all)) { writeFileSync(join(dir, `${name}${suffix}.svg`), svg(html)); files++ }
521
- }
522
- writeFileSync(join(dir, 'manifest.json'), JSON.stringify({ icons: Object.keys(icons), variants: ['', 'solid'] }) + '\n')
523
- return { dir, files, icons: Object.keys(icons).length }
391
+ return catalogFindings(root, analyze)
524
392
  }