@nubisco/ui 4.1.0 → 4.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.
@@ -20,6 +20,15 @@ export interface IGlyphOptions {
20
20
  packageName?: string;
21
21
  /** Log every rewrite. Useful when auditing what a page actually links. */
22
22
  verbose?: boolean;
23
+ /**
24
+ * Warn when a glyph forwarded through another component (`<NbButton
25
+ * :icon="…">`) is a value this plugin cannot see through, so no artwork was
26
+ * linked for it. On by default, once per file, because the alternative is
27
+ * the silence that lets an icon reach `NbIcon` with nothing to resolve it
28
+ * against. Turn it off in a codebase where those bindings are deliberate
29
+ * pass-through props, resolved by whoever passes them in.
30
+ */
31
+ warnUnresolved?: boolean;
23
32
  }
24
33
  export declare function nubiscoGlyphs(options?: IGlyphOptions): {
25
34
  name: string;
@@ -1 +1 @@
1
- {"version":3,"file":"glyphs.d.ts","sourceRoot":"","sources":["../../../src/plugins/vite/glyphs.ts"],"names":[],"mappings":"AA0CA,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,KAAK,CAAA;AAE9C,MAAM,WAAW,aAAa;IAC5B;;;;;OAKG;IACH,OAAO,CAAC,EAAE,iBAAiB,CAAA;IAC3B,4CAA4C;IAC5C,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,4CAA4C;IAC5C,KAAK,CAAC,EAAE,OAAO,CAAA;IACf;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,4EAA4E;IAC5E,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,0EAA0E;IAC1E,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAuFD,wBAAgB,aAAa,CAAC,OAAO,GAAE,aAAkB;;;;oBAwB/B,MAAM,MAAM,MAAM;;;;EA8I3C"}
1
+ {"version":3,"file":"glyphs.d.ts","sourceRoot":"","sources":["../../../src/plugins/vite/glyphs.ts"],"names":[],"mappings":"AA0CA,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,KAAK,CAAA;AAE9C,MAAM,WAAW,aAAa;IAC5B;;;;;OAKG;IACH,OAAO,CAAC,EAAE,iBAAiB,CAAA;IAC3B,4CAA4C;IAC5C,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,4CAA4C;IAC5C,KAAK,CAAC,EAAE,OAAO,CAAA;IACf;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,4EAA4E;IAC5E,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,0EAA0E;IAC1E,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB;;;;;;;OAOG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;CACzB;AAoLD,wBAAgB,aAAa,CAAC,OAAO,GAAE,aAAkB;;;;oBAyB/B,MAAM,MAAM,MAAM;;;;EA6L3C"}
@@ -3,7 +3,7 @@ import { existsSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import MagicString from 'magic-string';
6
- import { parse } from 'vue/compiler-sfc';
6
+ import { parse, babelParse } from 'vue/compiler-sfc';
7
7
  /**
8
8
  * Resolves icon and flag artwork at compile time.
9
9
  *
@@ -83,6 +83,95 @@ function staticWeight(element) {
83
83
  }
84
84
  return 'regular';
85
85
  }
86
+ /**
87
+ * Parse one template expression on its own. `babelParse` wants a program, so
88
+ * the expression is wrapped; a binding Vue accepts but Babel cannot read is
89
+ * simply unknown, which the caller treats as unresolved.
90
+ */
91
+ function parseExpression(expression) {
92
+ try {
93
+ const { program } = babelParse(`(${expression})`, {
94
+ sourceType: 'module',
95
+ plugins: ['typescript'],
96
+ });
97
+ const statement = program.body[0];
98
+ return statement?.type === 'ExpressionStatement'
99
+ ? statement.expression
100
+ : undefined;
101
+ }
102
+ catch {
103
+ return undefined;
104
+ }
105
+ }
106
+ /**
107
+ * Whether every value this expression can hand to the component is artwork the
108
+ * bundle already links: a literal this plugin just rewrote, or a glyph module
109
+ * the file imported itself.
110
+ *
111
+ * The point is what it refuses. `open ? 'caret-up' : 'caret-down'` produces one
112
+ * of two rewritten literals and nothing else, so it is resolved. `block?.icon
113
+ * || 'cube'` also contains a rewritten literal, but it can just as easily
114
+ * produce `block.icon`, which is a runtime value and needs the catalogue.
115
+ * Judging by "a rewritten literal appears and no quoted string survives" called
116
+ * that second one resolved, which meant adding a literal fallback silently
117
+ * removed the coverage the same expression had without one.
118
+ */
119
+ function producesOnlyLinkedGlyphs(node, isLinked) {
120
+ if (!node)
121
+ return false;
122
+ const recurse = (child) => producesOnlyLinkedGlyphs(child, isLinked);
123
+ switch (node.type) {
124
+ case 'Identifier':
125
+ return isLinked(node.name);
126
+ // The test is not a value the prop receives; the two arms are.
127
+ case 'ConditionalExpression':
128
+ return recurse(node.consequent) && recurse(node.alternate);
129
+ // `a || b`, `a ?? b` and `a && b` can all produce either side.
130
+ case 'LogicalExpression':
131
+ return recurse(node.left) && recurse(node.right);
132
+ case 'SequenceExpression':
133
+ return recurse(node.expressions[node.expressions.length - 1]);
134
+ case 'TSAsExpression':
135
+ case 'TSNonNullExpression':
136
+ case 'TSSatisfiesExpression':
137
+ case 'ParenthesizedExpression':
138
+ return recurse(node.expression);
139
+ default:
140
+ return false;
141
+ }
142
+ }
143
+ /**
144
+ * Local names bound to glyph modules by the file's own imports, so that
145
+ * `<NbIcon :name="GithubLogo" />` is understood as already-linked artwork
146
+ * rather than as a runtime name needing the whole catalogue.
147
+ */
148
+ function glyphImportLocals(script, packageName) {
149
+ const locals = new Set();
150
+ const from = packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
151
+ const pattern = new RegExp(`import\\s+([^;'"\n]+?)\\s+from\\s*['"]${from}/(?:icons|flags)/[^'"]+['"]`, 'g');
152
+ for (const match of script.matchAll(pattern)) {
153
+ const clause = match[1];
154
+ const namespace = clause.match(/\*\s+as\s+(\w+)/);
155
+ if (namespace)
156
+ locals.add(namespace[1]);
157
+ const defaultImport = clause.match(/^\s*(\w+)/);
158
+ if (defaultImport)
159
+ locals.add(defaultImport[1]);
160
+ const named = clause.match(/\{([^}]*)\}/);
161
+ if (named) {
162
+ for (const part of named[1].split(',')) {
163
+ const local = part
164
+ .trim()
165
+ .split(/\s+as\s+/)
166
+ .pop()
167
+ ?.trim();
168
+ if (local)
169
+ locals.add(local);
170
+ }
171
+ }
172
+ }
173
+ return locals;
174
+ }
86
175
  function staticPropKind(name, isIcon, isFlag) {
87
176
  if (isIcon)
88
177
  return name === 'name' || name === 'icon' ? 'icon' : undefined;
@@ -97,7 +186,7 @@ function staticPropKind(name, isIcon, isFlag) {
97
186
  return undefined;
98
187
  }
99
188
  export function nubiscoGlyphs(options = {}) {
100
- const { catalog = 'auto', icons = true, flags = true, packageName = '@nubisco/ui', verbose = false, } = options;
189
+ const { catalog = 'auto', icons = true, flags = true, packageName = '@nubisco/ui', verbose = false, warnUnresolved = true, } = options;
101
190
  const glyphRoot = options.glyphRoot ?? defaultGlyphRoot();
102
191
  let names;
103
192
  const enabled = (kind) => (kind === 'icon' ? icons : flags);
@@ -121,6 +210,11 @@ export function nubiscoGlyphs(options = {}) {
121
210
  const edits = [];
122
211
  const used = new Map();
123
212
  const catalogsNeeded = new Set();
213
+ const unresolvedForwards = [];
214
+ // Artwork the file already imports by hand counts as linked: binding an
215
+ // imported module is the documented no-plugin path, and pulling a
216
+ // catalogue in on top of it would be pure waste.
217
+ const glyphLocals = glyphImportLocals(`${descriptor.scriptSetup?.content ?? ''}\n${descriptor.script?.content ?? ''}`, packageName);
124
218
  const use = (glyph) => {
125
219
  const variable = identifierFor(glyph);
126
220
  used.set(variable, glyph);
@@ -160,16 +254,16 @@ export function nubiscoGlyphs(options = {}) {
160
254
  });
161
255
  continue;
162
256
  }
163
- /* ---- bound: :name="…" on NbIcon / NbFlag --------------------- */
257
+ /* ---- bound: :name="…", :icon="…", :flag="…" ------------------ */
164
258
  if (prop.type === NODE_DIRECTIVE &&
165
259
  prop.name === 'bind' &&
166
260
  prop.arg?.type === EXPR_SIMPLE &&
167
- prop.exp?.type === EXPR_SIMPLE &&
168
- (isIcon || isFlag)) {
169
- const kind = isIcon ? 'icon' : 'flag';
170
- if (!enabled(kind))
171
- continue;
172
- if (!['name', 'icon', 'flag'].includes(prop.arg.content))
261
+ prop.exp?.type === EXPR_SIMPLE) {
262
+ // Same rule as the static branch, so `:icon="a ? 'check' : 'copy'"`
263
+ // links the two icons whether it is written on NbIcon or forwarded
264
+ // through NbButton. Only the tag decides the kind.
265
+ const kind = staticPropKind(prop.arg.content, isIcon, isFlag);
266
+ if (!kind || !enabled(kind))
173
267
  continue;
174
268
  const expression = prop.exp.content;
175
269
  const rewritten = expression.replace(/(['"])([a-z0-9][a-z0-9-]*)\1/g, (match, _quote, literal) => known(kind, literal)
@@ -186,15 +280,45 @@ export function nubiscoGlyphs(options = {}) {
186
280
  replacement: rewritten,
187
281
  });
188
282
  }
189
- // Considered resolved when every value the expression can produce
190
- // is one of the modules just imported: at least one appears, and
191
- // no string literal survived that could not be accounted for.
192
- const resolved = rewritten.includes('__nb_') && !/['"]/.test(rewritten);
193
- if (!resolved)
283
+ const resolved = producesOnlyLinkedGlyphs(parseExpression(rewritten), (identifier) => used.has(identifier) || glyphLocals.has(identifier));
284
+ if (resolved)
285
+ continue;
286
+ // On NbIcon or NbFlag an unresolvable name is fatal at first
287
+ // render, so the catalogue is the only thing that can save it. On
288
+ // a forwarding component the same expression may just as well be
289
+ // a module arriving as a prop, and linking 1,500 icons on that
290
+ // guess would make the common wrapper the most expensive file in
291
+ // the bundle. So that case is reported rather than paid for.
292
+ if (isIcon || isFlag)
194
293
  catalogsNeeded.add(kind);
294
+ else
295
+ unresolvedForwards.push({
296
+ tag,
297
+ prop: prop.arg.content,
298
+ exp: expression,
299
+ });
195
300
  }
196
301
  }
197
302
  });
303
+ // One line per file, however many bindings it has: this is a note about
304
+ // the file, and repeating the same advice per expression would train
305
+ // people to skip it.
306
+ if (warnUnresolved && unresolvedForwards.length) {
307
+ const kinds = new Set(unresolvedForwards.map((f) => f.prop));
308
+ const register = kinds.has('flag')
309
+ ? 'registerFlags()'
310
+ : 'registerIcons()';
311
+ const catalogue = `${packageName}/${kinds.has('flag') ? 'flags' : 'icons'}/all`;
312
+ console.warn(`[nubisco-ui] ${path.relative(process.cwd(), file)}: no artwork ` +
313
+ `linked for ` +
314
+ unresolvedForwards
315
+ .map((f) => `<${f.tag} :${f.prop}="${f.exp}">`)
316
+ .join(', ') +
317
+ `. The value is only known at runtime, so it will throw on first ` +
318
+ `render unless something resolves it: ${register} for a known ` +
319
+ `set, or import '${catalogue}' in this file. Nothing to do if ` +
320
+ `these are pass-through props (set warnUnresolved: false).`);
321
+ }
198
322
  if (!used.size && !catalogsNeeded.size)
199
323
  return;
200
324
  const imports = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nubisco/ui",
3
- "version": "4.1.0",
3
+ "version": "4.1.1",
4
4
  "description": "Vue 3 UI component library",
5
5
  "packageManager": "pnpm@11.0.8",
6
6
  "repository": {
@@ -3,7 +3,7 @@ import { existsSync } from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import MagicString from 'magic-string'
6
- import { parse } from 'vue/compiler-sfc'
6
+ import { parse, babelParse } from 'vue/compiler-sfc'
7
7
 
8
8
  /**
9
9
  * Resolves icon and flag artwork at compile time.
@@ -63,6 +63,15 @@ export interface IGlyphOptions {
63
63
  packageName?: string
64
64
  /** Log every rewrite. Useful when auditing what a page actually links. */
65
65
  verbose?: boolean
66
+ /**
67
+ * Warn when a glyph forwarded through another component (`<NbButton
68
+ * :icon="…">`) is a value this plugin cannot see through, so no artwork was
69
+ * linked for it. On by default, once per file, because the alternative is
70
+ * the silence that lets an icon reach `NbIcon` with nothing to resolve it
71
+ * against. Turn it off in a codebase where those bindings are deliberate
72
+ * pass-through props, resolved by whoever passes them in.
73
+ */
74
+ warnUnresolved?: boolean
66
75
  }
67
76
 
68
77
  type TKind = 'icon' | 'flag'
@@ -136,6 +145,99 @@ function staticWeight(element: any): string | undefined {
136
145
  return 'regular'
137
146
  }
138
147
 
148
+ /**
149
+ * Parse one template expression on its own. `babelParse` wants a program, so
150
+ * the expression is wrapped; a binding Vue accepts but Babel cannot read is
151
+ * simply unknown, which the caller treats as unresolved.
152
+ */
153
+ function parseExpression(expression: string): any {
154
+ try {
155
+ const { program } = babelParse(`(${expression})`, {
156
+ sourceType: 'module',
157
+ plugins: ['typescript'],
158
+ })
159
+ const statement: any = program.body[0]
160
+ return statement?.type === 'ExpressionStatement'
161
+ ? statement.expression
162
+ : undefined
163
+ } catch {
164
+ return undefined
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Whether every value this expression can hand to the component is artwork the
170
+ * bundle already links: a literal this plugin just rewrote, or a glyph module
171
+ * the file imported itself.
172
+ *
173
+ * The point is what it refuses. `open ? 'caret-up' : 'caret-down'` produces one
174
+ * of two rewritten literals and nothing else, so it is resolved. `block?.icon
175
+ * || 'cube'` also contains a rewritten literal, but it can just as easily
176
+ * produce `block.icon`, which is a runtime value and needs the catalogue.
177
+ * Judging by "a rewritten literal appears and no quoted string survives" called
178
+ * that second one resolved, which meant adding a literal fallback silently
179
+ * removed the coverage the same expression had without one.
180
+ */
181
+ function producesOnlyLinkedGlyphs(
182
+ node: any,
183
+ isLinked: (name: string) => boolean,
184
+ ): boolean {
185
+ if (!node) return false
186
+ const recurse = (child: any) => producesOnlyLinkedGlyphs(child, isLinked)
187
+ switch (node.type) {
188
+ case 'Identifier':
189
+ return isLinked(node.name)
190
+ // The test is not a value the prop receives; the two arms are.
191
+ case 'ConditionalExpression':
192
+ return recurse(node.consequent) && recurse(node.alternate)
193
+ // `a || b`, `a ?? b` and `a && b` can all produce either side.
194
+ case 'LogicalExpression':
195
+ return recurse(node.left) && recurse(node.right)
196
+ case 'SequenceExpression':
197
+ return recurse(node.expressions[node.expressions.length - 1])
198
+ case 'TSAsExpression':
199
+ case 'TSNonNullExpression':
200
+ case 'TSSatisfiesExpression':
201
+ case 'ParenthesizedExpression':
202
+ return recurse(node.expression)
203
+ default:
204
+ return false
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Local names bound to glyph modules by the file's own imports, so that
210
+ * `<NbIcon :name="GithubLogo" />` is understood as already-linked artwork
211
+ * rather than as a runtime name needing the whole catalogue.
212
+ */
213
+ function glyphImportLocals(script: string, packageName: string): Set<string> {
214
+ const locals = new Set<string>()
215
+ const from = packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
216
+ const pattern = new RegExp(
217
+ `import\\s+([^;'"\n]+?)\\s+from\\s*['"]${from}/(?:icons|flags)/[^'"]+['"]`,
218
+ 'g',
219
+ )
220
+ for (const match of script.matchAll(pattern)) {
221
+ const clause = match[1]
222
+ const namespace = clause.match(/\*\s+as\s+(\w+)/)
223
+ if (namespace) locals.add(namespace[1])
224
+ const defaultImport = clause.match(/^\s*(\w+)/)
225
+ if (defaultImport) locals.add(defaultImport[1])
226
+ const named = clause.match(/\{([^}]*)\}/)
227
+ if (named) {
228
+ for (const part of named[1].split(',')) {
229
+ const local = part
230
+ .trim()
231
+ .split(/\s+as\s+/)
232
+ .pop()
233
+ ?.trim()
234
+ if (local) locals.add(local)
235
+ }
236
+ }
237
+ }
238
+ return locals
239
+ }
240
+
139
241
  function staticPropKind(
140
242
  name: string,
141
243
  isIcon: boolean,
@@ -157,6 +259,7 @@ export function nubiscoGlyphs(options: IGlyphOptions = {}) {
157
259
  flags = true,
158
260
  packageName = '@nubisco/ui',
159
261
  verbose = false,
262
+ warnUnresolved = true,
160
263
  } = options
161
264
 
162
265
  const glyphRoot = options.glyphRoot ?? defaultGlyphRoot()
@@ -186,6 +289,16 @@ export function nubiscoGlyphs(options: IGlyphOptions = {}) {
186
289
  const edits: IEdit[] = []
187
290
  const used = new Map<string, IGlyphImport>()
188
291
  const catalogsNeeded = new Set<TKind>()
292
+ const unresolvedForwards: { tag: string; prop: string; exp: string }[] =
293
+ []
294
+
295
+ // Artwork the file already imports by hand counts as linked: binding an
296
+ // imported module is the documented no-plugin path, and pulling a
297
+ // catalogue in on top of it would be pure waste.
298
+ const glyphLocals = glyphImportLocals(
299
+ `${descriptor.scriptSetup?.content ?? ''}\n${descriptor.script?.content ?? ''}`,
300
+ packageName,
301
+ )
189
302
 
190
303
  const use = (glyph: IGlyphImport) => {
191
304
  const variable = identifierFor(glyph)
@@ -229,17 +342,18 @@ export function nubiscoGlyphs(options: IGlyphOptions = {}) {
229
342
  continue
230
343
  }
231
344
 
232
- /* ---- bound: :name="…" on NbIcon / NbFlag --------------------- */
345
+ /* ---- bound: :name="…", :icon="…", :flag="…" ------------------ */
233
346
  if (
234
347
  prop.type === NODE_DIRECTIVE &&
235
348
  prop.name === 'bind' &&
236
349
  prop.arg?.type === EXPR_SIMPLE &&
237
- prop.exp?.type === EXPR_SIMPLE &&
238
- (isIcon || isFlag)
350
+ prop.exp?.type === EXPR_SIMPLE
239
351
  ) {
240
- const kind: TKind = isIcon ? 'icon' : 'flag'
241
- if (!enabled(kind)) continue
242
- if (!['name', 'icon', 'flag'].includes(prop.arg.content)) continue
352
+ // Same rule as the static branch, so `:icon="a ? 'check' : 'copy'"`
353
+ // links the two icons whether it is written on NbIcon or forwarded
354
+ // through NbButton. Only the tag decides the kind.
355
+ const kind = staticPropKind(prop.arg.content, isIcon, isFlag)
356
+ if (!kind || !enabled(kind)) continue
243
357
 
244
358
  const expression: string = prop.exp.content
245
359
  const rewritten = expression.replace(
@@ -262,16 +376,52 @@ export function nubiscoGlyphs(options: IGlyphOptions = {}) {
262
376
  })
263
377
  }
264
378
 
265
- // Considered resolved when every value the expression can produce
266
- // is one of the modules just imported: at least one appears, and
267
- // no string literal survived that could not be accounted for.
268
- const resolved =
269
- rewritten.includes('__nb_') && !/['"]/.test(rewritten)
270
- if (!resolved) catalogsNeeded.add(kind)
379
+ const resolved = producesOnlyLinkedGlyphs(
380
+ parseExpression(rewritten),
381
+ (identifier) =>
382
+ used.has(identifier) || glyphLocals.has(identifier),
383
+ )
384
+ if (resolved) continue
385
+
386
+ // On NbIcon or NbFlag an unresolvable name is fatal at first
387
+ // render, so the catalogue is the only thing that can save it. On
388
+ // a forwarding component the same expression may just as well be
389
+ // a module arriving as a prop, and linking 1,500 icons on that
390
+ // guess would make the common wrapper the most expensive file in
391
+ // the bundle. So that case is reported rather than paid for.
392
+ if (isIcon || isFlag) catalogsNeeded.add(kind)
393
+ else
394
+ unresolvedForwards.push({
395
+ tag,
396
+ prop: prop.arg.content,
397
+ exp: expression,
398
+ })
271
399
  }
272
400
  }
273
401
  })
274
402
 
403
+ // One line per file, however many bindings it has: this is a note about
404
+ // the file, and repeating the same advice per expression would train
405
+ // people to skip it.
406
+ if (warnUnresolved && unresolvedForwards.length) {
407
+ const kinds = new Set(unresolvedForwards.map((f) => f.prop))
408
+ const register = kinds.has('flag')
409
+ ? 'registerFlags()'
410
+ : 'registerIcons()'
411
+ const catalogue = `${packageName}/${kinds.has('flag') ? 'flags' : 'icons'}/all`
412
+ console.warn(
413
+ `[nubisco-ui] ${path.relative(process.cwd(), file)}: no artwork ` +
414
+ `linked for ` +
415
+ unresolvedForwards
416
+ .map((f) => `<${f.tag} :${f.prop}="${f.exp}">`)
417
+ .join(', ') +
418
+ `. The value is only known at runtime, so it will throw on first ` +
419
+ `render unless something resolves it: ${register} for a known ` +
420
+ `set, or import '${catalogue}' in this file. Nothing to do if ` +
421
+ `these are pass-through props (set warnUnresolved: false).`,
422
+ )
423
+ }
424
+
275
425
  if (!used.size && !catalogsNeeded.size) return
276
426
 
277
427
  const imports: string[] = []