@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.
- package/docs/client-scripts.md +84 -128
- package/docs/rich-text.md +9 -4
- package/package.json +5 -4
- package/src/astro/AtlasElement.astro +26 -0
- package/src/astro/ConsentElement.astro +20 -14
- package/src/astro/Image.astro +1 -2
- package/src/astro/MetaTags.astro +27 -14
- package/src/astro/RichText.astro +60 -43
- package/src/astro/client.ts +7 -8
- package/src/astro/consent.ts +10 -10
- package/src/astro/element.ts +59 -93
- package/src/astro/filters-view.ts +10 -20
- package/src/astro/filters.ts +108 -116
- package/src/astro/ref.ts +46 -0
- package/src/astro/markup.ts +0 -656
package/src/astro/RichText.astro
CHANGED
|
@@ -1,71 +1,88 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
3
|
* Renders what `site.rich()` returns. The site supplies the look: a class per
|
|
4
|
-
* `[v:name]` variant and one for links; everything else is decided here.
|
|
4
|
+
* `[v:name]` variant and one for links; everything else is decided here. Only
|
|
5
|
+
* the runs — the caller writes the element they sit in.
|
|
5
6
|
*
|
|
6
7
|
* ```astro
|
|
7
|
-
* <
|
|
8
|
+
* <p class="…">
|
|
9
|
+
* <RichText spans={rich("about.intro", { venue: "contact" })} variants={{ accent: "…" }} />
|
|
10
|
+
* </p>
|
|
8
11
|
* ```
|
|
9
12
|
*/
|
|
10
|
-
import type { HTMLTag } from "astro/types";
|
|
11
13
|
import type { RichText } from "../content/index.ts";
|
|
12
14
|
import { warn } from "../warn.ts";
|
|
13
15
|
|
|
14
16
|
interface Props {
|
|
15
17
|
readonly spans: RichText;
|
|
16
|
-
/** The element wrapping the runs. Defaults to `p`. */
|
|
17
|
-
readonly as?: HTMLTag;
|
|
18
|
-
readonly class?: string;
|
|
19
18
|
/** `[v:name]` → classes. */
|
|
20
19
|
readonly variants?: Readonly<Record<string, string>>;
|
|
20
|
+
/** `[v:name]` → attributes, so a script can find that run. */
|
|
21
|
+
readonly attrs?: Readonly<Record<string, Readonly<Record<string, string>>>>;
|
|
21
22
|
/** Classes on every link: `[a:]`, `[mail]` and `[tel]`. */
|
|
22
23
|
readonly link?: string;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
const {
|
|
26
|
-
spans,
|
|
27
|
-
as: Tag = "p",
|
|
28
|
-
class: className,
|
|
29
|
-
variants = {},
|
|
30
|
-
link,
|
|
31
|
-
}: Props = Astro.props;
|
|
26
|
+
const { spans, variants = {}, attrs = {}, link }: Props = Astro.props;
|
|
32
27
|
|
|
33
28
|
// The words still render: losing the sentence is worse than losing its style.
|
|
29
|
+
// A run with attributes and no class is a script's hook, not a missing style.
|
|
34
30
|
const variantClass = (variant: string): string | undefined => {
|
|
35
|
-
if (!Object.hasOwn(variants, variant)) {
|
|
31
|
+
if (!Object.hasOwn(variants, variant) && !Object.hasOwn(attrs, variant)) {
|
|
36
32
|
warn(
|
|
37
33
|
Astro.url.pathname,
|
|
38
|
-
`[v:${variant}] has no class in RichText's variants.`
|
|
34
|
+
`[v:${variant}] has no class in RichText's variants and no attrs.`
|
|
39
35
|
);
|
|
40
36
|
}
|
|
41
|
-
return variants[variant];
|
|
37
|
+
return Object.hasOwn(variants, variant) ? variants[variant] : undefined;
|
|
42
38
|
};
|
|
39
|
+
|
|
40
|
+
const variantAttrs = (variant: string) =>
|
|
41
|
+
Object.hasOwn(attrs, variant) ? attrs[variant] : undefined;
|
|
43
42
|
---
|
|
44
43
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
44
|
+
{spans.map((span) => {
|
|
45
|
+
switch (span.kind) {
|
|
46
|
+
case "text":
|
|
47
|
+
return span.text;
|
|
48
|
+
case "bold":
|
|
49
|
+
return <strong>{span.text}</strong>;
|
|
50
|
+
case "variant":
|
|
51
|
+
return (
|
|
52
|
+
<span
|
|
53
|
+
{...variantAttrs(span.variant)}
|
|
54
|
+
class={variantClass(span.variant)}>
|
|
55
|
+
{span.text}
|
|
56
|
+
</span>
|
|
57
|
+
);
|
|
58
|
+
case "break":
|
|
59
|
+
return <br />;
|
|
60
|
+
case "link":
|
|
61
|
+
return span.to === "external" ? (
|
|
62
|
+
<a
|
|
63
|
+
href={span.href}
|
|
64
|
+
class={link}
|
|
65
|
+
target='_blank'
|
|
66
|
+
rel='noopener noreferrer'>
|
|
67
|
+
{span.text}
|
|
68
|
+
</a>
|
|
69
|
+
) : (
|
|
70
|
+
<a
|
|
71
|
+
href={span.href}
|
|
72
|
+
class={link}>
|
|
73
|
+
{span.text}
|
|
74
|
+
</a>
|
|
75
|
+
);
|
|
76
|
+
case "mail":
|
|
77
|
+
case "tel":
|
|
78
|
+
return (
|
|
79
|
+
<a
|
|
80
|
+
href={span.href}
|
|
81
|
+
class={link}>
|
|
82
|
+
{span.text}
|
|
83
|
+
</a>
|
|
84
|
+
);
|
|
85
|
+
default:
|
|
86
|
+
return span satisfies never;
|
|
87
|
+
}
|
|
88
|
+
})}
|
package/src/astro/client.ts
CHANGED
|
@@ -20,14 +20,13 @@ export {
|
|
|
20
20
|
consentApplies,
|
|
21
21
|
consentStore,
|
|
22
22
|
} from "./consent.ts";
|
|
23
|
-
export {
|
|
23
|
+
export {
|
|
24
|
+
type Connect,
|
|
25
|
+
type ElementContext,
|
|
26
|
+
type ElementTag,
|
|
27
|
+
element,
|
|
28
|
+
} from "./element.ts";
|
|
24
29
|
export * from "./filters.ts";
|
|
25
30
|
export * from "./filters-view.ts";
|
|
26
|
-
export {
|
|
27
|
-
type Component,
|
|
28
|
-
type Marked,
|
|
29
|
-
type Markup,
|
|
30
|
-
marker,
|
|
31
|
-
markup,
|
|
32
|
-
} from "./markup.ts";
|
|
31
|
+
export { data, ref, refs } from "./ref.ts";
|
|
33
32
|
export * from "./youtube.ts";
|
package/src/astro/consent.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { CONSENT_UPDATE_GLOBAL } from "../analytics/google.ts";
|
|
2
|
-
import {
|
|
2
|
+
import type { ElementTag } from "./element.ts";
|
|
3
3
|
|
|
4
|
-
/** Internal to `ConsentBanner.astro`: the element and
|
|
5
|
-
export const
|
|
6
|
-
|
|
7
|
-
}
|
|
8
|
-
export const
|
|
9
|
-
export const
|
|
4
|
+
/** Internal to `ConsentBanner.astro`: the element, and what its script finds. */
|
|
5
|
+
export const consentBanner: ElementTag<"atlas-consent"> = {
|
|
6
|
+
tag: "atlas-consent",
|
|
7
|
+
};
|
|
8
|
+
export const CONSENT_ANSWER = "data-atlas-consent-answer";
|
|
9
|
+
export const CONSENT_REOPEN = "data-atlas-consent-reopen";
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* What a site's consent markup spreads: the two answer buttons inside
|
|
@@ -15,9 +15,9 @@ export const consentReopen = markup("atlas-consent-reopen");
|
|
|
15
15
|
* withdraw.
|
|
16
16
|
*/
|
|
17
17
|
export const consent = {
|
|
18
|
-
accept:
|
|
19
|
-
decline:
|
|
20
|
-
reopen: {
|
|
18
|
+
accept: { [CONSENT_ANSWER]: "granted" },
|
|
19
|
+
decline: { [CONSENT_ANSWER]: "denied" },
|
|
20
|
+
reopen: { [CONSENT_REOPEN]: "", hidden: "" },
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
/**
|
package/src/astro/element.ts
CHANGED
|
@@ -1,105 +1,92 @@
|
|
|
1
1
|
import { reportDevError } from "./dev-log.ts";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
type MarkupFields,
|
|
10
|
-
type Reserved,
|
|
11
|
-
ROOT_ATTRIBUTE,
|
|
12
|
-
} from "./markup.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What `AtlasElement` puts on every root: one name, so a project gives every
|
|
5
|
+
* root a display in a single stylesheet rule, and so `element` can say when a
|
|
6
|
+
* root was rendered by hand.
|
|
7
|
+
*/
|
|
8
|
+
export const ROOT_ATTRIBUTE = "data-atlas-root";
|
|
13
9
|
|
|
14
10
|
/** What is left to undo when the element leaves; usually nothing. */
|
|
15
11
|
// biome-ignore lint/suspicious/noConfusingVoidType: that is the distinction.
|
|
16
12
|
type Undo = void | (() => void);
|
|
17
13
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
all<T extends Element = HTMLElement>(): T[];
|
|
25
|
-
one<T extends Element = HTMLElement>(): T | null;
|
|
26
|
-
require<T extends Element = HTMLElement>(): T;
|
|
27
|
-
}
|
|
28
|
-
: {
|
|
29
|
-
all<T extends Element = HTMLElement>(): Marked<T, F>[];
|
|
30
|
-
one<T extends Element = HTMLElement>(): Marked<T, F> | null;
|
|
31
|
-
require<T extends Element = HTMLElement>(): Marked<T, F>;
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
/** Every role of an element, bound to one instance. */
|
|
35
|
-
export type ElementRoles<R extends ComponentRoles> = {
|
|
36
|
-
readonly [K in keyof R]: Lookups<R[K]>;
|
|
37
|
-
};
|
|
14
|
+
export interface ElementContext {
|
|
15
|
+
/** The element itself: where `ref` and `refs` search from. */
|
|
16
|
+
readonly root: HTMLElement;
|
|
17
|
+
/** Aborted when the element leaves, taking its listeners with it. */
|
|
18
|
+
readonly signal: AbortSignal;
|
|
19
|
+
}
|
|
38
20
|
|
|
39
21
|
/**
|
|
40
|
-
* What an element does while it is on the page. `signal`
|
|
41
|
-
*
|
|
42
|
-
* to undo is the returned function.
|
|
22
|
+
* What an element does while it is on the page. Anything `signal` does not
|
|
23
|
+
* already undo is the returned function.
|
|
43
24
|
*/
|
|
44
|
-
export type Connect
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
25
|
+
export type Connect = (context: ElementContext) => Undo;
|
|
26
|
+
|
|
27
|
+
/** What a template renders the element from, with `AtlasElement`. */
|
|
28
|
+
export interface ElementTag<Tag extends string = string> {
|
|
29
|
+
readonly tag: Tag;
|
|
30
|
+
}
|
|
49
31
|
|
|
50
32
|
/**
|
|
51
|
-
* A custom element
|
|
33
|
+
* A custom element's behaviour, registered under `tag`.
|
|
52
34
|
*
|
|
53
35
|
* ```ts
|
|
54
|
-
* // faq.ts — imported by the template, and by the page's script
|
|
55
|
-
* export const faq = element("go-faq", {
|
|
56
|
-
*
|
|
36
|
+
* // faq.ts — imported by the template for the tag, and by the page's script
|
|
37
|
+
* export const faq = element("go-faq", ({ root, signal }) => {
|
|
38
|
+
* const input = ref<HTMLInputElement>(root, "[data-faq-search]");
|
|
39
|
+
* …
|
|
40
|
+
* });
|
|
57
41
|
* ```
|
|
58
42
|
* ```astro
|
|
59
43
|
* <script src="./faq.ts"></script>
|
|
44
|
+
* <AtlasElement of={faq}><input data-faq-search type="search"></AtlasElement>
|
|
60
45
|
* ```
|
|
61
46
|
*
|
|
62
47
|
* Registers itself when loaded in a browser and does nothing in Node, so the
|
|
63
|
-
* template imports the same file
|
|
48
|
+
* template imports the same file.
|
|
64
49
|
*
|
|
65
50
|
* - **An element can enter the page more than once.** Moving it runs the undo
|
|
66
51
|
* and `connect` again; Astro's `ClientRouter` does so on every navigation.
|
|
67
|
-
* State that must survive belongs in a `WeakMap` keyed by `
|
|
52
|
+
* State that must survive belongs in a `WeakMap` keyed by `root`.
|
|
68
53
|
* - **The page's script must stay a bundled module** (a plain `<script>`), so
|
|
69
54
|
* the element has its children when it upgrades.
|
|
70
|
-
* - **A `connect` that throws leaves that one element
|
|
55
|
+
* - **Failures show in dev.** A `connect` that throws leaves that one element
|
|
56
|
+
* inert, and it, or any uncaught error on the page, lands in the dev panel.
|
|
71
57
|
* - **A tag defined twice keeps the first definition** and reports the second.
|
|
72
58
|
*/
|
|
73
|
-
export function element<
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
>(
|
|
78
|
-
tag: Tag & CheckedTagName<Tag>,
|
|
79
|
-
roles: R,
|
|
80
|
-
connect: Connect<R>
|
|
81
|
-
): Component<Tag, R> {
|
|
82
|
-
// Already checked by this function's own signature.
|
|
83
|
-
const contract = component(tag as never, roles) as Component<Tag, R>;
|
|
59
|
+
export function element<const Tag extends string>(
|
|
60
|
+
tag: Tag,
|
|
61
|
+
connect: Connect
|
|
62
|
+
): ElementTag<Tag> {
|
|
84
63
|
if (typeof customElements !== "undefined") {
|
|
85
|
-
|
|
64
|
+
watchDevErrors();
|
|
65
|
+
// Defining upgrades elements already on the page synchronously; a
|
|
66
|
+
// microtask later, `const x = element(…)` is assigned first.
|
|
67
|
+
queueMicrotask(() => define(tag, connect));
|
|
86
68
|
}
|
|
87
|
-
return
|
|
69
|
+
return { tag };
|
|
88
70
|
}
|
|
89
71
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
72
|
+
let watching = false;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Puts errors thrown outside `connect` — in a listener, after an `await` — in
|
|
76
|
+
* the dev panel too. Once per page load: the listeners outlive a body swap.
|
|
77
|
+
*/
|
|
78
|
+
function watchDevErrors(): void {
|
|
79
|
+
if (!import.meta.env.DEV || watching) return;
|
|
80
|
+
watching = true;
|
|
81
|
+
window.addEventListener("error", (event) => {
|
|
82
|
+
reportDevError("page", event.error ?? event.message);
|
|
83
|
+
});
|
|
84
|
+
window.addEventListener("unhandledrejection", (event) => {
|
|
85
|
+
reportDevError("page", event.reason);
|
|
86
|
+
});
|
|
95
87
|
}
|
|
96
88
|
|
|
97
|
-
function define
|
|
98
|
-
contract: Component<string, R>,
|
|
99
|
-
roles: R,
|
|
100
|
-
connect: Connect<R>
|
|
101
|
-
): void {
|
|
102
|
-
const { tag } = contract;
|
|
89
|
+
function define(tag: string, connect: Connect): void {
|
|
103
90
|
if (customElements.get(tag) !== undefined) {
|
|
104
91
|
reportDevError(
|
|
105
92
|
tag,
|
|
@@ -108,27 +95,6 @@ function define<R extends ComponentRoles>(
|
|
|
108
95
|
return;
|
|
109
96
|
}
|
|
110
97
|
|
|
111
|
-
const bind = (host: HTMLElement): ElementRoles<R> => {
|
|
112
|
-
const bound: Record<string, unknown> = {};
|
|
113
|
-
for (const [name, fields] of Object.entries(roles)) {
|
|
114
|
-
const role = (contract as unknown as Record<string, AnyRole>)[name];
|
|
115
|
-
if (role === undefined) continue;
|
|
116
|
-
bound[name] =
|
|
117
|
-
Object.keys(fields).length === 0
|
|
118
|
-
? {
|
|
119
|
-
all: () => role.all(host).map((each) => each.element),
|
|
120
|
-
one: () => role.one(host)?.element ?? null,
|
|
121
|
-
require: () => role.require(host).element,
|
|
122
|
-
}
|
|
123
|
-
: {
|
|
124
|
-
all: () => role.all(host),
|
|
125
|
-
one: () => role.one(host),
|
|
126
|
-
require: () => role.require(host),
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
return bound as ElementRoles<R>;
|
|
130
|
-
};
|
|
131
|
-
|
|
132
98
|
customElements.define(
|
|
133
99
|
tag,
|
|
134
100
|
class extends HTMLElement {
|
|
@@ -146,11 +112,11 @@ function define<R extends ComponentRoles>(
|
|
|
146
112
|
!this.hasAttribute(ROOT_ATTRIBUTE)
|
|
147
113
|
) {
|
|
148
114
|
throw new Error(
|
|
149
|
-
`<${tag}> carries no ${ROOT_ATTRIBUTE} —
|
|
115
|
+
`<${tag}> carries no ${ROOT_ATTRIBUTE} — is it rendered with <AtlasElement of={…}>?`
|
|
150
116
|
);
|
|
151
117
|
}
|
|
152
118
|
this.#undo =
|
|
153
|
-
connect(
|
|
119
|
+
connect({ root: this, signal: ac.signal }) ?? undefined;
|
|
154
120
|
} catch (error) {
|
|
155
121
|
reportDevError(this.localName, error);
|
|
156
122
|
}
|
|
@@ -10,21 +10,18 @@
|
|
|
10
10
|
* on a clear button, an empty message or a section with nothing left. No class,
|
|
11
11
|
* style or `aria`: that is where the choices live.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* it. Tailwind v4's preflight ships `[hidden] { display: none !important }`;
|
|
17
|
-
* without something like it these writes are inert and nothing filters.
|
|
18
|
-
* - **A result count wants `aria-live="polite"`**, in the template.
|
|
13
|
+
* **`hidden` needs help in a grid.** Any author rule setting `display` beats it.
|
|
14
|
+
* Tailwind v4's preflight ships `[hidden] { display: none !important }`; without
|
|
15
|
+
* something like it these writes are inert and nothing filters.
|
|
19
16
|
*
|
|
20
17
|
* See docs/client-scripts.md.
|
|
21
18
|
*/
|
|
22
19
|
|
|
23
|
-
import type { FieldMap, FilterChange, Filters } from "./filters.ts";
|
|
20
|
+
import type { FieldMap, FilterChange, Filters, KindOf } from "./filters.ts";
|
|
24
21
|
|
|
25
22
|
/** The `text` fields of `F`, so a search box cannot be pointed at a flag. */
|
|
26
23
|
export type TextFieldOf<F extends FieldMap> = {
|
|
27
|
-
[K in keyof F]: F[K] extends
|
|
24
|
+
[K in keyof F]: KindOf<F[K]> extends "text" ? K : never;
|
|
28
25
|
}[keyof F];
|
|
29
26
|
|
|
30
27
|
/**
|
|
@@ -38,12 +35,6 @@ export interface SearchBoxElements {
|
|
|
38
35
|
readonly clear?: HTMLElement | null;
|
|
39
36
|
/** The "nothing matched" message. Tracks the whole result set. */
|
|
40
37
|
readonly empty?: HTMLElement | null;
|
|
41
|
-
/**
|
|
42
|
-
* How many matched, as digits and nothing else: the sentence around them is
|
|
43
|
-
* translated, and not something to take apart in a browser. Give the count
|
|
44
|
-
* its own element and let the copy sit beside it.
|
|
45
|
-
*/
|
|
46
|
-
readonly count?: HTMLElement | null;
|
|
47
38
|
}
|
|
48
39
|
|
|
49
40
|
/**
|
|
@@ -54,20 +45,20 @@ export interface SearchBoxElements {
|
|
|
54
45
|
* ```ts
|
|
55
46
|
* const list = filters({ fields, items, onChange: … });
|
|
56
47
|
* const detach = list.attach();
|
|
57
|
-
* const unbind = searchBox(list, "q", { input, clear, empty
|
|
48
|
+
* const unbind = searchBox(list, "q", { input, clear, empty });
|
|
58
49
|
* ```
|
|
59
50
|
*/
|
|
60
|
-
export function searchBox<F extends FieldMap>(
|
|
61
|
-
list: Filters<F>,
|
|
51
|
+
export function searchBox<F extends FieldMap, T>(
|
|
52
|
+
list: Filters<F, T>,
|
|
62
53
|
field: TextFieldOf<F>,
|
|
63
54
|
elements: SearchBoxElements
|
|
64
55
|
): () => void {
|
|
65
|
-
const { input, clear, empty
|
|
56
|
+
const { input, clear, empty } = elements;
|
|
66
57
|
// The narrowing `TextFieldOf` already did: inside a generic function
|
|
67
58
|
// TypeScript cannot see that this field holds a string.
|
|
68
59
|
const name = field as Parameters<typeof list.set>[0];
|
|
69
60
|
|
|
70
|
-
const render = ({ state, matched }: FilterChange<F>): void => {
|
|
61
|
+
const render = ({ state, matched }: FilterChange<F, T>): void => {
|
|
71
62
|
const query = String(state[name]);
|
|
72
63
|
// Guarded by inequality: assigning `value` while someone types moves
|
|
73
64
|
// the caret to the end, and this exists for state that moved without
|
|
@@ -75,7 +66,6 @@ export function searchBox<F extends FieldMap>(
|
|
|
75
66
|
if (input != null && input.value !== query) input.value = query;
|
|
76
67
|
if (clear != null) clear.hidden = query === "";
|
|
77
68
|
if (empty != null) empty.hidden = matched.size > 0;
|
|
78
|
-
if (count != null) count.textContent = String(matched.size);
|
|
79
69
|
};
|
|
80
70
|
|
|
81
71
|
render(list);
|