@luigisbox/luma 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +90 -0
- package/README.md +615 -2
- package/a11y/audit.mjs +735 -0
- package/a11y/axe.mjs +73 -0
- package/a11y/report.mjs +347 -0
- package/a11y/run.mjs +256 -0
- package/a11y/wcag.mjs +233 -0
- package/bin/config.mjs +60 -0
- package/bin/luma.mjs +99 -0
- package/bin/paths.mjs +11 -0
- package/bin/tools.mjs +43 -0
- package/build/baseline.d.mts +8 -0
- package/build/baseline.mjs +167 -0
- package/build/tooling.mjs +102 -0
- package/build/vite.mjs +507 -0
- package/chrome/block.mjs +8 -0
- package/chrome/run.mjs +150 -0
- package/docs/ACCESSIBILITY.md +501 -0
- package/docs/INTEGRATION-PERFORMANCE.md +213 -0
- package/docs/PARITY.md +946 -0
- package/footprint/model.mjs +184 -0
- package/footprint/run.mjs +331 -0
- package/package.json +58 -4
- package/parity/alignment.mjs +109 -0
- package/parity/archive.mjs +223 -0
- package/parity/args.mjs +137 -0
- package/parity/behaviour.mjs +379 -0
- package/parity/compare.mjs +985 -0
- package/parity/engine.mjs +848 -0
- package/parity/hints.mjs +132 -0
- package/parity/hold.mjs +54 -0
- package/parity/init.mjs +703 -0
- package/parity/map.mjs +640 -0
- package/parity/owner.mjs +170 -0
- package/parity/pairing.mjs +110 -0
- package/parity/pixels.mjs +33 -0
- package/parity/probe.mjs +1239 -0
- package/parity/report.mjs +236 -0
- package/parity/run.mjs +267 -0
- package/snippet/early-hold.js +64 -0
- package/snippet/early-hold.min.js +1 -0
- package/src/analytics/chain.ts +137 -0
- package/src/analytics/collect.ts +64 -0
- package/src/analytics/datalayer.ts +67 -0
- package/src/analytics/from-ga4.ts +68 -0
- package/src/analytics/from-luma.ts +21 -0
- package/src/analytics/index.ts +24 -0
- package/src/analytics/outbox.ts +118 -0
- package/src/analytics/page.ts +46 -0
- package/src/analytics/pageview.ts +82 -0
- package/src/analytics/ref.ts +19 -0
- package/src/analytics/setup.ts +41 -0
- package/src/analytics/start.ts +53 -0
- package/src/analytics/triggers.ts +239 -0
- package/src/analytics/user.ts +12 -0
- package/src/analytics/wire.ts +67 -0
- package/src/api/ai.ts +98 -0
- package/src/api/index.ts +87 -0
- package/src/api/token-key.ts +42 -0
- package/src/api/token.ts +312 -0
- package/src/core/a11y-keys.ts +60 -0
- package/src/core/a11y.ts +160 -0
- package/src/core/boot-state.ts +6 -0
- package/src/core/color.ts +42 -0
- package/src/core/context.ts +108 -0
- package/src/core/early-rules.ts +96 -0
- package/src/core/entries.ts +25 -0
- package/src/core/fold.ts +7 -0
- package/src/core/health.ts +26 -0
- package/src/core/hold.ts +74 -0
- package/src/core/i18n.ts +66 -0
- package/src/core/image-load.ts +102 -0
- package/src/core/image-tags.ts +26 -0
- package/src/core/images.ts +170 -0
- package/src/core/integration.ts +126 -0
- package/src/core/kept.ts +25 -0
- package/src/core/listings.ts +100 -0
- package/src/core/locales.ts +14 -0
- package/src/core/own.ts +49 -0
- package/src/core/page.ts +14 -0
- package/src/core/prefetch.ts +88 -0
- package/src/core/recommenders.ts +166 -0
- package/src/core/reconciler.ts +397 -0
- package/src/core/result.ts +73 -0
- package/src/core/routes.ts +115 -0
- package/src/core/scheduler.ts +83 -0
- package/src/core/sense.ts +107 -0
- package/src/core/speculation.ts +59 -0
- package/src/core/stash.ts +38 -0
- package/src/core/storage.ts +45 -0
- package/src/core/styles.ts +106 -0
- package/src/core/support.ts +12 -0
- package/src/core/takeover.ts +220 -0
- package/src/core/templates.ts +74 -0
- package/src/core/tiles.ts +137 -0
- package/src/core/turbo.ts +64 -0
- package/src/core/url.ts +164 -0
- package/src/core/widget-inits.ts +40 -0
- package/src/env.d.ts +3 -0
- package/src/lit.ts +11 -0
- package/src/locales/cs.json +94 -0
- package/src/locales/de.json +88 -0
- package/src/locales/en.json +88 -0
- package/src/main.ts +493 -0
- package/src/types.ts +778 -0
- package/src/widgets/autocomplete/index.ts +1387 -0
- package/src/widgets/autocomplete/style-autocomplete.css +266 -0
- package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
- package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
- package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
- package/src/widgets/listing/element.ts +1772 -0
- package/src/widgets/listing/flow.ts +30 -0
- package/src/widgets/listing/instant.ts +133 -0
- package/src/widgets/listing/intent.ts +193 -0
- package/src/widgets/listing/query.ts +298 -0
- package/src/widgets/listing/stash.ts +97 -0
- package/src/widgets/listing/state.ts +131 -0
- package/src/widgets/listing/view.ts +257 -0
- package/src/widgets/plp/index.ts +11 -0
- package/src/widgets/plp/route.ts +263 -0
- package/src/widgets/recommender/index.ts +261 -0
- package/src/widgets/recommender/style-recommender.css +26 -0
- package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
- package/src/widgets/recommender/template-recommender-popup.ts +12 -0
- package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
- package/src/widgets/search/assets.ts +7 -0
- package/src/widgets/search/first.ts +15 -0
- package/src/widgets/search/form-url.ts +59 -0
- package/src/widgets/search/in-place.ts +226 -0
- package/src/widgets/search/index.ts +16 -0
- package/src/widgets/search/range.ts +260 -0
- package/src/widgets/search/route.ts +32 -0
- package/src/widgets/search/speculate.ts +202 -0
- package/src/widgets/search/style-search.css +65 -0
- package/src/widgets/search/template-search-box.ts +17 -0
- package/src/widgets/search/template-search-facets-default.ts +73 -0
- package/src/widgets/search/template-search-header.ts +14 -0
- package/src/widgets/search/template-search-pagination.ts +12 -0
- package/src/widgets/search/template-search-range-default.ts +48 -0
- package/src/widgets/search/template-search-result-default.ts +13 -0
- package/src/widgets/search/template-search-results-default.ts +8 -0
- package/tasks/model.mjs +116 -0
- package/tasks/run.mjs +305 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Recommenders' triggers and placement: an inline one is a placement rule
|
|
2
|
+
// (a placeholder until it is near the viewport), a popup one opens on "added
|
|
3
|
+
// to cart". In lbx-place with the reconciler, not in the entry: the entry
|
|
4
|
+
// (main.ts) loads it on a page an inline recommender's urlMatch / active()
|
|
5
|
+
// accepts, and at boot where a popup recommender is configured (its trigger
|
|
6
|
+
// may fire on any page).
|
|
7
|
+
|
|
8
|
+
import { mark, reportError } from './health';
|
|
9
|
+
import { recallWidgetHeight } from './hold';
|
|
10
|
+
import { addRule } from './reconciler';
|
|
11
|
+
import { onFlush, schedule, scheduleIn } from './scheduler';
|
|
12
|
+
import type { RecommenderConfigType } from '../types';
|
|
13
|
+
import type { WidgetContext } from '../main';
|
|
14
|
+
|
|
15
|
+
type Loaded = { mod: any; ctx: WidgetContext };
|
|
16
|
+
type Run = (where: string, p: Promise<Loaded>, f: (m: Loaded) => unknown, onFail?: () => void) => unknown;
|
|
17
|
+
export interface Reco {
|
|
18
|
+
id: string;
|
|
19
|
+
rc: RecommenderConfigType;
|
|
20
|
+
get: () => Promise<Loaded>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const toArray = <X>(v: X | X[] | undefined): X[] => (v == null ? [] : Array.isArray(v) ? v : [v]);
|
|
24
|
+
|
|
25
|
+
/** Each recommender's trigger; `setUp`: if one throws, the others still start. */
|
|
26
|
+
export const startRecommenders = (recos: Reco[], run: Run, setUp: <X>(where: string, f: () => X) => X | null) => {
|
|
27
|
+
for (const { id, rc, get } of recos) {
|
|
28
|
+
if (rc.confirm) setUp(id, () => popupTrigger(rc, get, run));
|
|
29
|
+
else setUp(id, () => inlineRecommender(id, rc, get, run));
|
|
30
|
+
}
|
|
31
|
+
schedule();
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Inline recommender: placed by a rule like everything else, but only a
|
|
36
|
+
* placeholder (reserved height) until it is two screens from the viewport.
|
|
37
|
+
* Then the chunk is loaded, the request made, and it renders into it. One
|
|
38
|
+
* screen was not enough: scrolling at ~2000 px/s (4x CPU, a 500 ms answer)
|
|
39
|
+
* it stood empty in view for ~230 ms; at two it is ready ~400 ms before
|
|
40
|
+
* (2026-10-11).
|
|
41
|
+
*/
|
|
42
|
+
function inlineRecommender(id: string, rc: RecommenderConfigType, get: () => Promise<Loaded>, run: Run) {
|
|
43
|
+
const re = rc.urlMatch ? new RegExp(rc.urlMatch) : null;
|
|
44
|
+
const page = rc.active ?? ((u: URL) => !re || re.test(u.pathname));
|
|
45
|
+
const io = new IntersectionObserver(
|
|
46
|
+
(entries) => {
|
|
47
|
+
for (const e of entries) {
|
|
48
|
+
if (!e.isIntersecting) continue;
|
|
49
|
+
io.unobserve(e.target);
|
|
50
|
+
const el = e.target as HTMLElement;
|
|
51
|
+
// Failed to load: no reserved gap, and not "ours present" for style-host.css.
|
|
52
|
+
const go = () =>
|
|
53
|
+
run(id, get(), ({ mod, ctx }) => mod.attach(ctx, el), () => {
|
|
54
|
+
el.style.minHeight = '';
|
|
55
|
+
el.dataset.state = 'empty';
|
|
56
|
+
});
|
|
57
|
+
// Within two screens while the page still loads (a short product page
|
|
58
|
+
// on a phone): after its load event, not competing with it.
|
|
59
|
+
if (document.readyState === 'complete') go();
|
|
60
|
+
else addEventListener('load', go, { once: true });
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
{ rootMargin: '0px 0px 200% 0px' },
|
|
64
|
+
);
|
|
65
|
+
const items = () => {
|
|
66
|
+
try {
|
|
67
|
+
return rc.items!();
|
|
68
|
+
} catch (e) {
|
|
69
|
+
reportError(`${id} items`, e);
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
// Nothing to recommend for (yet) on a page of its URL: looked at again for
|
|
74
|
+
// a while. The host may fill in what items() reads after it inserts the
|
|
75
|
+
// node (Angular sets bound attributes in its update pass; a task of ours
|
|
76
|
+
// can run in between), and sensing sees nodes, not attributes.
|
|
77
|
+
let emptyOn = '';
|
|
78
|
+
let emptySince = 0;
|
|
79
|
+
addRule({
|
|
80
|
+
id,
|
|
81
|
+
// Level-triggered: an input widget with nothing to recommend for is not on the page.
|
|
82
|
+
active: (u) => {
|
|
83
|
+
if (!page(u)) return false;
|
|
84
|
+
if (!rc.items || items().length > 0) return true;
|
|
85
|
+
const now = performance.now();
|
|
86
|
+
if (emptyOn !== u.href) (emptyOn = u.href), (emptySince = now);
|
|
87
|
+
if (now - emptySince < 3000) scheduleIn(100);
|
|
88
|
+
return false;
|
|
89
|
+
},
|
|
90
|
+
anchors: toArray(rc.anchor),
|
|
91
|
+
position: rc.position ?? 'after',
|
|
92
|
+
settleMs: rc.settleMs,
|
|
93
|
+
create: () => {
|
|
94
|
+
const el = document.createElement('lbx-reco');
|
|
95
|
+
if (rc.rootClass) el.className = rc.rootClass;
|
|
96
|
+
el.dataset.state = 'idle';
|
|
97
|
+
// Block before its chunk (and CSS) exists, so the reserved height applies.
|
|
98
|
+
el.style.display = 'block';
|
|
99
|
+
const h = recallWidgetHeight(id) || rc.reserve || 0;
|
|
100
|
+
if (h) el.style.minHeight = `${h}px`;
|
|
101
|
+
io.observe(el);
|
|
102
|
+
return el;
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Popup recommender opened by "added to cart", which the host only signals
|
|
109
|
+
* from the outside: a click on an add-to-cart control is the intent (which
|
|
110
|
+
* item), the host's own success notice appearing within windowMs is the
|
|
111
|
+
* confirmation. Neither alone opens it. The click already starts loading the
|
|
112
|
+
* chunk and asking for the item's recommendations, so both are there (or on
|
|
113
|
+
* their way) when the notice appears.
|
|
114
|
+
*/
|
|
115
|
+
function popupTrigger(rc: RecommenderConfigType, get: () => Promise<Loaded>, run: Run) {
|
|
116
|
+
if (!rc.intent) return reportError('reco popup', new Error('a popup recommender needs intent() in its _init.ts'));
|
|
117
|
+
let intent: { item: string; t: number } | null = null;
|
|
118
|
+
// Sensing sees nodes come and go, not attributes: a notice the host shows by
|
|
119
|
+
// a class (or style, hidden, open) on a node that stays is watched for while
|
|
120
|
+
// an intent waits, at most windowMs.
|
|
121
|
+
let watch: MutationObserver | null = null;
|
|
122
|
+
let unwatch = 0;
|
|
123
|
+
const watchNotice = () => {
|
|
124
|
+
watch ??= new MutationObserver(schedule);
|
|
125
|
+
watch.observe(document.documentElement, { attributes: true, subtree: true, attributeFilter: ['class', 'style', 'hidden', 'open', 'aria-hidden'] });
|
|
126
|
+
clearTimeout(unwatch);
|
|
127
|
+
unwatch = window.setTimeout(() => watch!.disconnect(), rc.windowMs ?? 10000);
|
|
128
|
+
};
|
|
129
|
+
addEventListener(
|
|
130
|
+
'click',
|
|
131
|
+
(e) => {
|
|
132
|
+
const t = e.target as Element | null;
|
|
133
|
+
if (!t || t.nodeType !== 1 || t.closest('[data-lbx]')) return;
|
|
134
|
+
let item: string | null = null;
|
|
135
|
+
try {
|
|
136
|
+
item = rc.intent!(t);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
reportError('reco popup intent', err);
|
|
139
|
+
}
|
|
140
|
+
if (!item) return;
|
|
141
|
+
intent = { item, t: performance.now() };
|
|
142
|
+
watchNotice();
|
|
143
|
+
// The chunk and the request now: the notice confirms it a moment later (their cart request).
|
|
144
|
+
run('reco popup prefetch', get(), ({ mod, ctx }) => mod.prefetchPopup(ctx, item));
|
|
145
|
+
},
|
|
146
|
+
true,
|
|
147
|
+
);
|
|
148
|
+
// A notice counts when it appears: a node the host adds, or one it keeps and
|
|
149
|
+
// shows again (a class toggled back on) after it stopped matching.
|
|
150
|
+
let shown = new Set(document.querySelectorAll(rc.confirm!));
|
|
151
|
+
onFlush(() => {
|
|
152
|
+
const now = new Set(document.querySelectorAll(rc.confirm!));
|
|
153
|
+
if (!now.size && !shown.size) return;
|
|
154
|
+
const appeared = [...now].filter((n) => !shown.has(n));
|
|
155
|
+
shown = now;
|
|
156
|
+
for (const n of appeared) {
|
|
157
|
+
if (!intent || performance.now() - intent.t > (rc.windowMs ?? 10000)) continue;
|
|
158
|
+
const { item } = intent;
|
|
159
|
+
intent = null;
|
|
160
|
+
watch?.disconnect();
|
|
161
|
+
const rect = n.getBoundingClientRect();
|
|
162
|
+
mark(`reco popup trigger ${item}`);
|
|
163
|
+
run(`reco popup`, get(), ({ mod, ctx }) => mod.openPopup(ctx, item, rect, n));
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
}
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
// Level-triggered placement engine. Each rule is a pure description of
|
|
2
|
+
// "when X, our element should sit at anchor Y". reconcile() converges the page
|
|
3
|
+
// to that description and is safe to run at any time, any number of times.
|
|
4
|
+
|
|
5
|
+
// Booting after `load` (script injected late by the host app): the framework has hydrated already, so anchors that exist on our first pass
|
|
6
|
+
// are stable and need no settle wait. Read at the entry's boot (this module arrives later, in lbx-place).
|
|
7
|
+
import { lateBoot } from './boot-state';
|
|
8
|
+
import { documentMayHydrate, hasFiber, OWN_ATTR, watchOwnRemovals, wholeDocument } from './own';
|
|
9
|
+
import { onFlush, scheduleIn, scheduleNow } from './scheduler';
|
|
10
|
+
import { bump, health, mark, reportError } from './health';
|
|
11
|
+
|
|
12
|
+
export type Position = 'prepend' | 'append' | 'before' | 'after';
|
|
13
|
+
|
|
14
|
+
export interface Rule {
|
|
15
|
+
id: string;
|
|
16
|
+
/** Is the rule applicable to the current page? Pure, cheap. */
|
|
17
|
+
active: (url: URL) => boolean;
|
|
18
|
+
/** Anchor candidates, most specific first. */
|
|
19
|
+
anchors: string[];
|
|
20
|
+
position: Position;
|
|
21
|
+
/**
|
|
22
|
+
* The anchor node must exist unchanged (same identity) for this long before
|
|
23
|
+
* we insert. Protects against frameworks that re-create or wipe the DOM
|
|
24
|
+
* right after we land (Angular destructive re-render, React hydration).
|
|
25
|
+
*/
|
|
26
|
+
settleMs?: number;
|
|
27
|
+
create: () => HTMLElement;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const rules: Rule[] = [];
|
|
31
|
+
// Rules whose element we placed and then lost to a host re-render. The anchor
|
|
32
|
+
// that replaces the lost one is the framework's client render: no settle wait
|
|
33
|
+
// (otherwise every re-render shows a blank gap for settleMs).
|
|
34
|
+
const lost = new Set<string>();
|
|
35
|
+
const placedOn = new Map<string, Element>();
|
|
36
|
+
// Our element per rule. When the host removes it (re-render, hydration) we put
|
|
37
|
+
// the same instance back: its DOM state and any in-flight user action survive
|
|
38
|
+
// (a click between our first render and their hydration is not lost).
|
|
39
|
+
const instances = new Map<string, HTMLElement>();
|
|
40
|
+
const firstSeen = new WeakMap<Element, number>();
|
|
41
|
+
const insertLog = new Map<string, number[]>();
|
|
42
|
+
const pausedUntil = new Map<string, number>();
|
|
43
|
+
|
|
44
|
+
// Server-rendered framework roots. Until the framework has hydrated the markup
|
|
45
|
+
// around an anchor, a node of ours there is a hydration mismatch: React
|
|
46
|
+
// re-renders the root on the client (a full-page flash), Vue drops our node,
|
|
47
|
+
// Angular reports NG0500. The host CSS hold covers the wait. Any other React
|
|
48
|
+
// root is known by its container key once createRoot/hydrateRoot has run
|
|
49
|
+
// (reactContainer(), below).
|
|
50
|
+
const REACT_ROOTS = '#__next, #___gatsby, [data-reactroot]';
|
|
51
|
+
const VUE_ROOTS = '#__nuxt, [data-server-rendered]';
|
|
52
|
+
// Astro removes `ssr` once an island has hydrated.
|
|
53
|
+
const SSR_ROOTS = `${REACT_ROOTS}, ${VUE_ROOTS}, [ng-server-context], astro-island[ssr]`;
|
|
54
|
+
const keyOf = (n: object, prefix: string) => Object.keys(n).find((k) => k.startsWith(prefix));
|
|
55
|
+
|
|
56
|
+
const CONTAINER = '__reactContainer$';
|
|
57
|
+
/**
|
|
58
|
+
* The React root around `el`: the element createRoot / hydrateRoot was given
|
|
59
|
+
* (React sets its container key on it), whatever its selector (a plain `#root`
|
|
60
|
+
* is as common as `#__next`), or the document React hydrates as a whole.
|
|
61
|
+
*/
|
|
62
|
+
const reactContainer = (el: Element): Element | Document | null => {
|
|
63
|
+
for (let a: Element | null = el; a; a = a.parentElement) if (keyOf(a, CONTAINER)) return a;
|
|
64
|
+
return wholeDocument() ? document : null;
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* React's root has yet to commit its hydration (React 18 and 19; no such flag
|
|
68
|
+
* before). Fibers on the anchor do not say so: a render React suspends and
|
|
69
|
+
* restarts leaves those of the render it threw away (a production storefront, 2026-10-02:
|
|
70
|
+
* ours in that markup was React error #418, the root rendered anew).
|
|
71
|
+
*/
|
|
72
|
+
const reactDehydrated = (c: Element | Document | null) => {
|
|
73
|
+
const k = c && keyOf(c, CONTAINER);
|
|
74
|
+
return !!k && (c as any)[k]?.stateNode?.current?.memoizedState?.isDehydrated === true;
|
|
75
|
+
};
|
|
76
|
+
// A wait for the parse to end (hydrated(), below) needs a reconcile when it does.
|
|
77
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => scheduleIn(0), { once: true });
|
|
78
|
+
// Turbo Drive caches a snapshot a tick after turbo:before-cache (Turbo 7.3+
|
|
79
|
+
// has removed our data-turbo-temporary nodes by then; for older Turbo and
|
|
80
|
+
// Turbolinks we remove them): nothing goes back in between. Our listener runs
|
|
81
|
+
// before Turbo sets its tick, so two nested ticks land after the snapshot.
|
|
82
|
+
// Or until Turbo has rendered the next page (turbo:render), if that is
|
|
83
|
+
// sooner: the snapshot holds the body it replaced (detached, ours not in it
|
|
84
|
+
// once placed in the new one), and a page that stays (a morphing refresh) is
|
|
85
|
+
// the next page itself. Without that, ours was missing for a frame or two
|
|
86
|
+
// after every Turbo render between our pages (found by Luma's Turbo
|
|
87
|
+
// tests).
|
|
88
|
+
let snapshotting = false;
|
|
89
|
+
const endSnapshot = () => {
|
|
90
|
+
if (!snapshotting) return;
|
|
91
|
+
snapshotting = false;
|
|
92
|
+
scheduleNow();
|
|
93
|
+
};
|
|
94
|
+
const beforeCache = () => {
|
|
95
|
+
snapshotting = true;
|
|
96
|
+
for (const el of document.querySelectorAll(`[${OWN_ATTR}][data-turbo-temporary]`)) el.remove();
|
|
97
|
+
// (Turbolinks snapshots in a setTimeout of 1 ms: a few ms more covers it.)
|
|
98
|
+
setTimeout(() => setTimeout(endSnapshot, 5));
|
|
99
|
+
};
|
|
100
|
+
addEventListener('turbo:before-cache', beforeCache);
|
|
101
|
+
addEventListener('turbolinks:before-cache', beforeCache);
|
|
102
|
+
addEventListener('turbo:render', endSnapshot);
|
|
103
|
+
addEventListener('turbolinks:render', endSnapshot);
|
|
104
|
+
const sinceLoad = () => {
|
|
105
|
+
const end = (performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined)?.loadEventEnd;
|
|
106
|
+
return end ? performance.now() - end : -1;
|
|
107
|
+
};
|
|
108
|
+
/** The longest we wait: below the takeover's hold timeout, or the listing would never show. */
|
|
109
|
+
const MAX_HYDRATION_WAIT = 3000;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* React has hydrated the markup at `node`: claimed it, or `node` is markup React
|
|
113
|
+
* does not own (HTML it set as a whole, a tag manager's) inside an element it
|
|
114
|
+
* has claimed, whose subtree it has therefore finished.
|
|
115
|
+
*/
|
|
116
|
+
const reactHydrated = (node: Element): boolean => {
|
|
117
|
+
if (hasFiber(node)) return true;
|
|
118
|
+
// A root container gets no fiber of its own (its key is set before hydration): its content's.
|
|
119
|
+
if (keyOf(node, CONTAINER)) return !!node.firstElementChild && reactHydrated(node.firstElementChild);
|
|
120
|
+
for (let c: Element = node, a = node.parentElement; a; c = a, a = a.parentElement) {
|
|
121
|
+
if (hasFiber(a)) return !inDehydratedSuspense(c);
|
|
122
|
+
if (keyOf(a, CONTAINER)) return false; // up to the root: nothing claimed yet
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** In a boundary React has not hydrated yet, at any level above `el`. */
|
|
128
|
+
const inAnyDehydratedBoundary = (el: Element) => {
|
|
129
|
+
for (let c: Element | null = el; c && c !== document.body; c = c.parentElement) if (inDehydratedSuspense(c)) return true;
|
|
130
|
+
return false;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Inside a Suspense boundary (between React's `<!--$-->` … `<!--/$-->`
|
|
135
|
+
* markers) whose content React has not claimed yet: selective hydration does
|
|
136
|
+
* the shell first, so the parent's fiber says nothing about the boundary.
|
|
137
|
+
*/
|
|
138
|
+
const inDehydratedSuspense = (el: Element): boolean => {
|
|
139
|
+
// React 19.2 streaming: content waits in a hidden <div id="S:…"> until it is revealed into its boundary.
|
|
140
|
+
const staged = el.closest('div[hidden][id^="S:"]');
|
|
141
|
+
if (staged && !hasFiber(staged)) return true;
|
|
142
|
+
// Suspense ($ $? $! $~) and Activity (&) boundaries hydrate on their own.
|
|
143
|
+
const open = (d: string) => d === '$' || d === '$?' || d === '$!' || d === '$~' || d === '&';
|
|
144
|
+
const close = (d: string) => d === '/$' || d === '/&';
|
|
145
|
+
let depth = 0;
|
|
146
|
+
for (let n = el.previousSibling; n; n = n.previousSibling) {
|
|
147
|
+
if (n.nodeType !== 8) continue;
|
|
148
|
+
const d = (n as Comment).data;
|
|
149
|
+
if (close(d)) depth++;
|
|
150
|
+
else if (open(d) && depth-- === 0) {
|
|
151
|
+
// Our boundary: hydrated once React has claimed its last element (it
|
|
152
|
+
// claims siblings in order, yielding in between: an earlier one says
|
|
153
|
+
// nothing about the rest).
|
|
154
|
+
let last: Element | null = null;
|
|
155
|
+
for (let m = n.nextSibling, level = 0; m; m = m.nextSibling) {
|
|
156
|
+
// Not ours: a node we placed `after` the boundary's last element is not React's.
|
|
157
|
+
if (m.nodeType === 1 && level === 0 && !(m as Element).hasAttribute(OWN_ATTR)) last = m as Element;
|
|
158
|
+
if (m.nodeType !== 8) continue;
|
|
159
|
+
const e = (m as Comment).data;
|
|
160
|
+
if (open(e)) level++;
|
|
161
|
+
else if (close(e) && level-- === 0) break;
|
|
162
|
+
}
|
|
163
|
+
return !(last && hasFiber(last));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* React is hydrating the root around the anchor (hydrateRoot has been called:
|
|
171
|
+
* the container carries React's key): past the cap we keep waiting, because
|
|
172
|
+
* inserting would make React re-render the whole root. The hold may time out
|
|
173
|
+
* meanwhile (their listing shows); ours is placed once React is done.
|
|
174
|
+
*/
|
|
175
|
+
const reactHydrating = (anchor: Element, age: number) => {
|
|
176
|
+
return age <= MAX_REACT_WAIT && reactDehydrated(reactContainer(anchor));
|
|
177
|
+
};
|
|
178
|
+
/** However long React takes, not longer than this. */
|
|
179
|
+
const MAX_REACT_WAIT = 15_000;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Angular will hydrate the server's markup around the anchor (a component
|
|
183
|
+
* there still has its hydration key): past the cap we keep waiting, as for
|
|
184
|
+
* React. A node of ours in it is NG0500 in a development build, and in
|
|
185
|
+
* production Angular binds the server's next node to the wrong element.
|
|
186
|
+
*/
|
|
187
|
+
const ngHydrating = (anchor: Element, pos: Position, age: number) => {
|
|
188
|
+
if (age > MAX_REACT_WAIT) return false;
|
|
189
|
+
const node = pos === 'before' || pos === 'after' ? anchor.parentElement ?? anchor : anchor;
|
|
190
|
+
return !!node.closest('[ng-server-context][ngh], [ng-server-context] [ngh]');
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* SvelteKit: the root is the element holding its boot script
|
|
195
|
+
* (`kit.start(app, element, …)`, at the end of the body), and Svelte 5
|
|
196
|
+
* hydrates it as a whole: a node of ours anywhere in it before then fails the
|
|
197
|
+
* hydration, and Svelte renders the whole page anew on the client (a flash,
|
|
198
|
+
* the header's state lost). Its router starts once hydration is done and sets
|
|
199
|
+
* `history.scrollRestoration = 'manual'` then (back to 'auto' on beforeunload:
|
|
200
|
+
* remembered). Until the boot script is parsed, its preloaded entry in <head> says so.
|
|
201
|
+
*/
|
|
202
|
+
// undefined: not looked for yet, or the document is still parsing (its boot script may come).
|
|
203
|
+
let svelteKit: Element | null | undefined;
|
|
204
|
+
let svelteKitStarted = false;
|
|
205
|
+
const svelteKitRoot = (anchor: Element) => {
|
|
206
|
+
if (svelteKit === undefined) {
|
|
207
|
+
const boot = [...document.scripts].find((s) => !s.src && s.text.includes('__sveltekit_') && s.text.includes('.start('));
|
|
208
|
+
if (boot || document.readyState !== 'loading') svelteKit = boot?.parentElement ?? null;
|
|
209
|
+
}
|
|
210
|
+
return svelteKit?.contains(anchor) ? svelteKit : null;
|
|
211
|
+
};
|
|
212
|
+
const svelteKitParsing = () => svelteKit === undefined && !!document.querySelector('link[href*="/immutable/entry/start."]');
|
|
213
|
+
|
|
214
|
+
const hydrated = (anchor: Element, pos: Position) => {
|
|
215
|
+
if (svelteKitRoot(anchor)) return (svelteKitStarted ||= history.scrollRestoration === 'manual');
|
|
216
|
+
// Any React root (its container key), and not before it has committed its hydration.
|
|
217
|
+
const react = reactContainer(anchor);
|
|
218
|
+
if (reactDehydrated(react)) return false;
|
|
219
|
+
const root = anchor.closest(SSR_ROOTS) ?? (react !== document ? (react as Element | null) : null) ?? (wholeDocument() ? document.body : null);
|
|
220
|
+
// Still parsing a Next.js page (its whole-document flag comes at the end of
|
|
221
|
+
// <body>, its assets are visible before) or a SvelteKit page (its boot
|
|
222
|
+
// script is at the end of <body>): wait for the parse. Any other page is
|
|
223
|
+
// placed at once (before first paint, as the hold intends).
|
|
224
|
+
if (!root) return !documentMayHydrate() && !svelteKitParsing();
|
|
225
|
+
// For a sibling insert, the parent's state is what counts (all of its children claimed).
|
|
226
|
+
const node = pos === 'before' || pos === 'after' ? anchor.parentElement ?? anchor : anchor;
|
|
227
|
+
// Frameworks with a signal: wait for it (the cap bounds the wait).
|
|
228
|
+
// For a sibling insert, the anchor's own place counts too: its parent may be
|
|
229
|
+
// claimed while the anchor sits in a boundary React has not hydrated yet.
|
|
230
|
+
if (root === document.body || root === react || root.matches(REACT_ROOTS)) {
|
|
231
|
+
// Next.js pages router: its hydration measure (it stays; the marks it is
|
|
232
|
+
// made of do not) says the shell is done, not the Suspense boundaries.
|
|
233
|
+
const shell = performance.getEntriesByName('Next.js-hydration', 'measure').length ? !inAnyDehydratedBoundary(node) : reactHydrated(node);
|
|
234
|
+
return shell && (node === anchor || !inDehydratedSuspense(anchor));
|
|
235
|
+
}
|
|
236
|
+
if (root.matches(VUE_ROOTS)) {
|
|
237
|
+
const vue = (root as any).__vue_app__;
|
|
238
|
+
const nuxt = vue?.config?.globalProperties?.$nuxt;
|
|
239
|
+
if (nuxt) return nuxt.isHydrating === false; // Nuxt 3 hydrates async parts after mount
|
|
240
|
+
return !!(vue || (root as any).__vue__); // Vue 3 / Vue 2: mounted
|
|
241
|
+
}
|
|
242
|
+
// Angular: the root component exists (its first tick hydrates the shell in
|
|
243
|
+
// that task), and no component around the anchor still has its hydration
|
|
244
|
+
// key (`ngh`, removed once Angular has read it): a lazy route's page
|
|
245
|
+
// hydrates later than the shell, when its chunk has arrived.
|
|
246
|
+
if ('__ngContext__' in root) {
|
|
247
|
+
const waiting = node.closest('[ngh]');
|
|
248
|
+
return !waiting || waiting === root || !root.contains(waiting);
|
|
249
|
+
}
|
|
250
|
+
if (root.localName === 'astro-island') return false; // it drops `ssr` when hydrated (the cap bounds the wait)
|
|
251
|
+
// No signal (a framework we cannot see): after load and a moment.
|
|
252
|
+
return lateBoot || sinceLoad() > 1000;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
export const addRule = (r: Rule) => rules.push(r);
|
|
256
|
+
|
|
257
|
+
/** The element (the server's, adopted) is this rule's instance: it stays where it is. */
|
|
258
|
+
export const claim = (id: string, el: HTMLElement) => void instances.set(id, el);
|
|
259
|
+
|
|
260
|
+
const isPlaced = (el: Element, anchor: Element, pos: Position) => {
|
|
261
|
+
switch (pos) {
|
|
262
|
+
case 'prepend':
|
|
263
|
+
case 'append':
|
|
264
|
+
return el.parentElement === anchor;
|
|
265
|
+
case 'before':
|
|
266
|
+
return el.nextElementSibling === anchor;
|
|
267
|
+
case 'after':
|
|
268
|
+
return el.previousElementSibling === anchor;
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const place = (el: Element, anchor: Element, pos: Position) => {
|
|
273
|
+
if (pos === 'prepend') anchor.prepend(el);
|
|
274
|
+
else if (pos === 'append') anchor.append(el);
|
|
275
|
+
else if (pos === 'before') anchor.before(el);
|
|
276
|
+
else anchor.after(el);
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
/** Detect a fight with the host (it keeps removing us) and back off. */
|
|
280
|
+
const allowInsert = (id: string, now: number) => {
|
|
281
|
+
if ((pausedUntil.get(id) || 0) > now) return false;
|
|
282
|
+
const log = (insertLog.get(id) || []).filter((t) => now - t < 3000);
|
|
283
|
+
log.push(now);
|
|
284
|
+
insertLog.set(id, log);
|
|
285
|
+
if (log.length > 5) {
|
|
286
|
+
bump(health.fights, id);
|
|
287
|
+
const backoff = 500 * 2 ** Math.min(health.fights[id], 5);
|
|
288
|
+
pausedUntil.set(id, now + backoff);
|
|
289
|
+
insertLog.set(id, []);
|
|
290
|
+
scheduleIn(backoff + 10);
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
return true;
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Not yet: the framework around the anchor has yet to hydrate it (bounded;
|
|
298
|
+
* never for a re-insert, except inside an Astro island: its `ssr` is exact,
|
|
299
|
+
* and Astro's ClientRouter brings a swapped-in page's anchor back as server
|
|
300
|
+
* markup in an island that hydrates after the swap; ours in it then fails
|
|
301
|
+
* that hydration and the island renders anew).
|
|
302
|
+
*/
|
|
303
|
+
const mustWait = (r: Rule, anchor: Element, age: number) =>
|
|
304
|
+
(!lost.has(r.id) || !!anchor.closest('astro-island[ssr]')) && !hydrated(anchor, r.position) && (age < MAX_HYDRATION_WAIT || reactHydrating(anchor, age) || ngHydrating(anchor, r.position, age));
|
|
305
|
+
|
|
306
|
+
const reconcileRule = (r: Rule, url: URL, now: number) => {
|
|
307
|
+
const mine = [...document.querySelectorAll(`[${OWN_ATTR}="${r.id}"]`)];
|
|
308
|
+
if (!r.active(url)) {
|
|
309
|
+
instances.delete(r.id); // leaving the route: a fresh element next time
|
|
310
|
+
placedOn.delete(r.id); // and no reference to the old page's nodes
|
|
311
|
+
mine.forEach((el) => el.remove());
|
|
312
|
+
if (mine.length) mark(`remove ${r.id}`);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
let anchor: Element | null = null;
|
|
316
|
+
for (const sel of r.anchors) if ((anchor = document.querySelector(sel))) break;
|
|
317
|
+
if (!anchor) {
|
|
318
|
+
bump(health.anchorMisses, r.id);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
// A server-rendered element (server-side rendering) is the page's own: it wins over one we
|
|
322
|
+
// placed. Otherwise only our own instance counts: a copy of it (a Turbo
|
|
323
|
+
// snapshot restored) is not live, it goes.
|
|
324
|
+
const keep =
|
|
325
|
+
mine.find((el) => el.hasAttribute('data-lbx-ssr') && isPlaced(el, anchor!, r.position)) ??
|
|
326
|
+
mine.find((el) => el === instances.get(r.id) && isPlaced(el, anchor!, r.position));
|
|
327
|
+
if (!keep && placedOn.has(r.id) && placedOn.get(r.id) !== anchor) lost.add(r.id);
|
|
328
|
+
mine.filter((el) => el !== keep).forEach((el) => el.remove());
|
|
329
|
+
if (keep) {
|
|
330
|
+
// The server's element becomes ours (it drops its marker once hydrated).
|
|
331
|
+
instances.set(r.id, keep as HTMLElement);
|
|
332
|
+
if (!keep.hasAttribute('data-turbo-temporary')) keep.setAttribute('data-turbo-temporary', '');
|
|
333
|
+
// Placed while parsing, before the page said React hydrates it as a whole
|
|
334
|
+
// (Remix, React Router 7 and Hydrogen say so at the end of <body>): out
|
|
335
|
+
// again until React has it, or React re-renders the document. Their
|
|
336
|
+
// scripts run after the parse, so it is gone before hydration starts.
|
|
337
|
+
// Within the same limits as the wait before an insert (React never hydrating must not keep ours out).
|
|
338
|
+
if (!firstSeen.has(anchor)) firstSeen.set(anchor, now);
|
|
339
|
+
if (!keep.hasAttribute('data-lbx-ssr') && wholeDocument() && mustWait(r, anchor, now - (firstSeen.get(anchor) ?? now))) {
|
|
340
|
+
keep.remove();
|
|
341
|
+
scheduleIn(200);
|
|
342
|
+
}
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (!firstSeen.has(anchor)) firstSeen.set(anchor, now);
|
|
347
|
+
const age = now - firstSeen.get(anchor)!;
|
|
348
|
+
// No settle wait on our first pass after load, or for a node the host just re-rendered away.
|
|
349
|
+
const settle = (lateBoot && health.reconciles === 0) || lost.has(r.id) ? 0 : r.settleMs ?? 0;
|
|
350
|
+
if (age < settle) {
|
|
351
|
+
scheduleIn(settle - age + 5);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (mustWait(r, anchor, age)) {
|
|
355
|
+
scheduleIn(200);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (snapshotting || !allowInsert(r.id, now)) return;
|
|
359
|
+
const prev = instances.get(r.id);
|
|
360
|
+
const el = prev && !prev.isConnected ? prev : r.create();
|
|
361
|
+
instances.set(r.id, el);
|
|
362
|
+
el.setAttribute(OWN_ATTR, r.id);
|
|
363
|
+
// Turbo Drive: not in its page snapshots (a restored copy would sit next to ours).
|
|
364
|
+
el.setAttribute('data-turbo-temporary', '');
|
|
365
|
+
place(el, anchor, r.position);
|
|
366
|
+
placedOn.set(r.id, anchor);
|
|
367
|
+
lost.delete(r.id);
|
|
368
|
+
bump(health.inserts, r.id);
|
|
369
|
+
mark(`insert ${r.id}`);
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
// Inserts are counted per URL: on an SPA (or htmx, Turbo) host every
|
|
373
|
+
// navigation re-renders the area, and a shopper going back three times in a
|
|
374
|
+
// few seconds is not a host fighting us. A host that does fight us keeps
|
|
375
|
+
// removing ours on the same URL.
|
|
376
|
+
let countedFor = location.href;
|
|
377
|
+
|
|
378
|
+
export const reconcile = () => {
|
|
379
|
+
const t0 = performance.now();
|
|
380
|
+
if (location.href !== countedFor) {
|
|
381
|
+
countedFor = location.href;
|
|
382
|
+
insertLog.clear();
|
|
383
|
+
}
|
|
384
|
+
const url = new URL(location.href);
|
|
385
|
+
for (const r of rules) {
|
|
386
|
+
try {
|
|
387
|
+
reconcileRule(r, url, t0);
|
|
388
|
+
} catch (e) {
|
|
389
|
+
reportError(`rule ${r.id}`, e);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
health.reconciles++;
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
onFlush(reconcile);
|
|
396
|
+
// A node of ours the host removes goes back before the next paint (core/sense.ts).
|
|
397
|
+
watchOwnRemovals();
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Results as templates see them: `r.a` = the hit's fields (discovery hits are
|
|
2
|
+
// flat: `@id`, `@type`, `@title`, then catalog fields), `r.c` = computed
|
|
3
|
+
// attributes (from `_global.computedAttributes`, set in _init.ts), evaluated
|
|
4
|
+
// on first access.
|
|
5
|
+
|
|
6
|
+
import type { Hit } from '../api';
|
|
7
|
+
import type { Result } from '../types';
|
|
8
|
+
|
|
9
|
+
/** First value of a field that may be an array. */
|
|
10
|
+
export const first = (v: unknown): any => (Array.isArray(v) ? v[0] : v);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A catalog URL only if it leads to a web page (http(s), or relative): a
|
|
14
|
+
* `javascript:` or `data:` value in feed data must never become a link.
|
|
15
|
+
* Browsers ignore tabs and newlines inside a scheme, so they are too.
|
|
16
|
+
*/
|
|
17
|
+
export const safeUrl = (u: unknown) => {
|
|
18
|
+
const s = String(first(u) ?? '').trim();
|
|
19
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(s.replace(/[\u0000-\u0020]/g, ''))?.[1].toLowerCase();
|
|
20
|
+
return !scheme || scheme === 'http' || scheme === 'https' ? s : '';
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const DEFAULTS: Record<string, (r: Result) => unknown> = {
|
|
24
|
+
id: (r) => r.id,
|
|
25
|
+
title: (r) => first(r.a['@title']) ?? '',
|
|
26
|
+
url: (r) => r.a.url ?? r.a.web_url,
|
|
27
|
+
image: (r) => first(r.a['lbx:image'] ?? r.a.image_url ?? r.a.image_link) ?? '',
|
|
28
|
+
price: (r) => first(r.a['lbx:price'] ?? r.a.price),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const resultFactory = (computed: Record<string, (r: Result) => unknown> = {}) => {
|
|
32
|
+
const fns = { ...DEFAULTS, ...computed };
|
|
33
|
+
return (hit: Hit): Result => {
|
|
34
|
+
const r = { id: hit['@id'], type: hit['@type'], a: hit } as Result;
|
|
35
|
+
const c: Record<string, unknown> = {};
|
|
36
|
+
for (const [k, fn] of Object.entries(fns))
|
|
37
|
+
Object.defineProperty(c, k, {
|
|
38
|
+
enumerable: true,
|
|
39
|
+
configurable: true,
|
|
40
|
+
get() {
|
|
41
|
+
// `url` becomes links and navigations, whoever computes it (an integration's own too).
|
|
42
|
+
const v = k === 'url' ? safeUrl(fn(r)) : fn(r);
|
|
43
|
+
Object.defineProperty(c, k, { value: v, enumerable: true });
|
|
44
|
+
return v;
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
r.c = c;
|
|
48
|
+
return r;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A suggestion that goes somewhere: products always (their tile has actions),
|
|
54
|
+
* other types (category, brand, ...) only with a URL other than the site's
|
|
55
|
+
* root. Feeds carry such records (a brand with web_url "/" and no logo):
|
|
56
|
+
* shown, they send the shopper to the homepage. A query suggestion goes to
|
|
57
|
+
* the site's search with its text (the API gives it no URL): it needs the text.
|
|
58
|
+
*/
|
|
59
|
+
export const navigable = (r: Result) => {
|
|
60
|
+
if (r.type === 'product') return true;
|
|
61
|
+
if (r.type === 'query') return !!plain(r.c.title).trim();
|
|
62
|
+
const u = String(r.c.url ?? '').trim();
|
|
63
|
+
if (!u) return false;
|
|
64
|
+
try {
|
|
65
|
+
const url = new URL(u, 'https://x.invalid/');
|
|
66
|
+
return !(url.pathname === '/' && !url.search);
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Text without highlighting markup. */
|
|
73
|
+
export const plain = (s: unknown) => String(first(s) ?? '').replace(/<\/?em>/g, '');
|