@jxsuite/schema 1.5.0 → 1.8.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.
- package/class-schema.json +1 -0
- package/defs/children-value.schema.ts +10 -1
- package/defs/class-def.schema.ts +1 -0
- package/defs/element-def.schema.ts +1 -1
- package/defs/expression-node.schema.ts +4 -1
- package/defs/external-class-def.schema.ts +20 -2
- package/defs/head-entry.schema.ts +6 -1
- package/defs/image-config.schema.ts +7 -0
- package/defs/index.ts +2 -1
- package/defs/project-config.schema.ts +299 -7
- package/defs/tag-expression.schema.ts +98 -0
- package/defs/tag-name.schema.ts +13 -1
- package/package.json +7 -3
- package/project-schema.json +441 -5
- package/schema.json +186 -11
- package/schemas/project.core.schema.json +401 -5
- package/src/asset-paths.ts +21 -0
- package/src/extension-registry.ts +12 -0
- package/src/format-registry.ts +32 -2
- package/src/guards.ts +62 -1
- package/src/ijson.ts +205 -0
- package/src/intl.ts +155 -0
- package/src/locale.ts +429 -0
- package/src/media-type.ts +217 -0
- package/src/parse.ts +78 -1
- package/src/schema.ts +56 -3
- package/types.ts +191 -4
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
"optimize": {
|
|
16
16
|
"type": "boolean"
|
|
17
17
|
},
|
|
18
|
+
"picture": {
|
|
19
|
+
"description": "Wrap a multi-format image in a <picture> with one <source> per format (default true). With it off the <img> carries a srcset in the preferred format alone, which browsers that cannot decode that format will still select from.",
|
|
20
|
+
"type": "boolean"
|
|
21
|
+
},
|
|
18
22
|
"quality": {
|
|
19
23
|
"properties": {
|
|
20
24
|
"avif": {
|
|
@@ -254,6 +258,158 @@
|
|
|
254
258
|
"description": "Output directory for compiled site.",
|
|
255
259
|
"type": "string"
|
|
256
260
|
},
|
|
261
|
+
"headers": {
|
|
262
|
+
"additionalProperties": false,
|
|
263
|
+
"description": "Emit dist/_headers — the response headers only the build can know. Honored by Cloudflare Pages/Workers assets and Netlify; on the node/bun adapters the file documents what a reverse proxy in front of them must send.",
|
|
264
|
+
"properties": {
|
|
265
|
+
"cache": {
|
|
266
|
+
"default": "auto",
|
|
267
|
+
"description": "\"auto\" marks the content-addressed image variants immutable (RFC 8246) and revalidates everything else (RFC 9111). \"off\" emits no Cache-Control at all.",
|
|
268
|
+
"enum": ["auto", "off"],
|
|
269
|
+
"type": "string"
|
|
270
|
+
},
|
|
271
|
+
"enabled": {
|
|
272
|
+
"default": true,
|
|
273
|
+
"type": "boolean"
|
|
274
|
+
},
|
|
275
|
+
"rules": {
|
|
276
|
+
"additionalProperties": {
|
|
277
|
+
"additionalProperties": {
|
|
278
|
+
"type": "string"
|
|
279
|
+
},
|
|
280
|
+
"type": "object"
|
|
281
|
+
},
|
|
282
|
+
"description": "Verbatim rules, merged after the generated block.",
|
|
283
|
+
"propertyNames": {
|
|
284
|
+
"pattern": "^/"
|
|
285
|
+
},
|
|
286
|
+
"type": "object"
|
|
287
|
+
},
|
|
288
|
+
"security": {
|
|
289
|
+
"additionalProperties": false,
|
|
290
|
+
"properties": {
|
|
291
|
+
"contentTypeOptions": {
|
|
292
|
+
"default": true,
|
|
293
|
+
"type": "boolean"
|
|
294
|
+
},
|
|
295
|
+
"csp": {
|
|
296
|
+
"default": false,
|
|
297
|
+
"description": "CSP Level 3. Off by default: unlike the other security headers this one governs code the build cannot see — a third-party script that loads a second script, a widget that opens a frame — so turning it on is a change to verify in the browser console, not a safe default. `script-src` is derived from the inline scripts and external origins the build emitted; `style-src` keeps 'unsafe-inline', because per-page <style> blocks and per-element style attributes have no site-wide hash.",
|
|
298
|
+
"oneOf": [
|
|
299
|
+
{
|
|
300
|
+
"type": "boolean"
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
"const": "report-only"
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
"additionalProperties": false,
|
|
307
|
+
"properties": {
|
|
308
|
+
"directives": {
|
|
309
|
+
"additionalProperties": {
|
|
310
|
+
"oneOf": [
|
|
311
|
+
{
|
|
312
|
+
"type": "string"
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
"const": false
|
|
316
|
+
}
|
|
317
|
+
]
|
|
318
|
+
},
|
|
319
|
+
"description": "Replace (string) or remove (false) a computed directive. A replacement is wholesale, so an addition restates the defaults.",
|
|
320
|
+
"type": "object"
|
|
321
|
+
},
|
|
322
|
+
"mode": {
|
|
323
|
+
"default": "enforce",
|
|
324
|
+
"enum": ["enforce", "report-only"]
|
|
325
|
+
},
|
|
326
|
+
"reportUri": {
|
|
327
|
+
"format": "uri-reference",
|
|
328
|
+
"type": "string"
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
"type": "object"
|
|
332
|
+
}
|
|
333
|
+
]
|
|
334
|
+
},
|
|
335
|
+
"frameOptions": {
|
|
336
|
+
"default": "SAMEORIGIN",
|
|
337
|
+
"oneOf": [
|
|
338
|
+
{
|
|
339
|
+
"enum": ["DENY", "SAMEORIGIN"],
|
|
340
|
+
"type": "string"
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
"const": false
|
|
344
|
+
}
|
|
345
|
+
]
|
|
346
|
+
},
|
|
347
|
+
"hsts": {
|
|
348
|
+
"default": false,
|
|
349
|
+
"description": "RFC 6797. Off by default: a wrong max-age locks the apex domain to HTTPS for that long, and the mistake is invisible until a certificate lapses. `preload` requires `includeSubDomains` and is a build error without it.",
|
|
350
|
+
"oneOf": [
|
|
351
|
+
{
|
|
352
|
+
"type": "boolean"
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
"additionalProperties": false,
|
|
356
|
+
"properties": {
|
|
357
|
+
"includeSubDomains": {
|
|
358
|
+
"default": true,
|
|
359
|
+
"type": "boolean"
|
|
360
|
+
},
|
|
361
|
+
"maxAge": {
|
|
362
|
+
"default": 31536000,
|
|
363
|
+
"minimum": 0,
|
|
364
|
+
"type": "integer"
|
|
365
|
+
},
|
|
366
|
+
"preload": {
|
|
367
|
+
"default": false,
|
|
368
|
+
"type": "boolean"
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
"type": "object"
|
|
372
|
+
}
|
|
373
|
+
]
|
|
374
|
+
},
|
|
375
|
+
"permissionsPolicy": {
|
|
376
|
+
"default": "camera=(), microphone=(), geolocation=()",
|
|
377
|
+
"oneOf": [
|
|
378
|
+
{
|
|
379
|
+
"type": "string"
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
"const": false
|
|
383
|
+
}
|
|
384
|
+
]
|
|
385
|
+
},
|
|
386
|
+
"referrerPolicy": {
|
|
387
|
+
"default": "strict-origin-when-cross-origin",
|
|
388
|
+
"oneOf": [
|
|
389
|
+
{
|
|
390
|
+
"enum": [
|
|
391
|
+
"no-referrer",
|
|
392
|
+
"no-referrer-when-downgrade",
|
|
393
|
+
"origin",
|
|
394
|
+
"origin-when-cross-origin",
|
|
395
|
+
"same-origin",
|
|
396
|
+
"strict-origin",
|
|
397
|
+
"strict-origin-when-cross-origin",
|
|
398
|
+
"unsafe-url"
|
|
399
|
+
],
|
|
400
|
+
"type": "string"
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
"const": false
|
|
404
|
+
}
|
|
405
|
+
]
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
"type": "object"
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
"type": "object"
|
|
412
|
+
},
|
|
257
413
|
"sitemap": {
|
|
258
414
|
"default": true,
|
|
259
415
|
"description": "Generate sitemap.xml from the route table (requires `url`). Set false to disable.",
|
|
@@ -283,9 +439,14 @@
|
|
|
283
439
|
"description": "Default charset for the page.",
|
|
284
440
|
"type": "string"
|
|
285
441
|
},
|
|
442
|
+
"dir": {
|
|
443
|
+
"description": "Base direction for the <html> element. Omitted when unset — a page's `$dir` overrides it. Right-to-left content renders left-to-right without this.",
|
|
444
|
+
"enum": ["ltr", "rtl", "auto"],
|
|
445
|
+
"type": "string"
|
|
446
|
+
},
|
|
286
447
|
"lang": {
|
|
287
448
|
"default": "en",
|
|
288
|
-
"description": "Default lang attribute for the <html> element.",
|
|
449
|
+
"description": "Default lang attribute for the <html> element. A page's `$lang` overrides it.",
|
|
289
450
|
"type": "string"
|
|
290
451
|
},
|
|
291
452
|
"layout": {
|
|
@@ -300,6 +461,20 @@
|
|
|
300
461
|
"type": "null"
|
|
301
462
|
}
|
|
302
463
|
]
|
|
464
|
+
},
|
|
465
|
+
"shadow": {
|
|
466
|
+
"default": false,
|
|
467
|
+
"description": "Default shadow-DOM mode for every component (spec.md §16.6). Off unless set: a shadow root isolates component styles from the page and replaces the <slot> emulation with real slot distribution. A component's own `$shadow` overrides this in both directions.",
|
|
468
|
+
"oneOf": [
|
|
469
|
+
{
|
|
470
|
+
"enum": ["open", "closed"],
|
|
471
|
+
"type": "string"
|
|
472
|
+
},
|
|
473
|
+
{
|
|
474
|
+
"const": false,
|
|
475
|
+
"type": "boolean"
|
|
476
|
+
}
|
|
477
|
+
]
|
|
303
478
|
}
|
|
304
479
|
},
|
|
305
480
|
"type": "object"
|
|
@@ -312,18 +487,191 @@
|
|
|
312
487
|
},
|
|
313
488
|
"type": "array"
|
|
314
489
|
},
|
|
490
|
+
"manifest": {
|
|
491
|
+
"additionalProperties": false,
|
|
492
|
+
"description": "W3C Web App Manifest, emitted as manifest.webmanifest with a <link rel=\"manifest\"> on every page. Absent means no manifest: it is a claim that a site is meant to be installed, and most are not.",
|
|
493
|
+
"properties": {
|
|
494
|
+
"backgroundColor": {
|
|
495
|
+
"type": "string"
|
|
496
|
+
},
|
|
497
|
+
"categories": {
|
|
498
|
+
"items": {
|
|
499
|
+
"type": "string"
|
|
500
|
+
},
|
|
501
|
+
"type": "array"
|
|
502
|
+
},
|
|
503
|
+
"description": {
|
|
504
|
+
"type": "string"
|
|
505
|
+
},
|
|
506
|
+
"dir": {
|
|
507
|
+
"enum": ["ltr", "rtl", "auto"],
|
|
508
|
+
"type": "string"
|
|
509
|
+
},
|
|
510
|
+
"display": {
|
|
511
|
+
"default": "standalone",
|
|
512
|
+
"enum": ["fullscreen", "standalone", "minimal-ui", "browser"],
|
|
513
|
+
"type": "string"
|
|
514
|
+
},
|
|
515
|
+
"enabled": {
|
|
516
|
+
"default": true,
|
|
517
|
+
"type": "boolean"
|
|
518
|
+
},
|
|
519
|
+
"icons": {
|
|
520
|
+
"description": "Icons at 192px and 512px are what browsers require before offering to install the site; the build warns when either is missing.",
|
|
521
|
+
"items": {
|
|
522
|
+
"additionalProperties": false,
|
|
523
|
+
"properties": {
|
|
524
|
+
"purpose": {
|
|
525
|
+
"type": "string"
|
|
526
|
+
},
|
|
527
|
+
"sizes": {
|
|
528
|
+
"examples": ["192x192"],
|
|
529
|
+
"type": "string"
|
|
530
|
+
},
|
|
531
|
+
"src": {
|
|
532
|
+
"type": "string"
|
|
533
|
+
},
|
|
534
|
+
"type": {
|
|
535
|
+
"type": "string"
|
|
536
|
+
}
|
|
537
|
+
},
|
|
538
|
+
"required": ["src"],
|
|
539
|
+
"type": "object"
|
|
540
|
+
},
|
|
541
|
+
"type": "array"
|
|
542
|
+
},
|
|
543
|
+
"lang": {
|
|
544
|
+
"type": "string"
|
|
545
|
+
},
|
|
546
|
+
"name": {
|
|
547
|
+
"description": "Defaults to the project name.",
|
|
548
|
+
"type": "string"
|
|
549
|
+
},
|
|
550
|
+
"orientation": {
|
|
551
|
+
"type": "string"
|
|
552
|
+
},
|
|
553
|
+
"scope": {
|
|
554
|
+
"type": "string"
|
|
555
|
+
},
|
|
556
|
+
"shortName": {
|
|
557
|
+
"type": "string"
|
|
558
|
+
},
|
|
559
|
+
"startUrl": {
|
|
560
|
+
"default": "/",
|
|
561
|
+
"type": "string"
|
|
562
|
+
},
|
|
563
|
+
"themeColor": {
|
|
564
|
+
"type": "string"
|
|
565
|
+
}
|
|
566
|
+
},
|
|
567
|
+
"type": "object"
|
|
568
|
+
},
|
|
569
|
+
"serviceWorker": {
|
|
570
|
+
"default": false,
|
|
571
|
+
"description": "W3C Service Worker, emitted as sw.js. Off unless declared — a worker is sticky, so it is the one output that can keep breaking visitors after a deploy tries to fix them. **Turning it off means `false`, not deleting the key**: `false` emits a tombstone at the same URL that unregisters the old worker and clears its caches, and a 404 there is not an instruction to stop. HTML is always network-first; only the content-addressed /images/_optimized/* is served cache-first.",
|
|
572
|
+
"oneOf": [
|
|
573
|
+
{
|
|
574
|
+
"type": "boolean"
|
|
575
|
+
},
|
|
576
|
+
{
|
|
577
|
+
"additionalProperties": false,
|
|
578
|
+
"properties": {
|
|
579
|
+
"enabled": {
|
|
580
|
+
"default": true,
|
|
581
|
+
"type": "boolean"
|
|
582
|
+
},
|
|
583
|
+
"offlineFallback": {
|
|
584
|
+
"description": "Page served for a failed navigation. Added to `precache` if not already there.",
|
|
585
|
+
"type": "string"
|
|
586
|
+
},
|
|
587
|
+
"precache": {
|
|
588
|
+
"description": "Site-absolute URLs fetched at install time. Each must be a file this build produces; one that is not fails the build, because a single unreachable entry stops the worker installing at all.",
|
|
589
|
+
"items": {
|
|
590
|
+
"pattern": "^/",
|
|
591
|
+
"type": "string"
|
|
592
|
+
},
|
|
593
|
+
"type": "array"
|
|
594
|
+
},
|
|
595
|
+
"scope": {
|
|
596
|
+
"default": "/",
|
|
597
|
+
"type": "string"
|
|
598
|
+
}
|
|
599
|
+
},
|
|
600
|
+
"type": "object"
|
|
601
|
+
}
|
|
602
|
+
]
|
|
603
|
+
},
|
|
604
|
+
"securityTxt": {
|
|
605
|
+
"additionalProperties": false,
|
|
606
|
+
"description": "RFC 9116 security.txt, emitted at .well-known/security.txt. `expires` is required by §2.5.5 and an absent or past value is a build error — an expired file advertises a reporting channel while telling the reporter not to trust it. A hand-placed public/.well-known/security.txt shadows this, which is how a clearsigned file ships.",
|
|
607
|
+
"properties": {
|
|
608
|
+
"acknowledgments": {
|
|
609
|
+
"items": {
|
|
610
|
+
"type": "string"
|
|
611
|
+
},
|
|
612
|
+
"type": "array"
|
|
613
|
+
},
|
|
614
|
+
"canonical": {
|
|
615
|
+
"type": "string"
|
|
616
|
+
},
|
|
617
|
+
"contact": {
|
|
618
|
+
"description": "At least one mailto:, https: or tel: URI (§2.5.3).",
|
|
619
|
+
"items": {
|
|
620
|
+
"type": "string"
|
|
621
|
+
},
|
|
622
|
+
"type": "array"
|
|
623
|
+
},
|
|
624
|
+
"enabled": {
|
|
625
|
+
"default": true,
|
|
626
|
+
"type": "boolean"
|
|
627
|
+
},
|
|
628
|
+
"encryption": {
|
|
629
|
+
"items": {
|
|
630
|
+
"type": "string"
|
|
631
|
+
},
|
|
632
|
+
"type": "array"
|
|
633
|
+
},
|
|
634
|
+
"expires": {
|
|
635
|
+
"format": "date-time",
|
|
636
|
+
"type": "string"
|
|
637
|
+
},
|
|
638
|
+
"hiring": {
|
|
639
|
+
"items": {
|
|
640
|
+
"type": "string"
|
|
641
|
+
},
|
|
642
|
+
"type": "array"
|
|
643
|
+
},
|
|
644
|
+
"policy": {
|
|
645
|
+
"items": {
|
|
646
|
+
"type": "string"
|
|
647
|
+
},
|
|
648
|
+
"type": "array"
|
|
649
|
+
},
|
|
650
|
+
"preferredLanguages": {
|
|
651
|
+
"description": "BCP 47 tags, validated and canonicalized like i18n.locales.",
|
|
652
|
+
"items": {
|
|
653
|
+
"pattern": "^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$",
|
|
654
|
+
"type": "string"
|
|
655
|
+
},
|
|
656
|
+
"type": "array"
|
|
657
|
+
}
|
|
658
|
+
},
|
|
659
|
+
"type": "object"
|
|
660
|
+
},
|
|
315
661
|
"i18n": {
|
|
316
662
|
"description": "Internationalization configuration.",
|
|
317
663
|
"properties": {
|
|
318
664
|
"defaultLocale": {
|
|
319
|
-
"description": "Default locale
|
|
665
|
+
"description": "Default locale, as a BCP 47 language tag. The pattern catches shape errors (`en_US`, `en--US`); the build is the authority on well-formedness.",
|
|
320
666
|
"examples": ["en"],
|
|
667
|
+
"pattern": "^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$",
|
|
321
668
|
"type": "string"
|
|
322
669
|
},
|
|
323
670
|
"locales": {
|
|
324
|
-
"description": "Available
|
|
671
|
+
"description": "Available locales, as BCP 47 language tags.",
|
|
325
672
|
"examples": [["en", "fr", "de"]],
|
|
326
673
|
"items": {
|
|
674
|
+
"pattern": "^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$",
|
|
327
675
|
"type": "string"
|
|
328
676
|
},
|
|
329
677
|
"type": "array"
|
|
@@ -360,14 +708,62 @@
|
|
|
360
708
|
},
|
|
361
709
|
"redirects": {
|
|
362
710
|
"additionalProperties": {
|
|
363
|
-
"
|
|
711
|
+
"oneOf": [
|
|
712
|
+
{
|
|
713
|
+
"description": "Destination path or URL; implies a 301.",
|
|
714
|
+
"type": "string"
|
|
715
|
+
},
|
|
716
|
+
{
|
|
717
|
+
"additionalProperties": false,
|
|
718
|
+
"description": "A redirect with an explicit RFC 9110 §15.4 status.",
|
|
719
|
+
"properties": {
|
|
720
|
+
"destination": {
|
|
721
|
+
"type": "string"
|
|
722
|
+
},
|
|
723
|
+
"status": {
|
|
724
|
+
"default": 301,
|
|
725
|
+
"description": "RFC 9110 §15.4 redirection status. 307 and 308 preserve the request method; 301 and 302 historically do not.",
|
|
726
|
+
"enum": [301, 302, 303, 307, 308],
|
|
727
|
+
"type": "integer"
|
|
728
|
+
}
|
|
729
|
+
},
|
|
730
|
+
"required": ["destination"],
|
|
731
|
+
"type": "object"
|
|
732
|
+
},
|
|
733
|
+
{
|
|
734
|
+
"additionalProperties": false,
|
|
735
|
+
"description": "A rewrite (proxy): the destination's content is served AT the source URL, with no redirect. Emitted as status 200 in `_redirects`, which is the host convention — not an HTTP redirection status.",
|
|
736
|
+
"properties": {
|
|
737
|
+
"destination": {
|
|
738
|
+
"type": "string"
|
|
739
|
+
},
|
|
740
|
+
"rewrite": {
|
|
741
|
+
"const": true,
|
|
742
|
+
"type": "boolean"
|
|
743
|
+
}
|
|
744
|
+
},
|
|
745
|
+
"required": ["destination", "rewrite"],
|
|
746
|
+
"type": "object"
|
|
747
|
+
}
|
|
748
|
+
]
|
|
364
749
|
},
|
|
365
|
-
"description": "Static redirect rules.
|
|
750
|
+
"description": "Static redirect and rewrite rules. Keys are source paths in URLPattern pathname syntax.",
|
|
366
751
|
"examples": [
|
|
367
752
|
{
|
|
753
|
+
"/api/*": {
|
|
754
|
+
"destination": "https://api.example.com/*",
|
|
755
|
+
"rewrite": true
|
|
756
|
+
},
|
|
757
|
+
"/legacy/*": {
|
|
758
|
+
"destination": "/archive/*",
|
|
759
|
+
"status": 308
|
|
760
|
+
},
|
|
368
761
|
"/old-about": "/about"
|
|
369
762
|
}
|
|
370
763
|
],
|
|
764
|
+
"propertyNames": {
|
|
765
|
+
"pattern": "^/"
|
|
766
|
+
},
|
|
371
767
|
"type": "object"
|
|
372
768
|
},
|
|
373
769
|
"state": {
|
package/src/asset-paths.ts
CHANGED
|
@@ -44,6 +44,27 @@ export function sidecarAssetPath(specifier: string): string {
|
|
|
44
44
|
return `${SIDECAR_ASSET_DIR}${base}.js`;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Map a bare npm specifier naming a **file** — a stylesheet, a font, a prebuilt script — to the URL
|
|
49
|
+
* the build copies it to.
|
|
50
|
+
*
|
|
51
|
+
* Unlike {@link sidecarAssetPath} this keeps the extension, because the file is copied rather than
|
|
52
|
+
* bundled and both the browser and the host dispatch on it.
|
|
53
|
+
*
|
|
54
|
+
* @docs framework/site/seo
|
|
55
|
+
*/
|
|
56
|
+
export function npmAssetPath(specifier: string): string {
|
|
57
|
+
const ext = /\.(\w+)$/.exec(specifier)?.[1] ?? "";
|
|
58
|
+
const base = specifier
|
|
59
|
+
.replace(/\.\w+$/, "")
|
|
60
|
+
.replaceAll("@", "")
|
|
61
|
+
.replaceAll(/[/\\]+/g, "-")
|
|
62
|
+
.replaceAll(/[^\w.-]/g, "-")
|
|
63
|
+
.replaceAll(/-+/g, "-")
|
|
64
|
+
.replaceAll(/^-|-$/g, "");
|
|
65
|
+
return ext === "" ? `${SIDECAR_ASSET_DIR}${base}` : `${SIDECAR_ASSET_DIR}${base}.${ext}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
47
68
|
// ─── Asset mounts (specs/extensions.md §8.5) ─────────────────────────────────
|
|
48
69
|
|
|
49
70
|
/**
|
|
@@ -156,6 +156,18 @@ export class ExtensionRegistry {
|
|
|
156
156
|
return this.#classes.filter((e) => e.capabilities.emit !== undefined);
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Classes declaring a `head` capability (§8.6), in declaration order.
|
|
161
|
+
*
|
|
162
|
+
* Separate from `emit` because the two answer different questions at different times: `emit`
|
|
163
|
+
* derives files from loaded content and runs long after every page was written, while `head`
|
|
164
|
+
* derives `<head>` entries from CONFIGURATION and must run before the first page is built. A feed
|
|
165
|
+
* needs both — the file from `emit`, the `<link rel="alternate">` from here.
|
|
166
|
+
*/
|
|
167
|
+
headProviders(): FormatEntry[] {
|
|
168
|
+
return this.#classes.filter((e) => e.capabilities.head !== undefined);
|
|
169
|
+
}
|
|
170
|
+
|
|
159
171
|
/** Classes declaring an `assets` capability (static asset mounts, §8.5), in declaration order. */
|
|
160
172
|
assetProviders(): FormatEntry[] {
|
|
161
173
|
return this.#classes.filter((e) => e.capabilities.assets !== undefined);
|
package/src/format-registry.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* (fetch + dynamic import) hosts.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { mediaTypeEssence, mediaTypeProblem } from "./media-type";
|
|
14
|
+
|
|
13
15
|
export type FormatCapability = "parse" | "serialize" | "discover" | "load";
|
|
14
16
|
|
|
15
17
|
export const FORMAT_CAPABILITIES: readonly FormatCapability[] = [
|
|
@@ -22,8 +24,9 @@ export const FORMAT_CAPABILITIES: readonly FormatCapability[] = [
|
|
|
22
24
|
/**
|
|
23
25
|
* All well-known static capability roles across admission blocks (specs/extensions.md §8): format
|
|
24
26
|
* roles plus project-section (`projectData`, `resolvePaths`), compile-away (`lower`), build-time
|
|
25
|
-
* asset emission (`emit`),
|
|
26
|
-
* (`dialect`, `deploySchema`, `bindings`, `testConnection`)
|
|
27
|
+
* asset emission (`emit`), `<head>` contribution (`head`), static asset mounts (`assets`),
|
|
28
|
+
* server-mount (`mount`), and connector (`dialect`, `deploySchema`, `bindings`, `testConnection`)
|
|
29
|
+
* roles.
|
|
27
30
|
*/
|
|
28
31
|
export type ExtensionCapability =
|
|
29
32
|
| FormatCapability
|
|
@@ -31,6 +34,7 @@ export type ExtensionCapability =
|
|
|
31
34
|
| "resolvePaths"
|
|
32
35
|
| "lower"
|
|
33
36
|
| "emit"
|
|
37
|
+
| "head"
|
|
34
38
|
| "assets"
|
|
35
39
|
| "mount"
|
|
36
40
|
| "dialect"
|
|
@@ -44,6 +48,7 @@ export const EXTENSION_CAPABILITIES: readonly ExtensionCapability[] = [
|
|
|
44
48
|
"resolvePaths",
|
|
45
49
|
"lower",
|
|
46
50
|
"emit",
|
|
51
|
+
"head",
|
|
47
52
|
"assets",
|
|
48
53
|
"mount",
|
|
49
54
|
"dialect",
|
|
@@ -160,6 +165,15 @@ export class FormatEntry {
|
|
|
160
165
|
readonly classDef: ClassDefLike;
|
|
161
166
|
readonly extensions: string[];
|
|
162
167
|
readonly mediaType: string | null;
|
|
168
|
+
/**
|
|
169
|
+
* The media type without its parameters — `text/markdown` for `text/markdown; variant=GFM`.
|
|
170
|
+
*
|
|
171
|
+
* Anything that keys on a type rather than describing one wants this: a File System Access
|
|
172
|
+
* `accept` map, an editor language id. Those broke the moment a format declared the `variant`
|
|
173
|
+
* parameter RFC 7763 defines, which is a good reason for the distinction to exist in the type
|
|
174
|
+
* rather than in each caller's split.
|
|
175
|
+
*/
|
|
176
|
+
readonly mediaTypeEssence: string | null;
|
|
163
177
|
readonly documentKinds: FormatDocumentKind[];
|
|
164
178
|
readonly exportTarget: boolean;
|
|
165
179
|
readonly remote: boolean;
|
|
@@ -183,6 +197,7 @@ export class FormatEntry {
|
|
|
183
197
|
const { format } = classDef;
|
|
184
198
|
this.extensions = (format?.extensions ?? []).map((e) => e.toLowerCase());
|
|
185
199
|
this.mediaType = format?.mediaType ?? null;
|
|
200
|
+
this.mediaTypeEssence = mediaTypeEssence(this.mediaType);
|
|
186
201
|
this.documentKinds = format?.documentKinds ?? [];
|
|
187
202
|
this.exportTarget = format?.exportTarget === true;
|
|
188
203
|
this.remote = format?.remote === true;
|
|
@@ -234,6 +249,21 @@ export class FormatRegistry {
|
|
|
234
249
|
constructor(entries: FormatEntry[]) {
|
|
235
250
|
this.#entries = entries;
|
|
236
251
|
|
|
252
|
+
/*
|
|
253
|
+
* A declared media type reaches an HTTP header, an editor's file association and a Studio
|
|
254
|
+
* label. Nothing checked it before, so `text/markdown;variant GFM` — one missing `=` — would
|
|
255
|
+
* have been served to a browser verbatim as a malformed header value (RFC 6838 §4.2).
|
|
256
|
+
*/
|
|
257
|
+
for (const entry of entries) {
|
|
258
|
+
if (entry.mediaType === null) {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const problem = mediaTypeProblem(entry.mediaType);
|
|
262
|
+
if (problem !== null) {
|
|
263
|
+
throw new Error(`Format "${entry.name}" declares an invalid mediaType: ${problem}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
237
267
|
// Ambiguous (extension, capability) claims are a configuration error.
|
|
238
268
|
for (const cap of FORMAT_CAPABILITIES) {
|
|
239
269
|
const seen = new Map<string, FormatEntry>();
|
package/src/guards.ts
CHANGED
|
@@ -19,9 +19,10 @@ import type {
|
|
|
19
19
|
JxPrototypeDef,
|
|
20
20
|
JxRef,
|
|
21
21
|
JxServerFnDef,
|
|
22
|
-
JxStatement,
|
|
23
22
|
JxStateObject,
|
|
23
|
+
JxStatement,
|
|
24
24
|
JxStyle,
|
|
25
|
+
JxTagExpression,
|
|
25
26
|
} from "../types";
|
|
26
27
|
|
|
27
28
|
// ─── JSON shape guards ──────────────────────────────────────────────────────────
|
|
@@ -241,3 +242,63 @@ export function paramNames(parameters: JxFunctionDef["parameters"]): string[] {
|
|
|
241
242
|
}
|
|
242
243
|
return parameters.map((p) => (typeof p === "string" ? p : p.name));
|
|
243
244
|
}
|
|
245
|
+
|
|
246
|
+
/** Whether an element's `tagName` is a choice rather than a name. */
|
|
247
|
+
export function isTagExpression(tagName: unknown): tagName is { $expression: JxTagExpression } {
|
|
248
|
+
return isJsonObject(tagName) && isJsonObject(tagName.$expression);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Every tag an element could be, without evaluating anything.
|
|
253
|
+
*
|
|
254
|
+
* ONE implementation, shared by the runtime, the three compiler targets and the studio, because
|
|
255
|
+
* four surfaces each writing their own enumeration is four chances to disagree about what an
|
|
256
|
+
* element can be — and disagreeing about that is how the `${…}` tagName shipped a page whose SSR
|
|
257
|
+
* markup and client render used different elements.
|
|
258
|
+
*
|
|
259
|
+
* A plain name yields itself, so every caller can treat the two forms alike.
|
|
260
|
+
*/
|
|
261
|
+
export function tagNameCandidates(tagName: unknown): string[] {
|
|
262
|
+
if (typeof tagName === "string") {
|
|
263
|
+
return [tagName];
|
|
264
|
+
}
|
|
265
|
+
if (!isTagExpression(tagName)) {
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
const expression = tagName.$expression;
|
|
269
|
+
if (expression.operator === "?:") {
|
|
270
|
+
return [...new Set([expression.value, expression.initial])];
|
|
271
|
+
}
|
|
272
|
+
return [...new Set([...Object.values(expression.cases), expression.default])];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The tag to show a human when the element could be several.
|
|
277
|
+
*
|
|
278
|
+
* The Outline draws one row per node and needs a label that is stable, short and honest: `a|div`
|
|
279
|
+
* says "this is one element whose tag is chosen" without pretending to know which. Rendering the
|
|
280
|
+
* first candidate would have been a quieter lie.
|
|
281
|
+
*/
|
|
282
|
+
export function displayTagName(tagName: unknown): string {
|
|
283
|
+
return typeof tagName === "string" ? tagName : tagNameCandidates(tagName).join("|");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Whether a `state` key is private (`spec.md` §5.6).
|
|
288
|
+
*
|
|
289
|
+
* A `#`-prefixed entry is ordinary state in every respect but one: it is not part of the
|
|
290
|
+
* component's interface. It never reaches the studio property panel, never appears in an exported
|
|
291
|
+
* CEM manifest, and is never settable through `$props` — which is the clause that needs a predicate
|
|
292
|
+
* rather than a convention, because the props paths are spread across two tiers.
|
|
293
|
+
*
|
|
294
|
+
* The rule lived inline at three call sites before it lived here, spelled `key.startsWith("#")`
|
|
295
|
+
* each time. That is survivable for two and a drift risk at four, and the interesting direction of
|
|
296
|
+
* drift is silent: a path that forgets the check does not fail, it just quietly makes a private
|
|
297
|
+
* entry writable from outside.
|
|
298
|
+
*
|
|
299
|
+
* @param {string} key - A `state` key
|
|
300
|
+
* @returns {boolean}
|
|
301
|
+
*/
|
|
302
|
+
export function isPrivateStateKey(key: string): boolean {
|
|
303
|
+
return key.startsWith("#");
|
|
304
|
+
}
|