@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
|
@@ -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,
|
|
@@ -11,18 +11,12 @@
|
|
|
11
11
|
<item>Unified single blocksToMarkdown signature (was duplicated with different signatures in app).</item>
|
|
12
12
|
<item>Added slugify export and markdown answer-block extraction utilities.</item>
|
|
13
13
|
<item>RFC-0372: toSemanticAnswerBlocks now returns SemanticBlock[] with blockType: "prose".</item>
|
|
14
|
+
<item>RFC-0915: replaced slugify import from extract.ts with slugId from canonical slug module.</item>
|
|
14
15
|
</CHANGE_SUMMARY>
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
18
|
import type { SemanticBlock } from "./models.ts";
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
export { slugify };
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Creates a URL-friendly slug from a string.
|
|
24
|
-
* Re-exports from extract.ts to keep page-utils self-contained for consumers.
|
|
25
|
-
*/
|
|
19
|
+
import { slugId } from "../slug/index.ts";
|
|
26
20
|
|
|
27
21
|
/**
|
|
28
22
|
* Extracts structured answer blocks from markdown body text.
|
|
@@ -99,7 +93,7 @@ export function toSemanticAnswerBlocks(
|
|
|
99
93
|
const hasMultipleParagraphs = /\n[ \t]*\n/.test(block.content.trim());
|
|
100
94
|
if (hasTable || hasMultipleParagraphs) {
|
|
101
95
|
return {
|
|
102
|
-
id:
|
|
96
|
+
id: slugId(block.heading),
|
|
103
97
|
blockType: "prose",
|
|
104
98
|
heading: block.heading,
|
|
105
99
|
summary: block.content.trim(),
|
|
@@ -115,7 +109,7 @@ export function toSemanticAnswerBlocks(
|
|
|
115
109
|
!firstLine.startsWith("-") && !firstLine.startsWith("*") && !firstLine.startsWith("#");
|
|
116
110
|
|
|
117
111
|
return {
|
|
118
|
-
id:
|
|
112
|
+
id: slugId(block.heading),
|
|
119
113
|
blockType: "prose",
|
|
120
114
|
heading: block.heading,
|
|
121
115
|
summary: isSummary ? firstLine : undefined,
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { splitSentences } from "@warpgogol/werkstatt-shared/share/semantic";
|
|
3
|
+
|
|
4
|
+
describe("splitSentences", () => {
|
|
5
|
+
it("splits simple English sentences", () => {
|
|
6
|
+
const result = splitSentences("Hello world. This is a test. Goodbye!", "en");
|
|
7
|
+
expect(result).toHaveLength(3);
|
|
8
|
+
expect(result[0]).toBe("Hello world.");
|
|
9
|
+
expect(result[1]).toBe("This is a test.");
|
|
10
|
+
expect(result[2]).toBe("Goodbye!");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("handles German abbreviations", () => {
|
|
14
|
+
const result = splitSentences("Das ist z.B. ein Test. Das ist ein weiterer Satz.", "de");
|
|
15
|
+
expect(result).toHaveLength(2);
|
|
16
|
+
expect(result[0]).toBe("Das ist z.B. ein Test.");
|
|
17
|
+
expect(result[1]).toBe("Das ist ein weiterer Satz.");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("handles English abbreviations", () => {
|
|
21
|
+
const result = splitSentences("Use e.g. this pattern. It works well.", "en");
|
|
22
|
+
expect(result).toHaveLength(2);
|
|
23
|
+
expect(result[0]).toBe("Use e.g. this pattern.");
|
|
24
|
+
expect(result[1]).toBe("It works well.");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("handles Ukrainian text", () => {
|
|
28
|
+
const result = splitSentences("Це перше речення. Це друге речення.", "uk");
|
|
29
|
+
expect(result).toHaveLength(2);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("returns empty array for empty input", () => {
|
|
33
|
+
expect(splitSentences("", "en")).toEqual([]);
|
|
34
|
+
expect(splitSentences(" ", "en")).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("handles single sentence without terminal punctuation", () => {
|
|
38
|
+
const result = splitSentences("Just some text without ending", "en");
|
|
39
|
+
expect(result).toHaveLength(1);
|
|
40
|
+
expect(result[0]).toBe("Just some text without ending");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("does not split on decimal numbers", () => {
|
|
44
|
+
const result = splitSentences("The price is 3.50 euros. That is cheap.", "en");
|
|
45
|
+
expect(result).toHaveLength(2);
|
|
46
|
+
expect(result[0]).toBe("The price is 3.50 euros.");
|
|
47
|
+
expect(result[1]).toBe("That is cheap.");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("does not split on URLs in Ukrainian text", () => {
|
|
51
|
+
const result = splitSentences(
|
|
52
|
+
"Дивіться https://my.raceresult.com/317721/results для деталей. Це друге речення.",
|
|
53
|
+
"uk",
|
|
54
|
+
);
|
|
55
|
+
expect(result).toHaveLength(2);
|
|
56
|
+
expect(result[0]).toBe("Дивіться https://my.raceresult.com/317721/results для деталей.");
|
|
57
|
+
expect(result[1]).toBe("Це друге речення.");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("does not split on URLs in German text", () => {
|
|
61
|
+
const result = splitSentences(
|
|
62
|
+
"Siehe https://my.raceresult.com/317721/results für Details. Das ist ein zweiter Satz.",
|
|
63
|
+
"de",
|
|
64
|
+
);
|
|
65
|
+
expect(result).toHaveLength(2);
|
|
66
|
+
expect(result[0]).toBe("Siehe https://my.raceresult.com/317721/results für Details.");
|
|
67
|
+
expect(result[1]).toBe("Das ist ein zweiter Satz.");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("splits at period before German umlaut", () => {
|
|
71
|
+
const result = splitSentences(
|
|
72
|
+
"Die Version wird festgehalten. Änderungen erfolgen gemäß § 15.",
|
|
73
|
+
"de",
|
|
74
|
+
);
|
|
75
|
+
expect(result).toHaveLength(2);
|
|
76
|
+
expect(result[0]).toBe("Die Version wird festgehalten.");
|
|
77
|
+
expect(result[1]).toBe("Änderungen erfolgen gemäß § 15.");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("does not split at numbered list markers", () => {
|
|
81
|
+
const result = splitSentences(
|
|
82
|
+
"1. Vorlage eines konkreten Angebots durch das Studio; 2. ausdrücklicher Annahme des Angebots durch den Kunden; 3. Auftragsbestätigung durch das Studio zustande.",
|
|
83
|
+
"de",
|
|
84
|
+
);
|
|
85
|
+
expect(result).toHaveLength(1);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("handles spaced German abbreviation z. B.", () => {
|
|
89
|
+
const result = splitSentences(
|
|
90
|
+
"Das Studio kann nicht wesentliche Prozesse (z. B. Werkzeuge) ändern. Der wesentliche Leistungsumfang kann nicht einseitig geändert werden.",
|
|
91
|
+
"de",
|
|
92
|
+
);
|
|
93
|
+
expect(result).toHaveLength(2);
|
|
94
|
+
expect(result[0]).toBe("Das Studio kann nicht wesentliche Prozesse (z. B. Werkzeuge) ändern.");
|
|
95
|
+
expect(result[1]).toBe("Der wesentliche Leistungsumfang kann nicht einseitig geändert werden.");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>Canonical heading anchor slug generation with stateful deduplication (RFC-0915, DNA-88). Wraps github-slugger.</purpose>
|
|
4
|
+
<non-goals>
|
|
5
|
+
<item>Do not handle URL slug generation — use slugUrl for that.</item>
|
|
6
|
+
</non-goals>
|
|
7
|
+
</MODULE_CONTRACT>
|
|
8
|
+
<CHANGE_SUMMARY>
|
|
9
|
+
<item>RFC-0915: wraps github-slugger as canonical heading slugger, replacing direct imports in werkstatt-site.</item>
|
|
10
|
+
</CHANGE_SUMMARY>
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import GithubSlugger from "github-slugger";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Stateful heading slug generator with deduplication.
|
|
17
|
+
* First "Fazit" → "fazit", second → "fazit-1".
|
|
18
|
+
* Wraps github-slugger for canonical heading anchor generation.
|
|
19
|
+
*/
|
|
20
|
+
export class HeadingSlugger {
|
|
21
|
+
private readonly slugger = new GithubSlugger();
|
|
22
|
+
|
|
23
|
+
slug(text: string): string {
|
|
24
|
+
return this.slugger.slug(text);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>Canonical slug generation public API barrel (RFC-0915, DNA-88). Sole entry point for all slug generation in the monorepo.</purpose>
|
|
4
|
+
<non-goals>
|
|
5
|
+
<item>Do not re-export strategy classes — consumers use slugUrl/slugId/HeadingSlugger only.</item>
|
|
6
|
+
</non-goals>
|
|
7
|
+
</MODULE_CONTRACT>
|
|
8
|
+
<CHANGE_SUMMARY>
|
|
9
|
+
<item>RFC-0915: created canonical slug module barrel.</item>
|
|
10
|
+
</CHANGE_SUMMARY>
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export { slugUrl } from "./slug-url.ts";
|
|
14
|
+
export { slugId } from "./slug-id.ts";
|
|
15
|
+
export { HeadingSlugger } from "./heading-slugger.ts";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>Canonical semantic block ID slug generation (RFC-0915, DNA-88). Replaces custom NFKD slugify() in extract.ts.</purpose>
|
|
4
|
+
<non-goals>
|
|
5
|
+
<item>Do not handle locale-aware URL slugs — use slugUrl for that.</item>
|
|
6
|
+
</non-goals>
|
|
7
|
+
</MODULE_CONTRACT>
|
|
8
|
+
<CHANGE_SUMMARY>
|
|
9
|
+
<item>RFC-0915: replaces custom NFKD-based slugify() in semantic/extract.ts with @sindresorhus/slugify wrapper.</item>
|
|
10
|
+
</CHANGE_SUMMARY>
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import slugify from "@sindresorhus/slugify";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Generates a semantic block ID from text.
|
|
17
|
+
* Uses @sindresorhus/slugify for robust Unicode handling.
|
|
18
|
+
* Returns "entity" if the input produces an empty slug.
|
|
19
|
+
*/
|
|
20
|
+
export function slugId(text: string): string {
|
|
21
|
+
return slugify(text) || "entity";
|
|
22
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>Canonical locale-aware URL slug generation (RFC-0915, DNA-88).</purpose>
|
|
4
|
+
<non-goals>
|
|
5
|
+
<item>Do not handle heading anchor deduplication — use HeadingSlugger for that.</item>
|
|
6
|
+
</non-goals>
|
|
7
|
+
</MODULE_CONTRACT>
|
|
8
|
+
<CHANGE_SUMMARY>
|
|
9
|
+
<item>RFC-0915: extracted from werkstatt-site/src/domain/geo/slug.ts as canonical URL slug function.</item>
|
|
10
|
+
</CHANGE_SUMMARY>
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { resolveSlugStrategy } from "./strategies.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Generates a locale-aware Latin URL slug from text.
|
|
17
|
+
* Uses German umlaut replacements for lang="de",
|
|
18
|
+
* Cyrillic transliteration for lang="uk",
|
|
19
|
+
* and default @sindresorhus/slugify for other/undefined langs.
|
|
20
|
+
* Returns "entity" if the input produces an empty slug.
|
|
21
|
+
*/
|
|
22
|
+
export function slugUrl(text: string, lang?: string): string {
|
|
23
|
+
return resolveSlugStrategy(lang).slug(text) || "entity";
|
|
24
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>Canonical slug generation strategies for locale-aware URL slug derivation (RFC-0915, DNA-88).</purpose>
|
|
4
|
+
<non-goals>
|
|
5
|
+
<item>Do not expose strategy classes directly — consumers use slugUrl() from slug-url.ts.</item>
|
|
6
|
+
</non-goals>
|
|
7
|
+
</MODULE_CONTRACT>
|
|
8
|
+
<CHANGE_SUMMARY>
|
|
9
|
+
<item>RFC-0915: extracted from werkstatt-site/src/domain/geo/slug.ts as canonical slug strategies.</item>
|
|
10
|
+
</CHANGE_SUMMARY>
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import slugify from "@sindresorhus/slugify";
|
|
14
|
+
import CyrillicToTranslit from "cyrillic-to-translit-js";
|
|
15
|
+
|
|
16
|
+
export interface SlugStrategy {
|
|
17
|
+
slug(name: string): string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface CyrillicTranslit {
|
|
21
|
+
transform(value: string): string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface CyrillicTranslitConstructor {
|
|
25
|
+
new (options: { preset: "uk" }): CyrillicTranslit;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const germanReplacements: Array<[string, string]> = [
|
|
29
|
+
["ä", "ae"],
|
|
30
|
+
["ö", "oe"],
|
|
31
|
+
["ü", "ue"],
|
|
32
|
+
["ß", "ss"],
|
|
33
|
+
["Ä", "Ae"],
|
|
34
|
+
["Ö", "Oe"],
|
|
35
|
+
["Ü", "Ue"],
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
class GermanSlugStrategy implements SlugStrategy {
|
|
39
|
+
slug(name: string): string {
|
|
40
|
+
return slugify(name, { customReplacements: germanReplacements });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class UkrainianSlugStrategy implements SlugStrategy {
|
|
45
|
+
private readonly translit = new (CyrillicToTranslit as unknown as CyrillicTranslitConstructor)({
|
|
46
|
+
preset: "uk",
|
|
47
|
+
});
|
|
48
|
+
slug(name: string): string {
|
|
49
|
+
return slugify(this.translit.transform(name));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
class DefaultSlugStrategy implements SlugStrategy {
|
|
54
|
+
slug(name: string): string {
|
|
55
|
+
return slugify(name);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const slugStrategies = new Map<string, SlugStrategy>([
|
|
60
|
+
["de", new GermanSlugStrategy()],
|
|
61
|
+
["uk", new UkrainianSlugStrategy()],
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
const defaultStrategy = new DefaultSlugStrategy();
|
|
65
|
+
|
|
66
|
+
export function resolveSlugStrategy(lang?: string): SlugStrategy {
|
|
67
|
+
return (lang ? slugStrategies.get(lang) : undefined) ?? defaultStrategy;
|
|
68
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>RFC-0915: unit tests for canonical slug module output compatibility.</purpose>
|
|
4
|
+
<keywords>RFC-0915, slug, slugUrl, slugId, HeadingSlugger, DNA-88</keywords>
|
|
5
|
+
<responsibilities>
|
|
6
|
+
<item>Verify slugUrl locale-aware output for DE, UK, and default locales.</item>
|
|
7
|
+
<item>Verify slugId semantic block ID output and empty fallback.</item>
|
|
8
|
+
<item>Verify HeadingSlugger deduplication behavior.</item>
|
|
9
|
+
</responsibilities>
|
|
10
|
+
</MODULE_CONTRACT>
|
|
11
|
+
<CHANGE_SUMMARY><item>RFC-0915: initial unit tests for canonical slug module.</item></CHANGE_SUMMARY>
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { test, expect, describe } from "vitest";
|
|
15
|
+
import { slugUrl, slugId, HeadingSlugger } from "../index.ts";
|
|
16
|
+
|
|
17
|
+
describe("slugUrl", () => {
|
|
18
|
+
test("German umlauts are expanded", () => {
|
|
19
|
+
expect(slugUrl("München", "de")).toBe("muenchen");
|
|
20
|
+
expect(slugUrl("Köln", "de")).toBe("koeln");
|
|
21
|
+
expect(slugUrl("Düsseldorf", "de")).toBe("duesseldorf");
|
|
22
|
+
expect(slugUrl("ß", "de")).toBe("ss");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("Ukrainian Cyrillic is transliterated", () => {
|
|
26
|
+
expect(slugUrl("Київ", "uk")).toBe("kyiv");
|
|
27
|
+
expect(slugUrl("Львів", "uk")).toBe("lviv");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("Default locale passes through", () => {
|
|
31
|
+
expect(slugUrl("Hello World")).toBe("hello-world");
|
|
32
|
+
expect(slugUrl("Berlin", "en")).toBe("berlin");
|
|
33
|
+
expect(slugUrl("New York", "en")).toBe("new-york");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("Returns entity for empty input", () => {
|
|
37
|
+
expect(slugUrl("")).toBe("entity");
|
|
38
|
+
expect(slugUrl("!!!", "de")).toBe("entity");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("Is idempotent", () => {
|
|
42
|
+
const once = slugUrl("Frankfurt am Main", "de");
|
|
43
|
+
expect(slugUrl(once, "de")).toBe(once);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("slugId", () => {
|
|
48
|
+
test("Generates kebab-case ID from heading", () => {
|
|
49
|
+
expect(slugId("Fazit")).toBe("fazit");
|
|
50
|
+
expect(slugId("Preisvergleich")).toBe("preisvergleich");
|
|
51
|
+
expect(slugId("FAQ & Antworten")).toBe("faq-and-antworten");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("Returns entity for empty input", () => {
|
|
55
|
+
expect(slugId("")).toBe("entity");
|
|
56
|
+
expect(slugId("!!!")).toBe("entity");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("HeadingSlugger", () => {
|
|
61
|
+
test("First occurrence has no suffix", () => {
|
|
62
|
+
const slugger = new HeadingSlugger();
|
|
63
|
+
expect(slugger.slug("Fazit")).toBe("fazit");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("Second occurrence gets -1 suffix", () => {
|
|
67
|
+
const slugger = new HeadingSlugger();
|
|
68
|
+
slugger.slug("Fazit");
|
|
69
|
+
expect(slugger.slug("Fazit")).toBe("fazit-1");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("Third occurrence gets -2 suffix", () => {
|
|
73
|
+
const slugger = new HeadingSlugger();
|
|
74
|
+
slugger.slug("Fazit");
|
|
75
|
+
slugger.slug("Fazit");
|
|
76
|
+
expect(slugger.slug("Fazit")).toBe("fazit-2");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("Different headings do not collide", () => {
|
|
80
|
+
const slugger = new HeadingSlugger();
|
|
81
|
+
expect(slugger.slug("Preis")).toBe("preis");
|
|
82
|
+
expect(slugger.slug("Fazit")).toBe("fazit");
|
|
83
|
+
});
|
|
84
|
+
});
|