@gyral/core 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +42 -0
- package/NOTICE +19 -0
- package/README.md +30 -5
- package/dist/call.d.ts +32 -0
- package/dist/call.js +63 -0
- package/dist/call.js.map +1 -0
- package/dist/children.d.ts +25 -3
- package/dist/children.js +18 -1
- package/dist/children.js.map +1 -1
- package/dist/cli/gyral-types.d.ts +2 -0
- package/dist/cli/gyral-types.js +23 -0
- package/dist/cli/gyral-types.js.map +1 -0
- package/dist/command.d.ts +54 -12
- package/dist/command.js +25 -11
- package/dist/command.js.map +1 -1
- package/dist/compiler/ast.d.ts +2 -0
- package/dist/compiler/ast.js +5 -1
- package/dist/compiler/ast.js.map +1 -1
- package/dist/compiler/compile.d.ts +5 -2
- package/dist/compiler/compile.js +36 -17
- package/dist/compiler/compile.js.map +1 -1
- package/dist/compiler/component-entries.d.ts +7 -0
- package/dist/compiler/component-entries.js +42 -0
- package/dist/compiler/component-entries.js.map +1 -0
- package/dist/compiler/components.d.ts +4 -0
- package/dist/compiler/components.js +182 -0
- package/dist/compiler/components.js.map +1 -0
- package/dist/compiler/facts.d.ts +31 -0
- package/dist/compiler/facts.js +285 -0
- package/dist/compiler/facts.js.map +1 -0
- package/dist/compiler/features.d.ts +34 -0
- package/dist/compiler/features.js +158 -0
- package/dist/compiler/features.js.map +1 -0
- package/dist/compiler/hooks.d.ts +4 -0
- package/dist/compiler/hooks.js +144 -89
- package/dist/compiler/hooks.js.map +1 -1
- package/dist/compiler/loader-source.d.ts +14 -0
- package/dist/compiler/loader-source.js +86 -0
- package/dist/compiler/loader-source.js.map +1 -0
- package/dist/compiler/locate.d.ts +14 -0
- package/dist/compiler/locate.js +78 -0
- package/dist/compiler/locate.js.map +1 -0
- package/dist/compiler/parse5-check.d.ts +1 -1
- package/dist/compiler/parse5-check.js +8 -3
- package/dist/compiler/parse5-check.js.map +1 -1
- package/dist/compiler/prop-schemas.d.ts +4 -0
- package/dist/compiler/prop-schemas.js +108 -0
- package/dist/compiler/prop-schemas.js.map +1 -0
- package/dist/compiler/scan.d.ts +13 -3
- package/dist/compiler/scan.js +21 -13
- package/dist/compiler/scan.js.map +1 -1
- package/dist/compiler/scope.d.ts +8 -0
- package/dist/compiler/scope.js +116 -0
- package/dist/compiler/scope.js.map +1 -0
- package/dist/compiler/tag-map.d.ts +41 -0
- package/dist/compiler/tag-map.js +126 -0
- package/dist/compiler/tag-map.js.map +1 -0
- package/dist/components-ready.d.ts +7 -0
- package/dist/components-ready.js +19 -0
- package/dist/components-ready.js.map +1 -0
- package/dist/define.d.ts +13 -4
- package/dist/define.js +8 -12
- package/dist/define.js.map +1 -1
- package/dist/devtools-events.d.ts +8 -0
- package/dist/devtools-events.js.map +1 -1
- package/dist/devtools-off.d.ts +1 -0
- package/dist/devtools-off.js +1 -0
- package/dist/devtools-off.js.map +1 -1
- package/dist/devtools.d.ts +3 -0
- package/dist/devtools.js +14 -0
- package/dist/devtools.js.map +1 -1
- package/dist/drivers-scope.js.map +1 -1
- package/dist/element-internals.d.ts +1 -0
- package/dist/element-internals.js +11 -0
- package/dist/element-internals.js.map +1 -0
- package/dist/element-types.d.ts +6 -2
- package/dist/element-types.js.map +1 -1
- package/dist/element.js +80 -27
- package/dist/element.js.map +1 -1
- package/dist/error-view.d.ts +10 -0
- package/dist/error-view.js +30 -0
- package/dist/error-view.js.map +1 -0
- package/dist/errors.d.ts +21 -0
- package/dist/errors.js +47 -0
- package/dist/errors.js.map +1 -0
- package/dist/errors.json +289 -0
- package/dist/eslint/imports.d.ts +2 -0
- package/dist/eslint/imports.js +2 -2
- package/dist/eslint/imports.js.map +1 -1
- package/dist/eslint/index.d.ts +2 -1
- package/dist/eslint/index.js +11 -3
- package/dist/eslint/index.js.map +1 -1
- package/dist/eslint/template-rule.d.ts +1 -0
- package/dist/eslint/template-rule.js +15 -5
- package/dist/eslint/template-rule.js.map +1 -1
- package/dist/eslint/unused-intent-rule.d.ts +2 -0
- package/dist/eslint/unused-intent-rule.js +186 -0
- package/dist/eslint/unused-intent-rule.js.map +1 -0
- package/dist/features.d.ts +2 -2
- package/dist/features.js.map +1 -1
- package/dist/focus.d.ts +6 -0
- package/dist/focus.js +32 -2
- package/dist/focus.js.map +1 -1
- package/dist/form-associated.d.ts +46 -0
- package/dist/form-associated.js +77 -0
- package/dist/form-associated.js.map +1 -0
- package/dist/forms.d.ts +42 -30
- package/dist/forms.js +85 -41
- package/dist/forms.js.map +1 -1
- package/dist/frame-lane.d.ts +11 -0
- package/dist/frame-lane.js +28 -0
- package/dist/frame-lane.js.map +1 -0
- package/dist/head.d.ts +61 -0
- package/dist/head.js +85 -0
- package/dist/head.js.map +1 -0
- package/dist/host-model.d.ts +34 -5
- package/dist/host-model.js +146 -23
- package/dist/host-model.js.map +1 -1
- package/dist/hydration-client.js +2 -2
- package/dist/hydration-client.js.map +1 -1
- package/dist/hydration-loader.d.ts +13 -0
- package/dist/hydration-loader.js +58 -0
- package/dist/hydration-loader.js.map +1 -0
- package/dist/hydration-off.d.ts +8 -0
- package/dist/hydration-off.js +25 -0
- package/dist/hydration-off.js.map +1 -0
- package/dist/hydration.d.ts +0 -12
- package/dist/hydration.js +3 -52
- package/dist/hydration.js.map +1 -1
- package/dist/index.d.ts +20 -24
- package/dist/index.js +10 -19
- package/dist/index.js.map +1 -1
- package/dist/indexeddb-types.d.ts +97 -0
- package/dist/indexeddb-types.js +2 -0
- package/dist/indexeddb-types.js.map +1 -0
- package/dist/indexeddb.d.ts +5 -0
- package/dist/indexeddb.js +118 -0
- package/dist/indexeddb.js.map +1 -0
- package/dist/intent.d.ts +41 -19
- package/dist/intent.js +144 -106
- package/dist/intent.js.map +1 -1
- package/dist/internal/indexeddb-open.d.ts +8 -0
- package/dist/internal/indexeddb-open.js +96 -0
- package/dist/internal/indexeddb-open.js.map +1 -0
- package/dist/internal/indexeddb-transaction.d.ts +4 -0
- package/dist/internal/indexeddb-transaction.js +134 -0
- package/dist/internal/indexeddb-transaction.js.map +1 -0
- package/dist/internal/interpreter.d.ts +12 -4
- package/dist/internal/interpreter.js +36 -48
- package/dist/internal/interpreter.js.map +1 -1
- package/dist/internal/token.d.ts +1 -0
- package/dist/internal/token.js +7 -0
- package/dist/internal/token.js.map +1 -0
- package/dist/internal.d.ts +20 -0
- package/dist/internal.js +22 -0
- package/dist/internal.js.map +1 -0
- package/dist/invoker-fallback-used.d.ts +4 -0
- package/dist/invoker-fallback-used.js +10 -0
- package/dist/invoker-fallback-used.js.map +1 -0
- package/dist/invoker-fallback.d.ts +4 -0
- package/dist/invoker-fallback.js +15 -0
- package/dist/invoker-fallback.js.map +1 -0
- package/dist/light-dom.d.ts +1 -1
- package/dist/light-dom.js.map +1 -1
- package/dist/prop.d.ts +30 -7
- package/dist/prop.js +19 -0
- package/dist/prop.js.map +1 -1
- package/dist/props.d.ts +8 -3
- package/dist/props.js +22 -15
- package/dist/props.js.map +1 -1
- package/dist/retry.d.ts +15 -0
- package/dist/retry.js +48 -0
- package/dist/retry.js.map +1 -0
- package/dist/scheduler.d.ts +7 -3
- package/dist/scheduler.js +38 -56
- package/dist/scheduler.js.map +1 -1
- package/dist/script-json.d.ts +2 -0
- package/dist/script-json.js +13 -0
- package/dist/script-json.js.map +1 -0
- package/dist/server-component.d.ts +1 -1
- package/dist/server-component.js +45 -14
- package/dist/server-component.js.map +1 -1
- package/dist/server.d.ts +8 -2
- package/dist/server.js +11 -2
- package/dist/server.js.map +1 -1
- package/dist/settled.d.ts +4 -3
- package/dist/settled.js +48 -26
- package/dist/settled.js.map +1 -1
- package/dist/spec-features-used.d.ts +21 -0
- package/dist/spec-features-used.js +50 -0
- package/dist/spec-features-used.js.map +1 -0
- package/dist/spec-features.d.ts +28 -0
- package/dist/spec-features.js +42 -0
- package/dist/spec-features.js.map +1 -0
- package/dist/states.d.ts +3 -5
- package/dist/states.js +22 -8
- package/dist/states.js.map +1 -1
- package/dist/storage.d.ts +69 -0
- package/dist/storage.js +158 -0
- package/dist/storage.js.map +1 -0
- package/dist/store-binding.js +2 -2
- package/dist/store-binding.js.map +1 -1
- package/dist/store-scope.d.ts +2 -4
- package/dist/store-scope.js +6 -16
- package/dist/store-scope.js.map +1 -1
- package/dist/store.js +36 -8
- package/dist/store.js.map +1 -1
- package/dist/subscription.d.ts +35 -0
- package/dist/subscription.js +74 -0
- package/dist/subscription.js.map +1 -0
- package/dist/subscriptions.d.ts +26 -0
- package/dist/subscriptions.js +98 -0
- package/dist/subscriptions.js.map +1 -0
- package/dist/transitions.d.ts +4 -5
- package/dist/transitions.js +10 -4
- package/dist/transitions.js.map +1 -1
- package/dist/types.d.ts +124 -27
- package/dist/types.js.map +1 -1
- package/dist/use-dialogs.d.ts +2 -0
- package/dist/use-dialogs.js +9 -0
- package/dist/use-dialogs.js.map +1 -0
- package/dist/use-disposal.d.ts +2 -0
- package/dist/use-disposal.js +9 -0
- package/dist/use-disposal.js.map +1 -0
- package/dist/use-error-view.d.ts +2 -0
- package/dist/use-error-view.js +9 -0
- package/dist/use-error-view.js.map +1 -0
- package/dist/use-errored.d.ts +2 -0
- package/dist/use-errored.js +9 -0
- package/dist/use-errored.js.map +1 -0
- package/dist/use-form-associated.d.ts +2 -0
- package/dist/use-form-associated.js +7 -0
- package/dist/use-form-associated.js.map +1 -0
- package/dist/use-frame-lane.d.ts +2 -0
- package/dist/use-frame-lane.js +10 -0
- package/dist/use-frame-lane.js.map +1 -0
- package/dist/use-invokers.d.ts +2 -0
- package/dist/use-invokers.js +10 -0
- package/dist/use-invokers.js.map +1 -0
- package/dist/use-states.d.ts +2 -0
- package/dist/use-states.js +10 -0
- package/dist/use-states.js.map +1 -0
- package/dist/use-subscriptions.d.ts +2 -0
- package/dist/use-subscriptions.js +10 -0
- package/dist/use-subscriptions.js.map +1 -0
- package/dist/use-transitions.d.ts +2 -0
- package/dist/use-transitions.js +10 -0
- package/dist/use-transitions.js.map +1 -0
- package/dist/view/attributes.d.ts +5 -0
- package/dist/view/attributes.js +5 -0
- package/dist/view/attributes.js.map +1 -1
- package/dist/view/compiled.d.ts +1 -1
- package/dist/view/compiled.js +1 -1
- package/dist/view/compiled.js.map +1 -1
- package/dist/view/index.d.ts +9 -4
- package/dist/view/index.js +9 -3
- package/dist/view/index.js.map +1 -1
- package/dist/view/loc.d.ts +9 -0
- package/dist/view/loc.js +50 -0
- package/dist/view/loc.js.map +1 -0
- package/dist/view/message.d.ts +17 -0
- package/dist/view/message.js +40 -0
- package/dist/view/message.js.map +1 -0
- package/dist/view/messages.d.ts +43 -0
- package/dist/view/messages.js +84 -0
- package/dist/view/messages.js.map +1 -0
- package/dist/view/normalize/check.d.ts +5 -2
- package/dist/view/normalize/check.js +6 -3
- package/dist/view/normalize/check.js.map +1 -1
- package/dist/view/normalize/emit.d.ts +2 -0
- package/dist/view/normalize/emit.js +40 -11
- package/dist/view/normalize/emit.js.map +1 -1
- package/dist/view/normalize/errors.js +1 -1
- package/dist/view/normalize/errors.js.map +1 -1
- package/dist/view/normalize/id.d.ts +5 -2
- package/dist/view/normalize/id.js +9 -2
- package/dist/view/normalize/id.js.map +1 -1
- package/dist/view/normalize/normalize.d.ts +7 -4
- package/dist/view/normalize/normalize.js +15 -9
- package/dist/view/normalize/normalize.js.map +1 -1
- package/dist/view/normalize/rules.d.ts +9 -0
- package/dist/view/normalize/rules.js +35 -5
- package/dist/view/normalize/rules.js.map +1 -1
- package/dist/view/normalize/svg.d.ts +14 -0
- package/dist/view/normalize/svg.js +40 -0
- package/dist/view/normalize/svg.js.map +1 -0
- package/dist/view/normalize/tokens.d.ts +5 -1
- package/dist/view/normalize/tokens.js.map +1 -1
- package/dist/view/normalize/tree.d.ts +8 -2
- package/dist/view/normalize/tree.js +58 -13
- package/dist/view/normalize/tree.js.map +1 -1
- package/dist/view/normalize/types.d.ts +24 -4
- package/dist/view/normalize/types.js +4 -1
- package/dist/view/normalize/types.js.map +1 -1
- package/dist/view/normalize/whitespace.js +4 -3
- package/dist/view/normalize/whitespace.js.map +1 -1
- package/dist/view/prepare-stub.js +2 -2
- package/dist/view/prepare-stub.js.map +1 -1
- package/dist/view/prepare.d.ts +5 -2
- package/dist/view/prepare.js +9 -3
- package/dist/view/prepare.js.map +1 -1
- package/dist/view/registry.d.ts +15 -0
- package/dist/view/registry.js +10 -2
- package/dist/view/registry.js.map +1 -1
- package/dist/view/render/adopt-attr.js +7 -1
- package/dist/view/render/adopt-attr.js.map +1 -1
- package/dist/view/render/adopt.js +21 -4
- package/dist/view/render/adopt.js.map +1 -1
- package/dist/view/render/attr-parts.d.ts +12 -1
- package/dist/view/render/attr-parts.js +38 -5
- package/dist/view/render/attr-parts.js.map +1 -1
- package/dist/view/render/child-part.js +5 -2
- package/dist/view/render/child-part.js.map +1 -1
- package/dist/view/render/dispose.d.ts +3 -0
- package/dist/view/render/dispose.js +76 -0
- package/dist/view/render/dispose.js.map +1 -0
- package/dist/view/render/hook-part.d.ts +3 -1
- package/dist/view/render/hook-part.js +21 -5
- package/dist/view/render/hook-part.js.map +1 -1
- package/dist/view/render/hook-run.d.ts +2 -0
- package/dist/view/render/hook-run.js +72 -0
- package/dist/view/render/hook-run.js.map +1 -0
- package/dist/view/render/hooks.d.ts +73 -5
- package/dist/view/render/hooks.js +79 -17
- package/dist/view/render/hooks.js.map +1 -1
- package/dist/view/render/hydrate.js +2 -2
- package/dist/view/render/hydrate.js.map +1 -1
- package/dist/view/render/keyed.d.ts +6 -0
- package/dist/view/render/keyed.js +15 -0
- package/dist/view/render/keyed.js.map +1 -0
- package/dist/view/render/mismatch.js +5 -3
- package/dist/view/render/mismatch.js.map +1 -1
- package/dist/view/render/modal.d.ts +3 -0
- package/dist/view/render/modal.js +39 -0
- package/dist/view/render/modal.js.map +1 -0
- package/dist/view/render/raw.d.ts +4 -1
- package/dist/view/render/raw.js +6 -2
- package/dist/view/render/raw.js.map +1 -1
- package/dist/view/render/render.d.ts +4 -1
- package/dist/view/render/render.js +17 -12
- package/dist/view/render/render.js.map +1 -1
- package/dist/view/render/toggles.d.ts +6 -0
- package/dist/view/render/toggles.js +6 -0
- package/dist/view/render/toggles.js.map +1 -0
- package/dist/view/render/warn.d.ts +14 -0
- package/dist/view/render/warn.js +45 -0
- package/dist/view/render/warn.js.map +1 -1
- package/dist/view/server/component.d.ts +6 -3
- package/dist/view/server/component.js +29 -7
- package/dist/view/server/component.js.map +1 -1
- package/dist/view/server/index.d.ts +20 -0
- package/dist/view/server/index.js +12 -4
- package/dist/view/server/index.js.map +1 -1
- package/dist/view/server/tags.d.ts +13 -2
- package/dist/view/server/tags.js +13 -2
- package/dist/view/server/tags.js.map +1 -1
- package/dist/view/server/values.d.ts +2 -0
- package/dist/view/server/values.js +8 -0
- package/dist/view/server/values.js.map +1 -1
- package/dist/view/server/writer.d.ts +9 -4
- package/dist/view/server/writer.js +33 -18
- package/dist/view/server/writer.js.map +1 -1
- package/dist/view/template-element.d.ts +7 -0
- package/dist/view/template-element.js +25 -5
- package/dist/view/template-element.js.map +1 -1
- package/dist/view/template.d.ts +10 -2
- package/dist/view/template.js +62 -8
- package/dist/view/template.js.map +1 -1
- package/dist/view/trusted-html.d.ts +6 -0
- package/dist/view/trusted-html.js +34 -0
- package/dist/view/trusted-html.js.map +1 -0
- package/dist/vite.d.ts +38 -31
- package/dist/vite.js +198 -31
- package/dist/vite.js.map +1 -1
- package/package.json +34 -3
- package/dist/hooks/labelled-by.d.ts +0 -13
- package/dist/hooks/labelled-by.js +0 -51
- package/dist/hooks/labelled-by.js.map +0 -1
- package/dist/random.d.ts +0 -13
- package/dist/random.js +0 -19
- package/dist/random.js.map +0 -1
package/dist/forms.js
CHANGED
|
@@ -1,12 +1,91 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { DEV } from '#view-dev';
|
|
2
|
+
// Every secret name a definition declared, for redacting previews (the devtools timeline).
|
|
3
|
+
const declaredSecrets = /* @__PURE__ */ new Set();
|
|
4
|
+
const DEFAULT_SECRET = /password|passcode|csrf|xsrf/i;
|
|
5
|
+
/** Whether `name` is never echoed: declared secret by a form, or a password-like name. */
|
|
6
|
+
export function isSecretField(name) {
|
|
7
|
+
return DEFAULT_SECRET.test(name) || declaredSecrets.has(name);
|
|
8
|
+
}
|
|
9
|
+
// Words that suggest a secret; an unlisted field named with one gets a development warning.
|
|
10
|
+
const SUSPECT = /* @__PURE__ */ new Set([
|
|
11
|
+
'password',
|
|
12
|
+
'passcode',
|
|
13
|
+
'token',
|
|
14
|
+
'secret',
|
|
15
|
+
'card',
|
|
16
|
+
'cvc',
|
|
17
|
+
'cvv',
|
|
18
|
+
'ssn',
|
|
19
|
+
'pin',
|
|
20
|
+
]);
|
|
21
|
+
const warned = /* @__PURE__ */ new Set();
|
|
22
|
+
function warnUnmarked(key) {
|
|
23
|
+
if (warned.has(key))
|
|
24
|
+
return;
|
|
25
|
+
const words = key
|
|
26
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
27
|
+
.toLowerCase()
|
|
28
|
+
.split(/[^a-z0-9]+/);
|
|
29
|
+
if (!words.some((w) => SUSPECT.has(w)))
|
|
30
|
+
return;
|
|
31
|
+
warned.add(key);
|
|
32
|
+
console.warn(`Gyral: form field "${key}" looks secret but isn't listed in defineForm(schema, { secret }); ` +
|
|
33
|
+
'its value is echoed back after a rejection.');
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The text values of a parsed submission, for re-filling the form after a rejection: Files and
|
|
37
|
+
* the fields `secret` names (or that look like passwords) are left out.
|
|
38
|
+
*/
|
|
39
|
+
function stickyValues(data, secret) {
|
|
40
|
+
const out = {};
|
|
41
|
+
for (const [key, value] of Object.entries(data)) {
|
|
42
|
+
if (DEFAULT_SECRET.test(key) || secret.includes(key))
|
|
43
|
+
continue;
|
|
44
|
+
if (DEV)
|
|
45
|
+
warnUnmarked(key);
|
|
46
|
+
if (typeof value === 'string')
|
|
47
|
+
out[key] = value;
|
|
48
|
+
else if (Array.isArray(value))
|
|
49
|
+
out[key] = value.filter((v) => typeof v === 'string');
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
function validate(schema, secret, intent, data) {
|
|
54
|
+
const raw = formDataToObject(data);
|
|
55
|
+
const settle = (result) => result.issues === undefined
|
|
56
|
+
? { ok: true, data: result.value }
|
|
57
|
+
: {
|
|
58
|
+
ok: false,
|
|
59
|
+
rejected: {
|
|
60
|
+
_tag: 'IntentRejected',
|
|
61
|
+
intent,
|
|
62
|
+
issues: toIssues(result.issues, ''),
|
|
63
|
+
values: stickyValues(raw, secret),
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
const result = schema['~standard'].validate(raw);
|
|
67
|
+
return result instanceof Promise ? result.then(settle) : settle(result);
|
|
68
|
+
}
|
|
69
|
+
export function defineForm(schema, options = {}) {
|
|
70
|
+
const secret = options.secret ?? [];
|
|
71
|
+
// Only the devtools timeline reads these, and it only runs in development.
|
|
72
|
+
if (DEV)
|
|
73
|
+
for (const name of secret)
|
|
74
|
+
declaredSecrets.add(name);
|
|
75
|
+
return {
|
|
76
|
+
_tag: 'FormDefinition',
|
|
77
|
+
schema,
|
|
78
|
+
secret,
|
|
79
|
+
validate: (data, intent = '') => validate(schema, secret, intent, data),
|
|
80
|
+
values: (data) => stickyValues(formDataToObject(data), secret),
|
|
81
|
+
};
|
|
3
82
|
}
|
|
4
83
|
/**
|
|
5
84
|
* FormData to a plain object. The same function runs on the server, so both sides agree.
|
|
6
85
|
* Repeated names become arrays; a `name[]` suffix always yields an array (named `name`).
|
|
7
86
|
* Values stay strings (or Files): the schema coerces them.
|
|
8
87
|
*/
|
|
9
|
-
|
|
88
|
+
function formDataToObject(data) {
|
|
10
89
|
const out = {};
|
|
11
90
|
for (const key of new Set(data.keys())) {
|
|
12
91
|
const values = data.getAll(key);
|
|
@@ -34,39 +113,10 @@ function parse(schema, value, intent, fallbackPath, toMsg) {
|
|
|
34
113
|
const result = schema['~standard'].validate(value);
|
|
35
114
|
return result instanceof Promise ? result.then(settle) : settle(result);
|
|
36
115
|
}
|
|
37
|
-
/** The text fields of a parsed form, for re-filling it after a rejection. */
|
|
38
|
-
export function formFields(data) {
|
|
39
|
-
const out = {};
|
|
40
|
-
for (const [key, value] of Object.entries(data)) {
|
|
41
|
-
if (typeof value === 'string')
|
|
42
|
-
out[key] = value;
|
|
43
|
-
else if (Array.isArray(value))
|
|
44
|
-
out[key] = value.filter((v) => typeof v === 'string');
|
|
45
|
-
}
|
|
46
|
-
return out;
|
|
47
|
-
}
|
|
48
|
-
const schemaOf = (definition) => ('_tag' in definition ? definition.schema : definition);
|
|
49
|
-
/**
|
|
50
|
-
* Validates submitted form data exactly as `form()` does. The no-JS server path
|
|
51
|
-
* (`formAction` in @gyral/ssr) uses it, so both paths produce the same `IntentRejected`.
|
|
52
|
-
*/
|
|
53
|
-
export function validateForm(definition, intent, data) {
|
|
54
|
-
const raw = formDataToObject(data);
|
|
55
|
-
const values = formFields(raw);
|
|
56
|
-
const rejected = (issues) => ({
|
|
57
|
-
ok: false,
|
|
58
|
-
rejected: { _tag: 'IntentRejected', intent, issues, values },
|
|
59
|
-
});
|
|
60
|
-
const settle = (result) => result.issues === undefined
|
|
61
|
-
? { ok: true, data: result.value }
|
|
62
|
-
: rejected(toIssues(result.issues, ''));
|
|
63
|
-
const result = schemaOf(definition)['~standard'].validate(raw);
|
|
64
|
-
return result instanceof Promise ? result.then(settle) : settle(result);
|
|
65
|
-
}
|
|
66
116
|
/**
|
|
67
117
|
* Intent parser for a `<form data-intent>` submission, validated by a schema. `toMsg` also
|
|
68
118
|
* gets the raw `FormData`, for sending the submission to the server (`submitForm` in
|
|
69
|
-
*
|
|
119
|
+
* `@gyral/http`) after it passed client-side validation.
|
|
70
120
|
*/
|
|
71
121
|
export function form(definition, toMsg) {
|
|
72
122
|
return (input) => {
|
|
@@ -74,7 +124,7 @@ export function form(definition, toMsg) {
|
|
|
74
124
|
if (formData === undefined)
|
|
75
125
|
return undefined;
|
|
76
126
|
const toParsed = (r) => r.ok ? toMsg(r.data, formData) : r.rejected;
|
|
77
|
-
const result =
|
|
127
|
+
const result = ('_tag' in definition ? definition : defineForm(definition)).validate(formData, input.name);
|
|
78
128
|
return result instanceof Promise ? result.then(toParsed) : toParsed(result);
|
|
79
129
|
};
|
|
80
130
|
}
|
|
@@ -104,7 +154,7 @@ const isFields = (value) => isRecord(value) &&
|
|
|
104
154
|
Object.values(value).every((v) => typeof v === 'string' || (Array.isArray(v) && v.every((x) => typeof x === 'string')));
|
|
105
155
|
/**
|
|
106
156
|
* A Standard Schema for `IntentRejected` as a server sends it (`formAction` answering JSON).
|
|
107
|
-
* Used by `submitForm` in
|
|
157
|
+
* Used by `submitForm` in `@gyral/http` to recognise a 422 rejection; usable with any decoder.
|
|
108
158
|
*/
|
|
109
159
|
export const intentRejectedSchema = {
|
|
110
160
|
'~standard': {
|
|
@@ -129,10 +179,4 @@ export const intentRejectedSchema = {
|
|
|
129
179
|
},
|
|
130
180
|
},
|
|
131
181
|
};
|
|
132
|
-
/** The `location` of a `FormRedirected` answer (from `formAction` in @gyral/ssr), if it is one. */
|
|
133
|
-
export function redirectedTo(body) {
|
|
134
|
-
return isRecord(body) && body['_tag'] === 'Redirected' && typeof body['location'] === 'string'
|
|
135
|
-
? body['location']
|
|
136
|
-
: undefined;
|
|
137
|
-
}
|
|
138
182
|
//# sourceMappingURL=forms.js.map
|
package/dist/forms.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"forms.js","sourceRoot":"","sources":["../src/forms.ts"],"names":[],"mappings":"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","sourcesContent":["// Forms, client half (docs/design-docs/0008-forms.md). Schemas are any Standard Schema v1.\nimport type { StandardSchemaV1 } from '@standard-schema/spec';\nimport type { FieldIssue, FormFields, IntentInput, IntentParser, IntentRejected } from './types.js';\n\n/** A form's schema, shared by the client component and (with @gyral/ssr) the server route. */\nexport interface FormDefinition<Schema extends StandardSchemaV1> {\n readonly _tag: 'FormDefinition';\n readonly schema: Schema;\n}\n\nexport function defineForm<Schema extends StandardSchemaV1>(\n schema: Schema,\n): FormDefinition<Schema> {\n return { _tag: 'FormDefinition', schema };\n}\n\nexport type FormValue = FormDataEntryValue | readonly FormDataEntryValue[];\n\n/**\n * FormData to a plain object. The same function runs on the server, so both sides agree.\n * Repeated names become arrays; a `name[]` suffix always yields an array (named `name`).\n * Values stay strings (or Files): the schema coerces them.\n */\nexport function formDataToObject(data: FormData): Readonly<Record<string, FormValue>> {\n const out: Record<string, FormValue> = {};\n for (const key of new Set(data.keys())) {\n const values = data.getAll(key);\n const [first] = values;\n if (key.endsWith('[]')) out[key.slice(0, -2)] = values;\n else if (values.length === 1 && first !== undefined) out[key] = first;\n else out[key] = values;\n }\n return out;\n}\n\nconst segment = (s: PropertyKey | StandardSchemaV1.PathSegment): string =>\n String(typeof s === 'object' ? s.key : s);\n\nfunction toIssues(\n issues: ReadonlyArray<StandardSchemaV1.Issue>,\n fallbackPath: string,\n): FieldIssue[] {\n return issues.map((issue) => {\n const path = issue.path?.map(segment).join('.') ?? '';\n return { path: path === '' ? fallbackPath : path, message: issue.message };\n });\n}\n\ntype Out<Schema extends StandardSchemaV1> = StandardSchemaV1.InferOutput<Schema>;\ntype Parsed<M> = M | IntentRejected | undefined;\n\nfunction parse<Schema extends StandardSchemaV1, M>(\n schema: Schema,\n value: unknown,\n intent: string,\n fallbackPath: string,\n toMsg: (data: Out<Schema>) => M | undefined,\n): Parsed<M> | Promise<Parsed<M>> {\n const settle = (result: StandardSchemaV1.Result<Out<Schema>>): Parsed<M> =>\n result.issues === undefined\n ? toMsg(result.value)\n : { _tag: 'IntentRejected', intent, issues: toIssues(result.issues, fallbackPath) };\n const result = schema['~standard'].validate(value);\n return result instanceof Promise ? result.then(settle) : settle(result);\n}\n\n/** The text fields of a parsed form, for re-filling it after a rejection. */\nexport function formFields(data: Readonly<Record<string, FormValue>>): FormFields {\n const out: Record<string, string | readonly string[]> = {};\n for (const [key, value] of Object.entries(data)) {\n if (typeof value === 'string') out[key] = value;\n else if (Array.isArray(value)) out[key] = value.filter((v) => typeof v === 'string');\n }\n return out;\n}\n\nconst schemaOf = <Schema extends StandardSchemaV1>(\n definition: FormDefinition<Schema> | Schema,\n): Schema => ('_tag' in definition ? definition.schema : definition);\n\nexport type FormResult<T> =\n | { readonly ok: true; readonly data: T }\n | { readonly ok: false; readonly rejected: IntentRejected };\n\n/**\n * Validates submitted form data exactly as `form()` does. The no-JS server path\n * (`formAction` in @gyral/ssr) uses it, so both paths produce the same `IntentRejected`.\n */\nexport function validateForm<Schema extends StandardSchemaV1>(\n definition: FormDefinition<Schema> | Schema,\n intent: string,\n data: FormData,\n): FormResult<Out<Schema>> | Promise<FormResult<Out<Schema>>> {\n const raw = formDataToObject(data);\n const values = formFields(raw);\n const rejected = (issues: readonly FieldIssue[]): FormResult<Out<Schema>> => ({\n ok: false,\n rejected: { _tag: 'IntentRejected', intent, issues, values },\n });\n const settle = (result: StandardSchemaV1.Result<Out<Schema>>): FormResult<Out<Schema>> =>\n result.issues === undefined\n ? { ok: true, data: result.value }\n : rejected(toIssues(result.issues, ''));\n const result = schemaOf(definition)['~standard'].validate(raw);\n return result instanceof Promise ? result.then(settle) : settle(result);\n}\n\n/**\n * Intent parser for a `<form data-intent>` submission, validated by a schema. `toMsg` also\n * gets the raw `FormData`, for sending the submission to the server (`submitForm` in\n * @gyral/http) after it passed client-side validation.\n */\nexport function form<Schema extends StandardSchemaV1, M>(\n definition: FormDefinition<Schema> | Schema,\n toMsg: (data: Out<Schema>, formData: FormData) => M | undefined,\n): IntentParser<M> {\n return (input: IntentInput) => {\n const { formData } = input;\n if (formData === undefined) return undefined;\n const toParsed = (r: FormResult<Out<Schema>>): Parsed<M> =>\n r.ok ? toMsg(r.data, formData) : r.rejected;\n const result = validateForm(definition, input.name, formData);\n return result instanceof Promise ? result.then(toParsed) : toParsed(result);\n };\n}\n\n/**\n * Intent parser for one control (pair with `data-intent-on=\"input\"` for live checks).\n * Validates `checked` for checkboxes and radios, otherwise `value`.\n */\nexport function field<Schema extends StandardSchemaV1, M>(\n schema: Schema,\n toMsg: (value: Out<Schema>) => M | undefined,\n): IntentParser<M> {\n return (input: IntentInput) => {\n const name = input.target.getAttribute('name') ?? input.name;\n return parse(schema, input.checked ?? input.value, input.name, name, toMsg);\n };\n}\n\n/**\n * Groups issues by field name. Call it in the `IntentRejected` reducer and keep the result in\n * state: `invalid()` re-applies an error only when it gets a new array.\n */\nexport function fieldErrors(\n issues: readonly FieldIssue[],\n): Readonly<Record<string, readonly string[]>> {\n const out: Record<string, string[]> = {};\n for (const { path, message } of issues) (out[path] ??= []).push(message);\n return out;\n}\n\nconst isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>\n typeof value === 'object' && value !== null;\n\nconst isIssue = (value: unknown): value is FieldIssue =>\n isRecord(value) && typeof value['path'] === 'string' && typeof value['message'] === 'string';\n\nconst isFields = (value: unknown): value is FormFields =>\n isRecord(value) &&\n Object.values(value).every(\n (v) => typeof v === 'string' || (Array.isArray(v) && v.every((x) => typeof x === 'string')),\n );\n\n/**\n * A Standard Schema for `IntentRejected` as a server sends it (`formAction` answering JSON).\n * Used by `submitForm` in @gyral/http to recognise a 422 rejection; usable with any decoder.\n */\nexport const intentRejectedSchema: StandardSchemaV1<unknown, IntentRejected> = {\n '~standard': {\n version: 1,\n vendor: 'gyral',\n validate: (value) => {\n if (\n !isRecord(value) ||\n value['_tag'] !== 'IntentRejected' ||\n typeof value['intent'] !== 'string' ||\n !Array.isArray(value['issues']) ||\n !value['issues'].every(isIssue) ||\n (value['values'] !== undefined && !isFields(value['values']))\n ) {\n return { issues: [{ message: 'Expected an IntentRejected message' }] };\n }\n const rejected: IntentRejected = {\n _tag: 'IntentRejected',\n intent: value['intent'],\n issues: value['issues'],\n ...(value['values'] === undefined ? {} : { values: value['values'] }),\n };\n return { value: rejected };\n },\n },\n};\n\n/** A server's JSON answer to a valid form post that would have redirected without JS. */\nexport interface FormRedirected {\n readonly _tag: 'Redirected';\n readonly location: string;\n}\n\n/** The `location` of a `FormRedirected` answer (from `formAction` in @gyral/ssr), if it is one. */\nexport function redirectedTo(body: unknown): string | undefined {\n return isRecord(body) && body['_tag'] === 'Redirected' && typeof body['location'] === 'string'\n ? body['location']\n : undefined;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"forms.js","sourceRoot":"","sources":["../src/forms.ts"],"names":[],"mappings":"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","sourcesContent":["// Forms, client half (docs/design-docs/0008-forms.md). Schemas are any Standard Schema v1.\nimport type { StandardSchemaV1 } from '@standard-schema/spec';\nimport { DEV } from '#view-dev';\nimport type { FieldIssue, FormFields, IntentInput, IntentParser, IntentRejected } from './types.js';\n\ntype In<Schema extends StandardSchemaV1> = StandardSchemaV1.InferInput<Schema>;\ntype Out<Schema extends StandardSchemaV1> = StandardSchemaV1.InferOutput<Schema>;\n\n/** The definition's field names, when its input is an object; otherwise any string. */\nexport type FieldName<Schema extends StandardSchemaV1> =\n In<Schema> extends Readonly<Record<string, unknown>> ? keyof In<Schema> & string : string;\n\nexport interface FormOptions<Schema extends StandardSchemaV1> {\n /**\n * Fields whose submitted values are never sent back: not in `IntentRejected.values`, not in\n * `values()`, not in the devtools timeline. Any posted field name may be listed, including\n * fields outside the schema. Names containing `password`, `passcode`, `csrf` or `xsrf` are\n * secret without being listed (the browser never restores a password field either, and a\n * CSRF token must come fresh from the server, not from the echo).\n */\n readonly secret?: readonly (FieldName<Schema> | (string & {}))[];\n}\n\n/**\n * A form's submitted text values, keyed by the definition's fields, for re-rendering the form\n * with what was typed (sticky fields). The same shape as `IntentRejected.values`: repeated names\n * (and `name[]`) are arrays; `File`s and secret fields are left out.\n */\nexport type FormValues<Schema extends StandardSchemaV1> =\n In<Schema> extends Readonly<Record<string, unknown>>\n ? { readonly [K in keyof In<Schema> & string]?: string | readonly string[] }\n : FormFields;\n\nexport type FormResult<T> =\n | { readonly ok: true; readonly data: T }\n | { readonly ok: false; readonly rejected: IntentRejected };\n\n/** A form's schema, shared by the client component and (with `@gyral/ssr`) the server route. */\nexport interface FormDefinition<Schema extends StandardSchemaV1> {\n readonly _tag: 'FormDefinition';\n readonly schema: Schema;\n /** The fields listed as secret (names containing `password`, `passcode`, `csrf` or `xsrf` are secret too). */\n readonly secret: readonly string[];\n /**\n * Validates submitted form data exactly as `form()` does; the server's `formAction` uses it,\n * so both paths produce the same `IntentRejected` (`intent` defaults to `''`).\n */\n readonly validate: (\n data: FormData,\n intent?: string,\n ) => FormResult<Out<Schema>> | Promise<FormResult<Out<Schema>>>;\n /** The submitted text values without secrets: what a re-rendered form may show again. */\n readonly values: (data: FormData) => FormValues<Schema>;\n}\n\n// Every secret name a definition declared, for redacting previews (the devtools timeline).\nconst declaredSecrets = /* @__PURE__ */ new Set<string>();\n\nconst DEFAULT_SECRET = /password|passcode|csrf|xsrf/i;\n\n/** Whether `name` is never echoed: declared secret by a form, or a password-like name. */\nexport function isSecretField(name: string): boolean {\n return DEFAULT_SECRET.test(name) || declaredSecrets.has(name);\n}\n\n// Words that suggest a secret; an unlisted field named with one gets a development warning.\nconst SUSPECT = /* @__PURE__ */ new Set([\n 'password',\n 'passcode',\n 'token',\n 'secret',\n 'card',\n 'cvc',\n 'cvv',\n 'ssn',\n 'pin',\n]);\nconst warned = /* @__PURE__ */ new Set<string>();\n\nfunction warnUnmarked(key: string): void {\n if (warned.has(key)) return;\n const words = key\n .replace(/([a-z0-9])([A-Z])/g, '$1 $2')\n .toLowerCase()\n .split(/[^a-z0-9]+/);\n if (!words.some((w) => SUSPECT.has(w))) return;\n warned.add(key);\n console.warn(\n `Gyral: form field \"${key}\" looks secret but isn't listed in defineForm(schema, { secret }); ` +\n 'its value is echoed back after a rejection.',\n );\n}\n\n/**\n * The text values of a parsed submission, for re-filling the form after a rejection: Files and\n * the fields `secret` names (or that look like passwords) are left out.\n */\nfunction stickyValues(\n data: Readonly<Record<string, FormValue>>,\n secret: readonly string[],\n): FormFields {\n const out: Record<string, string | readonly string[]> = {};\n for (const [key, value] of Object.entries(data)) {\n if (DEFAULT_SECRET.test(key) || secret.includes(key)) continue;\n if (DEV) warnUnmarked(key);\n if (typeof value === 'string') out[key] = value;\n else if (Array.isArray(value)) out[key] = value.filter((v) => typeof v === 'string');\n }\n return out;\n}\n\nfunction validate<Schema extends StandardSchemaV1>(\n schema: Schema,\n secret: readonly string[],\n intent: string,\n data: FormData,\n): FormResult<Out<Schema>> | Promise<FormResult<Out<Schema>>> {\n const raw = formDataToObject(data);\n const settle = (result: StandardSchemaV1.Result<Out<Schema>>): FormResult<Out<Schema>> =>\n result.issues === undefined\n ? { ok: true, data: result.value }\n : {\n ok: false,\n rejected: {\n _tag: 'IntentRejected',\n intent,\n issues: toIssues(result.issues, ''),\n values: stickyValues(raw, secret),\n },\n };\n const result = schema['~standard'].validate(raw);\n return result instanceof Promise ? result.then(settle) : settle(result);\n}\n\nexport function defineForm<Schema extends StandardSchemaV1>(\n schema: Schema,\n options: FormOptions<Schema> = {},\n): FormDefinition<Schema> {\n const secret: readonly string[] = options.secret ?? [];\n // Only the devtools timeline reads these, and it only runs in development.\n if (DEV) for (const name of secret) declaredSecrets.add(name);\n return {\n _tag: 'FormDefinition',\n schema,\n secret,\n validate: (data, intent = '') => validate(schema, secret, intent, data),\n values: (data) => stickyValues(formDataToObject(data), secret) as FormValues<Schema>,\n };\n}\n\nexport type FormValue = FormDataEntryValue | readonly FormDataEntryValue[];\n\n/**\n * FormData to a plain object. The same function runs on the server, so both sides agree.\n * Repeated names become arrays; a `name[]` suffix always yields an array (named `name`).\n * Values stay strings (or Files): the schema coerces them.\n */\nfunction formDataToObject(data: FormData): Readonly<Record<string, FormValue>> {\n const out: Record<string, FormValue> = {};\n for (const key of new Set(data.keys())) {\n const values = data.getAll(key);\n const [first] = values;\n if (key.endsWith('[]')) out[key.slice(0, -2)] = values;\n else if (values.length === 1 && first !== undefined) out[key] = first;\n else out[key] = values;\n }\n return out;\n}\n\nconst segment = (s: PropertyKey | StandardSchemaV1.PathSegment): string =>\n String(typeof s === 'object' ? s.key : s);\n\nfunction toIssues(\n issues: ReadonlyArray<StandardSchemaV1.Issue>,\n fallbackPath: string,\n): FieldIssue[] {\n return issues.map((issue) => {\n const path = issue.path?.map(segment).join('.') ?? '';\n return { path: path === '' ? fallbackPath : path, message: issue.message };\n });\n}\n\ntype Parsed<M> = M | IntentRejected | undefined;\n\nfunction parse<Schema extends StandardSchemaV1, M>(\n schema: Schema,\n value: unknown,\n intent: string,\n fallbackPath: string,\n toMsg: (data: Out<Schema>) => M | undefined,\n): Parsed<M> | Promise<Parsed<M>> {\n const settle = (result: StandardSchemaV1.Result<Out<Schema>>): Parsed<M> =>\n result.issues === undefined\n ? toMsg(result.value)\n : { _tag: 'IntentRejected', intent, issues: toIssues(result.issues, fallbackPath) };\n const result = schema['~standard'].validate(value);\n return result instanceof Promise ? result.then(settle) : settle(result);\n}\n\n/**\n * Intent parser for a `<form data-intent>` submission, validated by a schema. `toMsg` also\n * gets the raw `FormData`, for sending the submission to the server (`submitForm` in\n * `@gyral/http`) after it passed client-side validation.\n */\nexport function form<Schema extends StandardSchemaV1, M>(\n definition: FormDefinition<Schema> | Schema,\n toMsg: (data: Out<Schema>, formData: FormData) => M | undefined,\n): (input: IntentInput) => ReturnType<IntentParser<M>> {\n return (input: IntentInput) => {\n const { formData } = input;\n if (formData === undefined) return undefined;\n const toParsed = (r: FormResult<Out<Schema>>): Parsed<M> =>\n r.ok ? toMsg(r.data, formData) : r.rejected;\n const result = ('_tag' in definition ? definition : defineForm(definition)).validate(\n formData,\n input.name,\n );\n return result instanceof Promise ? result.then(toParsed) : toParsed(result);\n };\n}\n\n/**\n * Intent parser for one control (pair with `data-intent-on=\"input\"` for live checks).\n * Validates `checked` for checkboxes and radios, otherwise `value`.\n */\nexport function field<Schema extends StandardSchemaV1, M>(\n schema: Schema,\n toMsg: (value: Out<Schema>) => M | undefined,\n): (input: IntentInput) => ReturnType<IntentParser<M>> {\n return (input: IntentInput) => {\n const name = input.target.getAttribute('name') ?? input.name;\n return parse(schema, input.checked ?? input.value, input.name, name, toMsg);\n };\n}\n\n/**\n * Groups issues by field name. Call it in the `IntentRejected` reducer and keep the result in\n * state: `invalid()` re-applies an error only when it gets a new array.\n */\nexport function fieldErrors(\n issues: readonly FieldIssue[],\n): Readonly<Record<string, readonly string[]>> {\n const out: Record<string, string[]> = {};\n for (const { path, message } of issues) (out[path] ??= []).push(message);\n return out;\n}\n\nconst isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>\n typeof value === 'object' && value !== null;\n\nconst isIssue = (value: unknown): value is FieldIssue =>\n isRecord(value) && typeof value['path'] === 'string' && typeof value['message'] === 'string';\n\nconst isFields = (value: unknown): value is FormFields =>\n isRecord(value) &&\n Object.values(value).every(\n (v) => typeof v === 'string' || (Array.isArray(v) && v.every((x) => typeof x === 'string')),\n );\n\n/**\n * A Standard Schema for `IntentRejected` as a server sends it (`formAction` answering JSON).\n * Used by `submitForm` in `@gyral/http` to recognise a 422 rejection; usable with any decoder.\n */\nexport const intentRejectedSchema: StandardSchemaV1<unknown, IntentRejected> = {\n '~standard': {\n version: 1,\n vendor: 'gyral',\n validate: (value) => {\n if (\n !isRecord(value) ||\n value['_tag'] !== 'IntentRejected' ||\n typeof value['intent'] !== 'string' ||\n !Array.isArray(value['issues']) ||\n !value['issues'].every(isIssue) ||\n (value['values'] !== undefined && !isFields(value['values']))\n ) {\n return { issues: [{ message: 'Expected an IntentRejected message' }] };\n }\n const rejected: IntentRejected = {\n _tag: 'IntentRejected',\n intent: value['intent'],\n issues: value['issues'],\n ...(value['values'] === undefined ? {} : { values: value['values'] }),\n };\n return { value: rejected };\n },\n },\n};\n"]}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { HostTask } from './scheduler.js';
|
|
2
|
+
/** The frame lane as the scheduler uses it. */
|
|
3
|
+
export interface FrameLane {
|
|
4
|
+
/** Marks `task` for the next frame; then `mark` moves each waiting host to a microtask flush. */
|
|
5
|
+
add(task: HostTask, mark: (task: HostTask) => void): void;
|
|
6
|
+
/** A microtask mark took `task` out of the frame lane. */
|
|
7
|
+
drop(task: HostTask): void;
|
|
8
|
+
/** Hosts are waiting for a frame (settled() waits for them). */
|
|
9
|
+
pending(): boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare const frameLane: FrameLane;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** A frame-lane flush that no animation frame has run by then runs from a timer (hidden pages). */
|
|
2
|
+
const FRAME_FALLBACK_MS = 100;
|
|
3
|
+
/** Hosts marked only in the frame lane: they join the scheduler's dirty set when the frame comes. */
|
|
4
|
+
const framed = new Set();
|
|
5
|
+
let frameRaf = 0;
|
|
6
|
+
/** Set while a frame-lane flush is requested. */
|
|
7
|
+
let frameTimer;
|
|
8
|
+
export const frameLane = {
|
|
9
|
+
add(task, mark) {
|
|
10
|
+
framed.add(task);
|
|
11
|
+
if (frameTimer !== undefined)
|
|
12
|
+
return;
|
|
13
|
+
const run = () => {
|
|
14
|
+
cancelAnimationFrame(frameRaf);
|
|
15
|
+
clearTimeout(frameTimer);
|
|
16
|
+
frameTimer = undefined;
|
|
17
|
+
for (const host of framed)
|
|
18
|
+
mark(host);
|
|
19
|
+
};
|
|
20
|
+
frameRaf = requestAnimationFrame(run);
|
|
21
|
+
frameTimer = setTimeout(run, FRAME_FALLBACK_MS);
|
|
22
|
+
},
|
|
23
|
+
drop(task) {
|
|
24
|
+
framed.delete(task);
|
|
25
|
+
},
|
|
26
|
+
pending: () => framed.size > 0,
|
|
27
|
+
};
|
|
28
|
+
//# sourceMappingURL=frame-lane.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"frame-lane.js","sourceRoot":"","sources":["../src/frame-lane.ts"],"names":[],"mappings":"AAQA,mGAAmG;AACnG,MAAM,iBAAiB,GAAG,GAAG,CAAC;AAE9B,qGAAqG;AACrG,MAAM,MAAM,GAAG,IAAI,GAAG,EAAY,CAAC;AACnC,IAAI,QAAQ,GAAG,CAAC,CAAC;AACjB,iDAAiD;AACjD,IAAI,UAAqD,CAAC;AAY1D,MAAM,CAAC,MAAM,SAAS,GAAc;IAClC,GAAG,CAAC,IAAI,EAAE,IAAI;QACZ,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACjB,IAAI,UAAU,KAAK,SAAS;YAAE,OAAO;QACrC,MAAM,GAAG,GAAG,GAAS,EAAE;YACrB,oBAAoB,CAAC,QAAQ,CAAC,CAAC;YAC/B,YAAY,CAAC,UAAU,CAAC,CAAC;YACzB,UAAU,GAAG,SAAS,CAAC;YACvB,KAAK,MAAM,IAAI,IAAI,MAAM;gBAAE,IAAI,CAAC,IAAI,CAAC,CAAC;QACxC,CAAC,CAAC;QACF,QAAQ,GAAG,qBAAqB,CAAC,GAAG,CAAC,CAAC;QACtC,UAAU,GAAG,UAAU,CAAC,GAAG,EAAE,iBAAiB,CAAC,CAAC;IAClD,CAAC;IACD,IAAI,CAAC,IAAI;QACP,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IACtB,CAAC;IACD,OAAO,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,IAAI,GAAG,CAAC;CAC/B,CAAC","sourcesContent":["// The scheduler's frame lane (view/04-scheduler.md \"Frame lane\"): hosts marked by a message\n// their spec lists in `renderOnFrame` wait for the next animation frame, then join a microtask\n// flush, so many such messages in one frame cost one render. `requestAnimationFrame` doesn't\n// run in hidden pages, so a timer runs the frame's flush if no frame came first. Reached\n// through core's `#spec-features` (spec-features.ts): compiled builds bundle it only when a\n// module names `renderOnFrame` (compiler/features.ts, gyral-c5d.12).\nimport type { HostTask } from './scheduler.js';\n\n/** A frame-lane flush that no animation frame has run by then runs from a timer (hidden pages). */\nconst FRAME_FALLBACK_MS = 100;\n\n/** Hosts marked only in the frame lane: they join the scheduler's dirty set when the frame comes. */\nconst framed = new Set<HostTask>();\nlet frameRaf = 0;\n/** Set while a frame-lane flush is requested. */\nlet frameTimer: ReturnType<typeof setTimeout> | undefined;\n\n/** The frame lane as the scheduler uses it. */\nexport interface FrameLane {\n /** Marks `task` for the next frame; then `mark` moves each waiting host to a microtask flush. */\n add(task: HostTask, mark: (task: HostTask) => void): void;\n /** A microtask mark took `task` out of the frame lane. */\n drop(task: HostTask): void;\n /** Hosts are waiting for a frame (settled() waits for them). */\n pending(): boolean;\n}\n\nexport const frameLane: FrameLane = {\n add(task, mark) {\n framed.add(task);\n if (frameTimer !== undefined) return;\n const run = (): void => {\n cancelAnimationFrame(frameRaf);\n clearTimeout(frameTimer);\n frameTimer = undefined;\n for (const host of framed) mark(host);\n };\n frameRaf = requestAnimationFrame(run);\n frameTimer = setTimeout(run, FRAME_FALLBACK_MS);\n },\n drop(task) {\n framed.delete(task);\n },\n pending: () => framed.size > 0,\n};\n"]}
|
package/dist/head.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** A JSON value: what a JSON-LD block holds. */
|
|
2
|
+
export type JsonValue = string | number | boolean | null | readonly JsonValue[] | {
|
|
3
|
+
readonly [key: string]: JsonValue;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* `<meta name content>`, or `<meta property content>` (Open Graph uses `property`). `media`
|
|
7
|
+
* makes one entry per media query (`theme-color` for light and dark).
|
|
8
|
+
*/
|
|
9
|
+
export type HeadMeta = {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
readonly content: string;
|
|
12
|
+
readonly media?: string;
|
|
13
|
+
} | {
|
|
14
|
+
readonly property: string;
|
|
15
|
+
readonly content: string;
|
|
16
|
+
readonly media?: string;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* A metadata `<link>`: `alternate` (with `hreflang`), `icon` (with `sizes`, `type`), `manifest`
|
|
20
|
+
* (with `crossorigin`), `prev`/`next`, … Any other attribute is written as given.
|
|
21
|
+
*/
|
|
22
|
+
export interface HeadLink {
|
|
23
|
+
readonly rel: string;
|
|
24
|
+
readonly href: string;
|
|
25
|
+
readonly [attribute: string]: string;
|
|
26
|
+
}
|
|
27
|
+
/** A page's head: what `renderPage()` writes on the server and `setHead()` applies on the client. */
|
|
28
|
+
export interface Head {
|
|
29
|
+
readonly title: string;
|
|
30
|
+
readonly description?: string;
|
|
31
|
+
/** Absolute URL, usually `new URL(match.path, origin).href` (ADR 0009 "Canonical paths"). */
|
|
32
|
+
readonly canonical?: string;
|
|
33
|
+
/** `<meta name="robots">`, e.g. `'noindex, follow'`. */
|
|
34
|
+
readonly robots?: string;
|
|
35
|
+
/** Later entries win over earlier ones with the same name or property. */
|
|
36
|
+
readonly meta?: readonly HeadMeta[];
|
|
37
|
+
/** Later entries win over earlier ones with the same attributes other than `href`. */
|
|
38
|
+
readonly links?: readonly HeadLink[];
|
|
39
|
+
/** Structured data, each written as one `<script type="application/ld+json">`. */
|
|
40
|
+
readonly jsonLd?: readonly JsonValue[];
|
|
41
|
+
/** `<html lang>`. */
|
|
42
|
+
readonly lang?: string;
|
|
43
|
+
/** `<html dir>`. */
|
|
44
|
+
readonly dir?: 'ltr' | 'rtl' | 'auto';
|
|
45
|
+
}
|
|
46
|
+
/** The attribute that marks an element the head model manages; its value is the entry's key. */
|
|
47
|
+
export declare const HEAD_ATTRIBUTE = "data-gyral-head";
|
|
48
|
+
/** One managed element: its key, tag, attributes (in order) and, for JSON-LD, its text. */
|
|
49
|
+
export interface HeadEntry {
|
|
50
|
+
readonly key: string;
|
|
51
|
+
readonly tag: 'meta' | 'link' | 'script';
|
|
52
|
+
readonly attributes: readonly (readonly [name: string, value: string])[];
|
|
53
|
+
readonly text?: string;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A head's managed elements, in document order and without duplicates: description, robots,
|
|
57
|
+
* canonical, `meta`, `links`, then JSON-LD. Later `meta`/`links` entries with the same key win
|
|
58
|
+
* (at the later position); the typed description and robots win over `meta` entries. The
|
|
59
|
+
* title, `lang` and `dir` are not elements here.
|
|
60
|
+
*/
|
|
61
|
+
export declare function headEntries(head: Head): readonly HeadEntry[];
|
package/dist/head.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The head model (ADR 0019): one description of a page's managed <head> elements, shared by the
|
|
2
|
+
// server's page shell (@gyral/ssr `renderPage()`) and the router's `setHead()`. Types and a pure
|
|
3
|
+
// normalizer only, no DOM, so both halves agree on keys, order and duplicates.
|
|
4
|
+
import { DEV } from '#view-dev';
|
|
5
|
+
import { scriptSafeJson } from './script-json.js';
|
|
6
|
+
/** The attribute that marks an element the head model manages; its value is the entry's key. */
|
|
7
|
+
export const HEAD_ATTRIBUTE = 'data-gyral-head';
|
|
8
|
+
// Belong to renderPage() itself: changing them per navigation would unstyle the page, refetch
|
|
9
|
+
// modules, or apply policy too late. Checked in development only.
|
|
10
|
+
const REFUSED_REL = /(^|\s)(stylesheet|preload|modulepreload|canonical)(\s|$)/i;
|
|
11
|
+
function check(head) {
|
|
12
|
+
const refuse = (what) => {
|
|
13
|
+
throw new Error(`gyral head: ${what} is not a managed head entry (ADR 0019 "Keys, dedupe and order"). ` +
|
|
14
|
+
'Write it with renderPage() options or extraHead.');
|
|
15
|
+
};
|
|
16
|
+
for (const m of head.meta ?? []) {
|
|
17
|
+
if ('http-equiv' in m || 'charset' in m)
|
|
18
|
+
refuse('<meta http-equiv> or <meta charset>');
|
|
19
|
+
if ('name' in m && /^viewport$/i.test(m.name))
|
|
20
|
+
refuse('<meta name="viewport">');
|
|
21
|
+
}
|
|
22
|
+
for (const link of head.links ?? []) {
|
|
23
|
+
if (REFUSED_REL.test(link.rel))
|
|
24
|
+
refuse(`<link rel="${link.rel}">`);
|
|
25
|
+
}
|
|
26
|
+
if (head.canonical !== undefined && !/^[a-z][a-z\d+.-]*:/i.test(head.canonical)) {
|
|
27
|
+
console.warn(`gyral head: canonical "${head.canonical}" is not an absolute URL. Build it from a ` +
|
|
28
|
+
'configured origin: new URL(match.path, origin).href (ADR 0019).');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A head's managed elements, in document order and without duplicates: description, robots,
|
|
33
|
+
* canonical, `meta`, `links`, then JSON-LD. Later `meta`/`links` entries with the same key win
|
|
34
|
+
* (at the later position); the typed description and robots win over `meta` entries. The
|
|
35
|
+
* title, `lang` and `dir` are not elements here.
|
|
36
|
+
*/
|
|
37
|
+
export function headEntries(head) {
|
|
38
|
+
if (DEV)
|
|
39
|
+
check(head);
|
|
40
|
+
const out = new Map();
|
|
41
|
+
const add = (key, tag, attributes, text) => {
|
|
42
|
+
out.delete(key);
|
|
43
|
+
out.set(key, text === undefined ? { key, tag, attributes } : { key, tag, attributes, text });
|
|
44
|
+
};
|
|
45
|
+
const meta = (attr, name, content, media) => {
|
|
46
|
+
add(media === undefined ? `${attr}:${name}` : `${attr}:${name}|${media}`, 'meta', [
|
|
47
|
+
[attr, name],
|
|
48
|
+
['content', content],
|
|
49
|
+
...(media === undefined ? [] : [['media', media]]),
|
|
50
|
+
]);
|
|
51
|
+
};
|
|
52
|
+
if (head.description !== undefined)
|
|
53
|
+
meta('name', 'description', head.description);
|
|
54
|
+
if (head.robots !== undefined)
|
|
55
|
+
meta('name', 'robots', head.robots);
|
|
56
|
+
if (head.canonical !== undefined) {
|
|
57
|
+
add('canonical', 'link', [
|
|
58
|
+
['rel', 'canonical'],
|
|
59
|
+
['href', head.canonical],
|
|
60
|
+
]);
|
|
61
|
+
}
|
|
62
|
+
const typed = new Set(out.keys());
|
|
63
|
+
for (const m of head.meta ?? []) {
|
|
64
|
+
const [attr, name] = 'name' in m ? ['name', m.name] : ['property', m.property];
|
|
65
|
+
if (!typed.has(`${attr}:${name}`))
|
|
66
|
+
meta(attr, name, m.content, m.media);
|
|
67
|
+
}
|
|
68
|
+
for (const link of head.links ?? []) {
|
|
69
|
+
const attributes = Object.entries(link);
|
|
70
|
+
const key = attributes
|
|
71
|
+
.filter(([name]) => name !== 'href')
|
|
72
|
+
.map(([name, value]) => `${name}=${value}`)
|
|
73
|
+
.sort()
|
|
74
|
+
.join(';');
|
|
75
|
+
add(`link:${key}`, 'link', [
|
|
76
|
+
['rel', link.rel],
|
|
77
|
+
...attributes.filter(([name]) => name !== 'rel'),
|
|
78
|
+
]);
|
|
79
|
+
}
|
|
80
|
+
head.jsonLd?.forEach((value, n) => {
|
|
81
|
+
add(`ld:${String(n)}`, 'script', [['type', 'application/ld+json']], scriptSafeJson(value));
|
|
82
|
+
});
|
|
83
|
+
return [...out.values()];
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=head.js.map
|
package/dist/head.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"head.js","sourceRoot":"","sources":["../src/head.ts"],"names":[],"mappings":"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","sourcesContent":["// The head model (ADR 0019): one description of a page's managed <head> elements, shared by the\n// server's page shell (@gyral/ssr `renderPage()`) and the router's `setHead()`. Types and a pure\n// normalizer only, no DOM, so both halves agree on keys, order and duplicates.\nimport { DEV } from '#view-dev';\nimport { scriptSafeJson } from './script-json.js';\n\n/** A JSON value: what a JSON-LD block holds. */\nexport type JsonValue =\n string | number | boolean | null | readonly JsonValue[] | { readonly [key: string]: JsonValue };\n\n/**\n * `<meta name content>`, or `<meta property content>` (Open Graph uses `property`). `media`\n * makes one entry per media query (`theme-color` for light and dark).\n */\nexport type HeadMeta =\n | { readonly name: string; readonly content: string; readonly media?: string }\n | { readonly property: string; readonly content: string; readonly media?: string };\n\n/**\n * A metadata `<link>`: `alternate` (with `hreflang`), `icon` (with `sizes`, `type`), `manifest`\n * (with `crossorigin`), `prev`/`next`, … Any other attribute is written as given.\n */\nexport interface HeadLink {\n readonly rel: string;\n readonly href: string;\n readonly [attribute: string]: string;\n}\n\n/** A page's head: what `renderPage()` writes on the server and `setHead()` applies on the client. */\nexport interface Head {\n readonly title: string;\n readonly description?: string;\n /** Absolute URL, usually `new URL(match.path, origin).href` (ADR 0009 \"Canonical paths\"). */\n readonly canonical?: string;\n /** `<meta name=\"robots\">`, e.g. `'noindex, follow'`. */\n readonly robots?: string;\n /** Later entries win over earlier ones with the same name or property. */\n readonly meta?: readonly HeadMeta[];\n /** Later entries win over earlier ones with the same attributes other than `href`. */\n readonly links?: readonly HeadLink[];\n /** Structured data, each written as one `<script type=\"application/ld+json\">`. */\n readonly jsonLd?: readonly JsonValue[];\n /** `<html lang>`. */\n readonly lang?: string;\n /** `<html dir>`. */\n readonly dir?: 'ltr' | 'rtl' | 'auto';\n}\n\n/** The attribute that marks an element the head model manages; its value is the entry's key. */\nexport const HEAD_ATTRIBUTE = 'data-gyral-head';\n\n/** One managed element: its key, tag, attributes (in order) and, for JSON-LD, its text. */\nexport interface HeadEntry {\n readonly key: string;\n readonly tag: 'meta' | 'link' | 'script';\n readonly attributes: readonly (readonly [name: string, value: string])[];\n readonly text?: string;\n}\n\n// Belong to renderPage() itself: changing them per navigation would unstyle the page, refetch\n// modules, or apply policy too late. Checked in development only.\nconst REFUSED_REL = /(^|\\s)(stylesheet|preload|modulepreload|canonical)(\\s|$)/i;\n\nfunction check(head: Head): void {\n const refuse = (what: string): never => {\n throw new Error(\n `gyral head: ${what} is not a managed head entry (ADR 0019 \"Keys, dedupe and order\"). ` +\n 'Write it with renderPage() options or extraHead.',\n );\n };\n for (const m of head.meta ?? []) {\n if ('http-equiv' in m || 'charset' in m) refuse('<meta http-equiv> or <meta charset>');\n if ('name' in m && /^viewport$/i.test(m.name)) refuse('<meta name=\"viewport\">');\n }\n for (const link of head.links ?? []) {\n if (REFUSED_REL.test(link.rel)) refuse(`<link rel=\"${link.rel}\">`);\n }\n if (head.canonical !== undefined && !/^[a-z][a-z\\d+.-]*:/i.test(head.canonical)) {\n console.warn(\n `gyral head: canonical \"${head.canonical}\" is not an absolute URL. Build it from a ` +\n 'configured origin: new URL(match.path, origin).href (ADR 0019).',\n );\n }\n}\n\n/**\n * A head's managed elements, in document order and without duplicates: description, robots,\n * canonical, `meta`, `links`, then JSON-LD. Later `meta`/`links` entries with the same key win\n * (at the later position); the typed description and robots win over `meta` entries. The\n * title, `lang` and `dir` are not elements here.\n */\nexport function headEntries(head: Head): readonly HeadEntry[] {\n if (DEV) check(head);\n const out = new Map<string, HeadEntry>();\n const add = (\n key: string,\n tag: HeadEntry['tag'],\n attributes: HeadEntry['attributes'],\n text?: string,\n ): void => {\n out.delete(key);\n out.set(key, text === undefined ? { key, tag, attributes } : { key, tag, attributes, text });\n };\n const meta = (attr: string, name: string, content: string, media?: string): void => {\n add(media === undefined ? `${attr}:${name}` : `${attr}:${name}|${media}`, 'meta', [\n [attr, name],\n ['content', content],\n ...(media === undefined ? [] : [['media', media] as const]),\n ]);\n };\n if (head.description !== undefined) meta('name', 'description', head.description);\n if (head.robots !== undefined) meta('name', 'robots', head.robots);\n if (head.canonical !== undefined) {\n add('canonical', 'link', [\n ['rel', 'canonical'],\n ['href', head.canonical],\n ]);\n }\n const typed = new Set(out.keys());\n for (const m of head.meta ?? []) {\n const [attr, name] = 'name' in m ? ['name', m.name] : ['property', m.property];\n if (!typed.has(`${attr}:${name}`)) meta(attr, name, m.content, m.media);\n }\n for (const link of head.links ?? []) {\n const attributes = Object.entries(link);\n const key = attributes\n .filter(([name]) => name !== 'href')\n .map(([name, value]) => `${name}=${value}`)\n .sort()\n .join(';');\n add(`link:${key}`, 'link', [\n ['rel', link.rel],\n ...attributes.filter(([name]) => name !== 'rel'),\n ]);\n }\n head.jsonLd?.forEach((value, n) => {\n add(`ld:${String(n)}`, 'script', [['type', 'application/ld+json']], scriptSafeJson(value));\n });\n return [...out.values()];\n}\n"]}
|
package/dist/host-model.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type Command, type Next } from './command.js';
|
|
2
2
|
import { type FeatureHost, type LocalHost, type StoreLink } from './features.js';
|
|
3
|
+
import { type ErrorPhase, type GyralError } from './errors.js';
|
|
3
4
|
import type { Seed } from './hydration.js';
|
|
4
|
-
import type { ComponentSpec,
|
|
5
|
+
import type { ComponentSpec, IntentRejected, ParserCtx, Tagged } from './types.js';
|
|
5
6
|
type Bag = Readonly<Record<string, unknown>>;
|
|
6
7
|
/** A command as the model runs it. */
|
|
7
8
|
export type ModelCommand = Command<Tagged | IntentRejected>;
|
|
@@ -26,23 +27,51 @@ export declare class HostModel<S, P> implements LocalHost {
|
|
|
26
27
|
/** The store binding, created on first use. Only reached once `defineStore()` has run. */
|
|
27
28
|
readonly stores: () => StoreLink;
|
|
28
29
|
get ready(): boolean;
|
|
30
|
+
/** Set when `init` threw; the host has no state then. */
|
|
31
|
+
get failure(): GyralError | undefined;
|
|
29
32
|
/** The current state, running `init` (and `initialMessages`) on first read. */
|
|
30
33
|
state(initialMessages?: readonly Tagged[]): S;
|
|
34
|
+
/**
|
|
35
|
+
* Reports a failure of this host (ADR 0024) and, for a failed update, parse or command, sends
|
|
36
|
+
* `Errored` to its optional reducer. A failure while handling `Errored` is only reported.
|
|
37
|
+
*/
|
|
38
|
+
fail(cause: unknown, phase: ErrorPhase, text: string, msg?: string): GyralError;
|
|
31
39
|
hasReducer(tag: string): boolean;
|
|
32
40
|
/**
|
|
33
41
|
* Resumes from a server seed (ADR 0012): the server's state, or `init(props)`'s when the
|
|
34
42
|
* seed left it out. Returns init's commands, to start after the first render.
|
|
35
43
|
*/
|
|
36
44
|
resume(seed: Seed): readonly Cmd[];
|
|
37
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* PropsChanged when props differ from the last ones seen (`Object.is` per prop). A reducer
|
|
47
|
+
* that throws leaves them unseen, so the next render sends PropsChanged again (ADR 0024).
|
|
48
|
+
*/
|
|
38
49
|
syncProps(names: readonly string[]): void;
|
|
39
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Runs `msg`'s reducer. A reducer that throws changes nothing (no state, no commands): the
|
|
52
|
+
* failure is reported (ADR 0024) and dispatch returns false.
|
|
53
|
+
*/
|
|
54
|
+
dispatch(msg: Tagged, schedule?: boolean): boolean;
|
|
40
55
|
apply(next: Next<S, Tagged> | Next<S, Tagged | IntentRejected>): void;
|
|
41
56
|
/** Starts commands kept for later (init's, after a server-rendered host's first render). */
|
|
42
57
|
run(commands: readonly Cmd[]): void;
|
|
43
|
-
ctx():
|
|
44
|
-
/**
|
|
58
|
+
ctx(): ParserCtx<P, S>;
|
|
59
|
+
/**
|
|
60
|
+
* Resolves stores and runs kept commands; after a real detach, starts the subscriptions again
|
|
61
|
+
* (05 "Lifecycle"). Returns true if store instances were bound.
|
|
62
|
+
*/
|
|
45
63
|
connect(): boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Starts `spec.subscriptions` (ADR 0025): after the first render, as init's commands of a
|
|
66
|
+
* server-rendered host are, so nothing they emit can change what hydration adopts.
|
|
67
|
+
*/
|
|
68
|
+
subscribe(): void;
|
|
69
|
+
/** Aborts the running command of `lane` (a subscription that left, ADR 0025). */
|
|
70
|
+
cancel(lane: string): void;
|
|
71
|
+
/**
|
|
72
|
+
* Stops the commands one microtask later, unless the host is connected again first: a move
|
|
73
|
+
* (`appendChild`, `insertBefore`) disconnects and connects in one task and keeps them running.
|
|
74
|
+
*/
|
|
46
75
|
disconnect(): void;
|
|
47
76
|
}
|
|
48
77
|
export {};
|