@jini-ai/cms 0.5.5 → 0.5.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/dist/consent/express/index.d.ts +53 -0
- package/dist/consent/express/index.js +159 -0
- package/dist/consent/express/response.d.ts +21 -0
- package/dist/consent/express/response.js +89 -0
- package/dist/consent/html/client.d.ts +6 -0
- package/dist/consent/html/client.js +135 -0
- package/dist/consent/html/gate.d.ts +29 -0
- package/dist/consent/html/gate.js +104 -0
- package/dist/consent/html/index.d.ts +4 -0
- package/dist/consent/html/index.js +3 -0
- package/dist/consent/html/render.d.ts +27 -0
- package/dist/consent/html/render.js +62 -0
- package/dist/consent/html/static.d.ts +7 -0
- package/dist/consent/html/static.js +25 -0
- package/dist/consent/html/styles.d.ts +3 -0
- package/dist/consent/html/styles.js +22 -0
- package/dist/consent/index.d.ts +3 -0
- package/dist/consent/index.js +3 -0
- package/dist/consent/node/index.d.ts +47 -0
- package/dist/consent/node/index.js +169 -0
- package/dist/consent/policy.d.ts +47 -0
- package/dist/consent/policy.js +62 -0
- package/dist/consent/types.d.ts +40 -0
- package/dist/consent/types.js +5 -0
- package/dist/content-types/agent-tools.js +1 -1
- package/dist/content-types/tool-registrations.d.ts +2 -0
- package/dist/content-types/tool-registrations.js +1 -0
- package/dist/content-types/write-service.d.ts +2 -0
- package/dist/content-types/write-service.js +3 -0
- package/dist/forms/descriptor-rules.d.ts +9 -0
- package/dist/forms/descriptor-rules.js +112 -0
- package/dist/forms/email-format.d.ts +7 -0
- package/dist/forms/email-format.js +11 -0
- package/dist/forms/express/handler.js +4 -8
- package/dist/forms/field-registry.d.ts +193 -0
- package/dist/forms/field-registry.js +231 -0
- package/dist/forms/forms.d.ts +8 -5
- package/dist/forms/forms.js +29 -46
- package/dist/forms/html/html-authoring.js +63 -13
- package/dist/forms/html/html-submission-adapter.d.ts +5 -5
- package/dist/forms/html/html-submission-adapter.js +6 -13
- package/dist/forms/html-render.d.ts +10 -3
- package/dist/forms/html-render.js +116 -14
- package/dist/forms/index.d.ts +1 -0
- package/dist/forms/index.js +1 -0
- package/dist/forms/repo.memory.js +4 -4
- package/dist/forms/submission-attempts.d.ts +1 -1
- package/dist/forms/submission-attempts.js +13 -2
- package/dist/forms/submission-email-validation.js +2 -4
- package/dist/forms/submit-service.d.ts +3 -0
- package/dist/forms/submit-service.js +38 -5
- package/dist/forms/types.d.ts +50 -2
- package/dist/listing/batch.d.ts +53 -0
- package/dist/listing/batch.js +57 -0
- package/dist/listing/html.d.ts +72 -0
- package/dist/listing/html.js +109 -0
- package/dist/listing/index.d.ts +5 -0
- package/dist/listing/index.js +5 -0
- package/dist/listing/paths.d.ts +32 -0
- package/dist/listing/paths.js +37 -0
- package/dist/listing/see-more-script.d.ts +16 -0
- package/dist/listing/see-more-script.js +62 -0
- package/dist/media/content-type-sniffer.d.ts +1 -1
- package/dist/media/content-type-sniffer.js +15 -0
- package/dist/media/image-transformer.sharp.js +4 -1
- package/dist/media/media-service.d.ts +4 -0
- package/dist/media/media-service.js +8 -0
- package/dist/media/node.d.ts +1 -1
- package/dist/media/node.js +1 -1
- package/dist/media/transform-registry.js +1 -1
- package/dist/media/transform-types.d.ts +2 -0
- package/dist/media/transform-types.js +3 -0
- package/dist/media/video-frames.ffmpeg.d.ts +15 -2
- package/dist/media/video-frames.ffmpeg.js +28 -13
- package/dist/navigation/dropdown.d.ts +41 -0
- package/dist/navigation/dropdown.js +85 -0
- package/dist/navigation/index.d.ts +2 -0
- package/dist/navigation/index.js +2 -0
- package/dist/redirects/capture.d.ts +2 -0
- package/dist/redirects/capture.js +4 -0
- package/dist/redirects/redirects.d.ts +8 -0
- package/dist/redirects/redirects.js +14 -0
- package/dist/routing/index.d.ts +5 -0
- package/dist/routing/index.js +2 -0
- package/dist/routing/ports.d.ts +51 -0
- package/dist/routing/ports.js +2 -0
- package/dist/routing/routing.d.ts +138 -0
- package/dist/routing/routing.js +443 -0
- package/dist/routing/types.d.ts +164 -0
- package/dist/routing/types.js +2 -0
- package/dist/seo/media.d.ts +23 -0
- package/dist/seo/media.js +30 -5
- package/dist/seo/page-head.js +8 -1
- package/dist/seo/seo.d.ts +2 -0
- package/dist/seo/seo.js +11 -6
- package/dist/seo/types.d.ts +8 -0
- package/dist/seo/xml-escape.d.ts +1 -3
- package/dist/seo/xml-escape.js +1 -6
- package/dist/widgets/concurrency.d.ts +27 -1
- package/dist/widgets/concurrency.js +12 -23
- package/dist/widgets/config-validation.js +6 -0
- package/dist/widgets/gallery-resolver.d.ts +23 -0
- package/dist/widgets/gallery-resolver.js +80 -0
- package/dist/widgets/html/gallery-render.d.ts +24 -0
- package/dist/widgets/html/gallery-render.js +65 -0
- package/dist/widgets/html/html-embeds.d.ts +4 -0
- package/dist/widgets/html/html-embeds.js +7 -1
- package/dist/widgets/html/index.d.ts +3 -0
- package/dist/widgets/html/index.js +3 -0
- package/dist/widgets/html/server-fragment.d.ts +9 -0
- package/dist/widgets/html/server-fragment.js +38 -0
- package/dist/widgets/markers/gallery.d.ts +50 -0
- package/dist/widgets/markers/gallery.js +164 -0
- package/dist/widgets/markers/html-attributes.d.ts +6 -1
- package/dist/widgets/markers/html-attributes.js +24 -9
- package/dist/widgets/markers/index.d.ts +2 -0
- package/dist/widgets/markers/index.js +2 -0
- package/dist/widgets/markers/marker.d.ts +4 -0
- package/dist/widgets/markers/marker.js +6 -0
- package/dist/widgets/markers/media-reference.d.ts +11 -0
- package/dist/widgets/markers/media-reference.js +11 -0
- package/dist/widgets/page-embed-types.d.ts +1 -1
- package/dist/widgets/page-embed-types.js +1 -1
- package/dist/widgets/registry.d.ts +144 -10
- package/dist/widgets/registry.js +40 -0
- package/dist/widgets/resolver-service.d.ts +10 -0
- package/dist/widgets/resolver-service.js +73 -29
- package/dist/widgets/resolvers/create-core-resolvers.d.ts +3 -0
- package/dist/widgets/resolvers/create-core-resolvers.js +2 -0
- package/dist/widgets/resolvers/gallery.d.ts +12 -0
- package/dist/widgets/resolvers/gallery.js +33 -0
- package/dist/widgets/resolvers/index.d.ts +1 -0
- package/dist/widgets/resolvers/index.js +1 -0
- package/dist/widgets/types.d.ts +3 -1
- package/package.json +51 -6
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type Express, type Request, type Response } from 'express';
|
|
2
|
+
import { type ConsentConfig, type ConsentRecordPort } from '../types.js';
|
|
3
|
+
import type { ConsentGateOptions } from '../html/gate.js';
|
|
4
|
+
/** All host effects are explicit. Region defaults to unknown; no geo service is required. */
|
|
5
|
+
export interface ConsentHttpPorts {
|
|
6
|
+
config: {
|
|
7
|
+
get(required: Record<string, never>, optional?: Record<string, never>): Promise<ConsentConfig>;
|
|
8
|
+
};
|
|
9
|
+
records: ConsentRecordPort;
|
|
10
|
+
clock: {
|
|
11
|
+
nowMs(): number;
|
|
12
|
+
nowIso(): string;
|
|
13
|
+
};
|
|
14
|
+
ids: {
|
|
15
|
+
newId(): string;
|
|
16
|
+
};
|
|
17
|
+
readReceiptId(required: {
|
|
18
|
+
cookieHeader: string;
|
|
19
|
+
}, optional?: Record<string, never>): string | null;
|
|
20
|
+
origin(required: {
|
|
21
|
+
request: Request;
|
|
22
|
+
}, optional?: Record<string, never>): string;
|
|
23
|
+
region(required: {
|
|
24
|
+
request: Request;
|
|
25
|
+
}, optional?: Record<string, never>): string | null;
|
|
26
|
+
allowWrite(required: {
|
|
27
|
+
request: Request;
|
|
28
|
+
}, optional?: Record<string, never>): Promise<boolean>;
|
|
29
|
+
reportError(required: {
|
|
30
|
+
operation: 'read' | 'save';
|
|
31
|
+
error: unknown;
|
|
32
|
+
}, optional?: Record<string, never>): void;
|
|
33
|
+
}
|
|
34
|
+
/** Mount before public renderers, outside admin/API HTML. Durable proof precedes cookie issuance.
|
|
35
|
+
* This is a basic Consent Mode implementation: no Google script or denied cookieless ping loads.
|
|
36
|
+
* Errors are reported through the injected port, and never silently grant optional categories.
|
|
37
|
+
* @complexity Per page O(HTML bytes), one config/receipt read; per choice O(1), one durable write.
|
|
38
|
+
*/
|
|
39
|
+
export declare function registerConsentHttp({ app, ports }: {
|
|
40
|
+
app: Express;
|
|
41
|
+
ports: ConsentHttpPorts;
|
|
42
|
+
}, { endpoint, cookieName, nonce, necessaryScripts, resourceOrigins, delivery }?: {
|
|
43
|
+
endpoint?: string;
|
|
44
|
+
cookieName?: string;
|
|
45
|
+
nonce?: string | ((required: {
|
|
46
|
+
request: Request;
|
|
47
|
+
response: Response;
|
|
48
|
+
}) => string | undefined);
|
|
49
|
+
necessaryScripts?: ConsentGateOptions['necessaryScripts'];
|
|
50
|
+
resourceOrigins?: ConsentGateOptions['resourceOrigins'];
|
|
51
|
+
delivery?: 'live' | 'static';
|
|
52
|
+
}): void;
|
|
53
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import express, {} from 'express';
|
|
3
|
+
import { createConsentRecord, googleConsentSignals, readConsentRecord, resolveConsentRegion } from '../policy.js';
|
|
4
|
+
import { CONSENT_MAX_AGE_MS } from '../types.js';
|
|
5
|
+
import { gateConsentHtml } from '../html/index.js';
|
|
6
|
+
import { blockConsentResponse, captureConsentHtml } from './response.js';
|
|
7
|
+
function isPublicPage(request) {
|
|
8
|
+
return ['GET', 'HEAD'].includes(request.method) && !/^\/(?:admin|api|theme-assets|assets|\.well-known)(?:\/|$)/.test(request.path);
|
|
9
|
+
}
|
|
10
|
+
/** Missing/malformed IDs never reach storage. Changed policy versions require another choice.
|
|
11
|
+
* @complexity O(1) and at most one bounded record read. */
|
|
12
|
+
async function currentRecord({ request, ports, config }) {
|
|
13
|
+
try {
|
|
14
|
+
const id = ports.readReceiptId({ cookieHeader: request.get('cookie') ?? '' });
|
|
15
|
+
if (!id || !/^[a-zA-Z0-9_-]{16,128}$/.test(id))
|
|
16
|
+
return { record: null, failed: false };
|
|
17
|
+
const value = await ports.records.read({ id });
|
|
18
|
+
const record = readConsentRecord({ value, policyVersion: config.policyVersion, nowMs: ports.clock.nowMs() });
|
|
19
|
+
if (value !== null && record === null) {
|
|
20
|
+
ports.reportError({ operation: 'read', error: new Error('Invalid consent receipt.') });
|
|
21
|
+
return { record: null, failed: true };
|
|
22
|
+
}
|
|
23
|
+
return { record, failed: false };
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
// Corrupt, oversized or unreadable proof is absence, never a visitor-wide outage.
|
|
27
|
+
ports.reportError({ operation: 'read', error });
|
|
28
|
+
return { record: null, failed: true };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Browsers supply Origin on normal POST; Referer supports progressively enhanced no-JS forms.
|
|
32
|
+
* Refuse a request with neither, and ignore caller-selected redirect destinations.
|
|
33
|
+
* @complexity O(header length), bounded by the HTTP server. */
|
|
34
|
+
function sameOrigin(request, origin) {
|
|
35
|
+
const source = request.get('origin') ?? request.get('referer');
|
|
36
|
+
if (!source)
|
|
37
|
+
return false;
|
|
38
|
+
try {
|
|
39
|
+
return new URL(source).origin === new URL(origin).origin;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function respondError(response, status, error) {
|
|
46
|
+
response.status(status).json({ error });
|
|
47
|
+
}
|
|
48
|
+
/** Mount before public renderers, outside admin/API HTML. Durable proof precedes cookie issuance.
|
|
49
|
+
* This is a basic Consent Mode implementation: no Google script or denied cookieless ping loads.
|
|
50
|
+
* Errors are reported through the injected port, and never silently grant optional categories.
|
|
51
|
+
* @complexity Per page O(HTML bytes), one config/receipt read; per choice O(1), one durable write.
|
|
52
|
+
*/
|
|
53
|
+
export function registerConsentHttp({ app, ports }, { endpoint = '/api/site-consent', cookieName = 'jini_consent', nonce, necessaryScripts, resourceOrigins, delivery = 'live' } = {}) {
|
|
54
|
+
let lastGood;
|
|
55
|
+
app.get(`${endpoint}/status`, async (_request, response) => {
|
|
56
|
+
response.set('Cache-Control', 'no-store').json({ ready: true });
|
|
57
|
+
});
|
|
58
|
+
app.post(endpoint, express.urlencoded({ extended: false, limit: '2kb', parameterLimit: 12 }), async (request, response) => {
|
|
59
|
+
response.set('Cache-Control', 'no-store');
|
|
60
|
+
try {
|
|
61
|
+
const origin = ports.origin({ request });
|
|
62
|
+
if (!sameOrigin(request, origin)) {
|
|
63
|
+
respondError(response, 403, 'Cookie preferences must be saved from this site.');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (!await ports.allowWrite({ request })) {
|
|
67
|
+
respondError(response, 429, 'Too many cookie preference changes. Please try again later.');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const config = await ports.config.get({});
|
|
71
|
+
if (!config.enabled) {
|
|
72
|
+
respondError(response, 409, 'Cookie consent is disabled on this site.');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const gpc = request.get('sec-gpc') === '1' || request.body?.gpc === '1';
|
|
76
|
+
let record;
|
|
77
|
+
try {
|
|
78
|
+
record = createConsentRecord({ fields: request.body ?? {}, config, id: ports.ids.newId(), timestamp: ports.clock.nowIso(), regionMode: resolveConsentRegion({ configuredMode: config.regionMode, region: ports.region({ request }) }), gpc });
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
respondError(response, 400, error instanceof Error ? error.message : 'Invalid cookie preferences.');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
await ports.records.save({ record });
|
|
85
|
+
response.cookie(cookieName, record.id, { httpOnly: true, sameSite: 'lax', secure: new URL(origin).protocol === 'https:', path: '/', maxAge: CONSENT_MAX_AGE_MS });
|
|
86
|
+
if (request.get('accept')?.includes('application/json')) {
|
|
87
|
+
response.json({ record, signals: googleConsentSignals({ choices: record.choices }) });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const referer = request.get('referer');
|
|
91
|
+
let destination = '/';
|
|
92
|
+
if (referer && sameOrigin(request, origin)) {
|
|
93
|
+
const url = new URL(referer, origin);
|
|
94
|
+
// A root path beginning // would become a cross-origin Location header.
|
|
95
|
+
if (url.origin === new URL(origin).origin && !url.pathname.startsWith('//'))
|
|
96
|
+
destination = url.pathname + url.search;
|
|
97
|
+
}
|
|
98
|
+
response.redirect(303, destination);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
ports.reportError({ operation: 'save', error });
|
|
102
|
+
respondError(response, 500, 'Cookie preferences could not be saved. Optional scripts remain paused. Please try again.');
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
app.use((request, response, next) => {
|
|
106
|
+
if (!isPublicPage(request)) {
|
|
107
|
+
next();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
captureConsentHtml({ response, transform: ({ body }) => deliver({ body }) }, { reportError: ({ error }) => ports.reportError({ operation: 'read', error }) });
|
|
111
|
+
async function deliver({ body }, _options = {}) {
|
|
112
|
+
let config;
|
|
113
|
+
let failed = false;
|
|
114
|
+
try {
|
|
115
|
+
config = await ports.config.get({});
|
|
116
|
+
lastGood = config;
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
ports.reportError({ operation: 'read', error });
|
|
120
|
+
if (lastGood?.enabled === false)
|
|
121
|
+
return body;
|
|
122
|
+
// A failed policy/proof read cannot safely decide that consent is off.
|
|
123
|
+
config = lastGood ?? { enabled: true, regionMode: 'opt-in', policyVersion: 'unavailable', privacyPolicyUrl: '', title: 'Cookie preferences', message: 'Optional content is paused while preferences are unavailable.', acceptLabel: 'Accept all', rejectLabel: 'Reject non-essential', preferencesLabel: 'Cookie preferences' };
|
|
124
|
+
failed = true;
|
|
125
|
+
}
|
|
126
|
+
if (!config.enabled)
|
|
127
|
+
return body;
|
|
128
|
+
response.set('Cache-Control', 'private, no-store');
|
|
129
|
+
response.vary('Cookie');
|
|
130
|
+
response.vary('Sec-GPC');
|
|
131
|
+
response.removeHeader('Content-Length');
|
|
132
|
+
response.removeHeader('ETag');
|
|
133
|
+
if (delivery === 'static') {
|
|
134
|
+
// Exported pages cannot POST a receipt. Refuse the export instead of publishing a broken theme.
|
|
135
|
+
response.removeHeader('Content-Encoding');
|
|
136
|
+
response.status(409).type('text/plain');
|
|
137
|
+
return Buffer.from('Static export requires cookie consent to be disabled. Review optional scripts before exporting.');
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
if (response.getHeader('Content-Encoding') && response.getHeader('Content-Encoding') !== 'identity')
|
|
141
|
+
throw new Error('Consent requires uncompressed HTML.');
|
|
142
|
+
const receipt = failed ? { record: null, failed: false } : await currentRecord({ request, ports, config });
|
|
143
|
+
const record = receipt.record;
|
|
144
|
+
const regionMode = failed || receipt.failed ? 'opt-in' : resolveConsentRegion({ configuredMode: config.regionMode, region: ports.region({ request }) });
|
|
145
|
+
const requestNonce = typeof nonce === 'function' ? nonce({ request, response }) : nonce;
|
|
146
|
+
const html = gateConsentHtml({ html: body.toString('utf8'), config, record, regionMode, gpc: request.get('sec-gpc') === '1', endpoint }, { runtimeToken: randomUUID(), ...(requestNonce === undefined ? {} : { nonce: requestNonce }), ...(necessaryScripts === undefined ? {} : { necessaryScripts }), ...(resourceOrigins === undefined ? {} : { resourceOrigins }) });
|
|
147
|
+
return Buffer.from(html);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
ports.reportError({ operation: 'read', error });
|
|
151
|
+
// Last-resort browser enforcement keeps the original page available when HTML processing fails.
|
|
152
|
+
blockConsentResponse({ response });
|
|
153
|
+
return body;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
next();
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Response } from 'express';
|
|
2
|
+
/** Last-resort browser enforcement keeps original HTML available after a transform failure.
|
|
3
|
+
* Existing CSP remains enforced alongside this policy. @complexity O(existing header length). */
|
|
4
|
+
export declare function blockConsentResponse({ response }: {
|
|
5
|
+
response: Response;
|
|
6
|
+
}, _options?: {}): void;
|
|
7
|
+
/** Intercept actual HTML at the common write/end boundary, including sendFile and pipes.
|
|
8
|
+
* Non-HTML output uses native methods without invoking policy/storage. HTML must be complete
|
|
9
|
+
* before parsing; callbacks acknowledge buffered writes and final completion remains native.
|
|
10
|
+
* @complexity O(n) time/space for n response bytes, plus the injected transformation. */
|
|
11
|
+
export declare function captureConsentHtml({ response, transform }: {
|
|
12
|
+
response: Response;
|
|
13
|
+
transform: (required: {
|
|
14
|
+
body: Buffer;
|
|
15
|
+
}) => Promise<Buffer>;
|
|
16
|
+
}, { reportError }?: {
|
|
17
|
+
reportError?: (required: {
|
|
18
|
+
error: unknown;
|
|
19
|
+
}) => void;
|
|
20
|
+
}): void;
|
|
21
|
+
//# sourceMappingURL=response.d.ts.map
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** Last-resort browser enforcement keeps original HTML available after a transform failure.
|
|
2
|
+
* Existing CSP remains enforced alongside this policy. @complexity O(existing header length). */
|
|
3
|
+
export function blockConsentResponse({ response }, _options = {}) {
|
|
4
|
+
const policy = "default-src 'self'; script-src 'none'; frame-src 'none'; object-src 'none'; connect-src 'none'; img-src 'self' data:; media-src 'self'; font-src 'self'; style-src 'self' 'unsafe-inline'; base-uri 'self'";
|
|
5
|
+
const existing = response.getHeader('Content-Security-Policy');
|
|
6
|
+
response.setHeader('Content-Security-Policy', existing ? [...(Array.isArray(existing) ? existing.map(String) : [String(existing)]), policy] : policy);
|
|
7
|
+
response.setHeader('Cache-Control', 'private, no-store');
|
|
8
|
+
response.removeHeader('Content-Length');
|
|
9
|
+
response.removeHeader('ETag');
|
|
10
|
+
}
|
|
11
|
+
/** Intercept actual HTML at the common write/end boundary, including sendFile and pipes.
|
|
12
|
+
* Non-HTML output uses native methods without invoking policy/storage. HTML must be complete
|
|
13
|
+
* before parsing; callbacks acknowledge buffered writes and final completion remains native.
|
|
14
|
+
* @complexity O(n) time/space for n response bytes, plus the injected transformation. */
|
|
15
|
+
export function captureConsentHtml({ response, transform }, { reportError = () => console.error("Consent HTML transform failed; optional content is blocked.") } = {}) {
|
|
16
|
+
const write = response.write, end = response.end, writeHead = response.writeHead, flushHeaders = response.flushHeaders;
|
|
17
|
+
const chunks = [];
|
|
18
|
+
let bytes = 0, buffering = false;
|
|
19
|
+
const isHtml = (_chunk) => !response.getHeader('content-type') || String(response.getHeader('content-type')).toLowerCase().includes('text/html');
|
|
20
|
+
const restore = () => { response.write = write; response.end = end; response.writeHead = writeHead; response.flushHeaders = flushHeaders; };
|
|
21
|
+
// Explicit header flushes must wait for the HTML transform, including sendFile/stream output.
|
|
22
|
+
response.writeHead = function (status, reasonOrHeaders, headers) {
|
|
23
|
+
response.statusCode = status;
|
|
24
|
+
if (typeof reasonOrHeaders === 'string')
|
|
25
|
+
response.statusMessage = reasonOrHeaders;
|
|
26
|
+
const values = typeof reasonOrHeaders === 'string' ? headers : reasonOrHeaders;
|
|
27
|
+
if (Array.isArray(values))
|
|
28
|
+
for (let i = 0; i < values.length; i += 2)
|
|
29
|
+
response.setHeader(String(values[i]), values[i + 1]);
|
|
30
|
+
else if (values && typeof values === 'object')
|
|
31
|
+
for (const [name, value] of Object.entries(values))
|
|
32
|
+
if (value !== undefined)
|
|
33
|
+
response.setHeader(name, value);
|
|
34
|
+
return this;
|
|
35
|
+
};
|
|
36
|
+
response.flushHeaders = function () { if (!isHtml('')) {
|
|
37
|
+
restore();
|
|
38
|
+
flushHeaders.call(this);
|
|
39
|
+
} };
|
|
40
|
+
response.write = function (chunk, encoding, callback) {
|
|
41
|
+
if (!buffering && !isHtml(chunk)) {
|
|
42
|
+
restore();
|
|
43
|
+
return Reflect.apply(write, this, [chunk, encoding, callback]);
|
|
44
|
+
}
|
|
45
|
+
buffering = true;
|
|
46
|
+
const value = Buffer.isBuffer(chunk) ? chunk : chunk instanceof Uint8Array ? Buffer.from(chunk) : Buffer.from(String(chunk), typeof encoding === 'string' ? encoding : 'utf8');
|
|
47
|
+
chunks.push(value);
|
|
48
|
+
bytes += value.length;
|
|
49
|
+
const done = typeof encoding === 'function' ? encoding : callback;
|
|
50
|
+
if (typeof done === 'function')
|
|
51
|
+
queueMicrotask(() => done());
|
|
52
|
+
return true;
|
|
53
|
+
};
|
|
54
|
+
response.end = function (chunk, encoding, callback) {
|
|
55
|
+
if (!buffering && !isHtml(chunk)) {
|
|
56
|
+
restore();
|
|
57
|
+
return Reflect.apply(end, this, [chunk, encoding, callback]);
|
|
58
|
+
}
|
|
59
|
+
if (chunk !== undefined && chunk !== null && typeof chunk !== 'function') {
|
|
60
|
+
const value = Buffer.isBuffer(chunk) ? chunk : chunk instanceof Uint8Array ? Buffer.from(chunk) : Buffer.from(String(chunk), typeof encoding === 'string' ? encoding : 'utf8');
|
|
61
|
+
chunks.push(value);
|
|
62
|
+
bytes += value.length;
|
|
63
|
+
}
|
|
64
|
+
const done = typeof chunk === 'function' ? chunk : typeof encoding === 'function' ? encoding : callback;
|
|
65
|
+
const collected = Buffer.concat(chunks, bytes);
|
|
66
|
+
// Headerless streams may split their opening tag across chunks; inspect the complete body.
|
|
67
|
+
const type = response.getHeader('content-type');
|
|
68
|
+
const html = String(type).toLowerCase().includes('text/html') || (!type && /^\s*<[a-z!]/i.test(collected.toString('utf8')));
|
|
69
|
+
void Promise.resolve().then(() => html ? transform({ body: collected }) : collected).catch(error => {
|
|
70
|
+
if (!response.destroyed)
|
|
71
|
+
blockConsentResponse({ response });
|
|
72
|
+
// An observability outage must never hold page completion or grant optional requests.
|
|
73
|
+
try {
|
|
74
|
+
reportError({ error });
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
console.error('Consent error reporter failed; optional content is blocked.');
|
|
78
|
+
}
|
|
79
|
+
return collected;
|
|
80
|
+
}).then(body => {
|
|
81
|
+
if (response.destroyed)
|
|
82
|
+
return;
|
|
83
|
+
restore();
|
|
84
|
+
Reflect.apply(end, response, [body, typeof done === 'function' ? done : undefined]);
|
|
85
|
+
});
|
|
86
|
+
return this;
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=response.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Vanilla enhancement: forms remain ordinary POST forms without this script.
|
|
2
|
+
* Signals are queued before any released tag. A changed choice reloads to stop already-running
|
|
3
|
+
* scripts; a new grant is released only after the server durably saved its receipt.
|
|
4
|
+
* Script release is ordered and each template is consumed once. O(s + l) for s deferred elements and l late listeners. */
|
|
5
|
+
export declare const CONSENT_CLIENT: string;
|
|
6
|
+
//# sourceMappingURL=client.d.ts.map
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/** Vanilla enhancement: forms remain ordinary POST forms without this script.
|
|
2
|
+
* Signals are queued before any released tag. A changed choice reloads to stop already-running
|
|
3
|
+
* scripts; a new grant is released only after the server durably saved its receipt.
|
|
4
|
+
* Script release is ordered and each template is consumed once. O(s + l) for s deferred elements and l late listeners. */
|
|
5
|
+
export const CONSENT_CLIENT = String.raw `(function () {
|
|
6
|
+
'use strict';
|
|
7
|
+
var node = document.getElementById('jini-consent-config');
|
|
8
|
+
if (!node) return;
|
|
9
|
+
var state = JSON.parse(node.textContent);
|
|
10
|
+
var gpc = state.gpc || navigator.globalPrivacyControl === true;
|
|
11
|
+
var choices = gpc ? state.gpcChoices : state.choices;
|
|
12
|
+
if (gpc && choices.marketing !== true) {
|
|
13
|
+
// navigator GPC may be absent from the request headers; enforce resource denial before parsing site styles.
|
|
14
|
+
var policy = document.createElement('meta');
|
|
15
|
+
policy.httpEquiv = 'Content-Security-Policy';
|
|
16
|
+
policy.content = state.resourcePolicy;
|
|
17
|
+
document.head.appendChild(policy);
|
|
18
|
+
}
|
|
19
|
+
window.dataLayer = window.dataLayer || [];
|
|
20
|
+
function signal(command, signals) {
|
|
21
|
+
// Google consumes gtag-style Arguments entries, not arbitrary arrays/objects.
|
|
22
|
+
(function () { window.dataLayer.push(arguments); })('consent', command, signals);
|
|
23
|
+
}
|
|
24
|
+
signal('default', state.deniedSignals);
|
|
25
|
+
if (state.hasRecord || state.optOut) signal('update', gpc ? state.gpcSignals : state.signals);
|
|
26
|
+
async function release() {
|
|
27
|
+
// Released theme scripts may register DCL listeners after the native event has fired.
|
|
28
|
+
// Replay only listeners added during release, so already-running necessary scripts do not run twice.
|
|
29
|
+
var late = [];
|
|
30
|
+
var byCapture = [new Map(), new Map()];
|
|
31
|
+
var add = document.addEventListener;
|
|
32
|
+
var remove = document.removeEventListener;
|
|
33
|
+
document.addEventListener = function (type, listener, options) {
|
|
34
|
+
var capture = typeof options === 'boolean' ? options : Boolean(options && options.capture);
|
|
35
|
+
var tracked = byCapture[Number(capture)];
|
|
36
|
+
if (type === 'DOMContentLoaded' && listener && !tracked.has(listener)) {
|
|
37
|
+
var entry = { listener: listener, options: options, removed: false };
|
|
38
|
+
late.push(entry); tracked.set(listener, entry);
|
|
39
|
+
}
|
|
40
|
+
return add.call(this, type, listener, options);
|
|
41
|
+
};
|
|
42
|
+
document.removeEventListener = function (type, listener, options) {
|
|
43
|
+
var capture = typeof options === 'boolean' ? options : Boolean(options && options.capture);
|
|
44
|
+
var tracked = byCapture[Number(capture)];
|
|
45
|
+
if (type === 'DOMContentLoaded' && tracked.has(listener)) { tracked.get(listener).removed = true; tracked.delete(listener); }
|
|
46
|
+
return remove.call(this, type, listener, options);
|
|
47
|
+
};
|
|
48
|
+
try {
|
|
49
|
+
var scripts = Array.from(document.querySelectorAll('template[data-jini-consent-script],template[data-jini-consent-resource]'));
|
|
50
|
+
for (var template of scripts) {
|
|
51
|
+
if (!['functional', 'analytics', 'marketing'].includes(template.dataset.category) || choices[template.dataset.category] !== true) continue;
|
|
52
|
+
if (template.hasAttribute('data-jini-consent-resource')) { template.replaceWith(template.content); continue; }
|
|
53
|
+
var original = template.content.querySelector('script');
|
|
54
|
+
if (!original) continue;
|
|
55
|
+
var script = document.createElement('script');
|
|
56
|
+
for (var attribute of original.attributes) script.setAttribute(attribute.name, attribute.value);
|
|
57
|
+
if (original.nonce) script.nonce = original.nonce;
|
|
58
|
+
script.textContent = original.textContent;
|
|
59
|
+
script.async = false;
|
|
60
|
+
var completion = Promise.resolve();
|
|
61
|
+
if (script.src || script.type === 'module') {
|
|
62
|
+
completion = new Promise(function (resolve) {
|
|
63
|
+
var timer;
|
|
64
|
+
function finish() { clearTimeout(timer); resolve(); }
|
|
65
|
+
script.addEventListener('load', finish, { once: true });
|
|
66
|
+
script.addEventListener('error', finish, { once: true });
|
|
67
|
+
// A broken external tag must not hold unrelated later tags indefinitely.
|
|
68
|
+
timer = setTimeout(finish, 15000);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
template.replaceWith(script);
|
|
72
|
+
await completion;
|
|
73
|
+
}
|
|
74
|
+
document.querySelectorAll('[data-jini-consent-attributes]').forEach(function (element) {
|
|
75
|
+
if (!['functional', 'analytics', 'marketing'].includes(element.dataset.jiniConsentCategory) || choices[element.dataset.jiniConsentCategory] !== true) return;
|
|
76
|
+
var attributes = JSON.parse(element.dataset.jiniConsentAttributes);
|
|
77
|
+
Object.keys(attributes).forEach(function (name) { element.setAttribute(name, attributes[name]); });
|
|
78
|
+
element.removeAttribute('data-jini-consent-attributes');
|
|
79
|
+
});
|
|
80
|
+
} finally { document.addEventListener = add; document.removeEventListener = remove; }
|
|
81
|
+
var event = new Event('DOMContentLoaded');
|
|
82
|
+
late.forEach(function (entry) {
|
|
83
|
+
if (entry.removed || (entry.options && entry.options.signal && entry.options.signal.aborted)) return;
|
|
84
|
+
if (entry.options && entry.options.once) remove.call(document, 'DOMContentLoaded', entry.listener, entry.options);
|
|
85
|
+
try {
|
|
86
|
+
if (typeof entry.listener === 'function') entry.listener.call(document, event);
|
|
87
|
+
else entry.listener.handleEvent(event);
|
|
88
|
+
} catch (failure) {
|
|
89
|
+
// Native dispatch isolates listener errors; keep later theme initializers running too.
|
|
90
|
+
setTimeout(function () { throw failure; }, 0);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
document.addEventListener('DOMContentLoaded', function () {
|
|
95
|
+
var banner = document.getElementById('jini-consent');
|
|
96
|
+
if (gpc) {
|
|
97
|
+
banner.querySelector('[data-jini-gpc-note]').hidden = false;
|
|
98
|
+
banner.querySelector('[name=gpc]').value = '1';
|
|
99
|
+
['analytics', 'marketing'].forEach(function (category) {
|
|
100
|
+
var input = banner.querySelector('[name=' + category + ']');
|
|
101
|
+
input.checked = false; input.disabled = true;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
var preferences = document.getElementById('jini-consent-preferences');
|
|
105
|
+
banner.querySelector('[data-jini-do-not-sell]').addEventListener('click', function () {
|
|
106
|
+
preferences.open = true;
|
|
107
|
+
banner.querySelector('[data-jini-do-not-sell-button]').focus();
|
|
108
|
+
});
|
|
109
|
+
banner.querySelector('form').addEventListener('submit', async function (event) {
|
|
110
|
+
if (!event.submitter) return;
|
|
111
|
+
event.preventDefault();
|
|
112
|
+
var form = event.currentTarget;
|
|
113
|
+
var data = new URLSearchParams(new FormData(form));
|
|
114
|
+
data.set('action', event.submitter.value);
|
|
115
|
+
var buttons = Array.from(form.querySelectorAll('button'));
|
|
116
|
+
buttons.forEach(function (button) { button.disabled = true; });
|
|
117
|
+
var error = banner.querySelector('[data-jini-consent-error]');
|
|
118
|
+
error.hidden = true;
|
|
119
|
+
try {
|
|
120
|
+
var response = await fetch(form.action, { method: 'POST', credentials: 'same-origin',
|
|
121
|
+
headers: { 'Accept': 'application/json' }, body: data });
|
|
122
|
+
var result = await response.json();
|
|
123
|
+
if (!response.ok) throw new Error(result.error || 'Cookie preferences could not be saved.');
|
|
124
|
+
signal('update', result.signals);
|
|
125
|
+
window.location.reload();
|
|
126
|
+
} catch (failure) {
|
|
127
|
+
error.textContent = failure.message || 'Cookie preferences could not be saved. Please try again.';
|
|
128
|
+
error.hidden = false;
|
|
129
|
+
buttons.forEach(function (button) { button.disabled = false; });
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
release();
|
|
133
|
+
}, { once: true });
|
|
134
|
+
})();`;
|
|
135
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type ConsentConfig, type ConsentRecord, type ConsentRegionMode } from '../types.js';
|
|
2
|
+
/** Exact host manifest entries, never declarations taken from page HTML. */
|
|
3
|
+
export interface ConsentGateOptions {
|
|
4
|
+
nonce?: string;
|
|
5
|
+
runtimeToken?: string;
|
|
6
|
+
/** Host-reviewed theme origins allowed by the pre-consent resource policy. */
|
|
7
|
+
resourceOrigins?: readonly string[];
|
|
8
|
+
necessaryScripts?: readonly ({
|
|
9
|
+
src: string;
|
|
10
|
+
content?: never;
|
|
11
|
+
} | {
|
|
12
|
+
content: string;
|
|
13
|
+
src?: never;
|
|
14
|
+
})[];
|
|
15
|
+
}
|
|
16
|
+
/** Gate delivered HTML using the existing parser, including no-JS and non-script requests.
|
|
17
|
+
* Only host allowlisted scripts are necessary. JSON/JSON-LD and authored templates stay inert.
|
|
18
|
+
* Resource hints are removed so browsers cannot fetch before consent. Source offsets preserve
|
|
19
|
+
* unrelated markup byte-for-byte; wrapping a parent consumes its subtree without overlapping edits.
|
|
20
|
+
* @complexity O(n + s log s + a*k) time/O(n) space: n bytes, s edits, a scripts, k manifest entries. */
|
|
21
|
+
export declare function gateConsentHtml({ html, config, record, regionMode, gpc, endpoint }: {
|
|
22
|
+
html: string;
|
|
23
|
+
config: ConsentConfig;
|
|
24
|
+
record: ConsentRecord | null;
|
|
25
|
+
regionMode: ConsentRegionMode;
|
|
26
|
+
gpc: boolean;
|
|
27
|
+
endpoint: string;
|
|
28
|
+
}, options?: ConsentGateOptions): string;
|
|
29
|
+
//# sourceMappingURL=gate.d.ts.map
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { parse, serializeOuter } from 'parse5';
|
|
2
|
+
import { escapeHtml } from '@jini-ai/core/text';
|
|
3
|
+
import {} from '../types.js';
|
|
4
|
+
import { renderConsentHead, renderConsentUi } from './render.js';
|
|
5
|
+
import { CONSENT_CLIENT } from './client.js';
|
|
6
|
+
/** Relative resources belong to the host. A base element is removed before delivery.
|
|
7
|
+
* @complexity O(URL length). Unknown schemes and protocol-relative URLs are external. */
|
|
8
|
+
function external({ url }, _options = {}) {
|
|
9
|
+
// Browsers normalize backslashes and embedded ASCII URL whitespace before fetching.
|
|
10
|
+
const normalized = url.trim().replace(/[\t\r\n]/g, '').replace(/\\/g, '/');
|
|
11
|
+
return !/^(?:[?#]|\/(?!\/)|\.?\.?\/|[a-zA-Z0-9_-]+(?:[./?#]|$))/.test(normalized) || /^[a-z][a-z0-9+.-]*:|^\/\//i.test(normalized);
|
|
12
|
+
}
|
|
13
|
+
/** Gate delivered HTML using the existing parser, including no-JS and non-script requests.
|
|
14
|
+
* Only host allowlisted scripts are necessary. JSON/JSON-LD and authored templates stay inert.
|
|
15
|
+
* Resource hints are removed so browsers cannot fetch before consent. Source offsets preserve
|
|
16
|
+
* unrelated markup byte-for-byte; wrapping a parent consumes its subtree without overlapping edits.
|
|
17
|
+
* @complexity O(n + s log s + a*k) time/O(n) space: n bytes, s edits, a scripts, k manifest entries. */
|
|
18
|
+
export function gateConsentHtml({ html, config, record, regionMode, gpc, endpoint }, options = {}) {
|
|
19
|
+
if (!config.enabled)
|
|
20
|
+
return html;
|
|
21
|
+
const document = parse(html, { sourceCodeLocationInfo: true, scriptingEnabled: false });
|
|
22
|
+
const stack = [...document.childNodes];
|
|
23
|
+
const edits = [];
|
|
24
|
+
let headStart, htmlStart, bodyStart;
|
|
25
|
+
let doctypeEnd = 0;
|
|
26
|
+
while (stack.length) {
|
|
27
|
+
const node = stack.pop();
|
|
28
|
+
if (node.nodeName === '#documentType')
|
|
29
|
+
doctypeEnd = node.sourceCodeLocation?.endOffset ?? 0;
|
|
30
|
+
if (!('tagName' in node))
|
|
31
|
+
continue;
|
|
32
|
+
const location = node.sourceCodeLocation;
|
|
33
|
+
const attrs = new Map(node.attrs.map(attr => [attr.name, attr.value]));
|
|
34
|
+
if (node.tagName === 'html' && location?.startTag)
|
|
35
|
+
htmlStart = location.startTag.endOffset;
|
|
36
|
+
if (node.tagName === 'head' && location?.startTag)
|
|
37
|
+
headStart = location.startTag.endOffset;
|
|
38
|
+
if (node.tagName === 'body' && location?.startTag)
|
|
39
|
+
bodyStart = location.startTag.endOffset;
|
|
40
|
+
const text = node.childNodes.filter(child => child.nodeName === '#text').map(child => 'value' in child ? child.value : '').join('');
|
|
41
|
+
// A marker exempts only our exact runtime in this response, never the surrounding page.
|
|
42
|
+
const runtime = node.tagName === 'script' && options.runtimeToken !== undefined && attrs.get('data-jini-consent-runtime') === options.runtimeToken && text === CONSENT_CLIENT;
|
|
43
|
+
const necessary = node.tagName === 'script' && (runtime || options.necessaryScripts?.some(entry => entry.src !== undefined ? attrs.get('src') === entry.src : !attrs.has('src') && text === entry.content));
|
|
44
|
+
const reserved = runtime ? [] : node.attrs.filter(attr => attr.name.startsWith('data-jini-consent-') || (attr.name === 'id' && ['jini-consent', 'jini-consent-config', 'jini-consent-preferences'].includes(attr.value)));
|
|
45
|
+
// Authored markers cannot become another release owner or shadow our UI/state IDs.
|
|
46
|
+
node.attrs = node.attrs.filter(attr => !reserved.includes(attr));
|
|
47
|
+
const rel = (attrs.get('rel') ?? '').toLowerCase().split(/\s+/);
|
|
48
|
+
if (location && (node.tagName === 'base' || (node.tagName === 'meta' && attrs.get('http-equiv')?.toLowerCase() === 'refresh') || (node.tagName === 'link' && rel.some(value => ['preload', 'modulepreload', 'prefetch', 'prerender', 'preconnect', 'dns-prefetch'].includes(value))))) {
|
|
49
|
+
// An as=fetch preload can fetch the same optional script URL, so remove all resource hints.
|
|
50
|
+
edits.push({ start: location.startOffset, end: location.endOffset, text: '' });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const type = (attrs.get('type') ?? '').trim().toLowerCase();
|
|
54
|
+
// Default deny includes legacy JavaScript MIME types, import maps and speculation rules.
|
|
55
|
+
const dataScript = node.tagName === 'script' && ['application/json', 'application/ld+json', 'text/plain', 'text/template', 'text/x-template'].includes(type);
|
|
56
|
+
const remote = (attrs.has('srcset') && /(?:^|[,\s])(?:https?:|\/\/)/i.test(attrs.get('srcset'))) || ['src', 'href', 'poster', 'data', 'srcset', 'background'].some(key => attrs.has(key) && external({ url: attrs.get(key) }));
|
|
57
|
+
const cssRequests = (node.tagName === 'style' && /url\s*\(|(?:-webkit-)?image-set\s*\(|@import|\\/i.test(text));
|
|
58
|
+
const resource = ['iframe', 'frame', 'object', 'embed', 'video', 'audio', 'source', 'track', 'noscript'].includes(node.tagName)
|
|
59
|
+
|| (['img', 'image', 'input'].includes(node.tagName) && (remote || attrs.has('data-consent-category') || attrs.get('width') === '1' || attrs.get('height') === '1'))
|
|
60
|
+
|| (remote && !['a', 'area', 'form', 'html', 'head', 'body', 'script'].includes(node.tagName)) || cssRequests
|
|
61
|
+
|| (attrs.has('data-consent-category') && !['html', 'head', 'body', 'script'].includes(node.tagName));
|
|
62
|
+
const declared = attrs.get('data-consent-category');
|
|
63
|
+
// Page labels may select optional categories; they can never grant necessity.
|
|
64
|
+
const category = declared === undefined || declared === 'necessary' ? (remote || resource ? 'marketing' : 'functional') : ['functional', 'analytics', 'marketing'].includes(declared) ? declared : 'blocked';
|
|
65
|
+
if (location && ((node.tagName === 'script' && !dataScript && !necessary) || resource)) {
|
|
66
|
+
// Normalize ignored/misnested end tags so they cannot escape the new template boundary.
|
|
67
|
+
const source = serializeOuter(node, { scriptingEnabled: false });
|
|
68
|
+
// A template in SVG/MathML is not an inert HTML template. Remove foreign scripts/resources.
|
|
69
|
+
const marker = node.tagName === 'script' ? 'data-jini-consent-script' : 'data-jini-consent-resource';
|
|
70
|
+
edits.push({ start: location.startOffset, end: location.endOffset, text: node.namespaceURI === 'http://www.w3.org/1999/xhtml' ? `<template ${marker} data-category="${category}">${source}</template>` : '' });
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (location?.startTag) {
|
|
74
|
+
for (const attr of reserved) {
|
|
75
|
+
const attrLocation = location.attrs?.[attr.name];
|
|
76
|
+
if (attrLocation)
|
|
77
|
+
edits.push({ start: attrLocation.startOffset, end: attrLocation.endOffset, text: '' });
|
|
78
|
+
}
|
|
79
|
+
const deferred = {};
|
|
80
|
+
for (const attr of node.attrs) {
|
|
81
|
+
const attrLocation = location.attrs?.[attr.name];
|
|
82
|
+
if (!attrLocation)
|
|
83
|
+
continue;
|
|
84
|
+
if ((attr.name === 'background' && external({ url: attr.value })) || /^on/i.test(attr.name) || (['href', 'action', 'formaction'].includes(attr.name) && /^[\s\x00-\x20]*javascript:/i.test(attr.value)) || (attr.name === 'style' && /url\s*\(|(?:-webkit-)?image-set\s*\(|@import|\\/i.test(attr.value))) {
|
|
85
|
+
deferred[attr.name] = attr.value;
|
|
86
|
+
edits.push({ start: attrLocation.startOffset, end: attrLocation.endOffset, text: '' });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (Object.keys(deferred).length)
|
|
90
|
+
edits.push({ start: location.startTag.endOffset - 1, end: location.startTag.endOffset - 1, text: ` data-jini-consent-attributes="${escapeHtml({ text: JSON.stringify(deferred) })}" data-jini-consent-category="${category}"` });
|
|
91
|
+
}
|
|
92
|
+
// Authored template contents are already inert; traversing them would release never-mounted code.
|
|
93
|
+
if (node.tagName !== 'template')
|
|
94
|
+
stack.push(...node.childNodes);
|
|
95
|
+
}
|
|
96
|
+
const bootstrapOffset = headStart ?? htmlStart ?? doctypeEnd;
|
|
97
|
+
edits.push({ start: bootstrapOffset, end: bootstrapOffset, text: renderConsentHead({ record, gpc, regionMode }, options) });
|
|
98
|
+
edits.push({ start: bodyStart ?? bootstrapOffset, end: bodyStart ?? bootstrapOffset, text: renderConsentUi({ config, record, regionMode, gpc, endpoint }) });
|
|
99
|
+
edits.sort((a, b) => b.start - a.start);
|
|
100
|
+
for (const edit of edits)
|
|
101
|
+
html = html.slice(0, edit.start) + edit.text + html.slice(edit.end);
|
|
102
|
+
return html;
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=gate.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type ConsentConfig, type ConsentRecord, type ConsentRegionMode } from '../types.js';
|
|
2
|
+
/** Refuse executable/data/protocol-relative privacy links; local pages are the common case.
|
|
3
|
+
* @complexity O(url length). */
|
|
4
|
+
export declare function consentPolicyHref({ url }: {
|
|
5
|
+
url: string;
|
|
6
|
+
}, _options?: {}): string | null;
|
|
7
|
+
/** Render one accessible progressive form. Config text is always text, never trusted HTML.
|
|
8
|
+
* Necessary cookies/default rejection work without JS. @complexity O(config text length). */
|
|
9
|
+
export declare function renderConsentUi({ config, record, regionMode, gpc, endpoint }: {
|
|
10
|
+
config: ConsentConfig;
|
|
11
|
+
record: ConsentRecord | null;
|
|
12
|
+
regionMode: ConsentRegionMode;
|
|
13
|
+
gpc: boolean;
|
|
14
|
+
endpoint: string;
|
|
15
|
+
}, _options?: {}): string;
|
|
16
|
+
/** Bootstrap must be inserted before every site script; JSON cannot terminate its script element.
|
|
17
|
+
* @complexity O(config text length). */
|
|
18
|
+
export declare function renderConsentHead({ record, gpc, regionMode }: {
|
|
19
|
+
record: ConsentRecord | null;
|
|
20
|
+
gpc: boolean;
|
|
21
|
+
regionMode?: ConsentRegionMode;
|
|
22
|
+
}, { nonce, runtimeToken, resourceOrigins }?: {
|
|
23
|
+
nonce?: string;
|
|
24
|
+
runtimeToken?: string;
|
|
25
|
+
resourceOrigins?: readonly string[];
|
|
26
|
+
}): string;
|
|
27
|
+
//# sourceMappingURL=render.d.ts.map
|