@warpgogol/werkstatt-shared 0.8.0 → 0.8.2
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 +19 -4
- 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/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/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 +10 -2
- 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/src/share/tests/create-dev-props-validator.test.ts +0 -38
|
@@ -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("warp: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("warp: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")
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
</MODULE_CONTRACT>
|
|
9
9
|
<CHANGE_SUMMARY>
|
|
10
10
|
<item>Moved from app semantic/ids to packages/share/src/semantic/ids.ts — framework-agnostic, reusable across all apps.</item>
|
|
11
|
+
<item>RFC-0910: add canonicalRootUrl — unprefixed root URL for entity identity (Organization.url, WebSite.url).</item>
|
|
11
12
|
</CHANGE_SUMMARY>
|
|
12
13
|
*/
|
|
13
14
|
|
|
@@ -27,6 +28,18 @@ export function toAbsoluteUrl(baseUrl: string, path: string): string {
|
|
|
27
28
|
return new URL(path, `${baseUrl}/`).toString();
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
/**
|
|
32
|
+
* RFC-0910: produce the canonical root URL for entity identity.
|
|
33
|
+
*
|
|
34
|
+
* The entity root URL is language-independent — it is always `https://site/`
|
|
35
|
+
* regardless of the default language. This contrasts with page URLs, which
|
|
36
|
+
* are language-prefixed for non-default languages and unprefixed for the
|
|
37
|
+
* default language per RFC-0160.
|
|
38
|
+
*/
|
|
39
|
+
export function canonicalRootUrl(baseUrl: string): string {
|
|
40
|
+
return new URL("/", `${baseUrl}/`).toString();
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
export function toPathname(url: string): string {
|
|
31
44
|
return new URL(url).pathname;
|
|
32
45
|
}
|
|
@@ -97,5 +97,25 @@ export function buildOrganizationNode(context: JsonLdContext): JsonLdNode {
|
|
|
97
97
|
? { logo: { "@type": "ImageObject", url: page.organization.logo } }
|
|
98
98
|
: {}),
|
|
99
99
|
...(page.organization.image ? { image: page.organization.image } : {}),
|
|
100
|
+
...(page.organization.offer?.prices?.length
|
|
101
|
+
? { priceRange: buildPriceRange(page.organization.offer.prices) }
|
|
102
|
+
: {}),
|
|
100
103
|
};
|
|
101
104
|
}
|
|
105
|
+
|
|
106
|
+
function buildPriceRange(prices: Array<{ amount: string; currency?: string }>): string {
|
|
107
|
+
const numericAmounts = prices
|
|
108
|
+
.map((p) => Number.parseFloat(p.amount))
|
|
109
|
+
.filter((n) => !Number.isNaN(n));
|
|
110
|
+
if (numericAmounts.length === 0) return "";
|
|
111
|
+
const min = Math.min(...numericAmounts);
|
|
112
|
+
const max = Math.max(...numericAmounts);
|
|
113
|
+
const currency = prices.find((p) => p.currency)?.currency;
|
|
114
|
+
const formatAmount = (n: number) => (Number.isInteger(n) ? String(n) : n.toFixed(2));
|
|
115
|
+
if (min === max) {
|
|
116
|
+
return currency ? `${formatAmount(min)} ${currency}` : formatAmount(min);
|
|
117
|
+
}
|
|
118
|
+
return currency
|
|
119
|
+
? `${formatAmount(min)}–${formatAmount(max)} ${currency}`
|
|
120
|
+
: `${formatAmount(min)}–${formatAmount(max)}`;
|
|
121
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>RFC-0912: builds VideoObject JSON-LD nodes from SemanticBlock.video data for opted-in content videos. Reads variant-manifest-derived data populated by buildSemanticPageModelWith.</purpose>
|
|
4
|
+
<non-goals>
|
|
5
|
+
<item>Do not read the variant manifest directly — buildSemanticPageModelWith populates SemanticBlock.video before buildJsonLd runs.</item>
|
|
6
|
+
<item>Do not emit VideoObject for blocks without the seo.videoObject opt-in.</item>
|
|
7
|
+
</non-goals>
|
|
8
|
+
</MODULE_CONTRACT>
|
|
9
|
+
<CHANGE_SUMMARY>
|
|
10
|
+
<item>RFC-0912: initial implementation.</item>
|
|
11
|
+
</CHANGE_SUMMARY>
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { JsonLdContext } from "./context.ts";
|
|
15
|
+
import type { JsonLdNode } from "./types.ts";
|
|
16
|
+
|
|
17
|
+
function formatDuration(seconds: number): string {
|
|
18
|
+
const totalSec = Math.round(seconds);
|
|
19
|
+
const hours = Math.floor(totalSec / 3600);
|
|
20
|
+
const minutes = Math.floor((totalSec % 3600) / 60);
|
|
21
|
+
const secs = totalSec % 60;
|
|
22
|
+
const parts = ["PT"];
|
|
23
|
+
if (hours > 0) parts.push(`${hours}H`);
|
|
24
|
+
if (minutes > 0) parts.push(`${minutes}M`);
|
|
25
|
+
parts.push(`${secs}S`);
|
|
26
|
+
return parts.join("");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function buildVideoObjectNodes(context: JsonLdContext): JsonLdNode[] {
|
|
30
|
+
const { page, webpageId } = context;
|
|
31
|
+
const nodes: JsonLdNode[] = [];
|
|
32
|
+
|
|
33
|
+
for (const block of page.blocks) {
|
|
34
|
+
if (!block.video) continue;
|
|
35
|
+
|
|
36
|
+
const { seo, manifest } = block.video;
|
|
37
|
+
const nodeId = `${webpageId.replace("#/schema/webpage", "#/schema/video")}/${block.id}`;
|
|
38
|
+
|
|
39
|
+
const node: JsonLdNode = {
|
|
40
|
+
"@type": "VideoObject",
|
|
41
|
+
"@id": nodeId,
|
|
42
|
+
name: seo.name,
|
|
43
|
+
description: seo.description,
|
|
44
|
+
uploadDate: seo.uploadDate,
|
|
45
|
+
thumbnailUrl: manifest.posterUrl,
|
|
46
|
+
contentUrl: manifest.contentUrl,
|
|
47
|
+
...(manifest.durationSec != null
|
|
48
|
+
? { duration: formatDuration(manifest.durationSec) }
|
|
49
|
+
: {}),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
nodes.push(node);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return nodes;
|
|
56
|
+
}
|
|
@@ -90,7 +90,7 @@ export function buildWebPageNode(context: JsonLdContext): JsonLdNode {
|
|
|
90
90
|
? {
|
|
91
91
|
speakable: {
|
|
92
92
|
"@type": "SpeakableSpecification",
|
|
93
|
-
cssSelector: page.lead ? ["h1", ".section-
|
|
93
|
+
cssSelector: page.lead ? ["h1", ".section-header__subheading"] : ["h1"],
|
|
94
94
|
},
|
|
95
95
|
}
|
|
96
96
|
: {}),
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
</MODULE_CONTRACT>
|
|
9
9
|
<CHANGE_SUMMARY>
|
|
10
10
|
<item>Added initiative list graph node, breadcrumb fallback, and shared dedupe utility.</item>
|
|
11
|
+
<item>RFC-0912: added VideoObject node composition for opted-in content video blocks.</item>
|
|
11
12
|
</CHANGE_SUMMARY>
|
|
12
13
|
*/
|
|
13
14
|
|
|
@@ -22,12 +23,14 @@ import { buildPersonNodes } from "./jsonld/person.ts";
|
|
|
22
23
|
import { buildServiceNodes } from "./jsonld/service.ts";
|
|
23
24
|
import { dedupeGraph } from "./jsonld/shared.ts";
|
|
24
25
|
import type { JsonLdDocument } from "./jsonld/types.ts";
|
|
26
|
+
import { buildVideoObjectNodes } from "./jsonld/video.ts";
|
|
25
27
|
import { buildWebPageNode } from "./jsonld/webpage.ts";
|
|
26
28
|
import { buildWebSiteNode } from "./jsonld/website.ts";
|
|
27
29
|
import type { SemanticPageModel } from "./models.ts";
|
|
28
30
|
|
|
29
31
|
export type { JsonLdDocument } from "./jsonld/types.ts";
|
|
30
32
|
export type { JsonLdContext } from "./jsonld/context.ts";
|
|
33
|
+
export { buildVideoObjectNodes } from "./jsonld/video.ts";
|
|
31
34
|
|
|
32
35
|
export function buildJsonLd(page: SemanticPageModel): JsonLdDocument {
|
|
33
36
|
const context = createJsonLdContext(page);
|
|
@@ -68,6 +71,8 @@ export function buildJsonLd(page: SemanticPageModel): JsonLdDocument {
|
|
|
68
71
|
...(collectionListNode ? [collectionListNode] : []),
|
|
69
72
|
...(articleNode ? [articleNode] : []),
|
|
70
73
|
...(breadcrumbNode ? [breadcrumbNode] : []),
|
|
74
|
+
// RFC-0912: VideoObject nodes for opted-in content video blocks.
|
|
75
|
+
...buildVideoObjectNodes(context),
|
|
71
76
|
// RFC-0512: extra nodes from team profile pages (SoftwareApplication, CollectionPage).
|
|
72
77
|
...(page.extraGraphNodes ?? []),
|
|
73
78
|
]),
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
<item>RFC-0490: Added "collection" to the SemanticPageType closed enum.</item>
|
|
13
13
|
<item>RFC-0508: Added "participant" to the SemanticPageType closed enum.</item>
|
|
14
14
|
<item>RFC-0372: Unified SemanticBlock type replaces SemanticAnswerBlock + SemanticContentBlock; SemanticPageModel.blocks replaces answerBlocks/contentBlocks/bodyText.</item>
|
|
15
|
+
<item>RFC-0912: Added VideoSeoData type and optional SemanticBlock.video field for opted-in content video structured data.</item>
|
|
15
16
|
</CHANGE_SUMMARY>
|
|
16
17
|
*/
|
|
17
18
|
|
|
@@ -59,6 +60,12 @@ export type SemanticBreadcrumb = {
|
|
|
59
60
|
* Every block in a SemanticPageModel is represented by this single type, regardless of
|
|
60
61
|
* whether it was derived from prose parsing or frontmatter block extraction.
|
|
61
62
|
*/
|
|
63
|
+
/** RFC-0912: video SEO data populated by buildSemanticPageModelWith for opted-in content video blocks. */
|
|
64
|
+
export type VideoSeoData = {
|
|
65
|
+
seo: { name: string; description: string; uploadDate: string };
|
|
66
|
+
manifest: { posterUrl: string; durationSec?: number; contentUrl: string };
|
|
67
|
+
};
|
|
68
|
+
|
|
62
69
|
export type SemanticBlock = {
|
|
63
70
|
/** Stable id from frontmatter block.id (required) or slugified heading for prose-derived blocks. */
|
|
64
71
|
id: string;
|
|
@@ -77,6 +84,8 @@ export type SemanticBlock = {
|
|
|
77
84
|
/** Extractor metadata (absent for prose-derived blocks). */
|
|
78
85
|
extractedAt?: string;
|
|
79
86
|
extractorVersion?: string;
|
|
87
|
+
/** RFC-0912: video SEO data for opted-in content video blocks (seo.videoObject: true). Populated by buildSemanticPageModelWith from the variant manifest. */
|
|
88
|
+
video?: VideoSeoData;
|
|
80
89
|
};
|
|
81
90
|
|
|
82
91
|
/* RFC-0142: per-page llms inclusion depth. */
|
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
</MODULE_CONTRACT>
|
|
9
9
|
<CHANGE_SUMMARY>
|
|
10
10
|
<item>RFC-0148: extracted the shared org-profile assembler from the disk + Astro builders.</item>
|
|
11
|
+
<item>RFC-0910: Organization.url uses canonicalRootUrl (unprefixed root) instead of language-prefixed path.</item>
|
|
11
12
|
</CHANGE_SUMMARY>
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
|
-
import { getBaseUrl, toAbsoluteUrl } from "./ids.ts";
|
|
15
|
+
import { canonicalRootUrl, getBaseUrl, toAbsoluteUrl } from "./ids.ts";
|
|
15
16
|
import type {
|
|
16
17
|
SemanticInitiative,
|
|
17
18
|
SemanticLocation,
|
|
@@ -104,7 +105,7 @@ export function buildOrganizationProfile(input: OrganizationProfileInput): Seman
|
|
|
104
105
|
name: input.brandName,
|
|
105
106
|
legalName: input.legalName,
|
|
106
107
|
description: input.description,
|
|
107
|
-
url:
|
|
108
|
+
url: canonicalRootUrl(baseUrl),
|
|
108
109
|
foundingYear: input.foundingYear,
|
|
109
110
|
email: input.email,
|
|
110
111
|
registration: input.registration,
|