@weft-js/warp-icons 0.0.0-stage → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,16 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ravonus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
6
+ documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
7
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
8
+ persons to whom the Software is furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
11
+ Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
14
+ WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
15
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
16
+ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,28 @@
1
- # Temporary Holding Version
1
+ # @weft-js/warp-icons
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > **Early release (0.1.1).** APIs will change between minor versions.
4
+
5
+ weft's own icon set: 651 icons in warp's three expression tiers, each with a hollow and a solid form. `@weft-js/warp`
6
+ depends on it, so an app with warp already has it.
7
+
8
+ ```bash
9
+ npm install @weft-js/warp-icons
10
+ ```
11
+
12
+ ```tsx
13
+ import { CheckIcon } from '@weft-js/warp-icons'
14
+
15
+ export function Saved() {
16
+ return <p><CheckIcon label="Saved" /> Your changes are saved.</p>
17
+ }
18
+ ```
19
+
20
+ An icon you don't import costs nothing, and one you do adds about 164 B of brotli.
21
+
22
+ ## Docs
23
+
24
+ `weft docs icons` has the names and variants, sizes, tiers, `icons({ tier })` for `weft.config.ts`, and what happens with
25
+ a name the build can't follow. Its source is [packages/cli/docs/icons.md](../cli/docs/icons.md).
26
+
27
+ `npm run sheet` in this folder writes `contact-sheet.html`, where you can browse every icon by tier, variant, size and
28
+ theme.
package/compile.mjs ADDED
@@ -0,0 +1,524 @@
1
+ // The build's view of icons: walk an app's sources, follow every name to the literals it can be, and ship only those.
2
+ // Names go through variables, conditionals, static lists and maps, imports and props handed down through components.
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
+ // 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'
9
+
10
+ /** warp's expression stops, in order. */
11
+ export const STOPS = ['quiet', 'balanced', 'vivid']
12
+ /** The stop a page has unless it says otherwise. */
13
+ export const DEFAULT_STOP = 'balanced'
14
+ const SKIP = new Set(['node_modules', 'dist', '.weft', '.git', 'target', 'coverage'])
15
+ 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'])
19
+
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
+ /** The variant flags `<Icon>` takes. */
24
+ export const VARIANTS = ['solid']
25
+ /** Which variants an element asks for: '' is hollow, the default. A flag it can't read may be either. */
26
+ 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'))
28
+ for (const a of flags) {
29
+ 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 }
31
+ if (v?.value === false) continue
32
+ out.add(a.name.name); out.add('')
33
+ }
34
+ if (!out.size) out.add('')
35
+ return { set: out, fixed: out.size === 1 }
36
+ }
37
+
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]
48
+ }
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
+
52
+ function sources(root) {
53
+ const out = []
54
+ const walk = dir => {
55
+ for (const entry of readdirSync(dir)) {
56
+ if (SKIP.has(entry) || entry.startsWith('.')) continue
57
+ 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)
60
+ else if (extname(entry) === '.html') out.push(p)
61
+ }
62
+ }
63
+ walk(root)
64
+ return out
65
+ }
66
+
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(/^-/, '')
180
+ const TOOLS = new Set(['iconSet'])
181
+ /** `X`, `XHollow`, `XIcon`, `XIconSolid`: a pack member as a name and a variant. */
182
+ function member(id) {
183
+ const [, base, variant = ''] = /^(.+?)(Hollow|Solid|Action)?$/.exec(id.replace(/Icon(?=$|Hollow$|Solid$|Action$)/, ''))
184
+ return { name: kebab(base), variant: variant === 'Hollow' ? '' : variant.toLowerCase() }
185
+ }
186
+ function packName(id, catalog, names, missing) {
187
+ if (TOOLS.has(id)) return
188
+ const { name, variant } = member(id)
189
+ if (catalog.includes(name)) names.add(variant ? `${name}:${variant}` : name)
190
+ else missing()
191
+ }
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 }
195
+
196
+ /** Everything a build needs to know: the names, the stops, and where either escaped. */
197
+ export function analyze(root, { catalog }) {
198
+ const units = new Map()
199
+ const queue = sources(root)
200
+ while (queue.length) {
201
+ const file = queue.pop()
202
+ if (units.has(file)) continue
203
+ let code
204
+ try { code = readFileSync(file, 'utf8') } catch { continue }
205
+ const unit = read(file, code)
206
+ units.set(file, unit)
207
+ queue.push(...unit.related)
208
+ }
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
+ }
229
+
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 }
240
+ }
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
+
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
+ }
258
+
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` }
269
+ }
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
275
+ }
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
287
+ }
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
+ }
340
+ }
341
+
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) => {
346
+ const r = expression(unit, expr)
347
+ const kinds = element ? variants(element).set : new Set([''])
348
+ 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) })
354
+ }
355
+ if (r.dynamic) dynamic.push({ why: r.dynamic, ...where(unit, at) })
356
+ }
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) }
369
+ }
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) })
381
+ }
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) })
388
+ }
389
+ }
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
+ }
405
+
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
415
+ }
416
+
417
+ const literal = expr => {
418
+ const v = unwrap(expr)
419
+ return (v.type === 'Literal' || v.type === 'StringLiteral') && typeof v.value === 'string' ? v.value : undefined
420
+ }
421
+
422
+ /** `<Icon name="check">` becomes `<CheckIcon>`, the same output a named import gives, and `icon="check"` on a warp component takes `CheckIcon`. */
423
+ export function rewrite(code, file, catalog) {
424
+ if (!/@weft-js\/(warp-icons|warp)/.test(code)) return null
425
+ const unit = read(file, code)
426
+ 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'" })
431
+ // 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
+ }
442
+ for (const use of unit.uses) {
443
+ 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) } })
447
+ const name = pascal(v)
448
+ added.add(name)
449
+ edits.push({ start: use.at, end: use.end, text: `icon={${name}}` })
450
+ continue
451
+ }
452
+ if (!local || use.comp !== local || use.prop !== 'name') continue
453
+ 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) } })
456
+ if (!use.element.selfClosing) continue
457
+ const wanted = variants(use.element)
458
+ 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) } })
460
+ const name = pascal(v.value, [...wanted.set][0])
461
+ 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: '' })
464
+ }
465
+ if (!edits.length) return null
466
+ 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" : '')
469
+ return head + out
470
+ }
471
+
472
+ /** 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(', ')}.`
474
+ /** 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]))]
481
+
482
+ /** The vite side, loaded by @weft-js/core/vite when @weft-js/warp-icons is in the build. */
483
+ export function pass({ root, command, tier, source }) {
484
+ const build = command === 'build'
485
+ const catalog = list()
486
+ const result = build ? analyze(root, { catalog }) : null
487
+ const lazy = !!result?.dynamic.length
488
+ const stops = tier && tier !== 'all' ? new Set([tier]) : result && !tier ? result.stops : new Set(STOPS)
489
+ return { catalog, result, lazy, stops, module: () => namesModule(result.names, { lazy, source }) }
490
+ }
491
+
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. */
499
+ 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 }
524
+ }
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@weft-js/warp-icons",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Icons for warp and weft",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": [
8
+ "*.css"
9
+ ],
10
+ "exports": {
11
+ ".": "./src/gen/index.tsx",
12
+ "./all": "./src/all.tsx",
13
+ "./quiet": "./src/gen/all.tsx",
14
+ "./balanced": "./src/gen/all.tsx",
15
+ "./vivid": "./src/gen/all.tsx",
16
+ "./names": "./src/names.tsx",
17
+ "./icon": "./src/icon.tsx",
18
+ "./late": "./src/late.tsx",
19
+ "./compile": "./compile.mjs",
20
+ "./plugin": "./plugin.mjs",
21
+ "./icons.css": "./src/icons.css",
22
+ "./tiers.css": "./src/tiers.css"
23
+ },
24
+ "peerDependencies": {
25
+ "vite": "*",
26
+ "@weft-js/core": "^0.1.1"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "engines": {
32
+ "node": ">=26"
33
+ },
34
+ "keywords": [
35
+ "weft"
36
+ ],
37
+ "files": [
38
+ "src",
39
+ "plugin.mjs",
40
+ "compile.mjs",
41
+ "README.md",
42
+ "LICENSE"
43
+ ]
44
+ }
package/plugin.mjs ADDED
@@ -0,0 +1,33 @@
1
+ // icons() for weft.config.ts. With no tier, every icon carries its three tiers and CSS shows the one for the nearest
2
+ // data-expression. `tier` compiles one in: `@weft-js/warp-icons` resolves to that tier alone, warp's own icons included.
3
+ import { definePlugin } from '@weft-js/core/plugin'
4
+
5
+ const TIERS = ['quiet', 'balanced', 'vivid']
6
+
7
+ function options(input) {
8
+ for (const key of Object.keys(input)) if (key !== 'tier' && key !== 'source') throw Object.assign(new Error(`unknown option \`${key}\``), { path: key })
9
+ if (input.tier !== undefined && !TIERS.includes(input.tier)) throw Object.assign(new Error(`must be one of ${TIERS.join(', ')}`), { path: 'tier' })
10
+ if (input.source !== undefined && !/^https?:\/\/.+\/$/.test(input.source)) throw Object.assign(new Error('must be an http(s) URL ending in /'), { path: 'source' })
11
+ return { ...(input.tier && { tier: input.tier }), ...(input.source && { source: input.source }) }
12
+ }
13
+
14
+ const agents = "Import icons from `@weft-js/warp-icons`: `CheckIcon` is hollow, the default, `CheckIconSolid` the other form (`<Icon name=\"check\" solid />`); `<Icons.Check />` and warp's `<Icon name=\"check\" />` build to the same bytes. Tiers are a prop, `tier`, or the page's `data-expression`. Decorative by default; `label` gives one a name. An unknown literal name is I001 at the build and in `weft check`. A name only known at runtime is legal and loads the pack lazily (I002 reports it), or single files with `icons({ source })` and `weft icons publish <dir>`. `trash`, `bell`, `check`, `refresh` and `copy` animate with `action`. `weft docs icons` has the rest."
15
+
16
+ /** `icons()` for `weft.config.ts`: pins a tier if asked, and tells agents how the names work. */
17
+ export const icons = definePlugin({
18
+ name: '@weft-js/warp-icons',
19
+ version: '0.1.0',
20
+ weft: '*',
21
+ options,
22
+ contribute(o) {
23
+ return {
24
+ tokens: { namespace: 'icons', values: { tier: o.tier ?? 'runtime' }, ...((o.tier || o.source) && { alias: { ...(o.tier && { '@weft-js/warp-icons': `@weft-js/warp-icons/${o.tier}` }), ...(o.source && { '@weft-js/warp-icons/source': o.source }) } }) },
25
+ agents: { title: 'Icons', text: agents },
26
+ catalog: [{ name: 'Icon', import: '@weft-js/warp-icons/all', summary: 'Any icon by name, for names that come from data; costs the whole set' }],
27
+ map: [{ want: 'An icon', call: 'Import `NameIcon` from `@weft-js/warp-icons`; `npm run sheet` in the package draws them all', have: 'one export per icon' }],
28
+ }
29
+ },
30
+ })
31
+
32
+ /** `icons()`. */
33
+ export default icons