@livetemplate/client 0.11.6 → 0.11.8
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/dist/dom/directives.d.ts +81 -0
- package/dist/dom/directives.d.ts.map +1 -1
- package/dist/dom/directives.js +387 -0
- package/dist/dom/directives.js.map +1 -1
- package/dist/dom/event-delegation.d.ts.map +1 -1
- package/dist/dom/event-delegation.js +11 -0
- package/dist/dom/event-delegation.js.map +1 -1
- package/dist/dom/redact.d.ts +66 -0
- package/dist/dom/redact.d.ts.map +1 -0
- package/dist/dom/redact.js +194 -0
- package/dist/dom/redact.js.map +1 -0
- package/dist/livetemplate-client.browser.js +4 -4
- package/dist/livetemplate-client.browser.js.map +4 -4
- package/dist/livetemplate-client.d.ts.map +1 -1
- package/dist/livetemplate-client.js +32 -0
- package/dist/livetemplate-client.js.map +1 -1
- package/dist/tests/directives.test.js +399 -0
- package/dist/tests/directives.test.js.map +1 -1
- package/dist/tests/redact.test.d.ts +2 -0
- package/dist/tests/redact.test.d.ts.map +1 -0
- package/dist/tests/redact.test.js +268 -0
- package/dist/tests/redact.test.js.map +1 -0
- package/package.json +1 -1
package/dist/dom/directives.d.ts
CHANGED
|
@@ -225,4 +225,85 @@ export declare function handleAreaSelectDirectives(rootElement: Element, send: (
|
|
|
225
225
|
* + tears down livetemplate trees would leak listeners across mounts.
|
|
226
226
|
*/
|
|
227
227
|
export declare function teardownAreaSelectForRoot(rootElement: Element): void;
|
|
228
|
+
/**
|
|
229
|
+
* Apply url-hash directives. `lvt-fx:url-hash="<actionName>"` plus a
|
|
230
|
+
* `data-lvt-url-hash="<hash>"` attribute on an element (typically the
|
|
231
|
+
* `<body>`) wires a two-way bridge between server state and
|
|
232
|
+
* `location.hash`:
|
|
233
|
+
*
|
|
234
|
+
* - **State → URL** (every render): if `data-lvt-url-hash` differs
|
|
235
|
+
* from `location.hash`, mirror the data-attr into the URL via
|
|
236
|
+
* `history.pushState` when the path component changed (everything
|
|
237
|
+
* before the first `:`) or `history.replaceState` when only the
|
|
238
|
+
* target (line range / anchor) changed. Replace is the right
|
|
239
|
+
* default for line scrolls so the back-button cycles between files,
|
|
240
|
+
* not between every clicked line.
|
|
241
|
+
* - **URL → State** (on `hashchange` AND initial arm): dispatch
|
|
242
|
+
* `{action: <actionName>, data: {hash: <hash>}}` so the server can
|
|
243
|
+
* parse the hash and update its state (which then renders back as
|
|
244
|
+
* a matching data-attr — closing the loop).
|
|
245
|
+
*
|
|
246
|
+
* The directive uses `history.pushState`/`replaceState` (not
|
|
247
|
+
* `location.hash = ...`) for the state→URL direction precisely so
|
|
248
|
+
* those writes do NOT fire `hashchange` — only true user-initiated
|
|
249
|
+
* navigation (anchor click, address-bar edit, back-button) reaches
|
|
250
|
+
* the URL→state listener. This avoids the obvious infinite loop.
|
|
251
|
+
*
|
|
252
|
+
* Idempotent across renders: same action → keep listener + update
|
|
253
|
+
* send. Different action → cleanup + re-arm. Detached / attribute-
|
|
254
|
+
* removed elements are swept on every call (same pattern as
|
|
255
|
+
* area-select). The window listener is registered on first arm and
|
|
256
|
+
* removed when the armed map becomes empty.
|
|
257
|
+
*
|
|
258
|
+
* Coexistence with `setupHashLink`: prereview-style hashes
|
|
259
|
+
* (`README.md:L4`, `foo/bar.html:h-anchor`) never match a
|
|
260
|
+
* `document.getElementById(...)`, so the existing dialog/popover/
|
|
261
|
+
* details hash machinery silently no-ops. If a deep-link hash
|
|
262
|
+
* happens to collide with an element id, both handlers will fire —
|
|
263
|
+
* the server is expected to no-op on hashes that don't resolve to a
|
|
264
|
+
* known file.
|
|
265
|
+
*
|
|
266
|
+
* **Pre-encoding contract**: `data-lvt-url-hash` must hold the hash
|
|
267
|
+
* value already in URL-encoded form. The directive writes the
|
|
268
|
+
* attribute verbatim into `history.pushState`/`replaceState`, so a
|
|
269
|
+
* value containing spaces, `[`, `]`, `%`, or other reserved
|
|
270
|
+
* characters needs to be percent-encoded by the server. The hash
|
|
271
|
+
* sent to the action on `hashchange` is also passed through unmodified
|
|
272
|
+
* (no decoding) — both directions are byte-exact mirrors of what's
|
|
273
|
+
* in `location.hash`.
|
|
274
|
+
*
|
|
275
|
+
* **URL/state divergence after a non-deep-link initial load**: if
|
|
276
|
+
* the user lands with a native-anchor hash (`#hero`) AND the server
|
|
277
|
+
* has a selected file, the directive leaves the URL on `#hero` (case
|
|
278
|
+
* b) — URL and server state diverge until the user navigates. This
|
|
279
|
+
* is intentional: popovers/anchors aren't ours to overwrite. The
|
|
280
|
+
* next user action that triggers a server render will re-sync only
|
|
281
|
+
* once URL and state share a deep-link hash.
|
|
282
|
+
*
|
|
283
|
+
* **Path-only deep links require an extension**: the
|
|
284
|
+
* `looksLikeDeepLinkHash` heuristic dispatches only hashes
|
|
285
|
+
* containing `:`, `/`, or `.`. Extension-less root files
|
|
286
|
+
* (`#Makefile`, `#Dockerfile`, `#LICENSE`) won't dispatch as
|
|
287
|
+
* path-only deep links — use the line form (`#Makefile:L1`)
|
|
288
|
+
* instead. The trade-off favours not clobbering native-anchor
|
|
289
|
+
* machinery for single-token hashes.
|
|
290
|
+
*/
|
|
291
|
+
export declare function handleURLHashDirective(rootElement: Element, send: (message: {
|
|
292
|
+
action: string;
|
|
293
|
+
data: Record<string, unknown>;
|
|
294
|
+
}) => void): void;
|
|
295
|
+
/**
|
|
296
|
+
* Cancel url-hash listeners for every armed element under root. Same
|
|
297
|
+
* lifecycle role as teardownAreaSelectForRoot.
|
|
298
|
+
*/
|
|
299
|
+
export declare function teardownURLHashForRoot(rootElement: Element): void;
|
|
300
|
+
/**
|
|
301
|
+
* Test-only: reset the per-page dedupe Set that suppresses repeated
|
|
302
|
+
* `warnIfUnencodedHash` calls for the same hash value. Production
|
|
303
|
+
* code shouldn't need this — the Set is bounded by the number of
|
|
304
|
+
* unique malformed hashes — but tests that re-use the same hash
|
|
305
|
+
* across cases need to clear it or the second test won't see the
|
|
306
|
+
* warning. Mirrors `__resetAnimatedElementsForTesting`.
|
|
307
|
+
*/
|
|
308
|
+
export declare function __resetURLHashUnencodedWarnedForTesting(): void;
|
|
228
309
|
//# sourceMappingURL=directives.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"directives.d.ts","sourceRoot":"","sources":["../../dom/directives.ts"],"names":[],"mappings":"AA4CA;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iCAAiC,IAAI,IAAI,CAKxD;AAsBD;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,OAAO,EAAE,YAAY,CAAC,EAAE,OAAO,GAAG,IAAI,CA0CvF;AAED;;;GAGG;AACH,wBAAgB,0BAA0B,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAWrE;AAED;;GAEG;AACH,wBAAgB,4BAA4B,CAC1C,WAAW,EAAE,OAAO,EACpB,SAAS,EAAE,MAAM,EACjB,UAAU,CAAC,EAAE,MAAM,GAClB,IAAI,CAiBN;AAiLD;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAiBpE;AAED;;;GAGG;AACH,wBAAgB,4BAA4B,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAUvE;AAaD,wBAAgB,sBAAsB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAMjE;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,uBAAuB,IAAI,IAAI,CAG9C;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CA8FpE;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAMpE;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAQlE;AAwCD;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CA4BhE;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,IAAI,IAAI,CAW7C;AAkFD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAyGpE;AA4CD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyDG;AACH,wBAAgB,0BAA0B,CACxC,WAAW,EAAE,OAAO,EACpB,IAAI,EAAE,CAAC,OAAO,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,KAAK,IAAI,GACzE,IAAI,CAyCN;AAED;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAQpE"}
|
|
1
|
+
{"version":3,"file":"directives.d.ts","sourceRoot":"","sources":["../../dom/directives.ts"],"names":[],"mappings":"AA4CA;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iCAAiC,IAAI,IAAI,CAKxD;AAsBD;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,OAAO,EAAE,YAAY,CAAC,EAAE,OAAO,GAAG,IAAI,CA0CvF;AAED;;;GAGG;AACH,wBAAgB,0BAA0B,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAWrE;AAED;;GAEG;AACH,wBAAgB,4BAA4B,CAC1C,WAAW,EAAE,OAAO,EACpB,SAAS,EAAE,MAAM,EACjB,UAAU,CAAC,EAAE,MAAM,GAClB,IAAI,CAiBN;AAiLD;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAiBpE;AAED;;;GAGG;AACH,wBAAgB,4BAA4B,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAUvE;AAaD,wBAAgB,sBAAsB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAMjE;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,uBAAuB,IAAI,IAAI,CAG9C;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CA8FpE;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAMpE;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAQlE;AAwCD;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CA4BhE;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,IAAI,IAAI,CAW7C;AAkFD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAyGpE;AA4CD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyDG;AACH,wBAAgB,0BAA0B,CACxC,WAAW,EAAE,OAAO,EACpB,IAAI,EAAE,CAAC,OAAO,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,KAAK,IAAI,GACzE,IAAI,CAyCN;AAED;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CAQpE;AAqDD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8DG;AACH,wBAAgB,sBAAsB,CACpC,WAAW,EAAE,OAAO,EACpB,IAAI,EAAE,CAAC,OAAO,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,KAAK,IAAI,GACzE,IAAI,CAyFN;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,WAAW,EAAE,OAAO,GAAG,IAAI,CA0BjE;AAgFD;;;;;;;GAOG;AACH,wBAAgB,uCAAuC,IAAI,IAAI,CAE9D"}
|
package/dist/dom/directives.js
CHANGED
|
@@ -16,6 +16,9 @@ exports.setupToastClickOutside = setupToastClickOutside;
|
|
|
16
16
|
exports.handleShadowRootHydration = handleShadowRootHydration;
|
|
17
17
|
exports.handleAreaSelectDirectives = handleAreaSelectDirectives;
|
|
18
18
|
exports.teardownAreaSelectForRoot = teardownAreaSelectForRoot;
|
|
19
|
+
exports.handleURLHashDirective = handleURLHashDirective;
|
|
20
|
+
exports.teardownURLHashForRoot = teardownURLHashForRoot;
|
|
21
|
+
exports.__resetURLHashUnencodedWarnedForTesting = __resetURLHashUnencodedWarnedForTesting;
|
|
19
22
|
const reactive_attributes_1 = require("./reactive-attributes");
|
|
20
23
|
// ─── Trigger parsing for lvt-fx: attributes ─────────────────────────────────
|
|
21
24
|
const FX_LIFECYCLE_SET = new Set(["pending", "success", "error", "done"]);
|
|
@@ -999,6 +1002,390 @@ function teardownAreaSelectForRoot(rootElement) {
|
|
|
999
1002
|
}
|
|
1000
1003
|
}
|
|
1001
1004
|
}
|
|
1005
|
+
// urlHashArmed tracks `lvt-fx:url-hash` listeners and their last-
|
|
1006
|
+
// mirrored hash. Same Map-not-WeakMap reasoning as area-select: the
|
|
1007
|
+
// sweep iterates to detect elements whose attribute was removed by a
|
|
1008
|
+
// server diff, and detached elements are cleaned up via the same
|
|
1009
|
+
// sweep (isConnected check).
|
|
1010
|
+
//
|
|
1011
|
+
// Module-level singleton: shared across all LiveTemplateClient
|
|
1012
|
+
// instances in the window. Two clients arming DIFFERENT elements
|
|
1013
|
+
// each get their own entry; the shared window hashchange listener
|
|
1014
|
+
// iterates the map and dispatches through every armed entry's own
|
|
1015
|
+
// send, so a multi-client page sees each client receive its hash
|
|
1016
|
+
// event. Teardown is scoped per root via teardownURLHashForRoot, so
|
|
1017
|
+
// clients don't tear down each other's listeners.
|
|
1018
|
+
//
|
|
1019
|
+
// Same-element multi-arm is "last writer wins": the Map key is the
|
|
1020
|
+
// element, so a second client arming the same element runs the
|
|
1021
|
+
// existing entry's cleanup() and replaces it. The first client's
|
|
1022
|
+
// send is orphaned. This matches area-select's behavior and is fine
|
|
1023
|
+
// for the documented single-arm-per-element contract.
|
|
1024
|
+
const urlHashArmed = new Map();
|
|
1025
|
+
// urlHashWindowListener is the single window-level `hashchange`
|
|
1026
|
+
// listener shared across all armed elements. Registered on first arm,
|
|
1027
|
+
// removed when the armed map becomes empty. Per-element listeners
|
|
1028
|
+
// would multi-fire for the (rare) case of multiple armed elements;
|
|
1029
|
+
// one shared listener iterating the armed map keeps the dispatch
|
|
1030
|
+
// count deterministic.
|
|
1031
|
+
let urlHashWindowListener = null;
|
|
1032
|
+
/**
|
|
1033
|
+
* Apply url-hash directives. `lvt-fx:url-hash="<actionName>"` plus a
|
|
1034
|
+
* `data-lvt-url-hash="<hash>"` attribute on an element (typically the
|
|
1035
|
+
* `<body>`) wires a two-way bridge between server state and
|
|
1036
|
+
* `location.hash`:
|
|
1037
|
+
*
|
|
1038
|
+
* - **State → URL** (every render): if `data-lvt-url-hash` differs
|
|
1039
|
+
* from `location.hash`, mirror the data-attr into the URL via
|
|
1040
|
+
* `history.pushState` when the path component changed (everything
|
|
1041
|
+
* before the first `:`) or `history.replaceState` when only the
|
|
1042
|
+
* target (line range / anchor) changed. Replace is the right
|
|
1043
|
+
* default for line scrolls so the back-button cycles between files,
|
|
1044
|
+
* not between every clicked line.
|
|
1045
|
+
* - **URL → State** (on `hashchange` AND initial arm): dispatch
|
|
1046
|
+
* `{action: <actionName>, data: {hash: <hash>}}` so the server can
|
|
1047
|
+
* parse the hash and update its state (which then renders back as
|
|
1048
|
+
* a matching data-attr — closing the loop).
|
|
1049
|
+
*
|
|
1050
|
+
* The directive uses `history.pushState`/`replaceState` (not
|
|
1051
|
+
* `location.hash = ...`) for the state→URL direction precisely so
|
|
1052
|
+
* those writes do NOT fire `hashchange` — only true user-initiated
|
|
1053
|
+
* navigation (anchor click, address-bar edit, back-button) reaches
|
|
1054
|
+
* the URL→state listener. This avoids the obvious infinite loop.
|
|
1055
|
+
*
|
|
1056
|
+
* Idempotent across renders: same action → keep listener + update
|
|
1057
|
+
* send. Different action → cleanup + re-arm. Detached / attribute-
|
|
1058
|
+
* removed elements are swept on every call (same pattern as
|
|
1059
|
+
* area-select). The window listener is registered on first arm and
|
|
1060
|
+
* removed when the armed map becomes empty.
|
|
1061
|
+
*
|
|
1062
|
+
* Coexistence with `setupHashLink`: prereview-style hashes
|
|
1063
|
+
* (`README.md:L4`, `foo/bar.html:h-anchor`) never match a
|
|
1064
|
+
* `document.getElementById(...)`, so the existing dialog/popover/
|
|
1065
|
+
* details hash machinery silently no-ops. If a deep-link hash
|
|
1066
|
+
* happens to collide with an element id, both handlers will fire —
|
|
1067
|
+
* the server is expected to no-op on hashes that don't resolve to a
|
|
1068
|
+
* known file.
|
|
1069
|
+
*
|
|
1070
|
+
* **Pre-encoding contract**: `data-lvt-url-hash` must hold the hash
|
|
1071
|
+
* value already in URL-encoded form. The directive writes the
|
|
1072
|
+
* attribute verbatim into `history.pushState`/`replaceState`, so a
|
|
1073
|
+
* value containing spaces, `[`, `]`, `%`, or other reserved
|
|
1074
|
+
* characters needs to be percent-encoded by the server. The hash
|
|
1075
|
+
* sent to the action on `hashchange` is also passed through unmodified
|
|
1076
|
+
* (no decoding) — both directions are byte-exact mirrors of what's
|
|
1077
|
+
* in `location.hash`.
|
|
1078
|
+
*
|
|
1079
|
+
* **URL/state divergence after a non-deep-link initial load**: if
|
|
1080
|
+
* the user lands with a native-anchor hash (`#hero`) AND the server
|
|
1081
|
+
* has a selected file, the directive leaves the URL on `#hero` (case
|
|
1082
|
+
* b) — URL and server state diverge until the user navigates. This
|
|
1083
|
+
* is intentional: popovers/anchors aren't ours to overwrite. The
|
|
1084
|
+
* next user action that triggers a server render will re-sync only
|
|
1085
|
+
* once URL and state share a deep-link hash.
|
|
1086
|
+
*
|
|
1087
|
+
* **Path-only deep links require an extension**: the
|
|
1088
|
+
* `looksLikeDeepLinkHash` heuristic dispatches only hashes
|
|
1089
|
+
* containing `:`, `/`, or `.`. Extension-less root files
|
|
1090
|
+
* (`#Makefile`, `#Dockerfile`, `#LICENSE`) won't dispatch as
|
|
1091
|
+
* path-only deep links — use the line form (`#Makefile:L1`)
|
|
1092
|
+
* instead. The trade-off favours not clobbering native-anchor
|
|
1093
|
+
* machinery for single-token hashes.
|
|
1094
|
+
*/
|
|
1095
|
+
function handleURLHashDirective(rootElement, send) {
|
|
1096
|
+
// Sweep stale entries first — disconnected hosts AND hosts whose
|
|
1097
|
+
// attribute was removed by a server diff. Iterate via Array.from so
|
|
1098
|
+
// cleanup()'s delete() doesn't disturb the iterator.
|
|
1099
|
+
for (const [element, entry] of Array.from(urlHashArmed)) {
|
|
1100
|
+
if (!element.isConnected ||
|
|
1101
|
+
!element.hasAttribute("lvt-fx:url-hash")) {
|
|
1102
|
+
entry.cleanup();
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
// Match the root itself, descendants, AND the document body. The
|
|
1106
|
+
// url-hash directive is typically placed on `<body>`, but livetemplate
|
|
1107
|
+
// auto-injects its `<div data-lvt-id>` INSIDE body, so the rootElement
|
|
1108
|
+
// passed by the client is the wrapper div — a strict descendant of
|
|
1109
|
+
// body. Without the body check, a directive on `<body>` would never
|
|
1110
|
+
// arm. We accept body placement because URL hash is page-global
|
|
1111
|
+
// anyway; the directive's lifecycle is still tied to the wrapper via
|
|
1112
|
+
// teardownURLHashForRoot (called on disconnect of the wrapper).
|
|
1113
|
+
const matches = [];
|
|
1114
|
+
if (rootElement instanceof HTMLElement &&
|
|
1115
|
+
rootElement.hasAttribute("lvt-fx:url-hash")) {
|
|
1116
|
+
matches.push(rootElement);
|
|
1117
|
+
}
|
|
1118
|
+
const body = rootElement.ownerDocument?.body;
|
|
1119
|
+
if (body &&
|
|
1120
|
+
body !== rootElement &&
|
|
1121
|
+
body.hasAttribute("lvt-fx:url-hash") &&
|
|
1122
|
+
!matches.includes(body)) {
|
|
1123
|
+
matches.push(body);
|
|
1124
|
+
}
|
|
1125
|
+
rootElement
|
|
1126
|
+
.querySelectorAll("[lvt-fx\\:url-hash]")
|
|
1127
|
+
.forEach((el) => {
|
|
1128
|
+
if (!matches.includes(el))
|
|
1129
|
+
matches.push(el);
|
|
1130
|
+
});
|
|
1131
|
+
if (matches.length === 0)
|
|
1132
|
+
return;
|
|
1133
|
+
for (const el of matches) {
|
|
1134
|
+
const action = el.getAttribute("lvt-fx:url-hash");
|
|
1135
|
+
if (!action) {
|
|
1136
|
+
console.warn(`lvt-fx:url-hash requires an action name, got: ${JSON.stringify(action)}`);
|
|
1137
|
+
continue;
|
|
1138
|
+
}
|
|
1139
|
+
const dataHash = el.getAttribute("data-lvt-url-hash") || "";
|
|
1140
|
+
const existing = urlHashArmed.get(el);
|
|
1141
|
+
if (existing && existing.action === action) {
|
|
1142
|
+
existing.updateSend(send);
|
|
1143
|
+
mirrorDataAttrToLocation(existing, dataHash);
|
|
1144
|
+
continue;
|
|
1145
|
+
}
|
|
1146
|
+
if (existing)
|
|
1147
|
+
existing.cleanup();
|
|
1148
|
+
const entry = attachURLHash(el, action, send);
|
|
1149
|
+
urlHashArmed.set(el, entry);
|
|
1150
|
+
// First-arm sync: three cases, in priority order.
|
|
1151
|
+
const initialLocation = window.location.hash.replace(/^#/, "");
|
|
1152
|
+
if (initialLocation &&
|
|
1153
|
+
initialLocation !== dataHash &&
|
|
1154
|
+
looksLikeDeepLinkHash(initialLocation)) {
|
|
1155
|
+
// (a) URL has a deep-link hash that differs from server state.
|
|
1156
|
+
// URL "wins" on initial load — dispatch so the server can
|
|
1157
|
+
// reconcile, and seed currentDataHash so the converging render
|
|
1158
|
+
// doesn't try to mirror over the user's URL.
|
|
1159
|
+
entry.currentDataHash = initialLocation;
|
|
1160
|
+
send({ action, data: { hash: initialLocation } });
|
|
1161
|
+
}
|
|
1162
|
+
else if (initialLocation && !looksLikeDeepLinkHash(initialLocation)) {
|
|
1163
|
+
// (b) URL has a non-deep-link hash (e.g. `#hero` opening a
|
|
1164
|
+
// popover, or a native heading anchor). Leave it alone — it
|
|
1165
|
+
// belongs to other machinery (setupHashLink, native scroll).
|
|
1166
|
+
// Seed currentDataHash so a later mirror sees the data-attr
|
|
1167
|
+
// as the baseline to compare against, and only writes when
|
|
1168
|
+
// the user navigates away from the popover/anchor.
|
|
1169
|
+
entry.currentDataHash = dataHash;
|
|
1170
|
+
}
|
|
1171
|
+
else {
|
|
1172
|
+
// (c) URL is empty (or already matches the server). Mirror the
|
|
1173
|
+
// server's hash into the URL if any.
|
|
1174
|
+
mirrorDataAttrToLocation(entry, dataHash);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Cancel url-hash listeners for every armed element under root. Same
|
|
1180
|
+
* lifecycle role as teardownAreaSelectForRoot.
|
|
1181
|
+
*/
|
|
1182
|
+
function teardownURLHashForRoot(rootElement) {
|
|
1183
|
+
// Includes body when body is an ancestor of rootElement and body is
|
|
1184
|
+
// armed — the directive accepts body placement (see the matcher in
|
|
1185
|
+
// handleURLHashDirective), so teardown must symmetrically clean up
|
|
1186
|
+
// both directions.
|
|
1187
|
+
//
|
|
1188
|
+
// Multi-client caveat: a body-armed entry is shared across all
|
|
1189
|
+
// LiveTemplateClient instances (Map key is the element, so only one
|
|
1190
|
+
// entry per body). Tearing down client A's root will therefore also
|
|
1191
|
+
// tear down a body listener that client B armed last — there's no
|
|
1192
|
+
// "owner" tracked. Acceptable for the single-client case (the
|
|
1193
|
+
// common deployment) and matches the same-element-multi-arm
|
|
1194
|
+
// last-writer-wins behavior in attachURLHash. A "fix" that
|
|
1195
|
+
// restricted the body-cleanup branch to client A would leak
|
|
1196
|
+
// client A's own body listener — don't do that without also
|
|
1197
|
+
// tracking entry ownership.
|
|
1198
|
+
const body = rootElement.ownerDocument?.body;
|
|
1199
|
+
for (const [element, entry] of Array.from(urlHashArmed)) {
|
|
1200
|
+
if (rootElement.contains(element)) {
|
|
1201
|
+
entry.cleanup();
|
|
1202
|
+
continue;
|
|
1203
|
+
}
|
|
1204
|
+
if (body && element === body && body.contains(rootElement)) {
|
|
1205
|
+
entry.cleanup();
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
// mirrorDataAttrToLocation pushes `dataHash` into `location.hash` if
|
|
1210
|
+
// it differs from what's already in the URL. Chooses push vs replace
|
|
1211
|
+
// by comparing the path component (everything before the first `:`)
|
|
1212
|
+
// against the current location.hash's path: a path change is a "file
|
|
1213
|
+
// switch" (user-meaningful back-button entry) and gets pushState;
|
|
1214
|
+
// any other change is a target-only update (line scroll / anchor
|
|
1215
|
+
// scroll) and gets replaceState. Updates entry.currentDataHash so a
|
|
1216
|
+
// subsequent render with the same data-attr no-ops.
|
|
1217
|
+
//
|
|
1218
|
+
// Initial-mirror special case: if the URL was empty when we're
|
|
1219
|
+
// mirroring (no prior hash to compare against), use replaceState even
|
|
1220
|
+
// though the path-component comparison would say "changed". An empty
|
|
1221
|
+
// URL → first server hash isn't a "navigation" — we're establishing
|
|
1222
|
+
// the initial state. Using pushState here would let Back land the
|
|
1223
|
+
// user on `url-without-hash`, which re-triggers the same arm and
|
|
1224
|
+
// pushes the same hash again. Loop.
|
|
1225
|
+
//
|
|
1226
|
+
// Empty-dataHash special case: if the server transitions FROM a
|
|
1227
|
+
// selected file TO no-selection (state.URLHash() returns ""), we
|
|
1228
|
+
// would otherwise wipe location.hash entirely — including hashes the
|
|
1229
|
+
// directive doesn't own (a popover #hero the user opened during the
|
|
1230
|
+
// session). To stay safe, only clear when the URL currently holds a
|
|
1231
|
+
// deep-link-shaped hash; non-deep-link hashes are left alone.
|
|
1232
|
+
function mirrorDataAttrToLocation(entry, dataHash) {
|
|
1233
|
+
if (entry.currentDataHash === dataHash)
|
|
1234
|
+
return;
|
|
1235
|
+
const currentLocation = window.location.hash.replace(/^#/, "");
|
|
1236
|
+
if (currentLocation === dataHash) {
|
|
1237
|
+
entry.currentDataHash = dataHash;
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
if (currentLocation !== "" && !looksLikeDeepLinkHash(currentLocation)) {
|
|
1241
|
+
// URL is on something not ours (popover id, native anchor) —
|
|
1242
|
+
// don't clobber it, regardless of what the server's data-attr
|
|
1243
|
+
// says. This covers BOTH the server-clears case (dataHash="")
|
|
1244
|
+
// and the rarer server-changes-selection-while-popover-open
|
|
1245
|
+
// case (dataHash transitions from one file to another while
|
|
1246
|
+
// the URL is parked on a non-deep-link hash).
|
|
1247
|
+
entry.currentDataHash = dataHash;
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
warnIfUnencodedHash(dataHash);
|
|
1251
|
+
const targetURL = dataHash ? `#${dataHash}` : window.location.pathname + window.location.search;
|
|
1252
|
+
const oldPath = currentLocation.split(":")[0];
|
|
1253
|
+
const newPath = dataHash.split(":")[0];
|
|
1254
|
+
// Preserve existing history.state — passing `null` would clobber
|
|
1255
|
+
// anything other SPA-like code on the page stores there (scroll
|
|
1256
|
+
// position, modal flag, etc.). The state object is independent of
|
|
1257
|
+
// the URL we're rewriting, so carrying it forward is the right
|
|
1258
|
+
// default.
|
|
1259
|
+
const currentState = window.history.state;
|
|
1260
|
+
// Empty currentLocation means we're establishing the URL from a
|
|
1261
|
+
// blank slate (initial render with no prior URL hash) — that's NOT
|
|
1262
|
+
// a back-button-meaningful navigation, so always replaceState.
|
|
1263
|
+
// Otherwise: a path change is a file switch (push), a target-only
|
|
1264
|
+
// change is a line/anchor scroll (replace).
|
|
1265
|
+
if (currentLocation !== "" && oldPath !== newPath) {
|
|
1266
|
+
window.history.pushState(currentState, "", targetURL);
|
|
1267
|
+
}
|
|
1268
|
+
else {
|
|
1269
|
+
window.history.replaceState(currentState, "", targetURL);
|
|
1270
|
+
}
|
|
1271
|
+
entry.currentDataHash = dataHash;
|
|
1272
|
+
}
|
|
1273
|
+
// warnIfUnencodedHash flags `data-lvt-url-hash` values containing
|
|
1274
|
+
// characters that should be percent-encoded (raw space, `<`, `>`,
|
|
1275
|
+
// `"`, ``` ` ```, `#`, `[`, `]`, `%`). The directive writes the
|
|
1276
|
+
// hash verbatim into `pushState`/`replaceState`, so an unencoded
|
|
1277
|
+
// value will silently produce a malformed URL — `location.hash`
|
|
1278
|
+
// reads back differently from what was set. Cheap dev-time guard
|
|
1279
|
+
// against a server-side contract slip; dedupes by value to avoid
|
|
1280
|
+
// log spam.
|
|
1281
|
+
//
|
|
1282
|
+
// `%` is included because a raw `%` not followed by two hex digits
|
|
1283
|
+
// is itself a percent-encoding error. The check is a heuristic
|
|
1284
|
+
// (won't catch every malformed escape), but covers the common
|
|
1285
|
+
// "forgot to encode" cases.
|
|
1286
|
+
const urlHashUnencodedWarned = new Set();
|
|
1287
|
+
/**
|
|
1288
|
+
* Test-only: reset the per-page dedupe Set that suppresses repeated
|
|
1289
|
+
* `warnIfUnencodedHash` calls for the same hash value. Production
|
|
1290
|
+
* code shouldn't need this — the Set is bounded by the number of
|
|
1291
|
+
* unique malformed hashes — but tests that re-use the same hash
|
|
1292
|
+
* across cases need to clear it or the second test won't see the
|
|
1293
|
+
* warning. Mirrors `__resetAnimatedElementsForTesting`.
|
|
1294
|
+
*/
|
|
1295
|
+
function __resetURLHashUnencodedWarnedForTesting() {
|
|
1296
|
+
urlHashUnencodedWarned.clear();
|
|
1297
|
+
}
|
|
1298
|
+
function warnIfUnencodedHash(hash) {
|
|
1299
|
+
if (!hash || urlHashUnencodedWarned.has(hash))
|
|
1300
|
+
return;
|
|
1301
|
+
if (/[ <>"`#\[\]]/.test(hash) || /%(?![0-9A-Fa-f]{2})/.test(hash)) {
|
|
1302
|
+
urlHashUnencodedWarned.add(hash);
|
|
1303
|
+
console.warn(`lvt-fx:url-hash: data-lvt-url-hash="${hash}" contains characters that should be percent-encoded. The directive writes it verbatim into history.pushState/replaceState; malformed URLs result. Server-side FormatHash (or equivalent) should percent-escape path segments and target ids before serialization.`);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
// looksLikeDeepLinkHash discriminates URL hashes the prereview deep-
|
|
1307
|
+
// link grammar can produce (file path with optional :L<n> or :h-id)
|
|
1308
|
+
// from hashes that belong to other native machinery (HTML element
|
|
1309
|
+
// anchors, dialog/popover/details ids, etc.). Deep-link hashes always
|
|
1310
|
+
// contain at least one of: `:` (target separator), `/` (nested path),
|
|
1311
|
+
// or `.` (file extension). Empty → false.
|
|
1312
|
+
//
|
|
1313
|
+
// False positives are possible but cheap. A heading id like
|
|
1314
|
+
// `#v1.0.0`, `#menu/item`, or `#key:value` matches this heuristic
|
|
1315
|
+
// and will dispatch the action — but the consuming server is
|
|
1316
|
+
// expected to no-op on hashes whose path doesn't resolve to a known
|
|
1317
|
+
// file (prereview's SetURLHash does, via the loadDiffCached failure
|
|
1318
|
+
// path). The cost is one wasted roundtrip per false positive, which
|
|
1319
|
+
// is acceptable for the alternative of missing real deep links.
|
|
1320
|
+
//
|
|
1321
|
+
// False negatives: extension-less filenames at the repo root —
|
|
1322
|
+
// `#Makefile`, `#Dockerfile`, `#LICENSE` — don't match this
|
|
1323
|
+
// heuristic and won't be dispatched as path-only deep links. The
|
|
1324
|
+
// workaround is the line-form (`#Makefile:L1`), which always
|
|
1325
|
+
// dispatches. This trade-off is deliberate: a heuristic that
|
|
1326
|
+
// matched single-token hashes would also clobber every native
|
|
1327
|
+
// anchor / popover id, which is a much worse default. Consumers
|
|
1328
|
+
// that need extension-less file deep links can build a richer
|
|
1329
|
+
// directive on top.
|
|
1330
|
+
function looksLikeDeepLinkHash(hash) {
|
|
1331
|
+
if (!hash)
|
|
1332
|
+
return false;
|
|
1333
|
+
return hash.includes(":") || hash.includes("/") || hash.includes(".");
|
|
1334
|
+
}
|
|
1335
|
+
function attachURLHash(el, action, initialSend) {
|
|
1336
|
+
const entry = {
|
|
1337
|
+
action,
|
|
1338
|
+
send: initialSend,
|
|
1339
|
+
cleanup: () => {
|
|
1340
|
+
urlHashArmed.delete(el);
|
|
1341
|
+
if (urlHashArmed.size === 0 && urlHashWindowListener) {
|
|
1342
|
+
window.removeEventListener("hashchange", urlHashWindowListener);
|
|
1343
|
+
urlHashWindowListener = null;
|
|
1344
|
+
}
|
|
1345
|
+
},
|
|
1346
|
+
updateSend: (s) => {
|
|
1347
|
+
entry.send = s;
|
|
1348
|
+
},
|
|
1349
|
+
currentDataHash: "",
|
|
1350
|
+
};
|
|
1351
|
+
if (!urlHashWindowListener) {
|
|
1352
|
+
urlHashWindowListener = () => {
|
|
1353
|
+
const hash = window.location.hash.replace(/^#/, "");
|
|
1354
|
+
// Only dispatch hashes that look like deep-link targets — they
|
|
1355
|
+
// contain `:` (target separator), `/` (nested path), or `.`
|
|
1356
|
+
// (file extension). Plain element-id hashes like `#hero` or
|
|
1357
|
+
// `#confirm-delete-xyz` belong to the native anchor / dialog /
|
|
1358
|
+
// popover / details machinery (setupHashLink handles those) and
|
|
1359
|
+
// would otherwise be dispatched here, prompt a server no-op,
|
|
1360
|
+
// then get clobbered by the mirror step when the server's
|
|
1361
|
+
// data-attr (unchanged) doesn't match.
|
|
1362
|
+
//
|
|
1363
|
+
// Empty hash (user cleared the URL bar) is also intentionally
|
|
1364
|
+
// ignored. The directive treats the server as the source of
|
|
1365
|
+
// truth for "what's selected"; an empty URL is "user navigated
|
|
1366
|
+
// away from a hash" but not "deselect everything". If the user
|
|
1367
|
+
// wants to deselect, they use the in-app affordance
|
|
1368
|
+
// (clearSelection / Escape) which makes the server emit an
|
|
1369
|
+
// empty data-attr — at which point the mirror step propagates
|
|
1370
|
+
// the empty hash back to the URL.
|
|
1371
|
+
if (!looksLikeDeepLinkHash(hash))
|
|
1372
|
+
return;
|
|
1373
|
+
// Iterate via Array.from in case a dispatched action triggers a
|
|
1374
|
+
// render that mutates the armed map (e.g. tears down this
|
|
1375
|
+
// element). Each armed entry dispatches through its OWN send +
|
|
1376
|
+
// action so multi-arm is deterministic — typically the body is
|
|
1377
|
+
// the only armed element so this is one iteration.
|
|
1378
|
+
for (const e of Array.from(urlHashArmed.values())) {
|
|
1379
|
+
// Record the user-driven hash as the new baseline so the
|
|
1380
|
+
// next render's mirror step doesn't immediately revert it.
|
|
1381
|
+
e.currentDataHash = hash;
|
|
1382
|
+
e.send({ action: e.action, data: { hash } });
|
|
1383
|
+
}
|
|
1384
|
+
};
|
|
1385
|
+
window.addEventListener("hashchange", urlHashWindowListener);
|
|
1386
|
+
}
|
|
1387
|
+
return entry;
|
|
1388
|
+
}
|
|
1002
1389
|
// attachAreaSelect captures `send` in a mutable local so the
|
|
1003
1390
|
// idempotent re-arm path (same element, same action) can swap it via
|
|
1004
1391
|
// the returned `updateSend` callback without tearing down + rebuilding
|