@warpgogol/werkstatt-shared 0.7.0 → 0.8.1
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 +30 -14
- package/src/checks/index.ts +1 -0
- package/src/checks/result-helpers.ts +60 -6
- package/src/checks/suppressions-config.ts +26 -1
- package/src/content/system-manifest.ts +8 -0
- package/src/ontology/archetypes/index.json +17 -1
- package/src/ontology/schemas/page-entry.ts +5 -7
- package/src/ontology/schemas/system/manifest.ts +8 -0
- package/src/ontology/schemas/system/verification.ts +40 -0
- package/src/passport/sign.ts +20 -14
- package/src/share/agent/ard-catalog.ts +133 -0
- package/src/share/agent/index.ts +1 -0
- package/src/share/content-reference.ts +7 -1
- package/src/share/middleware/access-protection.ts +174 -0
- package/src/share/middleware/tests/access-protection.test.ts +123 -0
- package/src/share/page.ts +4 -2
- package/src/share/routes/template-filter.ts +36 -0
- package/src/share/routes/tests/template-filter.test.ts +40 -0
- package/src/share/scripts/lenis.ts +1 -0
- package/src/share/semantic/block-extractors/index.ts +24 -0
- package/src/share/semantic/build-page.ts +39 -1
- package/src/share/semantic/extract.ts +85 -11
- package/src/share/semantic/ids.ts +13 -0
- package/src/share/semantic/jsonld/organization.ts +20 -0
- package/src/share/semantic/jsonld/video.ts +56 -0
- package/src/share/semantic/jsonld/webpage.ts +1 -1
- package/src/share/semantic/jsonld.ts +5 -0
- package/src/share/semantic/models.ts +9 -0
- package/src/share/semantic/organization-profile.ts +3 -2
- package/src/share/semantic/page-utils.ts +4 -10
- package/src/share/semantic/tests/split-sentences.test.ts +97 -0
- package/src/share/slug/heading-slugger.ts +26 -0
- package/src/share/slug/index.ts +15 -0
- package/src/share/slug/slug-id.ts +22 -0
- package/src/share/slug/slug-url.ts +24 -0
- package/src/share/slug/strategies.ts +68 -0
- package/src/share/slug/tests/slug.test.ts +84 -0
- package/src/share/tests/ard-catalog.test.ts +129 -0
- package/src/share/tests/build-page-price-markers.test.ts +3 -0
- package/src/share/tests/jsonld-video.test.ts +143 -0
- package/src/share/tests/jsonld-webpage.test.ts +69 -0
- package/src/share/tests/organization-jsonld.test.ts +52 -0
- package/src/share/types/axiom-study.d.ts +22 -0
- package/src/share/utility-registry.yaml +36 -0
- package/src/surface/blueprint-schema.ts +1 -1
- package/src/surface/blueprint-types.ts +1 -1
- package/tsconfig.json +11 -8
|
@@ -205,6 +205,7 @@ export function resolveReferencesInString(
|
|
|
205
205
|
lang: string,
|
|
206
206
|
defaultLang: string,
|
|
207
207
|
sourceRef?: SourceRef,
|
|
208
|
+
onUnresolved?: (ref: string, error: string) => void,
|
|
208
209
|
): string {
|
|
209
210
|
// RFC-0731: Check for this. as a pure reference (entire text is this.field.path)
|
|
210
211
|
if (
|
|
@@ -219,6 +220,7 @@ export function resolveReferencesInString(
|
|
|
219
220
|
if (result.resolved) {
|
|
220
221
|
return formatValue(result.value);
|
|
221
222
|
}
|
|
223
|
+
onUnresolved?.(text.trim(), result.error ?? "unresolved this. reference");
|
|
222
224
|
}
|
|
223
225
|
}
|
|
224
226
|
THIS_SCAN_PATTERN.lastIndex = 0;
|
|
@@ -228,6 +230,7 @@ export function resolveReferencesInString(
|
|
|
228
230
|
if (result.resolved) {
|
|
229
231
|
return formatValue(result.value);
|
|
230
232
|
}
|
|
233
|
+
onUnresolved?.(text, result.error ?? "unresolved reference");
|
|
231
234
|
return text;
|
|
232
235
|
}
|
|
233
236
|
|
|
@@ -310,9 +313,12 @@ export async function resolveReferencesDeep(
|
|
|
310
313
|
lang: string,
|
|
311
314
|
defaultLang: string,
|
|
312
315
|
sourceRef?: SourceRef,
|
|
316
|
+
onUnresolved?: (ref: string, error: string) => void,
|
|
313
317
|
): Promise<unknown> {
|
|
314
318
|
return substituteRefsDeep(data, (value) =>
|
|
315
|
-
Promise.resolve(
|
|
319
|
+
Promise.resolve(
|
|
320
|
+
resolveReferencesInString(index, value, lang, defaultLang, sourceRef, onUnresolved),
|
|
321
|
+
),
|
|
316
322
|
);
|
|
317
323
|
}
|
|
318
324
|
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>RFC-0899: Runtime access protection middleware for dev/alt subdomains. Checks Host header and requires Basic Auth with a 4-digit PIN for dev.* and alt.* hosts. Sets X-Robots-Tag headers to prevent indexing.</purpose>
|
|
4
|
+
<keywords>middleware, access-protection, basic-auth, pin, dev, alt, RFC-0899</keywords>
|
|
5
|
+
<responsibilities>
|
|
6
|
+
<item>Check Host header against dev.* and alt.* patterns — pass through for main domain.</item>
|
|
7
|
+
<item>Require Basic Auth (username: access, password: ACCESS_PIN env var) for dev/alt hosts.</item>
|
|
8
|
+
<item>Set X-Robots-Tag: noindex, nofollow, noai, noimageai on ALL dev/alt responses (including 401).</item>
|
|
9
|
+
<item>Use constant-time string comparison for auth check to prevent timing attacks.</item>
|
|
10
|
+
<item>Pass through when ACCESS_PIN is unset (allows new sites before protection is configured).</item>
|
|
11
|
+
</responsibilities>
|
|
12
|
+
<non-goals>
|
|
13
|
+
<item>Do not modify the HTML response body — only headers and access gating.</item>
|
|
14
|
+
<item>Do not activate for the main/production domain, even if the PIN secret is set on the main Worker.</item>
|
|
15
|
+
<item>Do not use Node.js-specific APIs (Buffer, crypto.timingSafeEqual) — runs in Cloudflare Workers runtime.</item>
|
|
16
|
+
</non-goals>
|
|
17
|
+
</MODULE_CONTRACT>
|
|
18
|
+
<CHANGE_SUMMARY>
|
|
19
|
+
<item>RFC-0899: Initial access protection middleware for dev/alt subdomains.</item>
|
|
20
|
+
</CHANGE_SUMMARY>
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { defineMiddleware } from "astro:middleware";
|
|
24
|
+
|
|
25
|
+
const NOINDEX_HEADER = "noindex, nofollow, noai, noimageai";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Constant-time string comparison to prevent timing attacks.
|
|
29
|
+
* Returns true if both strings are equal, false otherwise.
|
|
30
|
+
* Always processes the full length of both strings regardless of match status.
|
|
31
|
+
*/
|
|
32
|
+
function constantTimeEqual(a: string, b: string): boolean {
|
|
33
|
+
if (a.length !== b.length) return false;
|
|
34
|
+
let diff = 0;
|
|
35
|
+
for (let i = 0; i < a.length; i++) {
|
|
36
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
37
|
+
}
|
|
38
|
+
return diff === 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Check if a host is a dev or alt subdomain.
|
|
43
|
+
*/
|
|
44
|
+
function isDevOrAltHost(host: string): boolean {
|
|
45
|
+
return host.startsWith("dev.") || host.startsWith("alt.");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* RFC-0899: Check access protection for a request. Called from the Worker entry point
|
|
50
|
+
* (worker.ts) before passing to the Astro handler. This is necessary because Astro
|
|
51
|
+
* middleware does not run for prerendered static pages (output: "static") — the
|
|
52
|
+
* Cloudflare adapter serves them from the ASSETS binding directly.
|
|
53
|
+
*
|
|
54
|
+
* Returns a 401 Response if access is denied, or null if the request should pass through.
|
|
55
|
+
* For dev/alt hosts with no PIN set, returns null (pass through) — caller should add
|
|
56
|
+
* X-Robots-Tag header to the response.
|
|
57
|
+
*
|
|
58
|
+
* @param request - The incoming Request
|
|
59
|
+
* @param env - The Worker env object (contains ACCESS_PIN)
|
|
60
|
+
* @returns 401 Response if denied, null if pass through
|
|
61
|
+
*/
|
|
62
|
+
export function checkAccessProtection(
|
|
63
|
+
request: Request,
|
|
64
|
+
env: Record<string, unknown>,
|
|
65
|
+
): Response | null {
|
|
66
|
+
const host = request.headers.get("host") ?? "";
|
|
67
|
+
if (!isDevOrAltHost(host)) return null;
|
|
68
|
+
|
|
69
|
+
const pin = (env.ACCESS_PIN as string | undefined) ?? undefined;
|
|
70
|
+
|
|
71
|
+
// No PIN set — allow access (caller should add X-Robots-Tag)
|
|
72
|
+
if (!pin) return null;
|
|
73
|
+
|
|
74
|
+
// Check Basic Auth
|
|
75
|
+
const auth = request.headers.get("authorization") ?? "";
|
|
76
|
+
const expected = `Basic ${btoa(`access:${pin}`)}`;
|
|
77
|
+
|
|
78
|
+
if (auth && constantTimeEqual(auth, expected)) {
|
|
79
|
+
return null; // Authenticated — pass through
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Not authenticated — challenge
|
|
83
|
+
return new Response("Authentication required", {
|
|
84
|
+
status: 401,
|
|
85
|
+
headers: {
|
|
86
|
+
"WWW-Authenticate": 'Basic realm="Staging Access"',
|
|
87
|
+
"X-Robots-Tag": NOINDEX_HEADER,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* RFC-0899: Add X-Robots-Tag header to a response for dev/alt subdomains.
|
|
94
|
+
* Called after the Astro handler returns a response, if the host is dev/alt.
|
|
95
|
+
*
|
|
96
|
+
* @param response - The response to modify
|
|
97
|
+
* @returns A new response with X-Robots-Tag added (if dev/alt host)
|
|
98
|
+
*/
|
|
99
|
+
export function addNoIndexHeaderIfNeeded(request: Request, response: Response): Response {
|
|
100
|
+
const host = request.headers.get("host") ?? "";
|
|
101
|
+
if (!isDevOrAltHost(host)) return response;
|
|
102
|
+
|
|
103
|
+
const headers = new Headers(response.headers);
|
|
104
|
+
headers.set("X-Robots-Tag", NOINDEX_HEADER);
|
|
105
|
+
return new Response(response.body, {
|
|
106
|
+
status: response.status,
|
|
107
|
+
statusText: response.statusText,
|
|
108
|
+
headers,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// cloudflare:workers is only available in the Cloudflare Workers runtime.
|
|
113
|
+
// In Astro build (Node.js) the static import fails — resolve lazily so the
|
|
114
|
+
// middleware still loads; ACCESS_PIN is undefined in build (no runtime binding).
|
|
115
|
+
async function resolveAccessPin(): Promise<string | undefined> {
|
|
116
|
+
try {
|
|
117
|
+
const { env } = await import("cloudflare:workers");
|
|
118
|
+
return (env.ACCESS_PIN as string | undefined) ?? undefined;
|
|
119
|
+
} catch {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* RFC-0899: Access protection middleware for dev/alt subdomains.
|
|
126
|
+
*
|
|
127
|
+
* At runtime, checks the Host header:
|
|
128
|
+
* - dev.* or alt.* → require Basic Auth with ACCESS_PIN env var
|
|
129
|
+
* - main.* or production domain → no protection, pass through
|
|
130
|
+
* - No PIN configured → pass through but still set X-Robots-Tag
|
|
131
|
+
*
|
|
132
|
+
* The auth check happens BEFORE next() to short-circuit unauthorized access.
|
|
133
|
+
* X-Robots-Tag is set on ALL dev/alt responses including 401 challenges.
|
|
134
|
+
*
|
|
135
|
+
* Uses `cloudflare:workers` env import (Astro v6 removed `context.locals.runtime.env`).
|
|
136
|
+
* Uses `btoa()` (Workers runtime), not Node.js Buffer.
|
|
137
|
+
*/
|
|
138
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
139
|
+
export const accessProtectionMiddleware = defineMiddleware(async (context: any, next: any) => {
|
|
140
|
+
const host = context.request.headers.get("host") ?? "";
|
|
141
|
+
const isDevOrAlt = host.startsWith("dev.") || host.startsWith("alt.");
|
|
142
|
+
|
|
143
|
+
if (!isDevOrAlt) {
|
|
144
|
+
return next();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const pin = await resolveAccessPin();
|
|
148
|
+
|
|
149
|
+
// No PIN set — allow access but still set noindex headers
|
|
150
|
+
if (!pin) {
|
|
151
|
+
const response = await next();
|
|
152
|
+
response.headers.set("X-Robots-Tag", NOINDEX_HEADER);
|
|
153
|
+
return response;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Check Basic Auth BEFORE calling next()
|
|
157
|
+
const auth = context.request.headers.get("authorization") ?? "";
|
|
158
|
+
const expected = `Basic ${btoa(`access:${pin}`)}`;
|
|
159
|
+
|
|
160
|
+
if (auth && constantTimeEqual(auth, expected)) {
|
|
161
|
+
const response = await next();
|
|
162
|
+
response.headers.set("X-Robots-Tag", NOINDEX_HEADER);
|
|
163
|
+
return response;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Not authenticated — challenge with noindex headers
|
|
167
|
+
return new Response("Authentication required", {
|
|
168
|
+
status: 401,
|
|
169
|
+
headers: {
|
|
170
|
+
"WWW-Authenticate": 'Basic realm="Staging Access"',
|
|
171
|
+
"X-Robots-Tag": NOINDEX_HEADER,
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>RFC-0899: Unit tests for access protection middleware logic — constant-time comparison, host matching, PIN gating.</purpose>
|
|
4
|
+
</MODULE_CONTRACT>
|
|
5
|
+
<CHANGE_SUMMARY>
|
|
6
|
+
<item>RFC-0899: Initial middleware unit tests.</item>
|
|
7
|
+
</CHANGE_SUMMARY>
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it, vi } from "vitest";
|
|
11
|
+
|
|
12
|
+
const mockEnv: Record<string, unknown> = {};
|
|
13
|
+
vi.mock("cloudflare:workers", () => ({
|
|
14
|
+
env: mockEnv,
|
|
15
|
+
}));
|
|
16
|
+
vi.mock("astro:middleware", () => ({
|
|
17
|
+
defineMiddleware: (handler: unknown) => handler,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
describe("RFC-0899: access protection middleware", () => {
|
|
21
|
+
async function loadMiddleware() {
|
|
22
|
+
return (await import("../access-protection")).accessProtectionMiddleware as (
|
|
23
|
+
context: unknown,
|
|
24
|
+
next: () => Promise<Response>,
|
|
25
|
+
) => Promise<Response>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeContext(host: string, authHeader?: string) {
|
|
29
|
+
const headers = new Map<string, string>();
|
|
30
|
+
headers.set("host", host);
|
|
31
|
+
if (authHeader) headers.set("authorization", authHeader);
|
|
32
|
+
return {
|
|
33
|
+
request: {
|
|
34
|
+
headers: {
|
|
35
|
+
get: (name: string) => headers.get(name.toLowerCase()) ?? null,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function runMiddleware(
|
|
42
|
+
handler: (context: unknown, next: () => Promise<Response>) => Promise<Response>,
|
|
43
|
+
host: string,
|
|
44
|
+
authHeader?: string,
|
|
45
|
+
): Promise<Response & { _nextCalled: boolean }> {
|
|
46
|
+
let nextCalled = false;
|
|
47
|
+
const nextResponse = new Response("page content", { status: 200 });
|
|
48
|
+
const result = await handler(makeContext(host, authHeader), async () => {
|
|
49
|
+
nextCalled = true;
|
|
50
|
+
return nextResponse;
|
|
51
|
+
});
|
|
52
|
+
return Object.assign(result, { _nextCalled: nextCalled });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
it("passes through for main domain (no dev/alt prefix)", async () => {
|
|
56
|
+
mockEnv.ACCESS_PIN = "1234";
|
|
57
|
+
const handler = await loadMiddleware();
|
|
58
|
+
const res = await runMiddleware(handler, "example.com");
|
|
59
|
+
expect(res._nextCalled).toBe(true);
|
|
60
|
+
expect(res.status).toBe(200);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("returns 401 for dev.* without auth header when PIN is set", async () => {
|
|
64
|
+
mockEnv.ACCESS_PIN = "1234";
|
|
65
|
+
const handler = await loadMiddleware();
|
|
66
|
+
const res = await runMiddleware(handler, "dev.example.com");
|
|
67
|
+
expect(res._nextCalled).toBe(false);
|
|
68
|
+
expect(res.status).toBe(401);
|
|
69
|
+
expect(res.headers.get("WWW-Authenticate")).toBe('Basic realm="Staging Access"');
|
|
70
|
+
expect(res.headers.get("X-Robots-Tag")).toBe("noindex, nofollow, noai, noimageai");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns 401 for alt.* without auth header when PIN is set", async () => {
|
|
74
|
+
mockEnv.ACCESS_PIN = "1234";
|
|
75
|
+
const handler = await loadMiddleware();
|
|
76
|
+
const res = await runMiddleware(handler, "alt.example.com");
|
|
77
|
+
expect(res._nextCalled).toBe(false);
|
|
78
|
+
expect(res.status).toBe(401);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("passes through dev.* with correct Basic Auth", async () => {
|
|
82
|
+
mockEnv.ACCESS_PIN = "1234";
|
|
83
|
+
const expected = `Basic ${btoa("access:1234")}`;
|
|
84
|
+
const handler = await loadMiddleware();
|
|
85
|
+
const res = await runMiddleware(handler, "dev.example.com", expected);
|
|
86
|
+
expect(res._nextCalled).toBe(true);
|
|
87
|
+
expect(res.status).toBe(200);
|
|
88
|
+
expect(res.headers.get("X-Robots-Tag")).toBe("noindex, nofollow, noai, noimageai");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("returns 401 for dev.* with wrong PIN", async () => {
|
|
92
|
+
mockEnv.ACCESS_PIN = "1234";
|
|
93
|
+
const wrong = `Basic ${btoa("access:9999")}`;
|
|
94
|
+
const handler = await loadMiddleware();
|
|
95
|
+
const res = await runMiddleware(handler, "dev.example.com", wrong);
|
|
96
|
+
expect(res._nextCalled).toBe(false);
|
|
97
|
+
expect(res.status).toBe(401);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("passes through dev.* when PIN is not set (no env var)", async () => {
|
|
101
|
+
delete mockEnv.ACCESS_PIN;
|
|
102
|
+
const handler = await loadMiddleware();
|
|
103
|
+
const res = await runMiddleware(handler, "dev.example.com");
|
|
104
|
+
expect(res._nextCalled).toBe(true);
|
|
105
|
+
expect(res.status).toBe(200);
|
|
106
|
+
expect(res.headers.get("X-Robots-Tag")).toBe("noindex, nofollow, noai, noimageai");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("passes through alt.* when PIN env is undefined", async () => {
|
|
110
|
+
mockEnv.ACCESS_PIN = undefined;
|
|
111
|
+
const handler = await loadMiddleware();
|
|
112
|
+
const res = await runMiddleware(handler, "alt.example.com");
|
|
113
|
+
expect(res._nextCalled).toBe(true);
|
|
114
|
+
expect(res.headers.get("X-Robots-Tag")).toBe("noindex, nofollow, noai, noimageai");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("does not set X-Robots-Tag on main domain", async () => {
|
|
118
|
+
mockEnv.ACCESS_PIN = "1234";
|
|
119
|
+
const handler = await loadMiddleware();
|
|
120
|
+
const res = await runMiddleware(handler, "example.com");
|
|
121
|
+
expect(res.headers.get("X-Robots-Tag")).toBe(null);
|
|
122
|
+
});
|
|
123
|
+
});
|
package/src/share/page.ts
CHANGED
|
@@ -44,7 +44,7 @@ export interface PageEntry {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
export interface BlockEntry {
|
|
47
|
-
id
|
|
47
|
+
id: string;
|
|
48
48
|
type?: string; // CMS-facing archetype slug — validated by page.block.validate
|
|
49
49
|
use?: string; // PlanetName or MoonName — normalized from type for internal resolution
|
|
50
50
|
props: Record<string, unknown>;
|
|
@@ -75,7 +75,7 @@ export interface ShellConfig {
|
|
|
75
75
|
* All blocks in ResolvedPage.blocks passed visibility — invisible blocks are dropped.
|
|
76
76
|
*/
|
|
77
77
|
export interface ResolvedBlock {
|
|
78
|
-
/** Stable kebab-case block id from the content entry (null
|
|
78
|
+
/** Stable kebab-case block id from the content entry. Mandatory for content blocks (RFC-0914); null only for shell blocks injected by the pipeline. */
|
|
79
79
|
readonly id: string | null;
|
|
80
80
|
/** The PlanetName or MoonName identifying this block's archetype. */
|
|
81
81
|
readonly planetName: string;
|
|
@@ -121,6 +121,8 @@ export interface SectionProps {
|
|
|
121
121
|
defaultLanguageCode: string;
|
|
122
122
|
/** Zero-padded section index (01, 02, ...) for anchors and styling */
|
|
123
123
|
sectionNumber: string;
|
|
124
|
+
/** RFC-0914: Stable kebab-case block id from content entry, used as HTML id */
|
|
125
|
+
blockId: string;
|
|
124
126
|
/** Optional link registry for CTA/link resolution */
|
|
125
127
|
linkRegistry?: Record<string, string | null>;
|
|
126
128
|
/** Complete block.props as declared in page frontmatter */
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>
|
|
4
|
+
RFC-0917 canonical placeholder route template filter. Detects Astro dynamic
|
|
5
|
+
route templates (e.g. `[slug]`, `[version]`) that are expanded by dedicated
|
|
6
|
+
route generators, not actual pages. All system.md consumers MUST import this
|
|
7
|
+
utility instead of reimplementing the bracket-detection check inline.
|
|
8
|
+
</purpose>
|
|
9
|
+
<non-goals>
|
|
10
|
+
<item>Do not expand or resolve placeholder templates — only detect them.</item>
|
|
11
|
+
<item>Do not validate route syntax — bracket presence is the only signal.</item>
|
|
12
|
+
</non-goals>
|
|
13
|
+
</MODULE_CONTRACT>
|
|
14
|
+
<CHANGE_SUMMARY>
|
|
15
|
+
<item>RFC-0917: Initial creation — centralize placeholder route template filtering.</item>
|
|
16
|
+
</CHANGE_SUMMARY>
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Returns `true` if any route value in the given routes map contains
|
|
21
|
+
* `[` or `]` characters, indicating an Astro dynamic route template
|
|
22
|
+
* (e.g. `nachweis/[slug]`, `verify/[version]`).
|
|
23
|
+
*
|
|
24
|
+
* Returns `false` for `undefined`, `null`, or empty routes — safe default
|
|
25
|
+
* that avoids false positives when routes are missing or not yet resolved.
|
|
26
|
+
*
|
|
27
|
+
* Canonical utility (RFC-0917). All `system.md` consumers
|
|
28
|
+
* MUST import from `@warpgogol/werkstatt-shared/share/routes/template-filter`.
|
|
29
|
+
* Enforcement: `utility.provenance.validate` (RFC-0916).
|
|
30
|
+
*/
|
|
31
|
+
export function hasPlaceholderRoutes(routes?: Record<string, string> | null): boolean {
|
|
32
|
+
if (!routes) return false;
|
|
33
|
+
return Object.values(routes).some(
|
|
34
|
+
(slug) => typeof slug === "string" && (slug.includes("[") || slug.includes("]")),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { hasPlaceholderRoutes } from "../template-filter.ts";
|
|
3
|
+
|
|
4
|
+
describe("hasPlaceholderRoutes", () => {
|
|
5
|
+
it("returns true for routes containing [slug]", () => {
|
|
6
|
+
expect(hasPlaceholderRoutes({ de: "nachweis/[slug]" })).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("returns true for routes containing [version]", () => {
|
|
10
|
+
expect(hasPlaceholderRoutes({ de: "verify/[version]" })).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns true for routes containing [...path] (rest params)", () => {
|
|
14
|
+
expect(hasPlaceholderRoutes({ de: "docs/[...path]" })).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns true for mixed routes (some plain, some placeholder)", () => {
|
|
18
|
+
expect(hasPlaceholderRoutes({ de: "home", en: "blog/[slug]" })).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("returns false for plain routes without brackets", () => {
|
|
22
|
+
expect(hasPlaceholderRoutes({ de: "home", en: "about" })).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("returns false for undefined routes", () => {
|
|
26
|
+
expect(hasPlaceholderRoutes(undefined)).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns false for null routes", () => {
|
|
30
|
+
expect(hasPlaceholderRoutes(null)).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns false for empty routes object", () => {
|
|
34
|
+
expect(hasPlaceholderRoutes({})).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("returns false when route values are non-string", () => {
|
|
38
|
+
expect(hasPlaceholderRoutes({ de: 123 as unknown as string })).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -500,3 +500,27 @@ for (const blockType of PASSPORT_NOOP_TYPES) {
|
|
|
500
500
|
},
|
|
501
501
|
});
|
|
502
502
|
}
|
|
503
|
+
|
|
504
|
+
// ---------------------------------------------------------------------------
|
|
505
|
+
// Site-specific block types that have no semantic text to extract but must be
|
|
506
|
+
// registered so page.blocks.extract.validate does not fail.
|
|
507
|
+
// ---------------------------------------------------------------------------
|
|
508
|
+
const SITE_NOOP_TYPES = [
|
|
509
|
+
"nachweis-list",
|
|
510
|
+
"nachweis-detail",
|
|
511
|
+
"nachweis-verify",
|
|
512
|
+
"gratitude",
|
|
513
|
+
"open-source-registry",
|
|
514
|
+
"mountain-journey",
|
|
515
|
+
"dynamic-status-block",
|
|
516
|
+
"service-metadata-block",
|
|
517
|
+
] as const;
|
|
518
|
+
|
|
519
|
+
for (const blockType of SITE_NOOP_TYPES) {
|
|
520
|
+
BLOCK_EXTRACTORS.register<Record<string, unknown>>({
|
|
521
|
+
blockType,
|
|
522
|
+
extract() {
|
|
523
|
+
return { heading: "" };
|
|
524
|
+
},
|
|
525
|
+
});
|
|
526
|
+
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
<CHANGE_SUMMARY>
|
|
11
11
|
<item>RFC-0144: initial extraction of the shared per-page builder from the two duplicated paths.</item>
|
|
12
12
|
<item>RFC-0372: unified all page types through extractContentBlocks + extractPageHeading; removed home-specific branch and extractMarkdownProps; extractContentBlocks returns SemanticBlock[].</item>
|
|
13
|
+
<item>RFC-0912: extract seo.videoObject opt-in from block props and attach VideoSeoData to SemanticBlock.video.</item>
|
|
13
14
|
</CHANGE_SUMMARY>
|
|
14
15
|
*/
|
|
15
16
|
|
|
@@ -22,6 +23,7 @@ import type {
|
|
|
22
23
|
SemanticPageModel,
|
|
23
24
|
SemanticPageType,
|
|
24
25
|
SemanticPerson,
|
|
26
|
+
VideoSeoData,
|
|
25
27
|
} from "./models.ts";
|
|
26
28
|
import { BLOCK_EXTRACTORS } from "./block-extraction.ts";
|
|
27
29
|
import "./block-extractors/index.ts";
|
|
@@ -132,6 +134,31 @@ async function resolveFaqEntries(
|
|
|
132
134
|
return faqEntries;
|
|
133
135
|
}
|
|
134
136
|
|
|
137
|
+
/** RFC-0912: extract video SEO data from block props when seo.videoObject opt-in is present. */
|
|
138
|
+
function extractVideoSeoData(props: Record<string, unknown>): VideoSeoData | undefined {
|
|
139
|
+
const seo = props["seo"];
|
|
140
|
+
if (!seo || typeof seo !== "object") return undefined;
|
|
141
|
+
const seoRecord = seo as Record<string, unknown>;
|
|
142
|
+
if (seoRecord["videoObject"] !== true) return undefined;
|
|
143
|
+
const name = seoRecord["name"];
|
|
144
|
+
const description = seoRecord["description"];
|
|
145
|
+
const uploadDate = seoRecord["uploadDate"];
|
|
146
|
+
if (
|
|
147
|
+
typeof name !== "string" ||
|
|
148
|
+
typeof description !== "string" ||
|
|
149
|
+
typeof uploadDate !== "string"
|
|
150
|
+
) {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
// The manifest data (posterUrl, durationSec, contentUrl) is populated by the
|
|
154
|
+
// render layer when the variant manifest is available. At build-page time we
|
|
155
|
+
// carry the seo fields; the render layer fills in the manifest data.
|
|
156
|
+
return {
|
|
157
|
+
seo: { name, description, uploadDate },
|
|
158
|
+
manifest: { posterUrl: "", contentUrl: "" },
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
135
162
|
/** RFC-0372: extract semantic text from declared blocks into SemanticBlock[]. */
|
|
136
163
|
function extractContentBlocks(
|
|
137
164
|
blocks: Array<Record<string, unknown>>,
|
|
@@ -140,7 +167,12 @@ function extractContentBlocks(
|
|
|
140
167
|
const result: SemanticBlock[] = [];
|
|
141
168
|
for (const block of blocks) {
|
|
142
169
|
const blockType = String(block["type"] ?? "");
|
|
143
|
-
const blockId =
|
|
170
|
+
const blockId = block["id"];
|
|
171
|
+
if (typeof blockId !== "string" || !blockId) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`[extractContentBlocks] Block #${result.length} in page ${ctx.pageId} (${ctx.lang}) is missing required \`id\` field. Run \`block.id.generate\` to backfill.`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
144
176
|
if (!blockType) continue;
|
|
145
177
|
const extractor = BLOCK_EXTRACTORS.get(blockType);
|
|
146
178
|
if (!extractor) continue;
|
|
@@ -156,6 +188,12 @@ function extractContentBlocks(
|
|
|
156
188
|
items: extracted.items,
|
|
157
189
|
extractedAt: new Date().toISOString(),
|
|
158
190
|
extractorVersion: "1.0.0",
|
|
191
|
+
// RFC-0912: attach video SEO data when the block has seo.videoObject opt-in.
|
|
192
|
+
...((block["props"] ?? block) &&
|
|
193
|
+
typeof (block["props"] ?? block) === "object" &&
|
|
194
|
+
extractVideoSeoData((block["props"] ?? block) as Record<string, unknown>)
|
|
195
|
+
? { video: extractVideoSeoData((block["props"] ?? block) as Record<string, unknown>) }
|
|
196
|
+
: {}),
|
|
159
197
|
});
|
|
160
198
|
}
|
|
161
199
|
return result;
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
</MODULE_CONTRACT>
|
|
8
8
|
<CHANGE_SUMMARY>
|
|
9
9
|
<item>RFC-0133: backfilled MODULE_MAP and CHANGE_SUMMARY markers for compass.validate compliance.</item>
|
|
10
|
+
<item>RFC-0915: removed custom slugify() — replaced by slugId from @warpgogol/werkstatt-shared/share/slug.</item>
|
|
10
11
|
</CHANGE_SUMMARY>
|
|
11
12
|
*/
|
|
12
13
|
|
|
@@ -16,17 +17,6 @@ export function normalizeWhitespace(value: string): string {
|
|
|
16
17
|
return value.replace(/\s+/g, " ").trim();
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
export function slugify(value: string): string {
|
|
20
|
-
return (
|
|
21
|
-
value
|
|
22
|
-
.toLowerCase()
|
|
23
|
-
.normalize("NFKD")
|
|
24
|
-
.replace(/[\u0300-\u036f]/g, "")
|
|
25
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
26
|
-
.replace(/^-+|-+$/g, "") || "entity"
|
|
27
|
-
);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
20
|
export type MarkdownSection = {
|
|
31
21
|
heading: string;
|
|
32
22
|
body: string;
|
|
@@ -62,6 +52,90 @@ export function extractParagraphs(markdown: string): string[] {
|
|
|
62
52
|
.filter(Boolean);
|
|
63
53
|
}
|
|
64
54
|
|
|
55
|
+
const SENTENCE_ABBREVIATIONS: Record<string, string[]> = {
|
|
56
|
+
de: [
|
|
57
|
+
"z.B.",
|
|
58
|
+
"z. B.",
|
|
59
|
+
"z.",
|
|
60
|
+
"etc.",
|
|
61
|
+
"Nr.",
|
|
62
|
+
"Abs.",
|
|
63
|
+
"§",
|
|
64
|
+
"S.",
|
|
65
|
+
"ca.",
|
|
66
|
+
"u.a.",
|
|
67
|
+
"u. a.",
|
|
68
|
+
"u.",
|
|
69
|
+
"vgl.",
|
|
70
|
+
"bspw.",
|
|
71
|
+
],
|
|
72
|
+
uk: ["т.д.", "т.п.", "п.", "ст.", "див.", "пор.", "напр.", "ім.", "о."],
|
|
73
|
+
en: ["e.g.", "i.e.", "etc.", "vs.", "Mr.", "Mrs.", "Dr.", "Inc.", "Ltd."],
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
function escapeRegex(text: string): string {
|
|
77
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildAbbreviationPattern(abbreviations: string[]): RegExp | undefined {
|
|
81
|
+
if (abbreviations.length === 0) return undefined;
|
|
82
|
+
const sorted = [...abbreviations].sort((a, b) => b.length - a.length);
|
|
83
|
+
return new RegExp(`(?:^|[^a-zA-Zа-яА-ЯёЁїЇіІєЄäöüÄÖÜß])(${sorted.map(escapeRegex).join("|")})$`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function splitSentences(text: string, locale: string = "en"): string[] {
|
|
87
|
+
const normalized = text.trim();
|
|
88
|
+
if (!normalized) return [];
|
|
89
|
+
|
|
90
|
+
const abbreviations = SENTENCE_ABBREVIATIONS[locale] ?? SENTENCE_ABBREVIATIONS.en;
|
|
91
|
+
const abbrevPattern = buildAbbreviationPattern(abbreviations);
|
|
92
|
+
|
|
93
|
+
const sentences: string[] = [];
|
|
94
|
+
let current = "";
|
|
95
|
+
|
|
96
|
+
const chars = [...normalized];
|
|
97
|
+
for (let i = 0; i < chars.length; i++) {
|
|
98
|
+
const char = chars[i];
|
|
99
|
+
current += char;
|
|
100
|
+
|
|
101
|
+
if (char !== "." && char !== "!" && char !== "?") continue;
|
|
102
|
+
|
|
103
|
+
const nextChar = chars[i + 1];
|
|
104
|
+
if (nextChar === undefined) {
|
|
105
|
+
sentences.push(current.trim());
|
|
106
|
+
current = "";
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (nextChar !== " " && nextChar !== "\n" && nextChar !== "\t") continue;
|
|
111
|
+
|
|
112
|
+
const beforeAbbrCheck = current.trimEnd();
|
|
113
|
+
|
|
114
|
+
if (abbrevPattern && abbrevPattern.test(beforeAbbrCheck)) continue;
|
|
115
|
+
|
|
116
|
+
// Skip numbered list markers (e.g., "1. ", "2. ") — a period after
|
|
117
|
+
// digits is a list marker, not a sentence boundary. After normalizeWhitespace
|
|
118
|
+
// newlines are spaces, so we check the token before the period.
|
|
119
|
+
const beforePeriod = current.slice(0, -1);
|
|
120
|
+
const lastSpaceIdx = Math.max(beforePeriod.lastIndexOf(" "), beforePeriod.lastIndexOf("\n"));
|
|
121
|
+
const tokenBeforePeriod = beforePeriod.slice(lastSpaceIdx + 1).trim();
|
|
122
|
+
if (/^\d+$/.test(tokenBeforePeriod)) continue;
|
|
123
|
+
|
|
124
|
+
const afterWhitespace = chars
|
|
125
|
+
.slice(i + 1)
|
|
126
|
+
.join("")
|
|
127
|
+
.match(/^\s*([A-ZÄÖÜА-ЯЁЇІЄ])/);
|
|
128
|
+
if (!afterWhitespace) continue;
|
|
129
|
+
|
|
130
|
+
sentences.push(current.trim());
|
|
131
|
+
current = "";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (current.trim()) sentences.push(current.trim());
|
|
135
|
+
|
|
136
|
+
return sentences.filter(Boolean);
|
|
137
|
+
}
|
|
138
|
+
|
|
65
139
|
export function extractListFacts(markdown: string): string[] {
|
|
66
140
|
return markdown
|
|
67
141
|
.split("\n")
|