@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.
@@ -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"}
@@ -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