@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/package.json CHANGED
@@ -1,8 +1,17 @@
1
1
  {
2
2
  "name": "@weft-js/warp-icons",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Icons for warp and weft",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Ravonus/weft.git",
9
+ "directory": "packages/warp-icons"
10
+ },
11
+ "homepage": "https://github.com/Ravonus/weft/tree/main/packages/warp-icons#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/Ravonus/weft/issues"
14
+ },
6
15
  "type": "module",
7
16
  "sideEffects": [
8
17
  "*.css"
@@ -23,7 +32,7 @@
23
32
  },
24
33
  "peerDependencies": {
25
34
  "vite": "*",
26
- "@weft-js/core": "^0.1.1"
35
+ "@weft-js/core": "^0.1.2"
27
36
  },
28
37
  "publishConfig": {
29
38
  "access": "public"
@@ -41,4 +50,4 @@
41
50
  "README.md",
42
51
  "LICENSE"
43
52
  ]
44
- }
53
+ }
package/plugin.mjs CHANGED
@@ -5,13 +5,18 @@ import { definePlugin } from '@weft-js/core/plugin'
5
5
  const TIERS = ['quiet', 'balanced', 'vivid']
6
6
 
7
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' })
8
+ for (const key of Object.keys(input))
9
+ if (key !== 'tier' && key !== 'source')
10
+ throw Object.assign(new Error(`unknown option \`${key}\``), { path: key })
11
+ if (input.tier !== undefined && !TIERS.includes(input.tier))
12
+ throw Object.assign(new Error(`must be one of ${TIERS.join(', ')}`), { path: 'tier' })
13
+ if (input.source !== undefined && !/^https?:\/\/.+\/$/.test(input.source))
14
+ throw Object.assign(new Error('must be an http(s) URL ending in /'), { path: 'source' })
11
15
  return { ...(input.tier && { tier: input.tier }), ...(input.source && { source: input.source }) }
12
16
  }
13
17
 
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."
18
+ const agents =
19
+ '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
20
 
16
21
  /** `icons()` for `weft.config.ts`: pins a tier if asked, and tells agents how the names work. */
17
22
  export const icons = definePlugin({
@@ -21,10 +26,31 @@ export const icons = definePlugin({
21
26
  options,
22
27
  contribute(o) {
23
28
  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 }) } }) },
29
+ tokens: {
30
+ namespace: 'icons',
31
+ values: { tier: o.tier ?? 'runtime' },
32
+ ...((o.tier || o.source) && {
33
+ alias: {
34
+ ...(o.tier && { '@weft-js/warp-icons': `@weft-js/warp-icons/${o.tier}` }),
35
+ ...(o.source && { '@weft-js/warp-icons/source': o.source }),
36
+ },
37
+ }),
38
+ },
25
39
  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' }],
40
+ catalog: [
41
+ {
42
+ name: 'Icon',
43
+ import: '@weft-js/warp-icons/all',
44
+ summary: 'Any icon by name, for names that come from data; costs the whole set',
45
+ },
46
+ ],
47
+ map: [
48
+ {
49
+ want: 'An icon',
50
+ call: 'Import `NameIcon` from `@weft-js/warp-icons`; `npm run sheet` in the package draws them all',
51
+ have: 'one export per icon',
52
+ },
53
+ ],
28
54
  }
29
55
  },
30
56
  })
package/src/action.tsx CHANGED
@@ -8,5 +8,11 @@ export type ActionIconProps = IconProps & { action?: IconTrigger | boolean }
8
8
 
9
9
  /*#__NO_SIDE_EFFECTS__*/
10
10
  export function acting(line: string, pixels: string) {
11
- return ({ action, ...rest }: ActionIconProps) => <Svg {...rest} data-act={typeof action === 'string' ? action : 'hover focus press'} html={line + pixels} />
11
+ return ({ action, ...rest }: ActionIconProps) => (
12
+ <Svg
13
+ {...rest}
14
+ data-act={typeof action === 'string' ? action : 'hover focus press'}
15
+ html={line + pixels}
16
+ />
17
+ )
12
18
  }
package/src/all.tsx CHANGED
@@ -6,4 +6,7 @@ import { lookup } from './lookup'
6
6
  export { actions, lookup, names, type IconActionName, type IconName, type IconVariant }
7
7
  export { Icon, type IconByNameProps } from './icon'
8
8
  /** Every icon, by its name. */
9
- export const icons = Object.fromEntries(names.map(n => [n, lookup(n)])) as Record<IconName, IconComponent>
9
+ export const icons = Object.fromEntries(names.map((n) => [n, lookup(n)])) as Record<
10
+ IconName,
11
+ IconComponent
12
+ >
@@ -0,0 +1,96 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { brotliCompressSync, constants } from 'node:zlib'
4
+
5
+ /** The icons that have an action, read from the generated names. */
6
+ export const actionList = () =>
7
+ [
8
+ ...readFileSync(new URL('./gen/names.ts', import.meta.url), 'utf8')
9
+ .match(/const actions = (\[.*?\])/)[1]
10
+ .matchAll(/"([a-z0-9-]+)"/g),
11
+ ].map((m) => m[1])
12
+ /** Every icon name, read from the generated names. */
13
+ export const list = () => [
14
+ ...new Set(
15
+ [
16
+ ...readFileSync(new URL('./gen/names.ts', import.meta.url), 'utf8')
17
+ .split('\n')[1]
18
+ .matchAll(/"([a-z0-9-]+)"/g),
19
+ ].map((m) => m[1]),
20
+ ),
21
+ ]
22
+
23
+ /** What the pack costs if a page takes it: every icon in one tier, brotli. */
24
+ export function packCost() {
25
+ const entry = readFileSync(new URL('./gen/all.tsx', import.meta.url), 'utf8')
26
+ const modules = [...entry.matchAll(/^export \* from '(\.\/all-[^']+)'$/gm)]
27
+ const header = entry
28
+ .slice(0, entry.indexOf('export * from'))
29
+ .replace("import '../svg'", "import { icon } from '../svg'")
30
+ const code =
31
+ header +
32
+ modules
33
+ .map(([, path]) =>
34
+ readFileSync(new URL(`./gen/${path.slice(2)}.tsx`, import.meta.url), 'utf8').replace(
35
+ /^.*\nimport \{ icon \} from '\.\.\/svg'\n\n/,
36
+ '',
37
+ ),
38
+ )
39
+ .join('')
40
+ return brotliCompressSync(Buffer.from(code), { params: { [constants.BROTLI_PARAM_QUALITY]: 5 } })
41
+ .length
42
+ }
43
+
44
+ /** `weft check`'s view: the same walk a build does, as findings. */
45
+ export function findings(root, analyze) {
46
+ const catalog = list(),
47
+ r = analyze(root, { catalog })
48
+ const kb = `${(packCost() / 1024).toFixed(0)} KB`
49
+ return [
50
+ ...r.unknown.map((u) => ({
51
+ code: 'I001',
52
+ file: u.file,
53
+ line: u.line,
54
+ column: u.column,
55
+ why: u.why ?? `no icon '${u.name}', did you mean '${u.suggest}'?`,
56
+ })),
57
+ ...r.dynamic.map((d) => ({
58
+ code: 'I002',
59
+ file: d.file,
60
+ line: d.line,
61
+ column: d.column,
62
+ 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).`,
63
+ })),
64
+ ...r.escaped.map((d) => ({
65
+ code: 'I003',
66
+ file: d.file,
67
+ line: d.line,
68
+ column: d.column,
69
+ why: `${d.why}, so every icon ships with the page (about ${kb} br).`,
70
+ })),
71
+ ]
72
+ }
73
+
74
+ /** Every icon and variant as a static .svg, for a CDN or bucket. Writes files; nothing is uploaded. */
75
+ export async function publish(dir) {
76
+ const { load } = await import('../scripts/build.mjs')
77
+ const { forms } = await import('../scripts/forms.mjs')
78
+ const icons = await load()
79
+ mkdirSync(dir, { recursive: true })
80
+ const svg = (html) =>
81
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">${html}</svg>\n`
82
+ let files = 0
83
+ for (const [name, entry] of Object.entries(icons)) {
84
+ const f = forms(name, entry)
85
+ const all = { '': f.hollow.line + f.hollow.pixels, '.solid': f.solid.line + f.solid.pixels }
86
+ for (const [suffix, html] of Object.entries(all)) {
87
+ writeFileSync(join(dir, `${name}${suffix}.svg`), svg(html))
88
+ files++
89
+ }
90
+ }
91
+ writeFileSync(
92
+ join(dir, 'manifest.json'),
93
+ JSON.stringify({ icons: Object.keys(icons), variants: ['', 'solid'] }) + '\n',
94
+ )
95
+ return { dir, files, icons: Object.keys(icons).length }
96
+ }
@@ -0,0 +1,19 @@
1
+ function distance(a, b) {
2
+ let row = Array.from({ length: b.length + 1 }, (_, i) => i)
3
+ for (let i = 1; i <= a.length; i++) {
4
+ const next = [i]
5
+ for (let j = 1; j <= b.length; j++)
6
+ next[j] = Math.min(next[j - 1] + 1, row[j] + 1, row[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
7
+ row = next
8
+ }
9
+ return row[b.length]
10
+ }
11
+ /** The name nearest to a typo, for "did you mean": a prefix counts as one edit closer. */
12
+ export const closest = (name, names) =>
13
+ names.reduce(
14
+ (best, n) => {
15
+ const d = distance(name, n) - (n.startsWith(name) ? 1 : 0)
16
+ return d < best.d ? { n, d } : best
17
+ },
18
+ { n: null, d: Infinity },
19
+ ).n
@@ -0,0 +1,33 @@
1
+ import { pascal } from './compile-pascal.mjs'
2
+
3
+ /** The module warp's name lookup becomes in a build: these names and no others, and the pack only if a name escaped. */
4
+ /** @param {Iterable<string>} names */
5
+ export function namesModule(names, { lazy = false, source } = {}) {
6
+ const list = [...names].sort()
7
+ const id = (key) => pascal(...key.split(':'))
8
+ const imports = list.length
9
+ ? `import { ${list.map(id).join(', ')} } from '@weft-js/warp-icons'\n`
10
+ : ''
11
+ const table = `const names = { ${list.map((n) => `${JSON.stringify(n)}: ${id(n)}`).join(', ')} }\nconst key = (name, variant) => (variant ? name + ':' + variant : name)\n`
12
+ 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`
13
+ if (source)
14
+ 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}`
15
+ if (lazy)
16
+ 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}`
17
+ return `${imports}${table}export const resolve = (name, variant) => names[key(name, variant)]\n${packExport}`
18
+ }
19
+
20
+ const VISIBLE = { quiet: 'oq', balanced: 'ob', vivid: 'v' }
21
+ /** Drop the drawings no shipped stop can show. With one stop left there is nothing to switch, so the switch goes too. */
22
+ export function trimTiers(source, stops) {
23
+ // a build imports the pack as a namespace (rewrite), and this default would keep every icon alive
24
+ const code = source
25
+ if (stops.size === 3) return code
26
+ const keep = [...stops].map((s) => VISIBLE[s]).join('')
27
+ const out = code.replace(/<path data-t="([oqbv])"[^>]*?\/>/g, (m, t) =>
28
+ keep.includes(t) ? m : '',
29
+ )
30
+ return stops.size === 1
31
+ ? out.replaceAll(/ data-t="[oqbv]"/g, '').replace("import '../tiers.css'\n", '')
32
+ : out
33
+ }
@@ -0,0 +1,4 @@
1
+ export const title = (name) => name.replace(/(^|-)(.)/g, (_, __, c) => c.toUpperCase())
2
+ /** The export a name and variant compile to: `check`, `solid` is `CheckIconSolid`. */
3
+ export const pascal = (name, variant = '') =>
4
+ `${title(name)}Icon${variant ? variant[0].toUpperCase() + variant.slice(1) : ''}`
@@ -0,0 +1,227 @@
1
+ import { existsSync, statSync } from 'node:fs'
2
+ import { dirname, extname, join, resolve as path } from 'node:path'
3
+ import { parseAst } from 'vite'
4
+ import { unwrap } from './compile-unwrap.mjs'
5
+
6
+ const ITERATORS = new Set([
7
+ 'map',
8
+ 'flatMap',
9
+ 'filter',
10
+ 'forEach',
11
+ 'find',
12
+ 'some',
13
+ 'every',
14
+ 'sort',
15
+ 'toSorted',
16
+ ])
17
+ function moduleFile(from, source) {
18
+ const base = path(dirname(from), source)
19
+ for (const c of [
20
+ base,
21
+ ...['.tsx', '.ts', '.jsx', '.js', '.mjs'].map((e) => base + e),
22
+ ...['tsx', 'ts', 'jsx', 'js'].map((e) => join(base, `index.${e}`)),
23
+ base.replace(/\.js$/, '.ts'),
24
+ base.replace(/\.js$/, '.tsx'),
25
+ ])
26
+ if (existsSync(c) && statSync(c).isFile()) return c
27
+ return null
28
+ }
29
+
30
+ function parse(file, code) {
31
+ try {
32
+ return parseAst(code, {
33
+ lang: file.endsWith('.tsx') ? 'tsx' : file.endsWith('.ts') ? 'ts' : 'jsx',
34
+ })
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ /** One source: its imports, what its names are bound to, and every place an icon name, a stop or a pack is used. */
41
+ export function read(file, code) {
42
+ const unit = {
43
+ file,
44
+ code,
45
+ imports: new Map(),
46
+ bindings: new Map(),
47
+ exports: new Map(),
48
+ uses: [],
49
+ sites: [],
50
+ stops: [],
51
+ related: [],
52
+ members: [],
53
+ idents: [],
54
+ warp: false,
55
+ }
56
+ if (extname(file) === '.html') {
57
+ for (const m of code.matchAll(/data-expression=["']?([a-z]+)/g))
58
+ unit.stops.push({ value: m[1] })
59
+ return unit
60
+ }
61
+ for (const m of code.matchAll(
62
+ /setAttribute\(\s*['"]data-expression['"]\s*,\s*(?:['"]([a-z]+)['"])?/g,
63
+ ))
64
+ unit.stops.push(m[1] ? { value: m[1] } : { dynamic: true, at: m.index })
65
+ if (/dataset\.expression\s*=/.test(code))
66
+ unit.stops.push({ dynamic: true, at: code.search(/dataset\.expression\s*=/) })
67
+ const ast = parse(file, code)
68
+ if (!ast) return unit
69
+ unit.ast = ast
70
+ visitUnit(unit, ast, file)
71
+ return unit
72
+ }
73
+
74
+ function visitUnit(unit, ast, file) {
75
+ const bind = (name, binding) => {
76
+ if (!unit.bindings.has(name)) unit.bindings.set(name, [])
77
+ unit.bindings.get(name).push(binding)
78
+ }
79
+ const pattern = (id, make) => {
80
+ if (id?.type === 'Identifier') bind(id.name, make(null))
81
+ else if (id?.type === 'AssignmentPattern') pattern(id.left, make)
82
+ else if (id?.type === 'ObjectPattern')
83
+ for (const p of id.properties) {
84
+ const key = p.key?.name ?? p.key?.value
85
+ if (key !== undefined) pattern(p.value, (inner) => make(inner ? [key, ...inner] : [key]))
86
+ }
87
+ }
88
+ const component = (name, params) => {
89
+ if (!name || !/^[A-Z]/.test(name)) return
90
+ const first = params?.[0] ?? params?.items?.[0]
91
+ if (first?.type === 'Identifier') bind(first.name, { kind: 'props', comp: name })
92
+ else if (first) pattern(first, (keys) => ({ kind: 'prop', comp: name, key: keys?.[0] }))
93
+ }
94
+ const visit = (node) => {
95
+ if (!node || typeof node !== 'object') return
96
+ if (Array.isArray(node)) {
97
+ for (const n of node) visit(n)
98
+ return
99
+ }
100
+ if (typeof node.type !== 'string') return
101
+ if (declarations(node, unit, file, pattern, component)) return
102
+ uses(node, unit)
103
+ for (const k of Object.keys(node)) {
104
+ if (k !== 'type' && k !== 'start' && k !== 'end') visit(node[k])
105
+ }
106
+ }
107
+ visit(ast.body)
108
+ }
109
+
110
+ function declarations(node, unit, file, pattern, component) {
111
+ switch (node.type) {
112
+ case 'ImportDeclaration': {
113
+ const source = node.source.value
114
+ if (source === '@weft-js/warp' || source.startsWith('@weft-js/warp/')) unit.warp = true
115
+ const target = source.startsWith('.') ? moduleFile(file, source) : null
116
+ if (target) unit.related.push(target)
117
+ for (const s of node.specifiers ?? []) {
118
+ const imported =
119
+ s.type === 'ImportNamespaceSpecifier'
120
+ ? '*'
121
+ : s.type === 'ImportDefaultSpecifier'
122
+ ? 'default'
123
+ : (s.imported.name ?? s.imported.value)
124
+ unit.imports.set(s.local.name, { source, imported, target })
125
+ }
126
+ return true
127
+ }
128
+ case 'ExportNamedDeclaration':
129
+ if (node.source && node.source.value.startsWith('.')) {
130
+ const t = moduleFile(file, node.source.value)
131
+ if (t) {
132
+ unit.related.push(t)
133
+ for (const s of node.specifiers ?? [])
134
+ unit.exports.set(s.exported.name, {
135
+ reexport: t,
136
+ name: s.local.name ?? s.local.value,
137
+ })
138
+ }
139
+ }
140
+ break
141
+ case 'ExportAllDeclaration':
142
+ if (node.source.value.startsWith('.')) {
143
+ const t = moduleFile(file, node.source.value)
144
+ if (t) {
145
+ unit.related.push(t)
146
+ ;(unit.stars ??= []).push(t)
147
+ }
148
+ }
149
+ break
150
+ case 'VariableDeclarator':
151
+ if (node.init) {
152
+ pattern(node.id, (keys) => ({ kind: 'value', expr: node.init, keys }))
153
+ const fn = unwrap(node.init)
154
+ if (node.id.type === 'Identifier' && /Function/.test(fn.type))
155
+ component(node.id.name, fn.params)
156
+ }
157
+ break
158
+ case 'FunctionDeclaration':
159
+ component(node.id?.name, node.params)
160
+ break
161
+ case 'CallExpression': {
162
+ const callee = node.callee
163
+ if (
164
+ callee.type === 'MemberExpression' &&
165
+ !callee.computed &&
166
+ ITERATORS.has(callee.property.name)
167
+ ) {
168
+ const fn = node.arguments[0] && unwrap(node.arguments[0])
169
+ if (fn && /Function/.test(fn.type))
170
+ pattern(fn.params?.[0] ?? fn.params?.items?.[0], (keys) => ({
171
+ kind: 'each',
172
+ of: callee.object,
173
+ keys,
174
+ }))
175
+ }
176
+ break
177
+ }
178
+ }
179
+ return false
180
+ }
181
+
182
+ function uses(node, unit) {
183
+ switch (node.type) {
184
+ case 'MemberExpression':
185
+ if (node.object.type === 'Identifier') unit.members.push({ local: node.object.name, node })
186
+ break
187
+ case 'JSXMemberExpression':
188
+ if (node.object.type === 'JSXIdentifier')
189
+ unit.members.push({ local: node.object.name, node, jsx: true })
190
+ break
191
+ case 'Identifier':
192
+ case 'JSXIdentifier':
193
+ unit.idents.push(node)
194
+ break
195
+ case 'JSXOpeningElement': {
196
+ const comp =
197
+ node.name.type === 'JSXIdentifier'
198
+ ? node.name.name
199
+ : node.name.type === 'JSXMemberExpression'
200
+ ? `${node.name.object.name}.${node.name.property.name}`
201
+ : null
202
+ if (!comp) break
203
+ for (const attr of node.attributes) {
204
+ if (attr.type !== 'JSXAttribute' || attr.name.type !== 'JSXIdentifier') continue
205
+ const value =
206
+ attr.value?.type === 'JSXExpressionContainer' ? attr.value.expression : attr.value
207
+ if (attr.name.name === 'data-expression' && value)
208
+ unit.stops.push({ expr: value, at: attr.start })
209
+ if (value && /^[A-Z]/.test(comp))
210
+ unit.uses.push({
211
+ comp,
212
+ prop: attr.name.name,
213
+ expr: value,
214
+ at: attr.start,
215
+ end: attr.end,
216
+ element: node,
217
+ })
218
+ }
219
+ break
220
+ }
221
+ case 'Property':
222
+ case 'ObjectProperty':
223
+ if (!node.computed && (node.key.name ?? node.key.value) === 'icon' && !node.method)
224
+ unit.sites.push({ soft: true, expr: node.value, at: node.start })
225
+ break
226
+ }
227
+ }
@@ -0,0 +1,169 @@
1
+ import { unwrap } from './compile-unwrap.mjs'
2
+
3
+ const empty = () => ({ values: new Set(), shapes: [], dynamic: null })
4
+ const merge = (into, r) => {
5
+ for (const v of r.values) into.values.add(v)
6
+ into.shapes.push(...r.shapes)
7
+ into.dynamic ??= r.dynamic
8
+ return into
9
+ }
10
+ const text = (unit, n) => unit.code.slice(n.start, n.end).slice(0, 60)
11
+
12
+ function exported(ctx, file, name, seen) {
13
+ const u = ctx.units.get(file)
14
+ if (!u) return { ...empty(), dynamic: `export ${name}` }
15
+ const re = u.exports.get(name)
16
+ if (re?.reexport) return exported(ctx, re.reexport, re.name, seen)
17
+ if (u.bindings.has(name)) return identifier(ctx, u, name, seen)
18
+ for (const star of u.stars ?? []) {
19
+ const r = exported(ctx, star, name, seen)
20
+ if (!r.dynamic) return r
21
+ }
22
+ return { ...empty(), dynamic: `export ${name}` }
23
+ }
24
+
25
+ function identifier(ctx, unit, name, seen) {
26
+ if (name === 'undefined') return empty()
27
+ const id = `${unit.file}#${name}`
28
+ if (seen.has(id)) return empty()
29
+ seen.add(id)
30
+ const imp = unit.imports.get(name)
31
+ if (imp) {
32
+ if (imp.source === '@weft-js/warp-icons' || imp.source.startsWith('@weft-js/warp-icons/'))
33
+ return empty()
34
+ if (imp.target && imp.imported !== '*') return exported(ctx, imp.target, imp.imported, seen)
35
+ return { ...empty(), dynamic: name }
36
+ }
37
+ const list = unit.bindings.get(name)
38
+ if (!list) return { ...empty(), dynamic: name }
39
+ const out = empty()
40
+ for (const b of list) merge(out, binding(ctx, unit, b, seen))
41
+ seen.delete(id)
42
+ return out
43
+ }
44
+
45
+ function pick(ctx, unit, from, keys, seen) {
46
+ let cur = from
47
+ for (const key of keys ?? []) {
48
+ const next = empty()
49
+ next.dynamic = cur.dynamic
50
+ for (const { shape, unit: u } of cur.shapes) merge(next, property(ctx, u, shape, key, seen))
51
+ cur = next
52
+ }
53
+ return cur
54
+ }
55
+
56
+ function binding(ctx, unit, b, seen) {
57
+ if (b.kind === 'value') return pick(ctx, unit, expression(ctx, unit, b.expr, seen), b.keys, seen)
58
+ if (b.kind === 'each') {
59
+ const list = expression(ctx, unit, b.of, seen),
60
+ items = empty()
61
+ items.dynamic = list.dynamic
62
+ for (const { shape, unit: u } of list.shapes)
63
+ if (shape.type === 'ArrayExpression')
64
+ for (const el of shape.elements) if (el) merge(items, expression(ctx, u, el, seen))
65
+ return pick(ctx, unit, items, b.keys, seen)
66
+ }
67
+ if (b.kind === 'prop')
68
+ return b.key ? forwarded(ctx, b.comp, b.key, seen) : { ...empty(), dynamic: `${b.comp} props` }
69
+ return { ...empty(), dynamic: `${b.comp} props` }
70
+ }
71
+
72
+ function forwarded(ctx, comp, prop, seen) {
73
+ const out = empty()
74
+ for (const { unit, use } of ctx.usesOf.get(`${comp}.${prop}`) ?? [])
75
+ merge(out, expression(ctx, unit, use.expr, seen))
76
+ return out
77
+ }
78
+
79
+ function property(ctx, unit, shape, key, seen) {
80
+ const out = empty()
81
+ if (shape.type === 'ObjectExpression') {
82
+ for (const p of shape.properties) {
83
+ if (p.type === 'SpreadElement') {
84
+ const s = expression(ctx, unit, p.argument, seen)
85
+ for (const x of s.shapes) merge(out, property(ctx, x.unit, x.shape, key, seen))
86
+ out.dynamic ??= s.dynamic
87
+ continue
88
+ }
89
+ const k = p.key?.name ?? p.key?.value
90
+ if (key === null || k === key) merge(out, expression(ctx, unit, p.value, seen))
91
+ }
92
+ } else if (shape.type === 'ArrayExpression')
93
+ for (const el of shape.elements) if (el) merge(out, expression(ctx, unit, el, seen))
94
+ return out
95
+ }
96
+
97
+ function expression(ctx, unit, raw, seen = new Set()) {
98
+ const node = unwrap(raw)
99
+ if (!node) return empty()
100
+ switch (node.type) {
101
+ case 'Literal':
102
+ case 'StringLiteral':
103
+ return typeof node.value === 'string'
104
+ ? { ...empty(), values: new Set([node.value]) }
105
+ : empty()
106
+ case 'TemplateLiteral':
107
+ return node.expressions.length
108
+ ? { ...empty(), dynamic: text(unit, node) }
109
+ : { ...empty(), values: new Set([node.quasis[0].value.cooked]) }
110
+ case 'ConditionalExpression':
111
+ return merge(
112
+ expression(ctx, unit, node.consequent, seen),
113
+ expression(ctx, unit, node.alternate, seen),
114
+ )
115
+ case 'LogicalExpression':
116
+ return merge(expression(ctx, unit, node.left, seen), expression(ctx, unit, node.right, seen))
117
+ case 'ArrayExpression':
118
+ case 'ObjectExpression':
119
+ return { ...empty(), shapes: [{ shape: node, unit }] }
120
+ case 'Identifier':
121
+ return identifier(ctx, unit, node.name, seen)
122
+ case 'ArrowFunctionExpression':
123
+ case 'FunctionExpression':
124
+ case 'JSXElement':
125
+ case 'JSXFragment':
126
+ return empty()
127
+ case 'MemberExpression': {
128
+ const object = unwrap(node.object)
129
+ const key = node.computed ? (unwrap(node.property)?.value ?? null) : node.property.name
130
+ if (object.type === 'Identifier') {
131
+ const props = unit.bindings.get(object.name)?.find((b) => b.kind === 'props')
132
+ if (props && key) return forwarded(ctx, props.comp, key, seen)
133
+ const imp = unit.imports.get(object.name)
134
+ if (imp?.imported === '*' && imp.target && key) return exported(ctx, imp.target, key, seen)
135
+ }
136
+ const base = expression(ctx, unit, object, seen),
137
+ out = empty()
138
+ out.dynamic = base.dynamic
139
+ for (const { shape, unit: u } of base.shapes) merge(out, property(ctx, u, shape, key, seen))
140
+ return out
141
+ }
142
+ case 'CallExpression': {
143
+ // a list method hands back the list, or an item of it
144
+ const c = node.callee
145
+ if (
146
+ c.type === 'MemberExpression' &&
147
+ !c.computed &&
148
+ ['filter', 'sort', 'toSorted', 'slice', 'reverse', 'concat'].includes(c.property.name)
149
+ )
150
+ return expression(ctx, unit, c.object, seen)
151
+ if (c.type === 'MemberExpression' && !c.computed && c.property.name === 'find') {
152
+ const base = expression(ctx, unit, c.object, seen),
153
+ out = empty()
154
+ out.dynamic = base.dynamic
155
+ for (const { shape, unit: u } of base.shapes)
156
+ merge(out, property(ctx, u, shape, null, seen))
157
+ return out
158
+ }
159
+ return { ...empty(), dynamic: text(unit, node) }
160
+ }
161
+ default:
162
+ return { ...empty(), dynamic: text(unit, node) }
163
+ }
164
+ }
165
+
166
+ export function resolver(units, usesOf) {
167
+ const ctx = { units, usesOf }
168
+ return { expression: (unit, raw, seen) => expression(ctx, unit, raw, seen) }
169
+ }