@duffcloudservices/cms 0.6.0 → 0.7.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.
@@ -0,0 +1,1003 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import yaml from 'js-yaml';
4
+
5
+ // src/seo/schemaGraph.ts
6
+ var SCHEMA_CONTEXT = "https://schema.org";
7
+ var LOCAL_BUSINESS_TYPES = /* @__PURE__ */ new Set([
8
+ "LocalBusiness",
9
+ "HomeAndConstructionBusiness",
10
+ "GeneralContractor",
11
+ "HVACBusiness",
12
+ "Plumber",
13
+ "Electrician",
14
+ "RoofingContractor",
15
+ "HousePainter",
16
+ "MovingCompany",
17
+ "MedicalBusiness",
18
+ "Dentist",
19
+ "Physician",
20
+ "MedicalClinic",
21
+ "LegalService",
22
+ "Attorney",
23
+ "Notary",
24
+ "AccountingService",
25
+ "FinancialService",
26
+ "InsuranceAgency",
27
+ "RealEstateAgent",
28
+ "Store",
29
+ "ProfessionalService",
30
+ "AutomotiveBusiness",
31
+ "AutoRepair",
32
+ "BeautySalon",
33
+ "HairSalon",
34
+ "HealthAndBeautyBusiness",
35
+ "FoodEstablishment",
36
+ "Restaurant"
37
+ ]);
38
+ function isLocalBusinessType(type) {
39
+ return !!type && LOCAL_BUSINESS_TYPES.has(type);
40
+ }
41
+ function trimSlash(url) {
42
+ return url.replace(/\/+$/, "");
43
+ }
44
+ function graphIds(siteUrl) {
45
+ const base = trimSlash(siteUrl);
46
+ return {
47
+ organization: `${base}/#organization`,
48
+ website: `${base}/#website`,
49
+ localBusiness: `${base}/#localbusiness`
50
+ };
51
+ }
52
+ function deriveSameAs(global) {
53
+ const s = global.social;
54
+ if (!s) return [];
55
+ const out = [];
56
+ const push = (u) => {
57
+ if (u && u.trim()) out.push(u.trim());
58
+ };
59
+ if (s.facebook) push(asUrl(s.facebook, "https://www.facebook.com/"));
60
+ if (s.instagram) push(asUrl(stripAt(s.instagram), "https://www.instagram.com/"));
61
+ if (s.linkedin) push(asUrl(s.linkedin, "https://www.linkedin.com/in/"));
62
+ if (s.youtube) push(asUrl(s.youtube, "https://www.youtube.com/"));
63
+ if (s.github) push(asUrl(stripAt(s.github), "https://github.com/"));
64
+ if (s.twitter) push(asUrl(stripAt(s.twitter), "https://twitter.com/"));
65
+ return out;
66
+ }
67
+ function stripAt(handle) {
68
+ return handle.startsWith("@") ? handle.slice(1) : handle;
69
+ }
70
+ function asUrl(value, base) {
71
+ if (/^https?:\/\//i.test(value)) return value;
72
+ return `${base}${value.replace(/^\/+/, "")}`;
73
+ }
74
+ function findLocalBusinessSchema(global) {
75
+ const schemas = global.schemas ?? [];
76
+ for (let i = 0; i < schemas.length; i++) {
77
+ if (isLocalBusinessType(schemas[i]?.type)) {
78
+ return { schema: schemas[i], index: i };
79
+ }
80
+ }
81
+ return null;
82
+ }
83
+ function graphAbsorbs(schema, global) {
84
+ if (schema.type === "Organization" || schema.type === "WebSite") return true;
85
+ const found = findLocalBusinessSchema(global);
86
+ return !!found && found.schema === schema;
87
+ }
88
+ function filterRealReviews(items) {
89
+ if (!Array.isArray(items)) return [];
90
+ const out = [];
91
+ for (const item of items) {
92
+ if (!item || typeof item !== "object") continue;
93
+ const ratingNum = Number(item.rating);
94
+ const text = typeof item.text === "string" ? item.text.trim() : "";
95
+ const authorName = typeof item.authorName === "string" ? item.authorName.trim() : "";
96
+ if (!Number.isFinite(ratingNum) || ratingNum <= 0) continue;
97
+ if (!text) continue;
98
+ if (!authorName) continue;
99
+ out.push({
100
+ rating: ratingNum,
101
+ text,
102
+ authorName,
103
+ ...typeof item.date === "string" && item.date.trim() ? { date: item.date.trim() } : {},
104
+ ...typeof item.locationName === "string" && item.locationName.trim() ? { locationName: item.locationName.trim() } : {}
105
+ });
106
+ }
107
+ return out;
108
+ }
109
+ var BEST_RATING = 5;
110
+ var WORST_RATING = 1;
111
+ function clampRating(value) {
112
+ if (value < WORST_RATING) return WORST_RATING;
113
+ if (value > BEST_RATING) return BEST_RATING;
114
+ return value;
115
+ }
116
+ function buildReviewSchemaParts(items) {
117
+ const real = filterRealReviews(items);
118
+ if (real.length === 0) return { review: [] };
119
+ const clamped = real.map((r) => ({ ...r, rating: clampRating(r.rating) }));
120
+ const review = clamped.map((r) => ({
121
+ "@type": "Review",
122
+ reviewRating: {
123
+ "@type": "Rating",
124
+ ratingValue: r.rating,
125
+ bestRating: BEST_RATING,
126
+ worstRating: WORST_RATING
127
+ },
128
+ author: { "@type": "Person", name: r.authorName },
129
+ reviewBody: r.text,
130
+ ...r.date ? { datePublished: r.date } : {}
131
+ }));
132
+ const sum = clamped.reduce((acc, r) => acc + r.rating, 0);
133
+ const mean = Math.round(sum / clamped.length * 10) / 10;
134
+ const aggregateRating = {
135
+ "@type": "AggregateRating",
136
+ ratingValue: mean,
137
+ reviewCount: clamped.length,
138
+ ratingCount: clamped.length,
139
+ bestRating: BEST_RATING,
140
+ worstRating: WORST_RATING
141
+ };
142
+ return { review, aggregateRating };
143
+ }
144
+ function buildGlobalGraph(global, opts = {}) {
145
+ const siteUrl = global.siteUrl ? trimSlash(global.siteUrl) : "";
146
+ if (!siteUrl) return [];
147
+ const ids = graphIds(siteUrl);
148
+ const name = global.siteName || "";
149
+ const logo = global.images?.logo;
150
+ const sameAs = deriveSameAs(global);
151
+ const organization = {
152
+ "@type": "Organization",
153
+ "@id": ids.organization,
154
+ ...name ? { name } : {},
155
+ url: `${siteUrl}/`,
156
+ ...logo ? { logo } : {},
157
+ ...sameAs.length ? { sameAs } : {}
158
+ };
159
+ const website = {
160
+ "@type": "WebSite",
161
+ "@id": ids.website,
162
+ url: `${siteUrl}/`,
163
+ ...name ? { name } : {},
164
+ publisher: { "@id": ids.organization }
165
+ };
166
+ const graph = [organization, website];
167
+ const found = findLocalBusinessSchema(global);
168
+ if (found) {
169
+ const props = found.schema.properties ?? {};
170
+ const localBusiness = {
171
+ "@type": found.schema.type,
172
+ "@id": ids.localBusiness,
173
+ // Preserve the hand-authored NAP/geo/hours/offers verbatim…
174
+ ...props,
175
+ // …but ensure the cross-link to the Organization is present.
176
+ parentOrganization: { "@id": ids.organization }
177
+ };
178
+ if (localBusiness.url == null) localBusiness.url = `${siteUrl}/`;
179
+ const reviewParts = buildReviewSchemaParts(opts.reviews);
180
+ if (reviewParts.review.length > 0) {
181
+ localBusiness.review = reviewParts.review;
182
+ if (reviewParts.aggregateRating) {
183
+ localBusiness.aggregateRating = reviewParts.aggregateRating;
184
+ }
185
+ }
186
+ graph.push(localBusiness);
187
+ }
188
+ return [
189
+ {
190
+ "@context": SCHEMA_CONTEXT,
191
+ "@graph": graph
192
+ }
193
+ ];
194
+ }
195
+ function buildBreadcrumbList(trail) {
196
+ if (!Array.isArray(trail) || trail.length <= 1) return [];
197
+ const itemListElement = trail.map((crumb, i) => ({
198
+ "@type": "ListItem",
199
+ position: i + 1,
200
+ name: crumb.name,
201
+ item: crumb.item
202
+ }));
203
+ return [
204
+ {
205
+ "@context": SCHEMA_CONTEXT,
206
+ "@type": "BreadcrumbList",
207
+ itemListElement
208
+ }
209
+ ];
210
+ }
211
+ function breadcrumbTrailFromRoute(route, siteUrl, titles = {}, homeName = "Home") {
212
+ const base = trimSlash(siteUrl);
213
+ const home = {
214
+ name: titles["/"] || homeName,
215
+ item: `${base}/`
216
+ };
217
+ const normalised = (route || "/").split("?")[0].split("#")[0];
218
+ if (normalised === "/" || normalised === "") return [home];
219
+ const segments = normalised.replace(/^\/+/, "").replace(/\/+$/, "").split("/");
220
+ const trail = [home];
221
+ let acc = "";
222
+ for (const seg of segments) {
223
+ acc += `/${seg}`;
224
+ trail.push({
225
+ name: titles[acc] || slugToTitle(seg),
226
+ item: `${base}${acc}`
227
+ });
228
+ }
229
+ return trail;
230
+ }
231
+ function slugToTitle(slug) {
232
+ const cleaned = (slug || "").replace(/[-_]+/g, " ").trim();
233
+ if (!cleaned) return slug || "";
234
+ return cleaned.split(/\s+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
235
+ }
236
+ function buildBlogPosting(meta, siteUrl, global) {
237
+ const headline = typeof meta.headline === "string" ? meta.headline.trim() : "";
238
+ if (!headline) return [];
239
+ const ids = graphIds(siteUrl);
240
+ const base = trimSlash(siteUrl);
241
+ const name = global?.siteName?.trim();
242
+ const orgNode = {
243
+ "@type": "Organization",
244
+ "@id": ids.organization,
245
+ ...name ? { name } : {},
246
+ ...base ? { url: `${base}/` } : {}
247
+ };
248
+ const node = {
249
+ "@context": SCHEMA_CONTEXT,
250
+ "@type": "BlogPosting",
251
+ headline,
252
+ author: orgNode,
253
+ publisher: orgNode
254
+ };
255
+ if (meta.description) node.description = meta.description;
256
+ if (meta.datePublished) node.datePublished = meta.datePublished;
257
+ if (meta.dateModified) node.dateModified = meta.dateModified;
258
+ if (meta.image) node.image = meta.image;
259
+ if (meta.url) node.mainEntityOfPage = meta.url;
260
+ return [node];
261
+ }
262
+ function filterRealFaq(entries) {
263
+ if (!Array.isArray(entries)) return [];
264
+ const out = [];
265
+ for (const entry of entries) {
266
+ if (!entry || typeof entry !== "object") continue;
267
+ const q = pickString(entry.q ?? entry.question);
268
+ const a = pickString(entry.a ?? entry.answer);
269
+ if (!q || !a) continue;
270
+ out.push({ question: q, answer: a });
271
+ }
272
+ return out;
273
+ }
274
+ function pickString(value) {
275
+ return typeof value === "string" ? value.trim() : "";
276
+ }
277
+ function buildFaqPage(entries) {
278
+ const real = filterRealFaq(entries);
279
+ if (real.length === 0) return [];
280
+ return [
281
+ {
282
+ "@context": SCHEMA_CONTEXT,
283
+ "@type": "FAQPage",
284
+ mainEntity: real.map((f) => ({
285
+ "@type": "Question",
286
+ name: f.question,
287
+ acceptedAnswer: { "@type": "Answer", text: f.answer }
288
+ }))
289
+ }
290
+ ];
291
+ }
292
+ function isReviewItemsKey(key) {
293
+ return /^reviews\..+\.items$/.test(key);
294
+ }
295
+ function findReviewItemsForPage(content, pageSlug) {
296
+ if (!content) return [];
297
+ const fromBlock = (block) => {
298
+ if (!block) return null;
299
+ for (const key of Object.keys(block)) {
300
+ if (isReviewItemsKey(key) && Array.isArray(block[key])) {
301
+ return block[key];
302
+ }
303
+ }
304
+ return null;
305
+ };
306
+ const page = content.pages?.[pageSlug];
307
+ return fromBlock(page) ?? fromBlock(content.global) ?? [];
308
+ }
309
+ function absolutizeUrl(value, siteUrl, fallback = "") {
310
+ const v = (value ?? "").trim();
311
+ if (!v) return fallback;
312
+ if (/^https?:\/\//i.test(v)) return v;
313
+ const base = trimSlash(siteUrl ?? "");
314
+ if (!base) return v;
315
+ return `${base}/${v.replace(/^\/+/, "")}`;
316
+ }
317
+
318
+ // src/seo/headTags.ts
319
+ function generateOpenGraphMeta(og, global, resolvedTitle, pageDescription, canonical) {
320
+ const tags = [];
321
+ tags.push({ property: "og:title", content: og.title || resolvedTitle });
322
+ tags.push({ property: "og:description", content: og.description || pageDescription });
323
+ tags.push({ property: "og:url", content: absolutizeUrl(og.url, global.siteUrl, canonical) });
324
+ tags.push({ property: "og:type", content: og.type || "website" });
325
+ const image = og.image || global.images?.ogDefault;
326
+ if (image) {
327
+ tags.push({ property: "og:image", content: image });
328
+ if (og.imageAlt || resolvedTitle) {
329
+ tags.push({ property: "og:image:alt", content: og.imageAlt || resolvedTitle });
330
+ }
331
+ if (og.imageWidth) {
332
+ tags.push({ property: "og:image:width", content: String(og.imageWidth) });
333
+ }
334
+ if (og.imageHeight) {
335
+ tags.push({ property: "og:image:height", content: String(og.imageHeight) });
336
+ }
337
+ }
338
+ if (global.siteName) {
339
+ tags.push({ property: "og:site_name", content: global.siteName });
340
+ }
341
+ if (global.locale) {
342
+ tags.push({ property: "og:locale", content: global.locale });
343
+ }
344
+ if (og.type === "article") {
345
+ if (og.publishedTime) {
346
+ tags.push({ property: "article:published_time", content: og.publishedTime });
347
+ }
348
+ if (og.modifiedTime) {
349
+ tags.push({ property: "article:modified_time", content: og.modifiedTime });
350
+ }
351
+ if (og.author) {
352
+ tags.push({ property: "article:author", content: og.author });
353
+ }
354
+ if (og.section) {
355
+ tags.push({ property: "article:section", content: og.section });
356
+ }
357
+ if (og.tags) {
358
+ og.tags.forEach((tag) => {
359
+ tags.push({ property: "article:tag", content: tag });
360
+ });
361
+ }
362
+ }
363
+ return tags;
364
+ }
365
+ function generateTwitterMeta(twitter, global, resolvedTitle, pageDescription) {
366
+ const tags = [];
367
+ tags.push({ name: "twitter:card", content: twitter.card || "summary_large_image" });
368
+ tags.push({ name: "twitter:title", content: twitter.title || resolvedTitle });
369
+ tags.push({ name: "twitter:description", content: twitter.description || pageDescription });
370
+ const image = twitter.image || global.images?.twitterDefault;
371
+ if (image) {
372
+ tags.push({ name: "twitter:image", content: image });
373
+ if (twitter.imageAlt || resolvedTitle) {
374
+ tags.push({ name: "twitter:image:alt", content: twitter.imageAlt || resolvedTitle });
375
+ }
376
+ }
377
+ const site = twitter.site || global.social?.twitter;
378
+ if (site) {
379
+ tags.push({ name: "twitter:site", content: site.startsWith("@") ? site : `@${site}` });
380
+ }
381
+ if (twitter.creator) {
382
+ tags.push({
383
+ name: "twitter:creator",
384
+ content: twitter.creator.startsWith("@") ? twitter.creator : `@${twitter.creator}`
385
+ });
386
+ }
387
+ return tags;
388
+ }
389
+ function generateJsonLd(schemas, global) {
390
+ return schemas.map((schema) => {
391
+ const base = {
392
+ "@context": "https://schema.org",
393
+ "@type": schema.type
394
+ };
395
+ if (schema.properties) {
396
+ Object.assign(base, schema.properties);
397
+ }
398
+ if (schema.type === "WebSite" && global.siteUrl && !base.url) {
399
+ base.url = global.siteUrl;
400
+ }
401
+ if (schema.type === "WebSite" && global.siteName && !base.name) {
402
+ base.name = global.siteName;
403
+ }
404
+ return base;
405
+ });
406
+ }
407
+ function resolvePageSeo(pageSlug, pagePath, seoConfig, fallbackTitle) {
408
+ const global = seoConfig?.global ?? {};
409
+ const page = seoConfig?.pages?.[pageSlug] ?? {};
410
+ let canonical = page.canonical || "";
411
+ if (!canonical && global.siteUrl) {
412
+ const path2 = pagePath ?? (pageSlug === "home" ? "/" : `/${pageSlug}`);
413
+ canonical = `${global.siteUrl.replace(/\/$/, "")}${path2}`;
414
+ }
415
+ const pageSpecificTitle = page.title || fallbackTitle;
416
+ let title;
417
+ if (pageSpecificTitle) {
418
+ title = page.noTitleTemplate || !global.titleTemplate ? pageSpecificTitle : global.titleTemplate.replace("%s", pageSpecificTitle);
419
+ } else {
420
+ title = global.defaultTitle || pageSlug;
421
+ }
422
+ const openGraph = {
423
+ type: page.openGraph?.type || "website",
424
+ title: page.openGraph?.title || title,
425
+ description: page.openGraph?.description || page.description || global.defaultDescription || "",
426
+ ...page.openGraph
427
+ };
428
+ const twitter = {
429
+ card: page.twitter?.card || "summary_large_image",
430
+ ...page.twitter
431
+ };
432
+ const schemas = [...global.schemas ?? [], ...page.schemas ?? []];
433
+ return {
434
+ title,
435
+ description: page.description || global.defaultDescription || "",
436
+ canonical,
437
+ robots: page.robots || global.robots || "index, follow",
438
+ openGraph,
439
+ twitter,
440
+ schemas,
441
+ alternates: page.alternates ?? [],
442
+ // Surface keywords on the resolved object so both runtime and emitter can
443
+ // emit the meta tag without re-reading the raw page config.
444
+ keywords: page.keywords
445
+ };
446
+ }
447
+ function buildHeadTags(pageSlug, pagePath, seoConfig, overrides) {
448
+ const resolved = resolvePageSeo(pageSlug, pagePath, seoConfig, overrides?.fallbackTitle);
449
+ const global = seoConfig?.global ?? {};
450
+ const title = overrides?.title ?? resolved.title;
451
+ const description = overrides?.description ?? resolved.description;
452
+ const robots = overrides?.robots ?? resolved.robots;
453
+ const keywords = overrides?.keywords ?? resolved.keywords;
454
+ const meta = [];
455
+ meta.push({ name: "description", content: description });
456
+ if (keywords && overrides?.includeKeywords) {
457
+ meta.push({ name: "keywords", content: keywords });
458
+ }
459
+ if (robots) {
460
+ meta.push({ name: "robots", content: robots });
461
+ }
462
+ if (global.verification?.google) {
463
+ meta.push({ name: "google-site-verification", content: global.verification.google });
464
+ }
465
+ if (global.verification?.bing) {
466
+ meta.push({ name: "msvalidate.01", content: global.verification.bing });
467
+ }
468
+ const ogMeta = generateOpenGraphMeta(resolved.openGraph, global, title, description, resolved.canonical);
469
+ meta.push(...ogMeta.map((t) => ({ property: t.property, content: t.content })));
470
+ const twitterMeta = generateTwitterMeta(resolved.twitter, global, title, description);
471
+ meta.push(...twitterMeta.map((t) => ({ name: t.name, content: t.content })));
472
+ if (overrides?.meta) {
473
+ meta.push(...overrides.meta);
474
+ }
475
+ const link = [];
476
+ if (resolved.canonical) {
477
+ link.push({ rel: "canonical", href: absolutizeUrl(resolved.canonical, global.siteUrl, resolved.canonical) });
478
+ }
479
+ resolved.alternates.forEach((alt) => {
480
+ link.push({ rel: "alternate", href: alt.href, hreflang: alt.hreflang });
481
+ });
482
+ const jsonLd = buildJsonLd(resolved, global, overrides);
483
+ const script = jsonLd.map((schema) => ({
484
+ type: "application/ld+json",
485
+ children: JSON.stringify(schema)
486
+ }));
487
+ return { title, meta, link, script, jsonLd, resolved };
488
+ }
489
+ function buildJsonLd(resolved, global, overrides) {
490
+ if (overrides?.schemas) return overrides.schemas;
491
+ const out = [];
492
+ const emitGraph = overrides?.emitGraph === true;
493
+ let perSchema = resolved.schemas;
494
+ if (emitGraph) {
495
+ const graph = buildGlobalGraph(global, { reviews: overrides?.reviews });
496
+ if (graph.length > 0) {
497
+ out.push(...graph);
498
+ perSchema = resolved.schemas.filter((s) => !graphAbsorbs(s, global));
499
+ }
500
+ }
501
+ out.push(...generateJsonLd(perSchema, global));
502
+ if (overrides?.breadcrumbTrail) {
503
+ out.push(...buildBreadcrumbList(overrides.breadcrumbTrail));
504
+ }
505
+ if (overrides?.blogMeta && global.siteUrl) {
506
+ const alreadyAuthored = resolved.schemas.some((s) => s.type === "BlogPosting");
507
+ if (!alreadyAuthored) {
508
+ out.push(...buildBlogPosting(overrides.blogMeta, global.siteUrl, global));
509
+ }
510
+ }
511
+ if (overrides?.faq) {
512
+ out.push(...buildFaqPage(overrides.faq));
513
+ }
514
+ return out;
515
+ }
516
+
517
+ // src/seo/spliceHeadHtml.ts
518
+ function escapeAttr(value) {
519
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
520
+ }
521
+ function escapeJsonLd(json) {
522
+ return json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
523
+ }
524
+ var MANAGED_META_NAMES = /* @__PURE__ */ new Set([
525
+ "description",
526
+ "keywords",
527
+ "robots",
528
+ "google-site-verification",
529
+ "msvalidate.01"
530
+ ]);
531
+ var MANAGED_PROPERTY_PREFIXES = ["og:", "article:"];
532
+ var MANAGED_NAME_PREFIXES = ["twitter:"];
533
+ function renderHeadTags(tags, indent = " ") {
534
+ const lines = [];
535
+ lines.push(`${indent}<title>${escapeAttr(tags.title)}</title>`);
536
+ for (const m of tags.meta) {
537
+ if (m.property !== void 0) {
538
+ lines.push(`${indent}<meta property="${escapeAttr(m.property)}" content="${escapeAttr(m.content)}" />`);
539
+ } else if (m.name !== void 0) {
540
+ lines.push(`${indent}<meta name="${escapeAttr(m.name)}" content="${escapeAttr(m.content)}" />`);
541
+ }
542
+ }
543
+ for (const l of tags.link) {
544
+ const hreflang = l.hreflang ? ` hreflang="${escapeAttr(l.hreflang)}"` : "";
545
+ lines.push(`${indent}<link rel="${escapeAttr(l.rel)}" href="${escapeAttr(l.href)}"${hreflang} />`);
546
+ }
547
+ for (const s of tags.script) {
548
+ lines.push(`${indent}<script type="${escapeAttr(s.type)}">${escapeJsonLd(s.children)}</script>`);
549
+ }
550
+ return lines.join("\n");
551
+ }
552
+ function stripManagedHeadTags(html) {
553
+ const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
554
+ if (!headMatch) return html;
555
+ let head = headMatch[1];
556
+ head = head.replace(/[ \t]*<title>[\s\S]*?<\/title>[ \t]*\r?\n?/gi, "");
557
+ head = head.replace(
558
+ /[ \t]*<script[^>]*type=["']application\/ld\+json["'][^>]*>[\s\S]*?<\/script>[ \t]*\r?\n?/gi,
559
+ ""
560
+ );
561
+ head = head.replace(/[ \t]*<meta\b[^>]*>[ \t]*\r?\n?/gi, (tag) => {
562
+ const nameMatch = tag.match(/\bname=["']([^"']*)["']/i);
563
+ const propMatch = tag.match(/\bproperty=["']([^"']*)["']/i);
564
+ const name = nameMatch?.[1]?.toLowerCase();
565
+ const property = propMatch?.[1]?.toLowerCase();
566
+ if (name) {
567
+ if (MANAGED_META_NAMES.has(name)) return "";
568
+ if (MANAGED_NAME_PREFIXES.some((p) => name.startsWith(p))) return "";
569
+ }
570
+ if (property) {
571
+ if (MANAGED_PROPERTY_PREFIXES.some((p) => property.startsWith(p))) return "";
572
+ }
573
+ return tag;
574
+ });
575
+ head = head.replace(
576
+ /[ \t]*<link\b[^>]*\brel=["'](?:canonical|alternate)["'][^>]*>[ \t]*\r?\n?/gi,
577
+ ""
578
+ );
579
+ return html.slice(0, headMatch.index + headMatch[0].indexOf(headMatch[1])) + head + html.slice(headMatch.index + headMatch[0].indexOf(headMatch[1]) + headMatch[1].length);
580
+ }
581
+ function spliceHeadHtml(html, tags) {
582
+ if (!/<\/head>/i.test(html)) {
583
+ return html;
584
+ }
585
+ const stripped = stripManagedHeadTags(html);
586
+ const fragment = renderHeadTags(tags);
587
+ return stripped.replace(/([ \t]*)<\/head>/i, (_m, indent) => {
588
+ return `${fragment}
589
+ ${indent}</head>`;
590
+ });
591
+ }
592
+ function loadPagesManifest(projectRoot, relativePagesPath, debug = false) {
593
+ const possiblePaths = [
594
+ path.resolve(projectRoot, relativePagesPath),
595
+ path.resolve(projectRoot, "..", relativePagesPath),
596
+ path.resolve(process.cwd(), relativePagesPath)
597
+ ];
598
+ let foundPath;
599
+ for (const testPath of possiblePaths) {
600
+ if (fs.existsSync(testPath)) {
601
+ foundPath = testPath;
602
+ break;
603
+ }
604
+ }
605
+ if (!foundPath) {
606
+ if (debug) {
607
+ console.warn("[dcs-seo] No pages.yaml found at:");
608
+ possiblePaths.forEach((p) => console.warn(` - ${p}`));
609
+ }
610
+ return null;
611
+ }
612
+ let raw;
613
+ try {
614
+ raw = yaml.load(fs.readFileSync(foundPath, "utf8"));
615
+ } catch (error) {
616
+ console.warn(`[dcs-seo] Failed to parse ${foundPath}:`, error);
617
+ return null;
618
+ }
619
+ const entries = parsePagesManifest(raw);
620
+ if (!entries) {
621
+ console.warn(`[dcs-seo] pages.yaml at ${foundPath} has no usable page entries`);
622
+ return null;
623
+ }
624
+ if (debug) {
625
+ console.log(`[dcs-seo] Loaded ${entries.length} routes from ${foundPath}`);
626
+ }
627
+ return entries;
628
+ }
629
+ function parsePagesManifest(raw) {
630
+ if (!raw || typeof raw !== "object") return null;
631
+ const pages = raw.pages;
632
+ if (!Array.isArray(pages)) return null;
633
+ const routes = [];
634
+ for (const entry of pages) {
635
+ if (!entry || typeof entry !== "object") continue;
636
+ const p = entry.path;
637
+ if (typeof p !== "string" || p.length === 0) continue;
638
+ const slug = entry.slug;
639
+ const title = entry.title;
640
+ routes.push({
641
+ slug: typeof slug === "string" ? slug : "",
642
+ path: p,
643
+ ...typeof title === "string" && title.length > 0 ? { title } : {}
644
+ });
645
+ }
646
+ return routes.length > 0 ? routes : null;
647
+ }
648
+
649
+ // src/seo/sitemap.ts
650
+ var XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>';
651
+ var URLSET_NS = "http://www.sitemaps.org/schemas/sitemap/0.9";
652
+ var AI_BOTS = ["GPTBot", "ClaudeBot", "PerplexityBot", "Google-Extended"];
653
+ function trimTrailingSlash(url) {
654
+ return url.replace(/\/+$/, "");
655
+ }
656
+ function sanitizeLlmsText(value) {
657
+ if (!value) return "";
658
+ return value.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").replace(/\]\(/g, "] (").replace(/[[\]]/g, " ").replace(/\s+/g, " ").trim().replace(/^([#>*+-]+)/, "\\$1");
659
+ }
660
+ function escapeXml(value) {
661
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
662
+ }
663
+ function globToRegExp(glob) {
664
+ const escaped = glob.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
665
+ return new RegExp(`^${escaped}$`);
666
+ }
667
+ function matchesExcludedGlob(routePath, excludedGlobs) {
668
+ return excludedGlobs.some((g) => globToRegExp(g).test(routePath));
669
+ }
670
+ var W3C_DATETIME_RE = /^\d{4}(-\d{2}(-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?)?)?$/;
671
+ function validW3CLastmod(value) {
672
+ if (typeof value !== "string") return void 0;
673
+ const v = value.trim();
674
+ if (!v) return void 0;
675
+ if (!W3C_DATETIME_RE.test(v)) return void 0;
676
+ const ms = Date.parse(v);
677
+ if (Number.isNaN(ms)) return void 0;
678
+ const dateOnly = v.match(/^(\d{4})-(\d{2})-(\d{2})$/);
679
+ if (dateOnly) {
680
+ const [, y, m, d] = dateOnly;
681
+ const dt = new Date(ms);
682
+ if (dt.getUTCFullYear() !== Number(y) || dt.getUTCMonth() + 1 !== Number(m) || dt.getUTCDate() !== Number(d)) {
683
+ return void 0;
684
+ }
685
+ }
686
+ return v;
687
+ }
688
+ function isRouteIndexable(route, seoConfig, opts = {}) {
689
+ const { exclude, noindex, excludedGlobs = [] } = opts;
690
+ if (exclude && (exclude.has(route.path) || route.slug && exclude.has(route.slug))) return false;
691
+ if (noindex && (noindex.has(route.path) || route.slug && noindex.has(route.slug))) return false;
692
+ if (matchesExcludedGlob(route.path, excludedGlobs)) return false;
693
+ const resolved = resolvePageSeo(route.slug, route.path, seoConfig, route.title);
694
+ if (/noindex/i.test(resolved.robots)) return false;
695
+ return true;
696
+ }
697
+ function resolveLoc(route, seoConfig, siteUrl) {
698
+ const resolved = resolvePageSeo(route.slug, route.path, seoConfig, route.title);
699
+ if (resolved.canonical) return resolved.canonical;
700
+ if (siteUrl) return `${trimTrailingSlash(siteUrl)}${route.path}`;
701
+ return null;
702
+ }
703
+ function buildSitemapXml(params) {
704
+ const {
705
+ routes,
706
+ siteUrl,
707
+ seoConfig,
708
+ exclude = [],
709
+ noindex = [],
710
+ excludedGlobs = [],
711
+ lastmod
712
+ } = params;
713
+ const excludeSet = new Set(exclude);
714
+ const noindexSet = new Set(noindex);
715
+ const seen = /* @__PURE__ */ new Set();
716
+ const locs = [];
717
+ for (const route of routes) {
718
+ if (!isRouteIndexable(route, seoConfig, { exclude: excludeSet, noindex: noindexSet, excludedGlobs })) {
719
+ continue;
720
+ }
721
+ const loc = resolveLoc(route, seoConfig, siteUrl);
722
+ if (!loc) continue;
723
+ if (seen.has(loc)) continue;
724
+ seen.add(loc);
725
+ locs.push(loc);
726
+ }
727
+ if (locs.length === 0) return "";
728
+ const validLastmod = validW3CLastmod(lastmod);
729
+ const lastmodLine = validLastmod ? `
730
+ <lastmod>${escapeXml(validLastmod)}</lastmod>` : "";
731
+ const urls = locs.map((loc) => ` <url>
732
+ <loc>${escapeXml(loc)}</loc>${lastmodLine}
733
+ </url>`).join("\n");
734
+ return `${XML_HEADER}
735
+ <urlset xmlns="${URLSET_NS}">
736
+ ${urls}
737
+ </urlset>
738
+ `;
739
+ }
740
+ function buildRobotsTxt(params) {
741
+ const { siteUrl, preview = false, robots = {}, hasSitemap = true } = params;
742
+ const { disallow = [], allow, extra = [], aiBots = true } = robots;
743
+ const lines = ["User-agent: *"];
744
+ if (preview) {
745
+ lines.push("Disallow: /");
746
+ if (aiBots) {
747
+ for (const bot of AI_BOTS) {
748
+ lines.push("");
749
+ lines.push(`User-agent: ${bot}`);
750
+ lines.push("Disallow: /");
751
+ }
752
+ }
753
+ if (extra.length > 0) lines.push(...extra);
754
+ return lines.join("\n") + "\n";
755
+ }
756
+ const allowLines = allow ?? ["/"];
757
+ for (const a of allowLines) lines.push(`Allow: ${a}`);
758
+ for (const d of disallow) lines.push(`Disallow: ${d}`);
759
+ if (aiBots) {
760
+ for (const bot of AI_BOTS) {
761
+ lines.push("");
762
+ lines.push(`User-agent: ${bot}`);
763
+ for (const a of allowLines) lines.push(`Allow: ${a}`);
764
+ for (const d of disallow) lines.push(`Disallow: ${d}`);
765
+ }
766
+ }
767
+ if (hasSitemap && siteUrl) {
768
+ lines.push("");
769
+ lines.push(`Sitemap: ${trimTrailingSlash(siteUrl)}/sitemap.xml`);
770
+ }
771
+ if (extra.length > 0) {
772
+ lines.push(...extra);
773
+ }
774
+ return lines.join("\n") + "\n";
775
+ }
776
+ function buildLlmsTxt(params) {
777
+ const { routes, siteUrl, seoConfig, exclude = [], noindex = [], excludedGlobs = [] } = params;
778
+ const excludeSet = new Set(exclude);
779
+ const noindexSet = new Set(noindex);
780
+ const global = seoConfig?.global ?? {};
781
+ const siteName = global.siteName;
782
+ const summary = sanitizeLlmsText(global.defaultDescription);
783
+ const entries = [];
784
+ for (const route of routes) {
785
+ if (!isRouteIndexable(route, seoConfig, { exclude: excludeSet, noindex: noindexSet, excludedGlobs })) {
786
+ continue;
787
+ }
788
+ const loc = resolveLoc(route, seoConfig, siteUrl);
789
+ if (!loc) continue;
790
+ const resolved = resolvePageSeo(route.slug, route.path, seoConfig, route.title);
791
+ entries.push({
792
+ // title + description are site-controlled free text → sanitize per-line.
793
+ title: sanitizeLlmsText(resolved.title),
794
+ canonical: loc,
795
+ description: sanitizeLlmsText(resolved.description)
796
+ });
797
+ }
798
+ if (!siteName && entries.length === 0) return "";
799
+ const out = [];
800
+ out.push(`# ${sanitizeLlmsText(siteName) || entries[0]?.title || "Site"}`);
801
+ if (summary) {
802
+ out.push("");
803
+ out.push(`> ${summary}`);
804
+ }
805
+ if (entries.length > 0) {
806
+ out.push("");
807
+ out.push("## Pages");
808
+ out.push("");
809
+ for (const e of entries) {
810
+ const desc = e.description ? `: ${e.description}` : "";
811
+ out.push(`- [${e.title}](${e.canonical})${desc}`);
812
+ }
813
+ }
814
+ return out.join("\n") + "\n";
815
+ }
816
+
817
+ // src/seo/vitepressTransform.ts
818
+ function defaultRelativePathToRoute(relativePath, params) {
819
+ let stem = (relativePath ?? "").replace(/\.md$/i, "");
820
+ if (params) {
821
+ for (const [key, value] of Object.entries(params)) {
822
+ if (value == null) continue;
823
+ stem = stem.replace(`[${key}]`, String(value));
824
+ }
825
+ }
826
+ if (stem === "index") return "/";
827
+ if (stem.endsWith("/index")) stem = stem.slice(0, -"/index".length);
828
+ return `/${stem}`;
829
+ }
830
+ function normaliseSiteUrl(siteUrl) {
831
+ return (siteUrl ?? "").replace(/\/$/, "");
832
+ }
833
+ function ldScript(obj) {
834
+ return ["script", { type: "application/ld+json" }, escapeJsonLd(JSON.stringify(obj))];
835
+ }
836
+ function buildVitePressSeoHead(pageData, options) {
837
+ const {
838
+ seoConfig,
839
+ pageTypeRules = [],
840
+ resolvePage,
841
+ relativePathToRoute = defaultRelativePathToRoute,
842
+ includeKeywords = true,
843
+ emitGraph = false,
844
+ emitBreadcrumbs = false,
845
+ breadcrumbTitles,
846
+ emitBlogPosting = false,
847
+ blogMatch,
848
+ emitFaq = false,
849
+ resolveReviews
850
+ } = options;
851
+ const global = seoConfig?.global ?? {};
852
+ const siteUrl = normaliseSiteUrl(global.siteUrl);
853
+ const fm = pageData.frontmatter ?? {};
854
+ const route = relativePathToRoute(pageData.relativePath, pageData.params);
855
+ const slug = route === "/" ? "home" : route.slice(1);
856
+ const canonical = route === "/" ? `${siteUrl}/` : `${siteUrl}${route}`;
857
+ const ctx = {
858
+ route,
859
+ slug,
860
+ canonical,
861
+ siteUrl,
862
+ frontmatter: fm,
863
+ global,
864
+ pageData
865
+ };
866
+ const resolved = resolvePageSeo(slug, route, seoConfig, fm.title);
867
+ const overrides = resolvePage?.(ctx) ?? {};
868
+ const rawTitle = overrides.title ?? pageSpecificSeoTitle(slug, seoConfig) ?? fm.title ?? resolved.title;
869
+ const templated = applyTitleTemplate(rawTitle, global, resolved);
870
+ const pageSeoDescription = seoConfig?.pages?.[slug]?.description;
871
+ const description = overrides.description || pageSeoDescription || fm.description || global.defaultDescription || "";
872
+ const keywords = overrides.keywords || seoConfig?.pages?.[slug]?.keywords || (Array.isArray(fm.tags) ? fm.tags.join(", ") : "");
873
+ const ogType = overrides.ogType || resolved.openGraph.type;
874
+ const ogImage = overrides.ogImage || resolved.openGraph.image || fm.headerImage || fm.image || global.images?.ogDefault;
875
+ const head = [];
876
+ if (includeKeywords && keywords) {
877
+ head.push(["meta", { name: "keywords", content: keywords }]);
878
+ }
879
+ head.push(["meta", { name: "robots", content: resolved.robots }]);
880
+ head.push(["link", { rel: "canonical", href: canonical }]);
881
+ if (global.verification?.google) {
882
+ head.push(["meta", { name: "google-site-verification", content: global.verification.google }]);
883
+ }
884
+ if (global.verification?.bing) {
885
+ head.push(["meta", { name: "msvalidate.01", content: global.verification.bing }]);
886
+ }
887
+ const pageOg = seoConfig?.pages?.[slug]?.openGraph;
888
+ const ogConfig = {
889
+ ...pageOg,
890
+ // Site-supplied extra OG fields (publishedTime/section/tags/…) — lower
891
+ // precedence than the explicit type/title/desc/image resolved below.
892
+ ...overrides.og,
893
+ type: ogType,
894
+ title: overrides.ogTitle || templated,
895
+ description: overrides.ogDescription || pageOg?.description || description,
896
+ ...ogImage ? { image: ogImage } : {},
897
+ url: canonical
898
+ };
899
+ for (const t of generateOpenGraphMeta(ogConfig, global, templated, description, canonical)) {
900
+ head.push(["meta", { property: t.property, content: t.content }]);
901
+ }
902
+ for (const t of generateTwitterMeta(resolved.twitter, global, templated, description)) {
903
+ head.push(["meta", { name: t.name, content: t.content }]);
904
+ }
905
+ let globalSchemas = resolved.schemas;
906
+ if (emitGraph) {
907
+ const reviews = resolveReviews?.(ctx);
908
+ const graph = buildGlobalGraph(global, { reviews });
909
+ if (graph.length > 0) {
910
+ for (const obj of graph) head.push(ldScript(obj));
911
+ globalSchemas = resolved.schemas.filter((s) => !graphAbsorbs(s, global));
912
+ }
913
+ }
914
+ for (const obj of generateJsonLd(globalSchemas, global)) {
915
+ head.push(ldScript(obj));
916
+ }
917
+ if (emitBreadcrumbs) {
918
+ const titles = breadcrumbTitles?.(ctx) ?? {};
919
+ const trail = breadcrumbTrailFromRoute(route, siteUrl, titles);
920
+ for (const obj of buildBreadcrumbList(trail)) head.push(ldScript(obj));
921
+ }
922
+ if (emitBlogPosting && (blogMatch ? blogMatch(ctx) : false)) {
923
+ const blogMeta = {
924
+ headline: typeof fm.title === "string" ? fm.title : slugToTitle(slug),
925
+ datePublished: typeof fm.date === "string" ? fm.date : void 0,
926
+ dateModified: typeof fm.lastUpdated === "string" ? fm.lastUpdated : void 0,
927
+ url: canonical,
928
+ image: typeof fm.headerImage === "string" ? fm.headerImage : typeof fm.image === "string" ? fm.image : void 0,
929
+ description: typeof fm.description === "string" ? fm.description : void 0
930
+ };
931
+ for (const obj of buildBlogPosting(blogMeta, siteUrl, global)) head.push(ldScript(obj));
932
+ }
933
+ if (emitFaq) {
934
+ const faq = Array.isArray(fm.faq) ? fm.faq : void 0;
935
+ for (const obj of buildFaqPage(faq)) head.push(ldScript(obj));
936
+ }
937
+ for (const rule of pageTypeRules) {
938
+ let matched = false;
939
+ try {
940
+ matched = rule.match(ctx);
941
+ } catch (err) {
942
+ console.warn("[dcs-seo] pageTypeRule.match threw; skipping rule:", err);
943
+ continue;
944
+ }
945
+ if (!matched) continue;
946
+ let objects = [];
947
+ try {
948
+ objects = rule.build(ctx) ?? [];
949
+ } catch (err) {
950
+ console.warn("[dcs-seo] pageTypeRule.build threw; skipping rule output:", err);
951
+ continue;
952
+ }
953
+ for (const obj of objects) {
954
+ if (obj) head.push(ldScript(obj));
955
+ }
956
+ }
957
+ return {
958
+ // Hand back the RAW title for `pageData.title`; VitePress applies its own
959
+ // `titleTemplate` to produce the templated <title> element.
960
+ title: rawTitle,
961
+ head,
962
+ description: description || void 0,
963
+ setPageTitle: overrides.setPageTitle ?? false
964
+ };
965
+ }
966
+ function pageSpecificSeoTitle(slug, seoConfig) {
967
+ return seoConfig?.pages?.[slug]?.title || void 0;
968
+ }
969
+ function applyTitleTemplate(rawTitle, global, resolved) {
970
+ if (rawTitle === resolved.title) return rawTitle;
971
+ if (!global.titleTemplate) return rawTitle;
972
+ return global.titleTemplate.replace("%s", rawTitle);
973
+ }
974
+ function createSeoTransformPageData(options) {
975
+ return function transformPageData(pageData) {
976
+ try {
977
+ const { head, title, description, setPageTitle } = buildVitePressSeoHead(pageData, options);
978
+ pageData.frontmatter = pageData.frontmatter ?? {};
979
+ const existing = pageData.frontmatter.head ?? [];
980
+ pageData.frontmatter.head = [...existing, ...head];
981
+ if (description) {
982
+ pageData.description = description;
983
+ }
984
+ if (setPageTitle && title) {
985
+ pageData.title = title;
986
+ }
987
+ if (options.debug) {
988
+ console.log(
989
+ `[dcs-seo] transformPageData ${pageData.relativePath} \u2192 +${head.length} head tag(s)` + (setPageTitle && title ? ` (title="${title}")` : "")
990
+ );
991
+ }
992
+ } catch (err) {
993
+ console.warn(
994
+ `[dcs-seo] createSeoTransformPageData failed for ${pageData?.relativePath}; head left unchanged:`,
995
+ err
996
+ );
997
+ }
998
+ };
999
+ }
1000
+
1001
+ export { AI_BOTS, absolutizeUrl, breadcrumbTrailFromRoute, buildBlogPosting, buildBreadcrumbList, buildFaqPage, buildGlobalGraph, buildHeadTags, buildLlmsTxt, buildReviewSchemaParts, buildRobotsTxt, buildSitemapXml, buildVitePressSeoHead, createSeoTransformPageData, defaultRelativePathToRoute, deriveSameAs, escapeJsonLd, filterRealFaq, filterRealReviews, findLocalBusinessSchema, findReviewItemsForPage, generateJsonLd, generateOpenGraphMeta, generateTwitterMeta, graphAbsorbs, graphIds, isLocalBusinessType, isRouteIndexable, loadPagesManifest, matchesExcludedGlob, parsePagesManifest, renderHeadTags, resolvePageSeo, slugToTitle, spliceHeadHtml, stripManagedHeadTags };
1002
+ //# sourceMappingURL=chunk-FUNIALH6.js.map
1003
+ //# sourceMappingURL=chunk-FUNIALH6.js.map