@ecopages/core 0.2.0-beta.15 → 0.2.0-beta.17
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/package.json +34 -2
- package/src/adapters/shared/fs-server-response-matcher.js +1 -1
- package/src/client/navigation-scripts/classification.d.ts +16 -0
- package/src/client/navigation-scripts/classification.js +41 -0
- package/src/client/navigation-scripts/index.d.ts +3 -0
- package/src/client/navigation-scripts/index.js +23 -0
- package/src/client/navigation-scripts/navigation-scripts.test.browser.d.ts +1 -0
- package/src/client/navigation-scripts/navigation-scripts.test.browser.js +65 -0
- package/src/client/navigation-scripts/registry.d.ts +11 -0
- package/src/client/navigation-scripts/registry.js +12 -0
- package/src/client/navigation-scripts/rerun-queue.d.ts +37 -0
- package/src/client/navigation-scripts/rerun-queue.js +73 -0
- package/src/client/scroll.d.ts +17 -0
- package/src/client/scroll.js +25 -0
- package/src/client/scroll.test.browser.d.ts +1 -0
- package/src/client/scroll.test.browser.js +37 -0
- package/src/client/view-transitions.d.ts +16 -0
- package/src/client/view-transitions.js +63 -0
- package/src/router/client/link-intent.d.ts +11 -21
- package/src/router/client/link-intent.js +10 -0
- package/src/router/client/link-intent.test.browser.js +12 -1
- package/src/router/client/link-navigation-policy.d.ts +39 -0
- package/src/router/client/link-navigation-policy.js +86 -0
- package/src/router/client/link-navigation-policy.test.browser.d.ts +1 -0
- package/src/router/client/link-navigation-policy.test.browser.js +92 -0
- package/src/utils/server-utils.module.d.ts +3 -2
- package/src/utils/server-utils.module.js +4 -6
- package/src/utils/static-file-extensions.d.ts +12 -0
- package/src/utils/static-file-extensions.js +48 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ecopages/core",
|
|
3
|
-
"version": "0.2.0-beta.
|
|
3
|
+
"version": "0.2.0-beta.17",
|
|
4
4
|
"description": "Core package for Ecopages",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ecopages",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"directory": "packages/core"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@ecopages/file-system": "0.2.0-beta.
|
|
20
|
+
"@ecopages/file-system": "0.2.0-beta.17",
|
|
21
21
|
"@ecopages/logger": "^0.2.3",
|
|
22
22
|
"@ecopages/scripts-injector": "^0.1.5",
|
|
23
23
|
"@oxc-project/runtime": "0.134.0",
|
|
@@ -74,6 +74,22 @@
|
|
|
74
74
|
"types": "./src/router/client/link-intent.d.ts",
|
|
75
75
|
"default": "./src/router/client/link-intent.js"
|
|
76
76
|
},
|
|
77
|
+
"./router/link-navigation-policy": {
|
|
78
|
+
"types": "./src/router/client/link-navigation-policy.d.ts",
|
|
79
|
+
"default": "./src/router/client/link-navigation-policy.js"
|
|
80
|
+
},
|
|
81
|
+
"./client/scroll": {
|
|
82
|
+
"types": "./src/client/scroll.d.ts",
|
|
83
|
+
"default": "./src/client/scroll.js"
|
|
84
|
+
},
|
|
85
|
+
"./client/view-transitions": {
|
|
86
|
+
"types": "./src/client/view-transitions.d.ts",
|
|
87
|
+
"default": "./src/client/view-transitions.js"
|
|
88
|
+
},
|
|
89
|
+
"./client/navigation-scripts": {
|
|
90
|
+
"types": "./src/client/navigation-scripts/index.d.ts",
|
|
91
|
+
"default": "./src/client/navigation-scripts/index.js"
|
|
92
|
+
},
|
|
77
93
|
"./eco": {
|
|
78
94
|
"browser": "./src/eco/eco.browser.js",
|
|
79
95
|
"types": "./src/eco/eco.d.ts",
|
|
@@ -263,6 +279,22 @@
|
|
|
263
279
|
"types": "./src/router/client/link-intent.d.ts",
|
|
264
280
|
"default": "./src/router/client/link-intent.js"
|
|
265
281
|
},
|
|
282
|
+
"./router/link-navigation-policy.ts": {
|
|
283
|
+
"types": "./src/router/client/link-navigation-policy.d.ts",
|
|
284
|
+
"default": "./src/router/client/link-navigation-policy.js"
|
|
285
|
+
},
|
|
286
|
+
"./client/scroll.ts": {
|
|
287
|
+
"types": "./src/client/scroll.d.ts",
|
|
288
|
+
"default": "./src/client/scroll.js"
|
|
289
|
+
},
|
|
290
|
+
"./client/view-transitions.ts": {
|
|
291
|
+
"types": "./src/client/view-transitions.d.ts",
|
|
292
|
+
"default": "./src/client/view-transitions.js"
|
|
293
|
+
},
|
|
294
|
+
"./client/navigation-scripts.ts": {
|
|
295
|
+
"types": "./src/client/navigation-scripts/index.d.ts",
|
|
296
|
+
"default": "./src/client/navigation-scripts/index.js"
|
|
297
|
+
},
|
|
266
298
|
"./eco.ts": {
|
|
267
299
|
"browser": "./src/eco/eco.browser.js",
|
|
268
300
|
"types": "./src/eco/eco.d.ts",
|
|
@@ -37,7 +37,7 @@ class FileSystemResponseMatcher {
|
|
|
37
37
|
* @returns Static file response or rendered 404 response.
|
|
38
38
|
*/
|
|
39
39
|
async handleNoMatch(requestUrl) {
|
|
40
|
-
const isStaticFileRequest = ServerUtils.
|
|
40
|
+
const isStaticFileRequest = ServerUtils.hasKnownStaticExtension(requestUrl);
|
|
41
41
|
if (!isStaticFileRequest) {
|
|
42
42
|
return this.renderCustomNotFoundResponseOrServerError(requestUrl);
|
|
43
43
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classifies head scripts for navigation-time persistence and execution.
|
|
3
|
+
* @module
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Returns whether a script tag carries non-executable data (for example JSON-LD).
|
|
7
|
+
*/
|
|
8
|
+
export declare function isNonExecutableHeadScript(element: Element): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Returns whether an identified inline head script should survive head diffing.
|
|
11
|
+
*/
|
|
12
|
+
export declare function shouldPersistExecutableInlineHeadScript(element: Element): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Returns whether a script is queued for rerun after navigation commit.
|
|
15
|
+
*/
|
|
16
|
+
export declare function isRerunScript(element: Element): element is HTMLScriptElement;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const EXECUTABLE_SCRIPT_TYPES = /* @__PURE__ */ new Set([
|
|
2
|
+
"application/javascript",
|
|
3
|
+
"application/ecmascript",
|
|
4
|
+
"module",
|
|
5
|
+
"text/ecmascript",
|
|
6
|
+
"text/javascript"
|
|
7
|
+
]);
|
|
8
|
+
function isNonExecutableHeadScript(element) {
|
|
9
|
+
if (element.tagName !== "SCRIPT") {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
const type = (element.getAttribute("type") ?? "").trim().toLowerCase();
|
|
13
|
+
if (!type) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
return !EXECUTABLE_SCRIPT_TYPES.has(type);
|
|
17
|
+
}
|
|
18
|
+
function shouldPersistExecutableInlineHeadScript(element) {
|
|
19
|
+
if (element.tagName !== "SCRIPT") {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
const scriptId = element.getAttribute("data-eco-script-id") || element.getAttribute("id");
|
|
23
|
+
if (!scriptId) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
if (element.hasAttribute("data-eco-rerun")) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
if (element.src) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
return !isNonExecutableHeadScript(element);
|
|
33
|
+
}
|
|
34
|
+
function isRerunScript(element) {
|
|
35
|
+
return element.tagName === "SCRIPT" && element.hasAttribute("data-eco-rerun");
|
|
36
|
+
}
|
|
37
|
+
export {
|
|
38
|
+
isNonExecutableHeadScript,
|
|
39
|
+
isRerunScript,
|
|
40
|
+
shouldPersistExecutableInlineHeadScript
|
|
41
|
+
};
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { isNonExecutableHeadScript, isRerunScript, shouldPersistExecutableInlineHeadScript } from './classification.js';
|
|
2
|
+
export { RERUN_SRC_ATTR, getRegisteredRerunScript, type RerunScriptCallback } from './registry.js';
|
|
3
|
+
export { collectRerunScripts, createRerunScriptUrl, findExistingRerunScript, flushPendingRerunScripts, isExternalModuleRerunScript, resetRerunNonceForTests, type PendingRerunScript, } from './rerun-queue.js';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { isNonExecutableHeadScript, isRerunScript, shouldPersistExecutableInlineHeadScript } from "./classification.js";
|
|
2
|
+
import { RERUN_SRC_ATTR, getRegisteredRerunScript } from "./registry.js";
|
|
3
|
+
import {
|
|
4
|
+
collectRerunScripts,
|
|
5
|
+
createRerunScriptUrl,
|
|
6
|
+
findExistingRerunScript,
|
|
7
|
+
flushPendingRerunScripts,
|
|
8
|
+
isExternalModuleRerunScript,
|
|
9
|
+
resetRerunNonceForTests
|
|
10
|
+
} from "./rerun-queue.js";
|
|
11
|
+
export {
|
|
12
|
+
RERUN_SRC_ATTR,
|
|
13
|
+
collectRerunScripts,
|
|
14
|
+
createRerunScriptUrl,
|
|
15
|
+
findExistingRerunScript,
|
|
16
|
+
flushPendingRerunScripts,
|
|
17
|
+
getRegisteredRerunScript,
|
|
18
|
+
isExternalModuleRerunScript,
|
|
19
|
+
isNonExecutableHeadScript,
|
|
20
|
+
isRerunScript,
|
|
21
|
+
resetRerunNonceForTests,
|
|
22
|
+
shouldPersistExecutableInlineHeadScript
|
|
23
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { isNonExecutableHeadScript } from "./classification.js";
|
|
3
|
+
import {
|
|
4
|
+
collectRerunScripts,
|
|
5
|
+
createRerunScriptUrl,
|
|
6
|
+
flushPendingRerunScripts,
|
|
7
|
+
resetRerunNonceForTests
|
|
8
|
+
} from "./rerun-queue.js";
|
|
9
|
+
import { RERUN_SRC_ATTR } from "./registry.js";
|
|
10
|
+
describe("isNonExecutableHeadScript", () => {
|
|
11
|
+
it("treats JSON-LD scripts as non-executable", () => {
|
|
12
|
+
const script = document.createElement("script");
|
|
13
|
+
script.type = "application/ld+json";
|
|
14
|
+
expect(isNonExecutableHeadScript(script)).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
it("treats untyped scripts as executable", () => {
|
|
17
|
+
const script = document.createElement("script");
|
|
18
|
+
expect(isNonExecutableHeadScript(script)).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
describe("collectRerunScripts and flushPendingRerunScripts", () => {
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
document.head.replaceChildren();
|
|
24
|
+
document.body.replaceChildren();
|
|
25
|
+
resetRerunNonceForTests();
|
|
26
|
+
});
|
|
27
|
+
it("replays registered rerun callbacks without duplicating script tags", () => {
|
|
28
|
+
const callback = vi.fn();
|
|
29
|
+
window.__ECO_PAGES__ = {
|
|
30
|
+
rerunScripts: { "eco-test": callback }
|
|
31
|
+
};
|
|
32
|
+
try {
|
|
33
|
+
flushPendingRerunScripts([
|
|
34
|
+
{
|
|
35
|
+
parent: "head",
|
|
36
|
+
attributes: [
|
|
37
|
+
["data-eco-rerun", ""],
|
|
38
|
+
["data-eco-script-id", "eco-test"]
|
|
39
|
+
],
|
|
40
|
+
textContent: "",
|
|
41
|
+
src: null,
|
|
42
|
+
scriptId: "eco-test"
|
|
43
|
+
}
|
|
44
|
+
]);
|
|
45
|
+
expect(callback).toHaveBeenCalledOnce();
|
|
46
|
+
expect(document.head.querySelector('script[data-eco-script-id="eco-test"]')).toBeNull();
|
|
47
|
+
} finally {
|
|
48
|
+
delete window.__ECO_PAGES__;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
it("cache-busts external module rerun scripts", () => {
|
|
52
|
+
const html = '<html><head><script data-eco-rerun src="/app.js" type="module"><\/script></head></html>';
|
|
53
|
+
const parsed = new DOMParser().parseFromString(html, "text/html");
|
|
54
|
+
const scripts = collectRerunScripts(parsed);
|
|
55
|
+
flushPendingRerunScripts(scripts);
|
|
56
|
+
const inserted = document.head.querySelector(`script[${RERUN_SRC_ATTR}]`);
|
|
57
|
+
expect(inserted?.getAttribute("src")).toMatch(/__eco_rerun=1/);
|
|
58
|
+
expect(inserted?.getAttribute(RERUN_SRC_ATTR)).toBe("/app.js");
|
|
59
|
+
});
|
|
60
|
+
it("appends a unique nonce on each module rerun url", () => {
|
|
61
|
+
const first = createRerunScriptUrl("/app.js");
|
|
62
|
+
const second = createRerunScriptUrl("/app.js");
|
|
63
|
+
expect(first).not.toBe(second);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `__ECO_PAGES__.rerunScripts` registry access for navigation reruns.
|
|
3
|
+
* @module
|
|
4
|
+
*/
|
|
5
|
+
export type RerunScriptCallback = () => void;
|
|
6
|
+
/** Attribute storing the original module `src` after cache-busting. */
|
|
7
|
+
export declare const RERUN_SRC_ATTR = "data-eco-rerun-src";
|
|
8
|
+
/**
|
|
9
|
+
* Returns a registered in-memory rerun callback for the given script id.
|
|
10
|
+
*/
|
|
11
|
+
export declare function getRegisteredRerunScript(scriptId: string | null): RerunScriptCallback | null;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const RERUN_SRC_ATTR = "data-eco-rerun-src";
|
|
2
|
+
function getRegisteredRerunScript(scriptId) {
|
|
3
|
+
if (!scriptId) {
|
|
4
|
+
return null;
|
|
5
|
+
}
|
|
6
|
+
const runtimeWindow = window;
|
|
7
|
+
return runtimeWindow.__ECO_PAGES__?.rerunScripts?.[scriptId] ?? null;
|
|
8
|
+
}
|
|
9
|
+
export {
|
|
10
|
+
RERUN_SRC_ATTR,
|
|
11
|
+
getRegisteredRerunScript
|
|
12
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Queueing and replay of `data-eco-rerun` scripts after navigation.
|
|
3
|
+
* @module
|
|
4
|
+
*/
|
|
5
|
+
export type PendingRerunScript = {
|
|
6
|
+
parent: 'head' | 'body';
|
|
7
|
+
attributes: Array<[string, string]>;
|
|
8
|
+
textContent: string;
|
|
9
|
+
src: string | null;
|
|
10
|
+
scriptId: string | null;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Collects rerun scripts from an incoming document.
|
|
14
|
+
*/
|
|
15
|
+
export declare function collectRerunScripts(document: Document, filter?: (script: HTMLScriptElement) => boolean): PendingRerunScript[];
|
|
16
|
+
/**
|
|
17
|
+
* Returns whether a rerun script is an external ES module that needs cache busting.
|
|
18
|
+
*/
|
|
19
|
+
export declare function isExternalModuleRerunScript(script: PendingRerunScript): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Finds an existing rerun script in `root` by id or by src and inline content.
|
|
22
|
+
*/
|
|
23
|
+
export declare function findExistingRerunScript(root: ParentNode, script: PendingRerunScript): HTMLScriptElement | null;
|
|
24
|
+
/**
|
|
25
|
+
* Appends a nonce query parameter so module scripts re-execute after navigation.
|
|
26
|
+
*/
|
|
27
|
+
export declare function createRerunScriptUrl(src: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* Replays queued rerun scripts after the incoming page body is in place.
|
|
30
|
+
*
|
|
31
|
+
* @remarks
|
|
32
|
+
* Flushed elements keep `data-eco-rerun` so head cleanup does not treat them
|
|
33
|
+
* as persistable inline scripts on the next navigation.
|
|
34
|
+
*/
|
|
35
|
+
export declare function flushPendingRerunScripts(scripts: readonly PendingRerunScript[]): void;
|
|
36
|
+
/** @remarks Test-only reset for nonce sequencing. */
|
|
37
|
+
export declare function resetRerunNonceForTests(): void;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { RERUN_SRC_ATTR, getRegisteredRerunScript } from "./registry.js";
|
|
2
|
+
let rerunNonce = 0;
|
|
3
|
+
function collectRerunScripts(document2, filter) {
|
|
4
|
+
return Array.from(document2.querySelectorAll("script[data-eco-rerun]")).filter((script) => filter ? filter(script) : true).map((script) => ({
|
|
5
|
+
parent: script.closest("body") ? "body" : "head",
|
|
6
|
+
attributes: Array.from(script.attributes).map((attribute) => [attribute.name, attribute.value]),
|
|
7
|
+
textContent: script.textContent ?? "",
|
|
8
|
+
src: script.getAttribute("src"),
|
|
9
|
+
scriptId: script.getAttribute("data-eco-script-id")
|
|
10
|
+
}));
|
|
11
|
+
}
|
|
12
|
+
function isExternalModuleRerunScript(script) {
|
|
13
|
+
if (!script.src) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
return script.attributes.some(([name, value]) => name === "type" && value === "module");
|
|
17
|
+
}
|
|
18
|
+
function findExistingRerunScript(root, script) {
|
|
19
|
+
const scripts = Array.from(root.querySelectorAll("script"));
|
|
20
|
+
if (script.scriptId) {
|
|
21
|
+
return scripts.find((candidate) => candidate.getAttribute("data-eco-script-id") === script.scriptId) ?? null;
|
|
22
|
+
}
|
|
23
|
+
return scripts.find(
|
|
24
|
+
(candidate) => (candidate.getAttribute(RERUN_SRC_ATTR) ?? candidate.getAttribute("src")) === script.src && (candidate.textContent ?? "") === script.textContent
|
|
25
|
+
) ?? null;
|
|
26
|
+
}
|
|
27
|
+
function createRerunScriptUrl(src) {
|
|
28
|
+
const url = new URL(src, document.baseURI);
|
|
29
|
+
url.searchParams.set("__eco_rerun", String(++rerunNonce));
|
|
30
|
+
return url.toString();
|
|
31
|
+
}
|
|
32
|
+
function flushPendingRerunScripts(scripts) {
|
|
33
|
+
for (const script of scripts) {
|
|
34
|
+
const targetParent = script.parent === "body" ? document.body : document.head;
|
|
35
|
+
const registeredRerun = getRegisteredRerunScript(script.scriptId);
|
|
36
|
+
const replacement = document.createElement("script");
|
|
37
|
+
const shouldBustModuleSrc = isExternalModuleRerunScript(script) && !registeredRerun;
|
|
38
|
+
for (const [name, value] of script.attributes) {
|
|
39
|
+
if (name === "src" && shouldBustModuleSrc) {
|
|
40
|
+
replacement.setAttribute(RERUN_SRC_ATTR, value);
|
|
41
|
+
replacement.setAttribute("src", createRerunScriptUrl(value));
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
replacement.setAttribute(name, value);
|
|
45
|
+
}
|
|
46
|
+
replacement.textContent = script.textContent;
|
|
47
|
+
const existingScript = findExistingRerunScript(targetParent, script);
|
|
48
|
+
if (registeredRerun) {
|
|
49
|
+
const needsScriptElement = isExternalModuleRerunScript(script);
|
|
50
|
+
if (!existingScript && needsScriptElement) {
|
|
51
|
+
targetParent.appendChild(replacement);
|
|
52
|
+
}
|
|
53
|
+
registeredRerun();
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (existingScript) {
|
|
57
|
+
existingScript.replaceWith(replacement);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
targetParent.appendChild(replacement);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function resetRerunNonceForTests() {
|
|
64
|
+
rerunNonce = 0;
|
|
65
|
+
}
|
|
66
|
+
export {
|
|
67
|
+
collectRerunScripts,
|
|
68
|
+
createRerunScriptUrl,
|
|
69
|
+
findExistingRerunScript,
|
|
70
|
+
flushPendingRerunScripts,
|
|
71
|
+
isExternalModuleRerunScript,
|
|
72
|
+
resetRerunNonceForTests
|
|
73
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Window scroll behavior during client-side navigations.
|
|
3
|
+
* @module
|
|
4
|
+
*/
|
|
5
|
+
export type WindowScrollBehavior = 'top' | 'preserve' | 'auto';
|
|
6
|
+
export type ManageWindowScrollOptions = {
|
|
7
|
+
scrollBehavior: WindowScrollBehavior;
|
|
8
|
+
smoothScroll: boolean;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Updates window scroll position after a navigation commit.
|
|
12
|
+
*
|
|
13
|
+
* @remarks
|
|
14
|
+
* Hash navigations always scroll to the target element and ignore `scrollBehavior`.
|
|
15
|
+
* Fragment targets are resolved by element id (`#section` → `getElementById('section')`).
|
|
16
|
+
*/
|
|
17
|
+
export declare function manageWindowScroll(newUrl: URL, previousUrl: URL, options: ManageWindowScrollOptions): void;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
function manageWindowScroll(newUrl, previousUrl, options) {
|
|
2
|
+
const { scrollBehavior, smoothScroll } = options;
|
|
3
|
+
if (newUrl.hash) {
|
|
4
|
+
const target = document.getElementById(newUrl.hash.slice(1));
|
|
5
|
+
target?.scrollIntoView({ behavior: smoothScroll ? "smooth" : "instant" });
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
const behavior = smoothScroll ? "smooth" : "instant";
|
|
9
|
+
switch (scrollBehavior) {
|
|
10
|
+
case "preserve":
|
|
11
|
+
break;
|
|
12
|
+
case "auto":
|
|
13
|
+
if (newUrl.pathname !== previousUrl.pathname) {
|
|
14
|
+
window.scrollTo({ top: 0, left: 0, behavior });
|
|
15
|
+
}
|
|
16
|
+
break;
|
|
17
|
+
case "top":
|
|
18
|
+
default:
|
|
19
|
+
window.scrollTo({ top: 0, left: 0, behavior });
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export {
|
|
24
|
+
manageWindowScroll
|
|
25
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { manageWindowScroll } from "./scroll.js";
|
|
3
|
+
const options = { scrollBehavior: "top", smoothScroll: false };
|
|
4
|
+
describe("manageWindowScroll", () => {
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
history.replaceState({}, "", "/");
|
|
7
|
+
document.body.replaceChildren();
|
|
8
|
+
vi.restoreAllMocks();
|
|
9
|
+
});
|
|
10
|
+
it("scrolls to top by default", () => {
|
|
11
|
+
const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation(() => {
|
|
12
|
+
});
|
|
13
|
+
manageWindowScroll(new URL("/next", window.location.origin), new URL("/prev", window.location.origin), options);
|
|
14
|
+
expect(scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: "instant" });
|
|
15
|
+
});
|
|
16
|
+
it("preserves scroll when scrollBehavior is preserve", () => {
|
|
17
|
+
const scrollTo = vi.spyOn(window, "scrollTo").mockImplementation(() => {
|
|
18
|
+
});
|
|
19
|
+
manageWindowScroll(new URL("/next", window.location.origin), new URL("/prev", window.location.origin), {
|
|
20
|
+
...options,
|
|
21
|
+
scrollBehavior: "preserve"
|
|
22
|
+
});
|
|
23
|
+
expect(scrollTo).not.toHaveBeenCalled();
|
|
24
|
+
});
|
|
25
|
+
it("scrolls to hash target by element id", () => {
|
|
26
|
+
const section = document.createElement("section");
|
|
27
|
+
section.id = "section";
|
|
28
|
+
section.scrollIntoView = vi.fn();
|
|
29
|
+
document.body.append(section);
|
|
30
|
+
manageWindowScroll(
|
|
31
|
+
new URL("/page#section", window.location.origin),
|
|
32
|
+
new URL("/page", window.location.origin),
|
|
33
|
+
options
|
|
34
|
+
);
|
|
35
|
+
expect(section.scrollIntoView).toHaveBeenCalledWith({ behavior: "instant" });
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* View Transition API helpers for `data-view-transition` markup.
|
|
3
|
+
* @module
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Assigns `view-transition-name` from `data-view-transition` and injects morph styles.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* Elements without `data-view-transition-animate="fade"` get a geometric morph:
|
|
10
|
+
* the old snapshot is hidden so cross-fade ghosting does not appear.
|
|
11
|
+
*/
|
|
12
|
+
export declare function applyViewTransitionNames(): void;
|
|
13
|
+
/**
|
|
14
|
+
* Clears inline transition names and dynamic morph styles after a transition ends.
|
|
15
|
+
*/
|
|
16
|
+
export declare function clearViewTransitionNames(): void;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const VIEW_TRANSITION_ATTR = "data-view-transition";
|
|
2
|
+
const VIEW_TRANSITION_ANIMATE_ATTR = "data-view-transition-animate";
|
|
3
|
+
const VIEW_TRANSITION_DURATION_ATTR = "data-view-transition-duration";
|
|
4
|
+
const DYNAMIC_STYLES_ID = "eco-vt-dynamic-styles";
|
|
5
|
+
function applyViewTransitionNames() {
|
|
6
|
+
const elements = document.querySelectorAll(`[${VIEW_TRANSITION_ATTR}]`);
|
|
7
|
+
const morphNames = [];
|
|
8
|
+
const customDurations = [];
|
|
9
|
+
elements.forEach((el) => {
|
|
10
|
+
const name = el.getAttribute(VIEW_TRANSITION_ATTR);
|
|
11
|
+
if (!name) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
el.style.viewTransitionName = name;
|
|
15
|
+
const animate = el.getAttribute(VIEW_TRANSITION_ANIMATE_ATTR);
|
|
16
|
+
if (animate !== "fade") {
|
|
17
|
+
morphNames.push(name);
|
|
18
|
+
}
|
|
19
|
+
const duration = el.getAttribute(VIEW_TRANSITION_DURATION_ATTR);
|
|
20
|
+
if (duration) {
|
|
21
|
+
customDurations.push({ name, duration });
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
if (morphNames.length > 0 || customDurations.length > 0) {
|
|
25
|
+
injectDynamicStyles(morphNames, customDurations);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function clearViewTransitionNames() {
|
|
29
|
+
const elements = document.querySelectorAll(`[${VIEW_TRANSITION_ATTR}]`);
|
|
30
|
+
elements.forEach((el) => {
|
|
31
|
+
el.style.viewTransitionName = "";
|
|
32
|
+
});
|
|
33
|
+
const styleEl = document.getElementById(DYNAMIC_STYLES_ID);
|
|
34
|
+
if (styleEl) {
|
|
35
|
+
styleEl.textContent = "";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function injectDynamicStyles(morphNames, customDurations) {
|
|
39
|
+
let styleEl = document.getElementById(DYNAMIC_STYLES_ID);
|
|
40
|
+
if (!styleEl) {
|
|
41
|
+
styleEl = document.createElement("style");
|
|
42
|
+
styleEl.id = DYNAMIC_STYLES_ID;
|
|
43
|
+
styleEl.setAttribute("data-eco-persist", "");
|
|
44
|
+
document.head.append(styleEl);
|
|
45
|
+
}
|
|
46
|
+
const morphCss = morphNames.map(
|
|
47
|
+
(name) => `
|
|
48
|
+
::view-transition-old(${name}) { display: none !important; }
|
|
49
|
+
::view-transition-new(${name}) { animation: none !important; opacity: 1 !important; }
|
|
50
|
+
`
|
|
51
|
+
).join("\n");
|
|
52
|
+
const durationCss = customDurations.map(
|
|
53
|
+
({ name, duration }) => `
|
|
54
|
+
::view-transition-group(${name}) { animation-duration: ${duration} !important; }
|
|
55
|
+
`
|
|
56
|
+
).join("\n");
|
|
57
|
+
styleEl.textContent = `${morphCss}
|
|
58
|
+
${durationCss}`;
|
|
59
|
+
}
|
|
60
|
+
export {
|
|
61
|
+
applyViewTransitionNames,
|
|
62
|
+
clearViewTransitionNames
|
|
63
|
+
};
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
* Timestamped navigation intent captured from pointer or hover activity.
|
|
16
16
|
*/
|
|
17
17
|
export type EcoPendingNavigationIntent = {
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
href: string;
|
|
19
|
+
timestamp: number;
|
|
20
20
|
};
|
|
21
21
|
/**
|
|
22
22
|
* Finds the nearest matching anchor within an event's composed path.
|
|
@@ -28,26 +28,16 @@ export type EcoPendingNavigationIntent = {
|
|
|
28
28
|
* @param linkSelector - Selector that identifies navigable anchors.
|
|
29
29
|
* @returns The matched anchor element, or `null` when no matching anchor exists.
|
|
30
30
|
*/
|
|
31
|
-
export declare function getAnchorFromNavigationEvent(
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
export declare function getAnchorFromNavigationEvent(event: MouseEvent | PointerEvent, linkSelector: string): HTMLAnchorElement | null;
|
|
32
|
+
/**
|
|
33
|
+
* Returns whether an href targets a static asset that should use a full document load.
|
|
34
|
+
*/
|
|
35
|
+
export declare function isStaticAssetHref(href: string): boolean;
|
|
35
36
|
/**
|
|
36
37
|
* Resolves a previously captured intent while a navigation is still in flight.
|
|
37
38
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* @param intent - Previously captured pointer or hover intent.
|
|
43
|
-
* @param hasInFlightNavigation - Whether a router navigation is still active.
|
|
44
|
-
* @param now - Current monotonic timestamp, usually from `performance.now()`.
|
|
45
|
-
* @param maxAgeMs - Maximum allowed age for the recovered intent.
|
|
46
|
-
* @returns The intended href when still valid, otherwise `null`.
|
|
39
|
+
* @remarks
|
|
40
|
+
* Pending intents expire quickly because they bridge pointer or hover capture
|
|
41
|
+
* and the later click when the DOM changes during a rapid navigation sequence.
|
|
47
42
|
*/
|
|
48
|
-
export declare function recoverPendingNavigationHref(
|
|
49
|
-
intent: EcoPendingNavigationIntent | null,
|
|
50
|
-
hasInFlightNavigation: boolean,
|
|
51
|
-
now: number,
|
|
52
|
-
maxAgeMs?: number,
|
|
53
|
-
): string | null;
|
|
43
|
+
export declare function recoverPendingNavigationHref(intent: EcoPendingNavigationIntent | null, hasInFlightNavigation: boolean, now: number, maxAgeMs?: number): string | null;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasKnownStaticExtension } from "../../utils/static-file-extensions.js";
|
|
1
2
|
function getAnchorFromNavigationEvent(event, linkSelector) {
|
|
2
3
|
const eventTarget = event.target;
|
|
3
4
|
if (eventTarget instanceof HTMLAnchorElement && eventTarget.matches(linkSelector)) {
|
|
@@ -19,6 +20,14 @@ function getAnchorFromNavigationEvent(event, linkSelector) {
|
|
|
19
20
|
(target) => target instanceof HTMLAnchorElement && target.matches(linkSelector)
|
|
20
21
|
);
|
|
21
22
|
}
|
|
23
|
+
function isStaticAssetHref(href) {
|
|
24
|
+
try {
|
|
25
|
+
const url = new URL(href, "http://localhost");
|
|
26
|
+
return hasKnownStaticExtension(url.pathname);
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
22
31
|
function recoverPendingNavigationHref(intent, hasInFlightNavigation, now, maxAgeMs = 1e3) {
|
|
23
32
|
if (!intent || !hasInFlightNavigation) {
|
|
24
33
|
return null;
|
|
@@ -30,5 +39,6 @@ function recoverPendingNavigationHref(intent, hasInFlightNavigation, now, maxAge
|
|
|
30
39
|
}
|
|
31
40
|
export {
|
|
32
41
|
getAnchorFromNavigationEvent,
|
|
42
|
+
isStaticAssetHref,
|
|
33
43
|
recoverPendingNavigationHref
|
|
34
44
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { getAnchorFromNavigationEvent, recoverPendingNavigationHref } from "./link-intent.js";
|
|
2
|
+
import { getAnchorFromNavigationEvent, isStaticAssetHref, recoverPendingNavigationHref } from "./link-intent.js";
|
|
3
3
|
describe("getAnchorFromNavigationEvent", () => {
|
|
4
4
|
it("returns the anchor when the event target is a text node inside it", () => {
|
|
5
5
|
const anchor = document.createElement("a");
|
|
@@ -31,6 +31,17 @@ describe("getAnchorFromNavigationEvent", () => {
|
|
|
31
31
|
expect(getAnchorFromNavigationEvent(event, "a[data-eco-link]")).toBe(anchor);
|
|
32
32
|
});
|
|
33
33
|
});
|
|
34
|
+
describe("isStaticAssetHref", () => {
|
|
35
|
+
it("returns true for known static asset paths", () => {
|
|
36
|
+
expect(isStaticAssetHref("/skill.txt")).toBe(true);
|
|
37
|
+
expect(isStaticAssetHref("/skill/reference/full-stack.md")).toBe(true);
|
|
38
|
+
expect(isStaticAssetHref("/assets/logo.png")).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
it("returns false for application routes", () => {
|
|
41
|
+
expect(isStaticAssetHref("/docs/getting-started/introduction")).toBe(false);
|
|
42
|
+
expect(isStaticAssetHref("/")).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
34
45
|
describe("recoverPendingNavigationHref", () => {
|
|
35
46
|
it("returns null for stale or missing pending intent state", () => {
|
|
36
47
|
expect(recoverPendingNavigationHref(null, true, 10)).toBeNull();
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical link interception, prefetch eligibility, and HTML fetch guards.
|
|
3
|
+
* @module
|
|
4
|
+
*/
|
|
5
|
+
export type LinkNavigationPolicyOptions = {
|
|
6
|
+
/** Attribute that forces a full document reload instead of SPA navigation. */
|
|
7
|
+
reloadAttribute?: string;
|
|
8
|
+
/** Attribute that opts a link out of prefetch. */
|
|
9
|
+
noPrefetchAttribute?: string;
|
|
10
|
+
};
|
|
11
|
+
export type LinkNavigationDecision = {
|
|
12
|
+
shouldIntercept: true;
|
|
13
|
+
href: string;
|
|
14
|
+
} | {
|
|
15
|
+
shouldIntercept: false;
|
|
16
|
+
reason: 'modified-click' | 'non-left-click' | 'external-target' | 'explicit-reload' | 'download' | 'no-prefetch' | 'invalid-href' | 'cross-origin' | 'static-asset' | 'same-page-hash';
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Returns whether an href only adds a hash fragment on the current page.
|
|
20
|
+
*/
|
|
21
|
+
export declare function isSamePageHashNavigationHref(href: string): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Decides whether a pointer or click on an anchor should be handled as SPA navigation.
|
|
24
|
+
*/
|
|
25
|
+
export declare function getLinkNavigationDecision(event: MouseEvent | PointerEvent, link: HTMLAnchorElement, options: LinkNavigationPolicyOptions): LinkNavigationDecision;
|
|
26
|
+
/**
|
|
27
|
+
* Returns the href when SPA navigation should intercept the click, otherwise `null`.
|
|
28
|
+
*/
|
|
29
|
+
export declare function getNavigableHrefFromClick(event: MouseEvent | PointerEvent, link: HTMLAnchorElement, options: LinkNavigationPolicyOptions): string | null;
|
|
30
|
+
/**
|
|
31
|
+
* Returns whether a link is eligible for hover or viewport prefetch.
|
|
32
|
+
*/
|
|
33
|
+
export declare function shouldPrefetchLink(link: HTMLAnchorElement, options: LinkNavigationPolicyOptions): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* @remarks
|
|
36
|
+
* Prefetch and SPA fetches request HTML. Only explicit `text/html` and
|
|
37
|
+
* `application/xhtml+xml` responses are accepted; missing or asset types are rejected.
|
|
38
|
+
*/
|
|
39
|
+
export declare function isHtmlPageResponse(response: Response): boolean;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { isStaticAssetHref } from "./link-intent.js";
|
|
2
|
+
function getLinkIneligibilityReason(link, options) {
|
|
3
|
+
if (options.noPrefetchAttribute && link.hasAttribute(options.noPrefetchAttribute)) {
|
|
4
|
+
return "no-prefetch";
|
|
5
|
+
}
|
|
6
|
+
if (options.reloadAttribute && link.hasAttribute(options.reloadAttribute)) {
|
|
7
|
+
return "explicit-reload";
|
|
8
|
+
}
|
|
9
|
+
if (link.hasAttribute("download")) {
|
|
10
|
+
return "download";
|
|
11
|
+
}
|
|
12
|
+
const href = link.getAttribute("href");
|
|
13
|
+
if (!href || href.startsWith("#") || href.startsWith("javascript:")) {
|
|
14
|
+
return "invalid-href";
|
|
15
|
+
}
|
|
16
|
+
const url = new URL(href, window.location.origin);
|
|
17
|
+
if (url.origin !== window.location.origin) {
|
|
18
|
+
return "cross-origin";
|
|
19
|
+
}
|
|
20
|
+
if (isStaticAssetHref(href)) {
|
|
21
|
+
return "static-asset";
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
function isSamePageHashNavigationHref(href) {
|
|
26
|
+
if (!href) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
const currentUrl = new URL(window.location.href);
|
|
30
|
+
const targetUrl = new URL(href, currentUrl);
|
|
31
|
+
return targetUrl.origin === currentUrl.origin && targetUrl.hash.length > 0 && targetUrl.pathname === currentUrl.pathname && targetUrl.search === currentUrl.search;
|
|
32
|
+
}
|
|
33
|
+
function getLinkNavigationDecision(event, link, options) {
|
|
34
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
|
35
|
+
return { shouldIntercept: false, reason: "modified-click" };
|
|
36
|
+
}
|
|
37
|
+
if (event.button !== 0) {
|
|
38
|
+
return { shouldIntercept: false, reason: "non-left-click" };
|
|
39
|
+
}
|
|
40
|
+
const target = link.getAttribute("target");
|
|
41
|
+
if (target && target !== "_self") {
|
|
42
|
+
return { shouldIntercept: false, reason: "external-target" };
|
|
43
|
+
}
|
|
44
|
+
const ineligibility = getLinkIneligibilityReason(link, options);
|
|
45
|
+
if (ineligibility) {
|
|
46
|
+
return { shouldIntercept: false, reason: ineligibility };
|
|
47
|
+
}
|
|
48
|
+
const href = link.getAttribute("href");
|
|
49
|
+
if (isSamePageHashNavigationHref(href)) {
|
|
50
|
+
return { shouldIntercept: false, reason: "same-page-hash" };
|
|
51
|
+
}
|
|
52
|
+
return { shouldIntercept: true, href };
|
|
53
|
+
}
|
|
54
|
+
function getNavigableHrefFromClick(event, link, options) {
|
|
55
|
+
const decision = getLinkNavigationDecision(event, link, options);
|
|
56
|
+
return decision.shouldIntercept ? decision.href : null;
|
|
57
|
+
}
|
|
58
|
+
function shouldPrefetchLink(link, options) {
|
|
59
|
+
if (getLinkIneligibilityReason(link, options)) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
const href = link.getAttribute("href");
|
|
63
|
+
try {
|
|
64
|
+
const url = new URL(href, window.location.origin);
|
|
65
|
+
const currentPath = window.location.pathname + window.location.search;
|
|
66
|
+
const targetPath = url.pathname + url.search;
|
|
67
|
+
return currentPath !== targetPath;
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function isHtmlPageResponse(response) {
|
|
73
|
+
const contentType = response.headers.get("Content-Type");
|
|
74
|
+
if (!contentType) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
const normalized = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
78
|
+
return normalized === "text/html" || normalized === "application/xhtml+xml";
|
|
79
|
+
}
|
|
80
|
+
export {
|
|
81
|
+
getLinkNavigationDecision,
|
|
82
|
+
getNavigableHrefFromClick,
|
|
83
|
+
isHtmlPageResponse,
|
|
84
|
+
isSamePageHashNavigationHref,
|
|
85
|
+
shouldPrefetchLink
|
|
86
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
getLinkNavigationDecision,
|
|
4
|
+
getNavigableHrefFromClick,
|
|
5
|
+
isHtmlPageResponse,
|
|
6
|
+
isSamePageHashNavigationHref,
|
|
7
|
+
shouldPrefetchLink
|
|
8
|
+
} from "./link-navigation-policy.js";
|
|
9
|
+
function createLink(href, attributes = {}) {
|
|
10
|
+
const link = document.createElement("a");
|
|
11
|
+
link.href = href;
|
|
12
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
13
|
+
link.setAttribute(key, value);
|
|
14
|
+
}
|
|
15
|
+
document.body.append(link);
|
|
16
|
+
return link;
|
|
17
|
+
}
|
|
18
|
+
function createMouseEvent(button = 0, modifiers = {}) {
|
|
19
|
+
return new MouseEvent("click", { bubbles: true, cancelable: true, button, ...modifiers });
|
|
20
|
+
}
|
|
21
|
+
const policy = { reloadAttribute: "data-eco-reload", noPrefetchAttribute: "data-eco-no-prefetch" };
|
|
22
|
+
describe("isSamePageHashNavigationHref", () => {
|
|
23
|
+
it("returns true for same pathname with a new hash", () => {
|
|
24
|
+
history.replaceState({}, "", "/docs/page");
|
|
25
|
+
expect(isSamePageHashNavigationHref("/docs/page#section")).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe("getLinkNavigationDecision", () => {
|
|
29
|
+
it("allows same-origin page links", () => {
|
|
30
|
+
const link = createLink("/page");
|
|
31
|
+
const decision = getLinkNavigationDecision(createMouseEvent(), link, policy);
|
|
32
|
+
expect(decision).toEqual({ shouldIntercept: true, href: "/page" });
|
|
33
|
+
});
|
|
34
|
+
it("rejects static asset links", () => {
|
|
35
|
+
const link = createLink("/skill.txt");
|
|
36
|
+
expect(getLinkNavigationDecision(createMouseEvent(), link, policy)).toEqual({
|
|
37
|
+
shouldIntercept: false,
|
|
38
|
+
reason: "static-asset"
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
it("rejects explicit reload links", () => {
|
|
42
|
+
const link = createLink("/logout", { "data-eco-reload": "" });
|
|
43
|
+
expect(getLinkNavigationDecision(createMouseEvent(), link, policy)).toEqual({
|
|
44
|
+
shouldIntercept: false,
|
|
45
|
+
reason: "explicit-reload"
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe("getNavigableHrefFromClick", () => {
|
|
50
|
+
it("returns href when navigation should be intercepted", () => {
|
|
51
|
+
const link = createLink("/page");
|
|
52
|
+
expect(getNavigableHrefFromClick(createMouseEvent(), link, policy)).toBe("/page");
|
|
53
|
+
});
|
|
54
|
+
it("returns null for static assets", () => {
|
|
55
|
+
const link = createLink("/llms.txt");
|
|
56
|
+
expect(getNavigableHrefFromClick(createMouseEvent(), link, policy)).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
describe("shouldPrefetchLink", () => {
|
|
60
|
+
it("prefetches internal page links", () => {
|
|
61
|
+
history.replaceState({}, "", "/");
|
|
62
|
+
const link = createLink("/docs/intro");
|
|
63
|
+
expect(shouldPrefetchLink(link, policy)).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
it("skips static assets and opt-outs", () => {
|
|
66
|
+
history.replaceState({}, "", "/");
|
|
67
|
+
expect(shouldPrefetchLink(createLink("/skill.txt"), policy)).toBe(false);
|
|
68
|
+
expect(shouldPrefetchLink(createLink("/page", { "data-eco-no-prefetch": "" }), policy)).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
describe("isHtmlPageResponse", () => {
|
|
72
|
+
it("accepts html content types", () => {
|
|
73
|
+
expect(isHtmlPageResponse(new Response("", { headers: { "Content-Type": "text/html; charset=utf-8" } }))).toBe(
|
|
74
|
+
true
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
it("rejects plain text responses", () => {
|
|
78
|
+
expect(isHtmlPageResponse(new Response("", { headers: { "Content-Type": "text/plain" } }))).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
describe("isHtmlPageResponse", () => {
|
|
82
|
+
it("accepts html and xhtml content types", () => {
|
|
83
|
+
expect(isHtmlPageResponse(new Response("", { headers: { "Content-Type": "text/html" } }))).toBe(true);
|
|
84
|
+
expect(isHtmlPageResponse(new Response("", { headers: { "Content-Type": "application/xhtml+xml" } }))).toBe(
|
|
85
|
+
true
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
it("rejects plain text and markdown responses", () => {
|
|
89
|
+
expect(isHtmlPageResponse(new Response("text", { headers: { "Content-Type": "text/plain" } }))).toBe(false);
|
|
90
|
+
expect(isHtmlPageResponse(new Response("# doc", { headers: { "Content-Type": "text/markdown" } }))).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasKnownStaticExtension } from './static-file-extensions.js';
|
|
1
2
|
/**
|
|
2
3
|
* Get the content type of a file based on its extension.
|
|
3
4
|
* @param file - The file name.
|
|
@@ -9,11 +10,11 @@ export declare const getContentType: (file: string) => string;
|
|
|
9
10
|
* @param file - The file name or path.
|
|
10
11
|
* @returns true if the extension is recognized.
|
|
11
12
|
*/
|
|
12
|
-
export
|
|
13
|
+
export { hasKnownStaticExtension } from './static-file-extensions.js';
|
|
13
14
|
/**
|
|
14
15
|
* A module for server utilities.
|
|
15
16
|
*/
|
|
16
17
|
export declare const ServerUtils: {
|
|
17
18
|
getContentType: (file: string) => string;
|
|
18
|
-
|
|
19
|
+
hasKnownStaticExtension: typeof hasKnownStaticExtension;
|
|
19
20
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasKnownStaticExtension } from "./static-file-extensions.js";
|
|
1
2
|
const ContentTypeMap = /* @__PURE__ */ new Map([
|
|
2
3
|
["jpg", "image/jpeg"],
|
|
3
4
|
["jpeg", "image/jpeg"],
|
|
@@ -42,16 +43,13 @@ const getContentType = (file) => {
|
|
|
42
43
|
const extension = file.split(".").pop() || "txt";
|
|
43
44
|
return ContentTypeMap.get(extension) || "text/plain";
|
|
44
45
|
};
|
|
45
|
-
|
|
46
|
-
const extension = file.split(".").pop();
|
|
47
|
-
return extension !== void 0 && ContentTypeMap.has(extension);
|
|
48
|
-
};
|
|
46
|
+
import { hasKnownStaticExtension as hasKnownStaticExtension2 } from "./static-file-extensions.js";
|
|
49
47
|
const ServerUtils = {
|
|
50
48
|
getContentType,
|
|
51
|
-
|
|
49
|
+
hasKnownStaticExtension
|
|
52
50
|
};
|
|
53
51
|
export {
|
|
54
52
|
ServerUtils,
|
|
55
53
|
getContentType,
|
|
56
|
-
|
|
54
|
+
hasKnownStaticExtension2 as hasKnownStaticExtension
|
|
57
55
|
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Known static asset extensions served directly from disk.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* Keep this list aligned with server static-file handling and client routers
|
|
6
|
+
* that must bypass SPA navigation for same-origin asset links.
|
|
7
|
+
*/
|
|
8
|
+
export declare const STATIC_FILE_EXTENSIONS: Set<string>;
|
|
9
|
+
/**
|
|
10
|
+
* Returns whether a path or filename ends with a known static asset extension.
|
|
11
|
+
*/
|
|
12
|
+
export declare function hasKnownStaticExtension(fileOrPath: string): boolean;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const STATIC_FILE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
2
|
+
"jpg",
|
|
3
|
+
"jpeg",
|
|
4
|
+
"png",
|
|
5
|
+
"gif",
|
|
6
|
+
"bmp",
|
|
7
|
+
"svg",
|
|
8
|
+
"tiff",
|
|
9
|
+
"webp",
|
|
10
|
+
"avif",
|
|
11
|
+
"ico",
|
|
12
|
+
"mp3",
|
|
13
|
+
"ogg",
|
|
14
|
+
"wav",
|
|
15
|
+
"mp4",
|
|
16
|
+
"webm",
|
|
17
|
+
"ogv",
|
|
18
|
+
"mov",
|
|
19
|
+
"txt",
|
|
20
|
+
"md",
|
|
21
|
+
"html",
|
|
22
|
+
"css",
|
|
23
|
+
"js",
|
|
24
|
+
"mjs",
|
|
25
|
+
"json",
|
|
26
|
+
"map",
|
|
27
|
+
"xml",
|
|
28
|
+
"webmanifest",
|
|
29
|
+
"wasm",
|
|
30
|
+
"csv",
|
|
31
|
+
"ttf",
|
|
32
|
+
"woff",
|
|
33
|
+
"woff2",
|
|
34
|
+
"otf",
|
|
35
|
+
"eot",
|
|
36
|
+
"gz",
|
|
37
|
+
"zip",
|
|
38
|
+
"pdf",
|
|
39
|
+
"doc"
|
|
40
|
+
]);
|
|
41
|
+
function hasKnownStaticExtension(fileOrPath) {
|
|
42
|
+
const extension = fileOrPath.split(".").pop();
|
|
43
|
+
return extension !== void 0 && STATIC_FILE_EXTENSIONS.has(extension);
|
|
44
|
+
}
|
|
45
|
+
export {
|
|
46
|
+
STATIC_FILE_EXTENSIONS,
|
|
47
|
+
hasKnownStaticExtension
|
|
48
|
+
};
|