altium-toolkit 1.0.9 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/docs/api.md +6 -2
  2. package/docs/model-format.md +29 -4
  3. package/docs/schemas/altium_toolkit/ci_artifact_bundle_a1.schema.json +80 -0
  4. package/docs/schemas/altium_toolkit/contract_gate_a1.schema.json +34 -0
  5. package/docs/schemas/altium_toolkit/draftsman_board_view_cache_a1.schema.json +115 -0
  6. package/docs/schemas/altium_toolkit/draftsman_digest_a1.schema.json +166 -0
  7. package/docs/schemas/altium_toolkit/host_capabilities_a1.schema.json +39 -0
  8. package/docs/schemas/altium_toolkit/library_merge_plan_a1.schema.json +56 -0
  9. package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +70 -0
  10. package/docs/schemas/altium_toolkit/netlist_a1.schema.json +6 -0
  11. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +856 -7
  12. package/docs/schemas/altium_toolkit/parser_compatibility_fuzz_a1.schema.json +25 -0
  13. package/docs/schemas/altium_toolkit/pcb_bom_profile_a1.schema.json +48 -0
  14. package/docs/schemas/altium_toolkit/pcb_layer_stack_a1.schema.json +98 -0
  15. package/docs/schemas/altium_toolkit/pcb_layer_stack_fidelity_a1.schema.json +66 -0
  16. package/docs/schemas/altium_toolkit/pcb_placed_footprint_extraction_a1.schema.json +31 -0
  17. package/docs/schemas/altium_toolkit/pcb_review_metadata_a1.schema.json +62 -0
  18. package/docs/schemas/altium_toolkit/pcb_rigid_flex_topology_a1.schema.json +52 -0
  19. package/docs/schemas/altium_toolkit/pcb_svg_semantics_a1.schema.json +27 -0
  20. package/docs/schemas/altium_toolkit/pcblib_parity_a1.schema.json +24 -0
  21. package/docs/schemas/altium_toolkit/project_bom_pnp_reconciliation_a1.schema.json +63 -0
  22. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +6 -0
  23. package/docs/schemas/altium_toolkit/project_document_graph_a1.schema.json +33 -0
  24. package/docs/schemas/altium_toolkit/project_outjob_digest_a1.schema.json +46 -0
  25. package/docs/schemas/altium_toolkit/project_script_a1.schema.json +50 -0
  26. package/docs/schemas/altium_toolkit/schematic_render_ops_a1.schema.json +55 -0
  27. package/docs/schemas/altium_toolkit/schematic_template_extraction_a1.schema.json +37 -0
  28. package/docs/schemas/altium_toolkit/svg_model_cross_link_a1.schema.json +39 -0
  29. package/package.json +1 -1
  30. package/src/core/altium/AltiumParser.mjs +12 -2
  31. package/src/core/altium/CiArtifactBundleBuilder.mjs +213 -0
  32. package/src/core/altium/ContractGateReportBuilder.mjs +351 -0
  33. package/src/core/altium/DraftsmanBoardViewMetadataBuilder.mjs +653 -0
  34. package/src/core/altium/DraftsmanDigestParser.mjs +928 -0
  35. package/src/core/altium/DraftsmanImagePayloadManifestBuilder.mjs +178 -0
  36. package/src/core/altium/HostCapabilityDiagnosticsBuilder.mjs +271 -0
  37. package/src/core/altium/LibraryQaReportBuilder.mjs +504 -0
  38. package/src/core/altium/LibraryRenderManifestBuilder.mjs +172 -2
  39. package/src/core/altium/ParserCompatibilityFuzzer.mjs +192 -0
  40. package/src/core/altium/PcbBomProfileBuilder.mjs +263 -0
  41. package/src/core/altium/PcbComponentKindPolicy.mjs +146 -0
  42. package/src/core/altium/PcbLayerStackFidelityReportBuilder.mjs +141 -0
  43. package/src/core/altium/PcbLayerStackInterchangeParser.mjs +453 -0
  44. package/src/core/altium/PcbLayerStackQueryHelper.mjs +195 -0
  45. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +906 -0
  46. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +488 -0
  47. package/src/core/altium/PcbLibModelParser.mjs +2 -0
  48. package/src/core/altium/PcbLibParityReportBuilder.mjs +242 -0
  49. package/src/core/altium/PcbModelParser.mjs +211 -22
  50. package/src/core/altium/PcbPadStackParser.mjs +171 -2
  51. package/src/core/altium/PcbPickPlacePositionResolver.mjs +11 -1
  52. package/src/core/altium/PcbPlacedFootprintManifestBuilder.mjs +338 -0
  53. package/src/core/altium/PcbPolygonRecordParser.mjs +120 -0
  54. package/src/core/altium/PcbRegionPrimitiveParser.mjs +71 -2
  55. package/src/core/altium/PcbReviewDrillMetadataBuilder.mjs +301 -0
  56. package/src/core/altium/PcbReviewMetadataBuilder.mjs +373 -0
  57. package/src/core/altium/PcbReviewPolygonRealizationBuilder.mjs +269 -0
  58. package/src/core/altium/PcbReviewRouteHighlightProfileBuilder.mjs +298 -0
  59. package/src/core/altium/PcbRigidFlexTopologyBuilder.mjs +171 -0
  60. package/src/core/altium/PcbRouteAnalysisBuilder.mjs +730 -0
  61. package/src/core/altium/PcbStatisticsBuilder.mjs +9 -0
  62. package/src/core/altium/PrintableTextDecoder.mjs +70 -6
  63. package/src/core/altium/PrjPcbModelParser.mjs +69 -2
  64. package/src/core/altium/PrjScrModelParser.mjs +386 -0
  65. package/src/core/altium/ProjectBomPnpReconciliationBuilder.mjs +237 -0
  66. package/src/core/altium/ProjectDesignBundleBuilder.mjs +76 -2
  67. package/src/core/altium/ProjectDocumentGraphBuilder.mjs +280 -0
  68. package/src/core/altium/ProjectNetlistExporter.mjs +5 -1
  69. package/src/core/altium/ProjectOutJobDigestBuilder.mjs +424 -13
  70. package/src/core/altium/SvgModelCrossLinkValidator.mjs +435 -0
  71. package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +300 -96
  72. package/src/core/circuit-json/CircuitJsonModelAdapterPcbElements.mjs +244 -0
  73. package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
  74. package/src/parser.mjs +21 -0
  75. package/src/ui/PcbFootprintPrimitiveSelector.mjs +13 -1
  76. package/src/ui/PcbScene3dBuilder.mjs +26 -4
  77. package/src/ui/PcbSvgRenderer.mjs +65 -0
  78. package/src/ui/SchematicRenderOpsSidecarBuilder.mjs +554 -0
  79. package/src/ui/SchematicSvgRenderer.mjs +48 -2
@@ -14,7 +14,8 @@ export class ProjectOutJobDigestBuilder {
14
14
  * @returns {object}
15
15
  */
16
16
  static build(project) {
17
- const documents = (project?.documents || [])
17
+ const projectDocuments = project?.documents || []
18
+ const documents = projectDocuments
18
19
  .filter((document) => document.kind === 'output-job')
19
20
  .map((document) => ({
20
21
  documentIndex: document.index,
@@ -22,23 +23,39 @@ export class ProjectOutJobDigestBuilder {
22
23
  normalizedPath: document.normalizedPath,
23
24
  fileName: document.fileName
24
25
  }))
26
+ const context = {
27
+ defaultPcbDocumentPath:
28
+ projectDocuments.find((document) => document.kind === 'pcb')
29
+ ?.path || ''
30
+ }
25
31
  const outputGroups = (project?.outputGroups || []).map((group) =>
26
- ProjectOutJobDigestBuilder.#outputGroup(group)
32
+ ProjectOutJobDigestBuilder.#outputGroup(group, context)
27
33
  )
28
34
  const outputCount = outputGroups.reduce(
29
35
  (sum, group) => sum + group.outputCount,
30
36
  0
31
37
  )
38
+ const outputs = outputGroups.flatMap((group) => group.outputs)
39
+ const expectedArtifacts =
40
+ ProjectOutJobDigestBuilder.#expectedArtifacts(outputGroups)
32
41
 
33
42
  return {
34
43
  schema: ProjectOutJobDigestBuilder.SCHEMA_ID,
35
44
  summary: {
36
45
  outJobDocumentCount: documents.length,
37
46
  outputGroupCount: outputGroups.length,
38
- outputCount
47
+ outputCount,
48
+ typedOutputCount: outputs.filter(
49
+ (output) => output.normalizedType !== 'unsupported'
50
+ ).length,
51
+ unsupportedOutputCount: outputs.filter(
52
+ (output) => output.normalizedType === 'unsupported'
53
+ ).length,
54
+ expectedArtifactCount: expectedArtifacts.manifest.outputs.length
39
55
  },
40
56
  documents,
41
57
  outputGroups,
58
+ expectedArtifacts,
42
59
  outputsByDocumentPath:
43
60
  ProjectOutJobDigestBuilder.#outputsByDocumentPath(outputGroups)
44
61
  }
@@ -47,19 +64,12 @@ export class ProjectOutJobDigestBuilder {
47
64
  /**
48
65
  * Normalizes one output group.
49
66
  * @param {object} group Project output group.
67
+ * @param {{ defaultPcbDocumentPath: string }} context Project context.
50
68
  * @returns {object}
51
69
  */
52
- static #outputGroup(group) {
70
+ static #outputGroup(group, context) {
53
71
  const outputs = (group.outputs || []).map((output) => ({
54
- index: output.index,
55
- type: output.type,
56
- name: output.name,
57
- documentPath: output.documentPath,
58
- normalizedDocumentPath: ProjectOutJobDigestBuilder.#normalizePath(
59
- output.documentPath
60
- ),
61
- variantName: output.variantName,
62
- isDefault: output.isDefault
72
+ ...ProjectOutJobDigestBuilder.#typedOutput(output, group, context)
63
73
  }))
64
74
 
65
75
  return {
@@ -88,6 +98,7 @@ export class ProjectOutJobDigestBuilder {
88
98
  outputGroupIndex: group.index,
89
99
  outputIndex: output.index,
90
100
  type: output.type,
101
+ normalizedType: output.normalizedType,
91
102
  name: output.name,
92
103
  variantName: output.variantName,
93
104
  isDefault: output.isDefault
@@ -106,4 +117,404 @@ export class ProjectOutJobDigestBuilder {
106
117
  static #normalizePath(path) {
107
118
  return String(path || '').replace(/\\/g, '/')
108
119
  }
120
+
121
+ /**
122
+ * Builds one typed output row.
123
+ * @param {object} output Raw output row.
124
+ * @param {object} group Owning output group.
125
+ * @param {{ defaultPcbDocumentPath: string }} context Project context.
126
+ * @returns {object}
127
+ */
128
+ static #typedOutput(output, group, context) {
129
+ const config = ProjectOutJobDigestBuilder.#mergedConfig(output)
130
+ const normalizedType = ProjectOutJobDigestBuilder.#normalizedType(
131
+ output.type
132
+ )
133
+ const category = ProjectOutJobDigestBuilder.#category(normalizedType)
134
+ const documentPath = ProjectOutJobDigestBuilder.#documentPath(
135
+ normalizedType,
136
+ output,
137
+ config,
138
+ context
139
+ )
140
+ const normalizedDocumentPath =
141
+ ProjectOutJobDigestBuilder.#normalizePath(documentPath)
142
+ const settings = ProjectOutJobDigestBuilder.#settings(
143
+ normalizedType,
144
+ output,
145
+ config,
146
+ documentPath
147
+ )
148
+ const base = {
149
+ index: output.index,
150
+ type: output.type,
151
+ normalizedType,
152
+ name: output.name,
153
+ documentPath: output.documentPath || '',
154
+ normalizedDocumentPath,
155
+ variantName: output.variantName || config.VariantName || '',
156
+ isDefault: output.isDefault,
157
+ category,
158
+ settings
159
+ }
160
+
161
+ return {
162
+ ...base,
163
+ expectedArtifact: ProjectOutJobDigestBuilder.#expectedArtifact(
164
+ base,
165
+ group
166
+ )
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Resolves the source document path for one output row.
172
+ * @param {string} normalizedType Stable output type.
173
+ * @param {object} output Raw output row.
174
+ * @param {Record<string, string>} config Merged config fields.
175
+ * @param {{ defaultPcbDocumentPath: string }} context Project context.
176
+ * @returns {string}
177
+ */
178
+ static #documentPath(normalizedType, output, config, context) {
179
+ const explicit = output.documentPath || config.DocumentPath || ''
180
+ if (explicit) return explicit
181
+
182
+ if (
183
+ normalizedType === 'bom' &&
184
+ ProjectOutJobDigestBuilder.#boolean(config.IncludePcbData) === true
185
+ ) {
186
+ return context.defaultPcbDocumentPath || ''
187
+ }
188
+
189
+ return ''
190
+ }
191
+
192
+ /**
193
+ * Merges parsed configuration rows into one field lookup.
194
+ * @param {object} output Output row.
195
+ * @returns {Record<string, string>}
196
+ */
197
+ static #mergedConfig(output) {
198
+ const config = {}
199
+
200
+ for (const row of output.configRows || []) {
201
+ if (row.record && !config.Record) {
202
+ config.Record = row.record
203
+ }
204
+ Object.assign(config, row.fields || {})
205
+ }
206
+
207
+ return config
208
+ }
209
+
210
+ /**
211
+ * Resolves a stable output type token.
212
+ * @param {string} type Native output type.
213
+ * @returns {string}
214
+ */
215
+ static #normalizedType(type) {
216
+ const normalized = String(type || '')
217
+ .trim()
218
+ .toLowerCase()
219
+ .replace(/[\s_]+/gu, '-')
220
+
221
+ if (normalized.includes('gerber')) return 'gerber'
222
+ if (normalized.includes('ncdrill') || normalized.includes('nc-drill')) {
223
+ return 'nc-drill'
224
+ }
225
+ if (normalized.includes('odb')) return 'odb'
226
+ if (
227
+ normalized.includes('pickplace') ||
228
+ normalized.includes('pick-place')
229
+ ) {
230
+ return 'pick-place'
231
+ }
232
+ if (normalized.includes('wirelist')) return 'wirelist'
233
+ if (normalized.includes('bom')) return 'bom'
234
+ if (normalized.includes('step')) return 'step'
235
+ if (normalized.includes('schematicprint')) return 'schematic-print'
236
+ if (
237
+ normalized.includes('pcbdrawing') ||
238
+ normalized.includes('draftsman')
239
+ ) {
240
+ return 'pcb-drawing'
241
+ }
242
+
243
+ return 'unsupported'
244
+ }
245
+
246
+ /**
247
+ * Resolves an output category.
248
+ * @param {string} normalizedType Stable output type.
249
+ * @returns {string}
250
+ */
251
+ static #category(normalizedType) {
252
+ return (
253
+ {
254
+ gerber: 'fabrication',
255
+ 'nc-drill': 'fabrication',
256
+ odb: 'fabrication',
257
+ 'pick-place': 'assembly',
258
+ wirelist: 'netlist',
259
+ bom: 'report',
260
+ step: 'export',
261
+ 'schematic-print': 'documentation',
262
+ 'pcb-drawing': 'documentation'
263
+ }[normalizedType] || 'unsupported'
264
+ )
265
+ }
266
+
267
+ /**
268
+ * Builds typed settings for one output row.
269
+ * @param {string} normalizedType Stable output type.
270
+ * @param {object} output Output row.
271
+ * @param {Record<string, string>} config Merged config fields.
272
+ * @param {string} documentPath Resolved output document path.
273
+ * @returns {object}
274
+ */
275
+ static #settings(normalizedType, output, config, documentPath) {
276
+ const common = ProjectOutJobDigestBuilder.#stripEmpty({
277
+ record: config.Record || '',
278
+ documentPath
279
+ })
280
+
281
+ switch (normalizedType) {
282
+ case 'gerber':
283
+ return ProjectOutJobDigestBuilder.#stripEmpty({
284
+ ...common,
285
+ units: config.GerberUnit || config.Units || '',
286
+ decimals: ProjectOutJobDigestBuilder.#number(
287
+ config.NumberOfDecimals
288
+ ),
289
+ plotLayers: ProjectOutJobDigestBuilder.#plotLayers(
290
+ config['Plot.Set']
291
+ )
292
+ })
293
+ case 'nc-drill':
294
+ return ProjectOutJobDigestBuilder.#stripEmpty({
295
+ ...common,
296
+ units: config.Units || '',
297
+ separatePlated:
298
+ ProjectOutJobDigestBuilder.#boolean(
299
+ config.SeparatePlated
300
+ ) ??
301
+ ProjectOutJobDigestBuilder.#boolean(
302
+ config.GenerateSeparatePlatedNonPlatedFiles
303
+ )
304
+ })
305
+ case 'pick-place':
306
+ return ProjectOutJobDigestBuilder.#stripEmpty({
307
+ ...common,
308
+ units: config.Units || '',
309
+ generateCsv: ProjectOutJobDigestBuilder.#boolean(
310
+ config.GenerateCSVFormat
311
+ ),
312
+ includeStandardNoBom: ProjectOutJobDigestBuilder.#boolean(
313
+ config.IncludeStandardNoBOM
314
+ )
315
+ })
316
+ case 'wirelist':
317
+ return ProjectOutJobDigestBuilder.#stripEmpty({
318
+ ...common,
319
+ units: config.Units || '',
320
+ generateText: ProjectOutJobDigestBuilder.#boolean(
321
+ config.GenerateTextFormat
322
+ ),
323
+ includeVariations: ProjectOutJobDigestBuilder.#boolean(
324
+ config.IncludeVariations
325
+ )
326
+ })
327
+ case 'bom':
328
+ return ProjectOutJobDigestBuilder.#stripEmpty({
329
+ ...common,
330
+ includePcbData: ProjectOutJobDigestBuilder.#boolean(
331
+ config.IncludePcbData
332
+ ),
333
+ includeAlternatives: ProjectOutJobDigestBuilder.#boolean(
334
+ config.IncludeAlternatives
335
+ ),
336
+ batchMode: ProjectOutJobDigestBuilder.#number(
337
+ config.BatchMode
338
+ ),
339
+ viewType: ProjectOutJobDigestBuilder.#number(
340
+ config.ViewType
341
+ )
342
+ })
343
+ case 'step':
344
+ return ProjectOutJobDigestBuilder.#stripEmpty({
345
+ ...common,
346
+ exportModelsOption: ProjectOutJobDigestBuilder.#number(
347
+ config.ExportModelsOption
348
+ ),
349
+ exportHolesOption: ProjectOutJobDigestBuilder.#number(
350
+ config.ExportHolesOption
351
+ )
352
+ })
353
+ case 'schematic-print':
354
+ return ProjectOutJobDigestBuilder.#stripEmpty({
355
+ ...common,
356
+ paperKind: config.PaperKind || '',
357
+ printScale: ProjectOutJobDigestBuilder.#number(
358
+ config.PrintScale
359
+ )
360
+ })
361
+ case 'pcb-drawing':
362
+ return ProjectOutJobDigestBuilder.#stripEmpty({
363
+ ...common,
364
+ variantName: config.VariantName || ''
365
+ })
366
+ default:
367
+ return common
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Builds expected artifact metadata for all output rows.
373
+ * @param {object[]} outputGroups Output groups.
374
+ * @returns {object}
375
+ */
376
+ static #expectedArtifacts(outputGroups) {
377
+ const outputs = outputGroups.flatMap((group) =>
378
+ group.outputs.map((output) => output.expectedArtifact)
379
+ )
380
+
381
+ return {
382
+ schema: 'altium-toolkit.project.expected-artifacts.a1',
383
+ summary: {
384
+ outputCount: outputs.length,
385
+ unsupportedOutputCount: outputs.filter(
386
+ (output) => output.unsupported
387
+ ).length
388
+ },
389
+ manifest: {
390
+ outputs
391
+ }
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Builds one expected artifact row.
397
+ * @param {object} output Typed output row.
398
+ * @param {object} group Owning group.
399
+ * @returns {object}
400
+ */
401
+ static #expectedArtifact(output, group) {
402
+ const artifact = ProjectOutJobDigestBuilder.#stripEmpty({
403
+ key:
404
+ ProjectOutJobDigestBuilder.#slug(group.name || 'outputs') +
405
+ '/' +
406
+ String(output.index).padStart(2, '0') +
407
+ '-' +
408
+ ProjectOutJobDigestBuilder.#slug(
409
+ output.name || output.normalizedType
410
+ ),
411
+ outputGroupName: group.name || '',
412
+ outputName: output.name || '',
413
+ outputType: output.normalizedType,
414
+ category: output.category,
415
+ documentPath:
416
+ output.settings.documentPath || output.documentPath || '',
417
+ normalizedDocumentPath: output.normalizedDocumentPath,
418
+ variantName: output.variantName,
419
+ format: ProjectOutJobDigestBuilder.#format(output),
420
+ units: output.settings.units,
421
+ unsupported:
422
+ output.normalizedType === 'unsupported' ? true : undefined
423
+ })
424
+ artifact.variantName = output.variantName || ''
425
+ return artifact
426
+ }
427
+
428
+ /**
429
+ * Resolves the expected artifact format token.
430
+ * @param {object} output Typed output row.
431
+ * @returns {string}
432
+ */
433
+ static #format(output) {
434
+ if (output.normalizedType === 'pick-place') {
435
+ return output.settings.generateCsv === false
436
+ ? 'pick-place-text'
437
+ : 'pick-place-csv'
438
+ }
439
+
440
+ return (
441
+ {
442
+ gerber: 'gerber',
443
+ 'nc-drill': 'nc-drill',
444
+ odb: 'odb',
445
+ wirelist: 'wirelist',
446
+ bom: 'bom',
447
+ step: 'step',
448
+ 'schematic-print': 'pdf',
449
+ 'pcb-drawing': 'pcbdwf'
450
+ }[output.normalizedType] || 'unknown'
451
+ )
452
+ }
453
+
454
+ /**
455
+ * Parses plot-layer tokens from Altium plot-layer state strings.
456
+ * @param {string | undefined} value Plot-layer state.
457
+ * @returns {string[] | undefined}
458
+ */
459
+ static #plotLayers(value) {
460
+ const layers = String(value || '')
461
+ .split(',')
462
+ .map((segment) => segment.trim().split('~')[0])
463
+ .filter(Boolean)
464
+
465
+ return layers.length ? layers : undefined
466
+ }
467
+
468
+ /**
469
+ * Parses one numeric field.
470
+ * @param {unknown} value Raw value.
471
+ * @returns {number | undefined}
472
+ */
473
+ static #number(value) {
474
+ const parsed = Number.parseFloat(String(value ?? '').trim())
475
+ return Number.isFinite(parsed) ? parsed : undefined
476
+ }
477
+
478
+ /**
479
+ * Parses one optional boolean field.
480
+ * @param {unknown} value Raw value.
481
+ * @returns {boolean | undefined}
482
+ */
483
+ static #boolean(value) {
484
+ const raw = String(value ?? '')
485
+ .trim()
486
+ .toLowerCase()
487
+ if (!raw) return undefined
488
+ return ['1', 't', 'true', 'yes'].includes(raw)
489
+ }
490
+
491
+ /**
492
+ * Removes empty values while preserving booleans and zeroes.
493
+ * @param {object} value Source object.
494
+ * @returns {object}
495
+ */
496
+ static #stripEmpty(value) {
497
+ return Object.fromEntries(
498
+ Object.entries(value || {}).filter(([, entryValue]) => {
499
+ if (Array.isArray(entryValue)) return entryValue.length > 0
500
+ if (typeof entryValue === 'string') return entryValue.length > 0
501
+ return entryValue !== null && entryValue !== undefined
502
+ })
503
+ )
504
+ }
505
+
506
+ /**
507
+ * Builds a stable slug for output manifest keys.
508
+ * @param {string} value Raw value.
509
+ * @returns {string}
510
+ */
511
+ static #slug(value) {
512
+ return (
513
+ String(value || '')
514
+ .trim()
515
+ .toLowerCase()
516
+ .replace(/[^a-z0-9]+/gu, '-')
517
+ .replace(/^-|-$/gu, '') || 'output'
518
+ )
519
+ }
109
520
  }