@escape-game-over/atlas 0.1.25 → 0.1.27

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.
@@ -73,15 +73,15 @@ runs when the element enters the page, `signal` ends what it registered when the
73
73
  element leaves, and what it returns is the undo for everything else.
74
74
 
75
75
  ```ts
76
- export const thing = element("go-thing", { button: marker }, (host, { button }, signal) => {
77
- button.one()?.addEventListener("click", open, { signal }); // ends with the visit
78
- return loop.attach(host); // and so does this
76
+ export const thing = element("go-thing", ({ root, signal }) => {
77
+ ref(root, "[data-thing-button]").addEventListener("click", open, { signal }); // ends with the visit
78
+ return loop.attach(root); // and so does this
79
79
  });
80
80
  ```
81
81
 
82
82
  An element can enter the page more than once — moving it in the DOM runs the
83
83
  undo and then the function again — so it has to be able to run twice. State that
84
- must survive a move goes in a `WeakMap` keyed by `host`, which is what the
84
+ must survive a move goes in a `WeakMap` keyed by `root`, which is what the
85
85
  carousel example does with its index.
86
86
 
87
87
  **The trap this exists for is view transitions.** A bundled `<script src>` is an
@@ -98,50 +98,46 @@ handled. Without a re-`attach` there is simply nothing wired.
98
98
  ## `filters`
99
99
 
100
100
  A list already rendered by the server, narrowed in the browser and mirrored in
101
- the URL. The caller declares fields; the item shape follows from them.
101
+ the URL. The caller declares fields and hands over its own items — elements,
102
+ records, whatever it holds — with how to read each one's values.
102
103
 
103
104
  ```ts
104
105
  const list = filters({
105
- fields: {
106
- q: { kind: "text" },
107
- category: { kind: "choice" },
108
- featured: { kind: "flag" },
106
+ fields: { q: "text", category: "choice", featured: "flag" },
107
+ items: rows,
108
+ values: (row) => ({
109
+ q: data(row, "text"),
110
+ category: data(row, "category"),
111
+ featured: row.hasAttribute("data-featured"),
112
+ }),
113
+ onChange: ({ matched }) => {
114
+ for (const row of rows) row.hidden = !matched.has(row);
109
115
  },
110
- items: entries.map((entry) => ({
111
- key: entry.id,
112
- values: {
113
- q: `${entry.title} ${entry.summary}`,
114
- category: entry.category,
115
- featured: entry.featured,
116
- },
117
- })),
118
- onChange: ({ matched, state }) => { /* the project's DOM writes, all of them */ },
119
116
  });
120
117
 
121
118
  const detach = list.attach();
122
119
  ```
123
120
 
124
- Fields are plain data rather than `text()` / `choice()` / `flag()` builders:
125
- three exported names that general — `text` above all — in a module a project
126
- imports by name, to return exactly this object. `values` is typed *from*
121
+ A field is its kind, and its URL parameter is its own name — to rename the
122
+ parameter, rename the field. `{ kind: "text", url: false }` keeps a field out of
123
+ the URL, for a list whose page owns the URL itself. `values` is typed *from*
127
124
  `fields`, so a `flag` demands a boolean and a mismatch is a compile error rather
128
- than an item that silently never matches.
125
+ than an item that silently never matches. It is read once per item, at
126
+ construction. `matched` is a set of the caller's own items, so there is no key
127
+ to invent, carry in the markup and map back.
129
128
 
130
129
  | Kind | State | Item value | Matches when | In the URL | History |
131
130
  | -------- | --------------- | --------------- | ------------------------------------------------ | ------------------------------- | ------- |
132
131
  | `text` | the query | searchable text | every folded query word appears; empty keeps all | `?q=…`, dropped if empty | replace |
133
132
  | `choice` | one value, `""` | one, or several | the item holds the chosen one; `""` keeps all | `?category=…`, dropped if empty | push |
134
- | `flag` | boolean | boolean | off keeps all; on keeps only items that carry it | `?featured=1`, absent when off | push |
133
+ | `flag` | boolean | boolean | off keeps all; on keeps only items that carry it | `?featured`, absent when off | push |
135
134
 
136
135
  **A `choice` item may hold several values.** The state stays one — the tab strip
137
136
  picks one category, the URL carries one — but an item can sit in more than one
138
137
  and then answers to any of them:
139
138
 
140
139
  ```ts
141
- items: [
142
- { key: "burgos", values: { category: "city" } },
143
- { key: "avila", values: { category: ["walk", "city"] } },
144
- ]
140
+ values: (room) => ({ category: data(room, "categories").split(" ") }),
145
141
  ```
146
142
 
147
143
  An escape room is adventure *and* sci-fi; a film is a comedy *and* a drama. This
@@ -175,8 +171,6 @@ pass. What is deliberately absent is one field holding several selected values;
175
171
  that is a `choices` kind, and it forces a URL encoding decision (repeated
176
172
  parameters or comma-joined) that nothing has needed yet.
177
173
 
178
- `param` defaults to the field's name. `param: false` keeps a field state-only, out of the URL.
179
-
180
174
  ### Facet counts: `matchedWithout`
181
175
 
182
176
  A dropdown that says how many results each option would give needs a different
@@ -191,7 +185,7 @@ onChange({ matched }) {
191
185
  for (const option of countryOptions) {
192
186
  const value = option.dataset.choice;
193
187
  const count = rows.filter(
194
- (row) => rest.has(row.key) && row.country === value
188
+ (row) => rest.has(row) && row.country === value
195
189
  ).length;
196
190
  // …the project writes `count` wherever its design puts it
197
191
  }
@@ -236,21 +230,12 @@ instead of flashing the whole list back. Nothing writes to the URL before
236
230
  `attach`, because `attach` is what reads it — a `set` on an unattached instance
237
231
  would overwrite state nobody had loaded.
238
232
 
239
- A set flag is written `1` and an unset one is **absent** rather than `=0`. One
240
- spelling for off keeps the URL short and leaves a single form to parse; the cost
241
- is that a hand-written `?featured=0` reads as off, which is the answer it would have
242
- got anyway. `1` and not `true` because that is what the existing lists already
243
- emit, and links people have shared should keep resolving to the view they named.
244
-
245
- ### Two things it refuses at construction
246
-
247
- Both are silent otherwise:
233
+ A flag is on by being there, as an HTML boolean attribute is: written as the bare
234
+ `?featured`, and **absent** when off. Reading asks only whether the name is
235
+ present, so `?featured=1` from an older link still reads as on — and so does a
236
+ hand-written `?featured=false`, just as `hidden="false"` still hides.
248
237
 
249
- - **Two items under one key.** `matched` could not say which of them matched.
250
- - **Two fields on one URL parameter.** Each write erases the other's value, which
251
- reads as a filter that will not stay set.
252
-
253
- And a `set` to the value already held announces nothing and pushes nothing, so
238
+ A `set` to the value already held announces nothing and pushes nothing, so
254
239
  re-clicking the current tab does not add a history step to walk back through.
255
240
 
256
241
  ## `filters-view`
@@ -275,9 +260,9 @@ const list = filters({
275
260
  });
276
261
  const detach = list.attach();
277
262
  const unbind = searchBox(list, "q", {
278
- input: one<HTMLInputElement>("[data-filter-search]"),
279
- clear: one("[data-filter-clear]"),
280
- empty: one("[data-filter-empty]"),
263
+ input: ref<HTMLInputElement>(root, "[data-filter-search]"),
264
+ clear: ref(root, "[data-filter-clear]"),
265
+ empty: ref(root, "[data-filter-empty]"),
281
266
  });
282
267
  ```
283
268
 
@@ -363,96 +348,70 @@ skips the helper and writes it in `onChange`. Nothing degrades.
363
348
 
364
349
  ## `element`
365
350
 
366
- A script that enhances server-rendered markup has to agree with the template on
367
- attribute names, and nothing checks that agreement. `data-filter-regoin` in the
368
- template and `dataset.filterRegion` in the script compile, build and ship, and
369
- the facet simply never matches. `element` names every attribute a script and its
370
- template share, types the values in them, and registers the behaviour.
351
+ A custom element that enhances server-rendered markup: `element` registers the
352
+ behaviour and runs it for as long as the element is on the page, and `ref` and
353
+ `refs` find what it works on by plain attribute selectors.
371
354
 
372
355
  ```ts
373
- // faq.ts — imported by the template, and loaded by the page's script
374
- export const faq = element("go-faq", {
375
- row: { key: { kind: "text" }, status: { kind: "choice", of: ["open", "soon"] } },
376
- search: marker,
377
- }, (host, { row, search }, signal) => {
378
- for (const { element, values } of row.all()) {
379
- values.status; // "open" | "soon"
356
+ // faq.ts — imported by the template for the tag, and loaded by the page's script
357
+ export const faq = element("go-faq", ({ root, signal }) => {
358
+ const input = ref<HTMLInputElement>(root, "[data-faq-search]");
359
+ for (const row of refs<HTMLDetailsElement>(root, "[data-faq-row]")) {
360
+ row.dataset.key;
380
361
  }
381
- const input = search.one<HTMLInputElement>(); // a marker role is the element itself
382
362
  });
383
363
  ```
384
364
 
385
365
  ```astro
366
+ ---
367
+ import AtlasElement from "@escape-game-over/atlas/astro/element";
368
+ ---
369
+
386
370
  <script src="./faq.ts"></script>
387
371
 
388
- <go-faq {...faq.root}>
389
- <input {...faq.search.attrs()} type="search">
390
- <details {...faq.row.attrs({ key, status: "open" })}>…</details>
391
- </go-faq>
372
+ <AtlasElement of={faq} class="…">
373
+ <input data-faq-search type="search">
374
+ <details data-faq-row data-key={key}>…</details>
375
+ </AtlasElement>
392
376
  ```
393
377
 
394
378
  It registers itself when loaded in a browser and does nothing in Node, so the
395
- template imports the same file for the contract.
396
-
397
- - **Names are derived, never spelled.** `data-network-row` marks the element and
398
- `data-network-row-status` holds a field. A field that does not exist is a
399
- compile error in the template and in the script.
400
- - **Values are typed on the way in.** `attrs` takes each field's type: a missing
401
- required field, or a status outside its list, fails `astro check`. It also
402
- checks at run time, because this runs at build time and a value can arrive as
403
- a plain `string` from an env var or a cast; a throw there fails the build
404
- rather than shipping a page whose script throws on every visit.
405
- - **And parsed on the way out.** `read`, `all`, `one` and `require` parse every
406
- field and throw, naming the element and the attribute, when one is missing or
407
- malformed. A malformed match fails the whole `all` rather than being skipped:
408
- a list that quietly loses a row is the failure this exists to prevent.
409
-
410
- The kinds are data, not builders, for the reason `filters` gives:
411
- `text`, `number`, `list`, `template`, `choice` and `flag`, all but `flag`
412
- optionally `optional`. A `flag` is off by being absent, never `"false"`, so a
413
- stylesheet can select it with a bare attribute.
414
-
415
- A `template` is the one that keeps a sentence out of the script. It is written
416
- as the translated string with its placeholders left in, and read back as the
417
- function that fills them:
379
+ template imports the same file for the tag.
380
+
381
+ - **What the script asks for is what devtools shows.** A selector is a string
382
+ the template wrote as an attribute; there is no naming scheme to decode.
383
+ Prefix the names per element (`data-faq-…`) so two elements sharing a
384
+ subtree cannot pick up each other's.
385
+ - **Missing is loud in dev.** `ref` throws naming the root and the selector;
386
+ `refs` returns what it found, none included. A throw inside `connect` leaves
387
+ that element inert and lands in the dev panel, as does any uncaught error on
388
+ the page — a failing listener, a rejected `await`.
389
+ - **Values are the DOM's.** `data(row, "key")` reads `data-key` and throws, as
390
+ `ref` does, when it is missing; an optional one is `row.dataset.state`. Parse
391
+ where the script needs a number or a list. State a stylesheet reads is a plain attribute the
392
+ script sets — `data-blocked` — which Tailwind selects as `group-data-blocked:`.
393
+ - **The root says it is one.** `AtlasElement` renders the tag with
394
+ `data-atlas-root`, so a project gives every root a display in one stylesheet
395
+ rule. A root rendered by hand says so on the dev server.
396
+
397
+ A sentence the script updates keeps its words in the page. The copy wraps the
398
+ changing part in a variant run, `RichText`'s `attrs` gives that run an
399
+ attribute, and the script writes only that run:
400
+
401
+ ```ts
402
+ "faq.matched": { "en-US": "[v:count]{count}[/v] answers found" },
403
+ ```
418
404
 
419
405
  ```astro
420
- <p {...faq.line.attrs({ sentence: t("faq.matched", { count: "{count}" }) })}></p>
406
+ <RichText spans={rich("faq.matched", { count: "" })} attrs={{ count: { "data-faq-count": "" } }} />
421
407
  ```
422
408
 
423
409
  ```ts
424
- line.element.textContent = line.values.sentence({ count: String(hits.size) });
410
+ for (const each of refs(root, "[data-faq-count]")) each.textContent = String(hits.size);
425
411
  ```
426
412
 
427
- So no script spells `"{count}"`, and a sentence that never says one of its own
428
- placeholders fails the build rather than shipping a brace to a reader.
429
-
430
- `write` sets fields back onto an element, for state a stylesheet reads — a
431
- dropdown that is blocked — so the script uses the names the template did.
432
-
433
- **What it still cannot reach is the stylesheet.** Tailwind generates CSS from
434
- class names it finds as literals in the source, so a variant keyed on one of
435
- these attributes has to spell it: `group-data-network-dropdown-blocked/dd:`.
436
- That is the one place a rename is not a compile error. It is also the one place
437
- a drift is visible, since the style stops applying, where a script reading the
438
- wrong attribute fails silently. `attribute(field)` returns the name for the
439
- consumers that can take a computed one, such as an `attributeFilter`.
440
-
441
- It keeps the rule at the top of this page in the only reading that admits a
442
- lookup: it finds only elements carrying its own marker, writes only its own
443
- attributes, and sets no class, `aria` or `hidden`. The names are the project's,
444
- so nothing in this package has to be matched.
445
-
446
- - **Names come from the tag.** Every attribute is `data-<tag>-<role>` plus the
447
- field, and the browser refuses to define one tag twice, so two elements cannot
448
- share an attribute.
449
- - **Lookups stay in their instance.** A nested copy of the element keeps its
450
- own roles.
451
- - **The root says it is one.** `{...faq.root}` writes `data-atlas-root`, so a
452
- project gives every root a display in one stylesheet rule. Forget it and the
453
- element says so on the dev server.
454
- - **Names are checked in the editor.** A tag without a hyphen, or a role that is
455
- not camelCase, is a compile error where it is written.
413
+ Marks are parsed before placeholders are filled, so a run filled with `""`
414
+ still renders as an empty element for the script to write into.
456
415
 
457
416
  ## `youtube`
458
417
 
@@ -587,16 +546,13 @@ right properties is a faithful stand-in.
587
546
  `matches` read live as a browser's is, and fakes the `<video>` down to
588
547
  `paused`, `currentSrc`, `networkState`, `autoplay`, `play`, `pause` and `load`,
589
548
  including what resource selection does after a reload.
590
- - `tests/markup.test.ts` fakes elements down to four attribute methods and a
591
- root that answers bare attribute selectors, and round-trips every field kind
592
- through `attrs` and `read`. `type-tests/markup.ts` pins the half that is a
593
- compile error.
594
- - `tests/component.test.ts` builds a small element tree with parents and
595
- `closest`, and pins the scoping: a nested instance, two side by side, a root
596
- inside the instance, and a broken nested element that must not be read.
597
- `type-tests/component.ts` pins the role types and the reserved keys.
598
-
599
- `carousel`, `consent` and `element` have none. They need a real DOM and
549
+ - `tests/ref.test.ts` fakes a root down to its two query methods, and pins
550
+ that `ref` throws naming the root and the selector.
551
+ - `tests/element.test.ts` stubs `customElements` to upgrade on `define`, as a
552
+ browser does, and pins what `connect` is handed.
553
+
554
+ `carousel` and `consent` have none, and `element` has no lifecycle test — moves
555
+ and teardown. They need a real DOM and
600
556
  this package carries no environment for one; adding `happy-dom` as a dev
601
557
  dependency and setting `environment` in `vitest.config.ts` is what that would
602
558
  take.
package/docs/rich-text.md CHANGED
@@ -234,15 +234,20 @@ faqPage([{ question, answer: html(rich("faq.city.a", { network: "network" })) }]
234
234
 
235
235
  ## The renderer half
236
236
 
237
- `@escape-game-over/atlas/astro/rich-text` renders the runs. The site supplies the
238
- look — a class per `[v:name]` and one for links — usually in a small wrapper
237
+ `@escape-game-over/atlas/astro/rich-text` renders the runs, and only the runs:
238
+ the caller writes the element around them. The site supplies the look — a class
239
+ per `[v:name]` and one for links — usually in a small wrapper
239
240
  ([`examples/b2c/src/components/RichText.astro`](../examples/b2c/src/components/RichText.astro)):
240
241
 
241
242
  ```astro
242
- <RichText spans={rich("about.intro", { venue: "contact" })} variants={{ accent: "…" }} link="…" />
243
+ <p class="…">
244
+ <RichText spans={rich("about.intro", { venue: "contact" })} variants={{ accent: "…" }} link="…" />
245
+ </p>
243
246
  ```
244
247
 
245
- A variant with no class still renders its words, and warns at build.
248
+ `attrs` puts attributes on a variant's run, so a script can find it and rewrite
249
+ its words — see [client-scripts.md](client-scripts.md). A variant with neither a
250
+ class nor attrs still renders its words, and warns at build.
246
251
 
247
252
  ## What fails, and where
248
253
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escape-game-over/atlas",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "type": "module",
5
5
  "description": "Typed, data-driven machinery for static multi-locale, multi-deployment Astro sites.",
6
6
  "private": false,
@@ -18,6 +18,7 @@
18
18
  "./client": "./src/astro/client.ts",
19
19
  "./astro/consent-banner": "./src/astro/ConsentBanner.astro",
20
20
  "./astro/document": "./src/astro/Document.astro",
21
+ "./astro/element": "./src/astro/AtlasElement.astro",
21
22
  "./astro/image": "./src/astro/Image.astro",
22
23
  "./astro/rich-text": "./src/astro/RichText.astro"
23
24
  },
@@ -50,10 +51,10 @@
50
51
  "typescript": ">=5"
51
52
  },
52
53
  "devDependencies": {
53
- "@biomejs/biome": "2.5.13",
54
- "@types/node": "26.5.1",
54
+ "@biomejs/biome": "2.5.14",
55
+ "@types/node": "26.6.2",
55
56
  "@vitest/coverage-istanbul": "5.0.1",
56
- "astro": "7.3.2",
57
+ "astro": "7.3.3",
57
58
  "typescript": "6.0.3",
58
59
  "vitest": "5.0.1"
59
60
  }
@@ -0,0 +1,26 @@
1
+ ---
2
+ /**
3
+ * Renders a custom element with the root attribute: what a stylesheet gives
4
+ * every root its display by, and what `element` checks for in dev.
5
+ *
6
+ * ```astro
7
+ * <AtlasElement of={trailer} data-video={video} class="…">…</AtlasElement>
8
+ * ```
9
+ */
10
+ import type { HTMLAttributes } from "astro/types";
11
+ import { type ElementTag, ROOT_ATTRIBUTE } from "./element.ts";
12
+
13
+ interface Props extends HTMLAttributes<"div"> {
14
+ readonly of: ElementTag;
15
+ }
16
+
17
+ const { of, ...rest } = Astro.props;
18
+ const Tag = of.tag;
19
+ ---
20
+
21
+ <Tag
22
+ {...rest}
23
+ {...{ [ROOT_ATTRIBUTE]: "" }}
24
+ >
25
+ <slot />
26
+ </Tag>
@@ -1,55 +1,61 @@
1
1
  ---
2
2
  /** Internal to `ConsentBanner.astro`, so its script ships only where it renders. */
3
3
  import type { HTMLAttributes } from "astro/types";
4
+ import AtlasElement from "./AtlasElement.astro";
4
5
  import { consentBanner } from "./consent.ts";
5
6
 
6
7
  type Props = HTMLAttributes<"div">;
7
8
  ---
8
9
 
9
- <atlas-consent
10
+ <AtlasElement
11
+ of={consentBanner}
10
12
  {...Astro.props}
11
- {...consentBanner.root}
12
13
  hidden
13
14
  role="dialog"
14
15
  aria-modal="false"
15
16
  >
16
17
  <slot />
17
- </atlas-consent>
18
+ </AtlasElement>
18
19
 
19
20
  <script>
20
21
  import {
21
22
  applyConsent,
22
- CONSENT_ROLES,
23
+ CONSENT_ANSWER,
24
+ CONSENT_REOPEN,
23
25
  consentApplies,
24
- consentReopen,
25
26
  consentStore,
26
27
  } from "./consent.ts";
27
28
  import { element } from "./element.ts";
29
+ import { refs } from "./ref.ts";
28
30
 
29
- element("atlas-consent", CONSENT_ROLES, (host, { answer }, signal) => {
31
+ element("atlas-consent", ({ root, signal }) => {
30
32
  // The head script left no consent hook: nothing here sets a cookie.
31
33
  if (!consentApplies()) return;
32
34
 
33
35
  const store = consentStore();
34
36
  const show = (): void => {
35
- host.hidden = false;
37
+ root.hidden = false;
36
38
  };
37
39
 
38
- for (const { element, values } of answer.all()) {
39
- element.addEventListener(
40
+ for (const button of refs(root, `[${CONSENT_ANSWER}]`)) {
41
+ const choice = button.getAttribute(CONSENT_ANSWER);
42
+ if (choice !== "granted" && choice !== "denied") {
43
+ throw new Error(`${CONSENT_ANSWER} is "${choice}"`);
44
+ }
45
+ button.addEventListener(
40
46
  "click",
41
47
  () => {
42
- store.record(values.choice);
43
- host.hidden = true;
48
+ store.record(choice);
49
+ root.hidden = true;
44
50
  },
45
51
  { signal }
46
52
  );
47
53
  }
48
54
 
49
55
  // Outside the banner — withdrawing must be as easy as granting.
50
- for (const { element } of consentReopen.all(document)) {
51
- element.hidden = false;
52
- element.addEventListener("click", show, { signal });
56
+ for (const button of refs(document, `[${CONSENT_REOPEN}]`)) {
57
+ button.hidden = false;
58
+ button.addEventListener("click", show, { signal });
53
59
  }
54
60
 
55
61
  // Applied, not re-recorded: recording stamps today's date, and an answer
@@ -95,8 +95,7 @@ const picture: ComponentProps<typeof Picture> = {
95
95
  src={src}
96
96
  alt={alt}
97
97
  closeLabel={zoom.closeLabel}
98
- class={zoom.class}
99
- >
98
+ class={zoom.class}>
100
99
  <Picture {...picture} />
101
100
  </Zoom>
102
101
  )}
@@ -25,27 +25,40 @@ const { tags } = Astro.props;
25
25
  // again as HTML would turn each quote into an entity and leave a block no
26
26
  // parser reads.
27
27
  if (tag.kind === "script") {
28
- return <script type={tag.type} is:inline set:html={tag.content} />;
28
+ return (
29
+ <script
30
+ type={tag.type}
31
+ is:inline
32
+ set:html={tag.content}
33
+ />
34
+ );
29
35
  }
30
36
  // `is:inline` so the bundler leaves a third-party URL alone, and `defer`
31
37
  // because a script that blocks the parser costs the thing it measures.
32
38
  if (tag.kind === "externalScript") {
33
- return <script is:inline defer src={tag.src} {...tag.attrs} />;
39
+ return (
40
+ <script
41
+ is:inline
42
+ defer
43
+ src={tag.src}
44
+ {...tag.attrs}
45
+ />
46
+ );
34
47
  }
35
48
  // Tag Manager's fallback, and only ever in the body list. Hidden the way
36
49
  // Google's own snippet hides it — a zero-sized iframe nothing can see.
37
50
  if (tag.kind === "noscriptFrame") {
38
- return (
39
- <noscript>
40
- <iframe
41
- src={tag.src}
42
- title="Google Tag Manager"
43
- height="0"
44
- width="0"
45
- style="display:none;visibility:hidden"
46
- />
47
- </noscript>
48
- );
51
+ return (
52
+ <noscript>
53
+ <iframe
54
+ src={tag.src}
55
+ title='Google Tag Manager'
56
+ height='0'
57
+ width='0'
58
+ style='display:none;visibility:hidden'
59
+ />
60
+ </noscript>
61
+ );
49
62
  }
50
63
  if (tag.kind === "meta") return <meta {...tag.attrs} />;
51
64
  // Named rather than left to a trailing `return`, which used to catch
@@ -59,4 +72,4 @@ const { tags } = Astro.props;
59
72
  // copied into every project.
60
73
  const unhandled: never = tag;
61
74
  throw new Error(`Unhandled meta tag: ${JSON.stringify(unhandled)}`);
62
- })}
75
+ })}