@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.
Files changed (47) hide show
  1. package/package.json +30 -14
  2. package/src/checks/index.ts +1 -0
  3. package/src/checks/result-helpers.ts +60 -6
  4. package/src/checks/suppressions-config.ts +26 -1
  5. package/src/content/system-manifest.ts +8 -0
  6. package/src/ontology/archetypes/index.json +17 -1
  7. package/src/ontology/schemas/page-entry.ts +5 -7
  8. package/src/ontology/schemas/system/manifest.ts +8 -0
  9. package/src/ontology/schemas/system/verification.ts +40 -0
  10. package/src/passport/sign.ts +20 -14
  11. package/src/share/agent/ard-catalog.ts +133 -0
  12. package/src/share/agent/index.ts +1 -0
  13. package/src/share/content-reference.ts +7 -1
  14. package/src/share/middleware/access-protection.ts +174 -0
  15. package/src/share/middleware/tests/access-protection.test.ts +123 -0
  16. package/src/share/page.ts +4 -2
  17. package/src/share/routes/template-filter.ts +36 -0
  18. package/src/share/routes/tests/template-filter.test.ts +40 -0
  19. package/src/share/scripts/lenis.ts +1 -0
  20. package/src/share/semantic/block-extractors/index.ts +24 -0
  21. package/src/share/semantic/build-page.ts +39 -1
  22. package/src/share/semantic/extract.ts +85 -11
  23. package/src/share/semantic/ids.ts +13 -0
  24. package/src/share/semantic/jsonld/organization.ts +20 -0
  25. package/src/share/semantic/jsonld/video.ts +56 -0
  26. package/src/share/semantic/jsonld/webpage.ts +1 -1
  27. package/src/share/semantic/jsonld.ts +5 -0
  28. package/src/share/semantic/models.ts +9 -0
  29. package/src/share/semantic/organization-profile.ts +3 -2
  30. package/src/share/semantic/page-utils.ts +4 -10
  31. package/src/share/semantic/tests/split-sentences.test.ts +97 -0
  32. package/src/share/slug/heading-slugger.ts +26 -0
  33. package/src/share/slug/index.ts +15 -0
  34. package/src/share/slug/slug-id.ts +22 -0
  35. package/src/share/slug/slug-url.ts +24 -0
  36. package/src/share/slug/strategies.ts +68 -0
  37. package/src/share/slug/tests/slug.test.ts +84 -0
  38. package/src/share/tests/ard-catalog.test.ts +129 -0
  39. package/src/share/tests/build-page-price-markers.test.ts +3 -0
  40. package/src/share/tests/jsonld-video.test.ts +143 -0
  41. package/src/share/tests/jsonld-webpage.test.ts +69 -0
  42. package/src/share/tests/organization-jsonld.test.ts +52 -0
  43. package/src/share/types/axiom-study.d.ts +22 -0
  44. package/src/share/utility-registry.yaml +36 -0
  45. package/src/surface/blueprint-schema.ts +1 -1
  46. package/src/surface/blueprint-types.ts +1 -1
  47. 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(resolveReferencesInString(index, value, lang, defaultLang, sourceRef)),
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?: string;
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 if not declared). */
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
+ });
@@ -79,6 +79,7 @@ export async function initLenis(
79
79
  offset: -headerOffset,
80
80
  immediate: prefersReducedMotion,
81
81
  });
82
+ history.replaceState(null, "", href);
82
83
  }
83
84
  }
84
85
  }
@@ -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 = String(block["id"] ?? `block-${result.length}`);
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")