@warpgogol/werkstatt-shared 0.8.5 → 0.8.6
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 +7 -1
- package/src/share/entitlement.ts +5 -0
- package/src/share/import-scan.ts +71 -0
- package/src/share/scripts/external-link-qr.ts +196 -0
- package/src/share/scripts/external-links.ts +24 -2
- package/src/share/scripts/index.ts +1 -0
- package/src/share/scripts/orchestrator.ts +17 -4
- package/tsconfig.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@warpgogol/werkstatt-shared",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.6",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -210,6 +210,10 @@
|
|
|
210
210
|
"types": "./src/share/image-provider.ts",
|
|
211
211
|
"default": "./src/share/image-provider.ts"
|
|
212
212
|
},
|
|
213
|
+
"./share/import-scan": {
|
|
214
|
+
"types": "./src/share/import-scan.ts",
|
|
215
|
+
"default": "./src/share/import-scan.ts"
|
|
216
|
+
},
|
|
213
217
|
"./share/knowledge": {
|
|
214
218
|
"types": "./src/share/knowledge/index.ts",
|
|
215
219
|
"default": "./src/share/knowledge/index.ts"
|
|
@@ -1371,6 +1375,7 @@
|
|
|
1371
1375
|
"hls.js": "^1.6.16",
|
|
1372
1376
|
"lenis": "^1.3.25",
|
|
1373
1377
|
"plyr": "^3.8.4",
|
|
1378
|
+
"qrcode": "^1.5.4",
|
|
1374
1379
|
"gray-matter": "^4.0.3",
|
|
1375
1380
|
"yaml": "^2.9.0",
|
|
1376
1381
|
"zod": "^4.4.3"
|
|
@@ -1387,6 +1392,7 @@
|
|
|
1387
1392
|
"eslint": "^10.8.0",
|
|
1388
1393
|
"fast-check": "^4.9.0",
|
|
1389
1394
|
"gsap": "^3.15.0",
|
|
1395
|
+
"@types/qrcode": "^1.5.5",
|
|
1390
1396
|
"typescript": "^6.0.3",
|
|
1391
1397
|
"typescript-eslint": "8.65.0",
|
|
1392
1398
|
"vitest": "^4.1.10"
|
package/src/share/entitlement.ts
CHANGED
|
@@ -13,6 +13,7 @@ agnostic contract consumed by the build-time resolver, the feature gates, and ru
|
|
|
13
13
|
<item>RFC-0169: initial implementation.</item>
|
|
14
14
|
<item>RFC-0706: add nachweis feature for Nachweisregister commercial module.</item>
|
|
15
15
|
<item>RFC-0741: add multi-currency feature for multi-currency build pipeline.</item>
|
|
16
|
+
<item>RFC-0932: add external-link-qr feature for QR code modal entitlement module.</item>
|
|
16
17
|
</CHANGE_SUMMARY>
|
|
17
18
|
*/
|
|
18
19
|
|
|
@@ -37,6 +38,8 @@ export const ENTITLED_FEATURES = [
|
|
|
37
38
|
"nachweis",
|
|
38
39
|
// RFC-0741: multi-currency entitled feature
|
|
39
40
|
"multi-currency",
|
|
41
|
+
// RFC-0932: external-link QR code modal module
|
|
42
|
+
"external-link-qr",
|
|
40
43
|
] as const;
|
|
41
44
|
|
|
42
45
|
export type EntitledFeature = (typeof ENTITLED_FEATURES)[number];
|
|
@@ -62,6 +65,8 @@ export const STRIPE_FEATURE_LOOKUP_MAP: Record<string, EntitledFeature> = {
|
|
|
62
65
|
feature_nachweis: "nachweis",
|
|
63
66
|
// RFC-0741
|
|
64
67
|
feature_multi_currency: "multi-currency",
|
|
68
|
+
// RFC-0932
|
|
69
|
+
feature_external_link_qr: "external-link-qr",
|
|
65
70
|
};
|
|
66
71
|
|
|
67
72
|
/**
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>Shared utility for scanning TypeScript source files for import specifiers
|
|
4
|
+
matching a pattern. Used by werkstatt.autonomy.validate, werkstatt.shared.validate,
|
|
5
|
+
and forge.autonomy.validate to avoid code duplication (Fowler: Duplicated Code).</purpose>
|
|
6
|
+
<keywords>scan, import, utility, shared, validate, autonomy</keywords>
|
|
7
|
+
<non-goals>
|
|
8
|
+
<item>Does not define what is forbidden or exempt — callers provide the pattern and filter.</item>
|
|
9
|
+
<item>Does not scan test files — .test.ts and .spec.ts are always excluded.</item>
|
|
10
|
+
</non-goals>
|
|
11
|
+
</MODULE_CONTRACT>
|
|
12
|
+
<CHANGE_SUMMARY>
|
|
13
|
+
<item>RFC-0868: extract shared directory-scanning utility from autonomy-validate and shared-validate.</item>
|
|
14
|
+
<item>RFC-0940: move to @warpgogol/werkstatt-shared so forge can import without depending on @warpgogol/werkstatt.</item>
|
|
15
|
+
</CHANGE_SUMMARY>
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
19
|
+
import { join, relative } from "node:path";
|
|
20
|
+
|
|
21
|
+
export interface ImportViolation {
|
|
22
|
+
file: string;
|
|
23
|
+
specifier: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const EXCLUDE_DIRS = new Set(["node_modules", "tests", "tests-handoff", "dist", "templates"]);
|
|
27
|
+
const EXCLUDE_SUFFIXES = [".test.ts", ".spec.ts"];
|
|
28
|
+
|
|
29
|
+
const IMPORT_PATTERN =
|
|
30
|
+
/(?:^|\n)\s*(?:import\s+(?:type\s+)?[^;]+?\s+from\s+|require\s*\(\s*)["'`]([^"'`]+)["'`]/g;
|
|
31
|
+
|
|
32
|
+
function shouldExcludeFile(fileName: string): boolean {
|
|
33
|
+
return EXCLUDE_SUFFIXES.some((suffix) => fileName.endsWith(suffix));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function scanDirectoryForImports(
|
|
37
|
+
dir: string,
|
|
38
|
+
workspaceRoot: string,
|
|
39
|
+
specifierFilter: (specifier: string) => boolean,
|
|
40
|
+
): Promise<{ violations: ImportViolation[]; scannedFiles: number }> {
|
|
41
|
+
let scannedFiles = 0;
|
|
42
|
+
const violations: ImportViolation[] = [];
|
|
43
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
44
|
+
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
const fullPath = join(dir, entry.name);
|
|
47
|
+
|
|
48
|
+
if (entry.isDirectory()) {
|
|
49
|
+
if (EXCLUDE_DIRS.has(entry.name)) continue;
|
|
50
|
+
const subResult = await scanDirectoryForImports(fullPath, workspaceRoot, specifierFilter);
|
|
51
|
+
scannedFiles += subResult.scannedFiles;
|
|
52
|
+
violations.push(...subResult.violations);
|
|
53
|
+
} else if (entry.name.endsWith(".ts") && !shouldExcludeFile(entry.name)) {
|
|
54
|
+
scannedFiles++;
|
|
55
|
+
const content = await readFile(fullPath, "utf8").catch(() => "");
|
|
56
|
+
let match: RegExpExecArray | null;
|
|
57
|
+
const pattern = new RegExp(IMPORT_PATTERN.source, "g");
|
|
58
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
59
|
+
const specifier = match[1]!;
|
|
60
|
+
if (specifierFilter(specifier)) {
|
|
61
|
+
violations.push({
|
|
62
|
+
file: relative(workspaceRoot, fullPath),
|
|
63
|
+
specifier,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { violations, scannedFiles };
|
|
71
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>RFC-0932: Client-side QR code modal for external links. Attaches click/keyboard
|
|
4
|
+
handlers to [data-qr-trigger] elements, lazy-loads the qrcode library on first activation,
|
|
5
|
+
and manages the modal lifecycle (open, close, focus trap, body scroll lock).</purpose>
|
|
6
|
+
<non-goals>
|
|
7
|
+
<item>Do not generate QR codes for internal links or non-HTTP links.</item>
|
|
8
|
+
<item>Do not manage entitlement state — the orchestrator only calls initExternalLinkQr() when entitled.</item>
|
|
9
|
+
<item>Do not create the modal DOM — the modal element is rendered by layout-component.astro.</item>
|
|
10
|
+
</non-goals>
|
|
11
|
+
</MODULE_CONTRACT>
|
|
12
|
+
<CHANGE_SUMMARY>
|
|
13
|
+
<item>RFC-0932: initial implementation — QR modal lifecycle, dynamic qrcode import, focus trap, body scroll lock.</item>
|
|
14
|
+
</CHANGE_SUMMARY>
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface ExternalLinkQrOptions {
|
|
18
|
+
/** CSS selector for the modal container element. Default: "[data-external-link-qr-modal]" */
|
|
19
|
+
modalSelector?: string;
|
|
20
|
+
/** QR code error correction level. Default: "M" */
|
|
21
|
+
errorCorrectionLevel?: "L" | "M" | "Q" | "H";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DEFAULT_MODAL_SELECTOR = "[data-external-link-qr-modal]";
|
|
25
|
+
const DEFAULT_ERROR_CORRECTION = "M" as const;
|
|
26
|
+
|
|
27
|
+
let initialized = false;
|
|
28
|
+
|
|
29
|
+
export function initExternalLinkQr(options?: ExternalLinkQrOptions): void {
|
|
30
|
+
if (initialized) return;
|
|
31
|
+
initialized = true;
|
|
32
|
+
|
|
33
|
+
const modalSelector = options?.modalSelector ?? DEFAULT_MODAL_SELECTOR;
|
|
34
|
+
const errorCorrectionLevel = options?.errorCorrectionLevel ?? DEFAULT_ERROR_CORRECTION;
|
|
35
|
+
|
|
36
|
+
const modalEl = document.querySelector<HTMLElement>(modalSelector);
|
|
37
|
+
if (!modalEl) return;
|
|
38
|
+
const modal: HTMLElement = modalEl;
|
|
39
|
+
|
|
40
|
+
const overlay = modal.querySelector<HTMLElement>("[data-qr-overlay]");
|
|
41
|
+
const canvas = modal.querySelector<HTMLCanvasElement>("[data-qr-canvas]");
|
|
42
|
+
const closeBtnEl = modal.querySelector<HTMLButtonElement>("[data-qr-close]");
|
|
43
|
+
const openLinkBtn = modal.querySelector<HTMLAnchorElement>("[data-qr-open-link]");
|
|
44
|
+
const titleEl = modal.querySelector<HTMLElement>("[data-qr-title]");
|
|
45
|
+
const errorEl = modal.querySelector<HTMLElement>("[data-qr-error]");
|
|
46
|
+
|
|
47
|
+
if (!overlay || !closeBtnEl) return;
|
|
48
|
+
const closeBtn: HTMLButtonElement = closeBtnEl;
|
|
49
|
+
|
|
50
|
+
let lastFocused: HTMLElement | null = null;
|
|
51
|
+
|
|
52
|
+
function openModal(href: string, trigger: HTMLElement): void {
|
|
53
|
+
lastFocused = trigger;
|
|
54
|
+
|
|
55
|
+
if (titleEl) {
|
|
56
|
+
try {
|
|
57
|
+
const url = new URL(href);
|
|
58
|
+
titleEl.textContent = url.hostname.replace(/^www\./, "");
|
|
59
|
+
} catch {
|
|
60
|
+
titleEl.textContent = href;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (openLinkBtn) {
|
|
65
|
+
openLinkBtn.setAttribute("href", href);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (errorEl) {
|
|
69
|
+
errorEl.hidden = true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
modal.hidden = false;
|
|
73
|
+
document.body.style.overflow = "hidden";
|
|
74
|
+
|
|
75
|
+
if (canvas) {
|
|
76
|
+
generateQr(href).catch(() => {
|
|
77
|
+
if (errorEl) {
|
|
78
|
+
errorEl.hidden = false;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
closeBtn.focus();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function generateQr(href: string): Promise<void> {
|
|
87
|
+
if (!canvas) return;
|
|
88
|
+
const { toCanvas } = await import("qrcode");
|
|
89
|
+
await toCanvas(canvas, href, {
|
|
90
|
+
width: 256,
|
|
91
|
+
margin: 2,
|
|
92
|
+
errorCorrectionLevel,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function closeModal(): void {
|
|
97
|
+
modal.hidden = true;
|
|
98
|
+
document.body.style.overflow = "";
|
|
99
|
+
|
|
100
|
+
if (canvas) {
|
|
101
|
+
const ctx = canvas.getContext("2d");
|
|
102
|
+
if (ctx) {
|
|
103
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (lastFocused) {
|
|
108
|
+
lastFocused.focus();
|
|
109
|
+
lastFocused = null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isModalOpen(): boolean {
|
|
114
|
+
return !modal.hidden;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function handleTriggerClick(e: Event): void {
|
|
118
|
+
const trigger = e.currentTarget as HTMLElement;
|
|
119
|
+
const anchor = trigger.closest("a");
|
|
120
|
+
if (!anchor) return;
|
|
121
|
+
|
|
122
|
+
const href = anchor.getAttribute("href");
|
|
123
|
+
if (!href) return;
|
|
124
|
+
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
e.stopPropagation();
|
|
127
|
+
openModal(href, trigger);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function handleTriggerKeydown(e: KeyboardEvent): void {
|
|
131
|
+
if (e.key !== "Enter" && e.key !== " ") return;
|
|
132
|
+
e.preventDefault();
|
|
133
|
+
e.stopPropagation();
|
|
134
|
+
const trigger = e.currentTarget as HTMLElement;
|
|
135
|
+
const anchor = trigger.closest("a");
|
|
136
|
+
if (!anchor) return;
|
|
137
|
+
const href = anchor.getAttribute("href");
|
|
138
|
+
if (!href) return;
|
|
139
|
+
openModal(href, trigger);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function handleKeydown(e: KeyboardEvent): void {
|
|
143
|
+
if (!isModalOpen()) return;
|
|
144
|
+
|
|
145
|
+
if (e.key === "Escape") {
|
|
146
|
+
e.preventDefault();
|
|
147
|
+
closeModal();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (e.key === "Tab") {
|
|
152
|
+
const focusable = Array.from(
|
|
153
|
+
modal.querySelectorAll<HTMLElement>('button, [href], [tabindex]:not([tabindex="-1"])'),
|
|
154
|
+
).filter((el) => !el.hasAttribute("disabled") && el.offsetParent !== null);
|
|
155
|
+
|
|
156
|
+
if (focusable.length === 0) return;
|
|
157
|
+
|
|
158
|
+
const first = focusable[0];
|
|
159
|
+
const last = focusable[focusable.length - 1];
|
|
160
|
+
|
|
161
|
+
if (e.shiftKey) {
|
|
162
|
+
if (document.activeElement === first) {
|
|
163
|
+
e.preventDefault();
|
|
164
|
+
last.focus();
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
if (document.activeElement === last) {
|
|
168
|
+
e.preventDefault();
|
|
169
|
+
first.focus();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function attachTriggers(): void {
|
|
176
|
+
const triggers = document.querySelectorAll<HTMLElement>("[data-qr-trigger]");
|
|
177
|
+
for (const trigger of triggers) {
|
|
178
|
+
trigger.addEventListener("click", handleTriggerClick);
|
|
179
|
+
trigger.addEventListener("keydown", handleTriggerKeydown);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
overlay.addEventListener("click", closeModal);
|
|
184
|
+
closeBtn.addEventListener("click", closeModal);
|
|
185
|
+
document.addEventListener("keydown", handleKeydown);
|
|
186
|
+
|
|
187
|
+
if (canvas) {
|
|
188
|
+
canvas.style.cursor = "pointer";
|
|
189
|
+
canvas.addEventListener("click", () => {
|
|
190
|
+
const link = openLinkBtn?.getAttribute("href");
|
|
191
|
+
if (link) window.open(link, "_blank", "noopener,noreferrer");
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
attachTriggers();
|
|
196
|
+
}
|
|
@@ -1,20 +1,29 @@
|
|
|
1
1
|
/*
|
|
2
2
|
<MODULE_CONTRACT>
|
|
3
|
-
<purpose>Applies security attributes to external anchor elements on the page
|
|
3
|
+
<purpose>Applies security attributes to external anchor elements on the page. When the
|
|
4
|
+
external-link-qr entitlement is active, also injects a QR trigger span into each external link.</purpose>
|
|
4
5
|
<non-goals>
|
|
5
6
|
<item>Do not handle internal navigation or routing.</item>
|
|
6
7
|
<item>Do not modify non-HTTP links (mailto:, tel:, etc.).</item>
|
|
8
|
+
<item>Do not generate QR codes — that is the responsibility of external-link-qr.ts.</item>
|
|
7
9
|
</non-goals>
|
|
8
10
|
</MODULE_CONTRACT>
|
|
9
11
|
<CHANGE_SUMMARY>
|
|
10
12
|
<item>RFC-0011 Phase 2: Extracted from layout.astro inline script into src/scripts canonical pattern.</item>
|
|
11
13
|
<item>Migrated to @warpgogol/werkstatt-shared/share/scripts for platform-wide reuse.</item>
|
|
14
|
+
<item>RFC-0932: accept ExternalLinkBehaviorOptions, inject QR trigger span when entitled.</item>
|
|
12
15
|
</CHANGE_SUMMARY>
|
|
13
16
|
*/
|
|
14
17
|
|
|
18
|
+
export interface ExternalLinkBehaviorOptions {
|
|
19
|
+
/** Whether the external-link-qr entitlement is resolved. Default false (fail-closed). */
|
|
20
|
+
externalLinkQrEntitled?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
15
23
|
// @ai-invariant: This function must only modify external HTTP/HTTPS links. Never touch mailto:, tel:, or internal links.
|
|
16
|
-
export function applyExternalLinkBehavior(): void {
|
|
24
|
+
export function applyExternalLinkBehavior(options?: ExternalLinkBehaviorOptions): void {
|
|
17
25
|
const anchors = document.querySelectorAll("a[href]");
|
|
26
|
+
const qrEntitled = options?.externalLinkQrEntitled === true;
|
|
18
27
|
|
|
19
28
|
for (const anchor of anchors) {
|
|
20
29
|
const href = anchor.getAttribute("href");
|
|
@@ -41,5 +50,18 @@ export function applyExternalLinkBehavior(): void {
|
|
|
41
50
|
anchor.setAttribute("data-external-link", "1");
|
|
42
51
|
anchor.setAttribute("target", "_blank");
|
|
43
52
|
anchor.setAttribute("rel", "noopener noreferrer");
|
|
53
|
+
|
|
54
|
+
if (qrEntitled && anchor.getAttribute("data-external-link-qr") !== "off") {
|
|
55
|
+
if (!anchor.querySelector("[data-qr-trigger]")) {
|
|
56
|
+
const trigger = document.createElement("span");
|
|
57
|
+
trigger.className = "external-link-qr-trigger";
|
|
58
|
+
trigger.setAttribute("role", "button");
|
|
59
|
+
trigger.setAttribute("tabindex", "0");
|
|
60
|
+
trigger.setAttribute("aria-label", "QR-Code anzeigen");
|
|
61
|
+
trigger.setAttribute("data-qr-trigger", "");
|
|
62
|
+
trigger.textContent = "\u2197";
|
|
63
|
+
anchor.appendChild(trigger);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
44
66
|
}
|
|
45
67
|
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// scheduler.ts is loaded dynamically by orchestrator.ts and lordicon.ts
|
|
18
18
|
export * from "./lordicon.ts";
|
|
19
19
|
export * from "./external-links.ts";
|
|
20
|
+
export * from "./external-link-qr.ts";
|
|
20
21
|
export * from "./lenis.ts";
|
|
21
22
|
export * from "./orchestrator.ts";
|
|
22
23
|
// gsap-counter.ts (RFC-0040) is loaded dynamically by orchestrator.ts
|
|
@@ -34,6 +34,8 @@ export interface OrchestrationOptions {
|
|
|
34
34
|
videoPlayers?: boolean;
|
|
35
35
|
/** RFC-0205: opt in to Lenis smooth scroll. Default false — avoids loading the 17 KB bundle when not needed. */
|
|
36
36
|
smoothScroll?: boolean;
|
|
37
|
+
/** RFC-0932: entitlement state for external-link-qr module. Passed from layout. */
|
|
38
|
+
externalLinkQrEntitled?: boolean;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
const has = (selector: string) => document.querySelector(selector) instanceof Element;
|
|
@@ -49,11 +51,15 @@ export async function runStandardLayoutOrchestration(
|
|
|
49
51
|
// 1. External links security (mandatory)
|
|
50
52
|
if (has("a[href]")) {
|
|
51
53
|
if (document.readyState === "loading") {
|
|
52
|
-
document.addEventListener(
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
document.addEventListener(
|
|
55
|
+
"DOMContentLoaded",
|
|
56
|
+
() => applyExternalLinkBehavior({ externalLinkQrEntitled: options.externalLinkQrEntitled }),
|
|
57
|
+
{
|
|
58
|
+
once: true,
|
|
59
|
+
},
|
|
60
|
+
);
|
|
55
61
|
} else {
|
|
56
|
-
applyExternalLinkBehavior();
|
|
62
|
+
applyExternalLinkBehavior({ externalLinkQrEntitled: options.externalLinkQrEntitled });
|
|
57
63
|
}
|
|
58
64
|
}
|
|
59
65
|
|
|
@@ -139,4 +145,11 @@ export async function runStandardLayoutOrchestration(
|
|
|
139
145
|
await initVideoPlayers({ prefersReducedMotion });
|
|
140
146
|
});
|
|
141
147
|
}
|
|
148
|
+
|
|
149
|
+
// 11. RFC-0932: external-link QR code modal (opt-in via externalLinkQrEntitled: true).
|
|
150
|
+
// The modal element is rendered by layout-component.astro only on entitled sites.
|
|
151
|
+
if (options.externalLinkQrEntitled && has("[data-external-link-qr-modal]")) {
|
|
152
|
+
const { initExternalLinkQr } = await import("./external-link-qr.ts");
|
|
153
|
+
initExternalLinkQr();
|
|
154
|
+
}
|
|
142
155
|
}
|