scribe-cms 0.0.8 → 0.0.10
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/README.md +5 -5
- package/dist/cli/index.cjs +433 -384
- package/dist/cli/index.cjs.map +1 -1
- package/dist/cli/index.js +433 -384
- package/dist/cli/index.js.map +1 -1
- package/dist/index.cjs +643 -512
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +643 -513
- package/dist/index.js.map +1 -1
- package/dist/runtime.cjs +287 -261
- package/dist/runtime.cjs.map +1 -1
- package/dist/runtime.js +286 -261
- package/dist/runtime.js.map +1 -1
- package/dist/src/config/resolve-config.d.ts.map +1 -1
- package/dist/src/core/builtin-fields.d.ts +8 -8
- package/dist/src/core/builtin-fields.d.ts.map +1 -1
- package/dist/src/core/types.d.ts +11 -4
- package/dist/src/core/types.d.ts.map +1 -1
- package/dist/src/create-project.d.ts.map +1 -1
- package/dist/src/export/build-static-raw-exports.d.ts.map +1 -1
- package/dist/src/i18n/build-url.d.ts +16 -3
- package/dist/src/i18n/build-url.d.ts.map +1 -1
- package/dist/src/i18n/resolve-document.d.ts +3 -2
- package/dist/src/i18n/resolve-document.d.ts.map +1 -1
- package/dist/src/index.d.ts +3 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/loader/create-loader.d.ts.map +1 -1
- package/dist/src/redirects/build-json-redirects.d.ts +6 -0
- package/dist/src/redirects/build-json-redirects.d.ts.map +1 -0
- package/dist/src/redirects/build-redirects.d.ts +9 -7
- package/dist/src/redirects/build-redirects.d.ts.map +1 -1
- package/dist/src/redirects/load-type-redirects.d.ts +14 -0
- package/dist/src/redirects/load-type-redirects.d.ts.map +1 -0
- package/dist/src/redirects/redirect-schema.d.ts +31 -0
- package/dist/src/redirects/redirect-schema.d.ts.map +1 -0
- package/dist/src/runtime.d.ts +3 -1
- package/dist/src/runtime.d.ts.map +1 -1
- package/dist/src/sitemap/generate-sitemap.d.ts.map +1 -1
- package/dist/src/validate/validate-project.d.ts.map +1 -1
- package/dist/src/validate/validate-redirects.d.ts +4 -0
- package/dist/src/validate/validate-redirects.d.ts.map +1 -0
- package/dist/studio/server.cjs +90 -61
- package/dist/studio/server.cjs.map +1 -1
- package/dist/studio/server.js +90 -61
- package/dist/studio/server.js.map +1 -1
- package/package.json +1 -1
- package/dist/src/core/slug-aliases.d.ts +0 -27
- package/dist/src/core/slug-aliases.d.ts.map +0 -1
- package/dist/src/redirects/translation-index.d.ts +0 -4
- package/dist/src/redirects/translation-index.d.ts.map +0 -1
package/dist/cli/index.js
CHANGED
|
@@ -222,14 +222,14 @@ var SLUG_PLACEHOLDER = "{slug}";
|
|
|
222
222
|
function isRoutableType(type) {
|
|
223
223
|
return typeof type.path === "string" && type.path.length > 0;
|
|
224
224
|
}
|
|
225
|
-
function assertValidPathTemplate(
|
|
226
|
-
if (!
|
|
227
|
-
throw new Error(`Content type "${typeId}": path must start with / (got "${
|
|
225
|
+
function assertValidPathTemplate(path16, typeId) {
|
|
226
|
+
if (!path16.startsWith("/")) {
|
|
227
|
+
throw new Error(`Content type "${typeId}": path must start with / (got "${path16}")`);
|
|
228
228
|
}
|
|
229
|
-
const count = (
|
|
229
|
+
const count = (path16.match(/\{slug\}/g) ?? []).length;
|
|
230
230
|
if (count !== 1) {
|
|
231
231
|
throw new Error(
|
|
232
|
-
`Content type "${typeId}": path must contain exactly one {slug} (got "${
|
|
232
|
+
`Content type "${typeId}": path must contain exactly one {slug} (got "${path16}")`
|
|
233
233
|
);
|
|
234
234
|
}
|
|
235
235
|
}
|
|
@@ -243,19 +243,69 @@ function pathSuffix(template) {
|
|
|
243
243
|
if (idx === -1) throw new Error(`Invalid path template (missing {slug}): ${template}`);
|
|
244
244
|
return template.slice(idx + SLUG_PLACEHOLDER.length);
|
|
245
245
|
}
|
|
246
|
-
function
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const
|
|
257
|
-
const
|
|
258
|
-
|
|
246
|
+
function resolveDefaultLocaleRouting() {
|
|
247
|
+
return { strategy: "path-prefix", prefixDefaultLocale: false };
|
|
248
|
+
}
|
|
249
|
+
function splitPathAndSearch(pathname) {
|
|
250
|
+
const q = pathname.indexOf("?");
|
|
251
|
+
if (q === -1) return { pathname, search: "" };
|
|
252
|
+
return { pathname: pathname.slice(0, q), search: pathname.slice(q) };
|
|
253
|
+
}
|
|
254
|
+
function createUrlBuilder(config) {
|
|
255
|
+
const localeRouting = config.localeRouting ?? resolveDefaultLocaleRouting();
|
|
256
|
+
const defaultLocale = config.defaultLocale;
|
|
257
|
+
const prefixedLocales = localeRouting.strategy === "path-prefix" ? localeRouting.prefixDefaultLocale ? [...config.locales] : config.locales.filter((locale) => locale !== defaultLocale) : config.locales.filter((locale) => locale !== defaultLocale);
|
|
258
|
+
function applyLocaleToPath(pathname, locale) {
|
|
259
|
+
if (locale === defaultLocale) {
|
|
260
|
+
if (localeRouting.strategy === "path-prefix" && localeRouting.prefixDefaultLocale) {
|
|
261
|
+
return `/${defaultLocale}${pathname}`;
|
|
262
|
+
}
|
|
263
|
+
return pathname;
|
|
264
|
+
}
|
|
265
|
+
if (localeRouting.strategy === "search-param") {
|
|
266
|
+
const { pathname: base, search } = splitPathAndSearch(pathname);
|
|
267
|
+
const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
|
|
268
|
+
params.set(localeRouting.param, locale);
|
|
269
|
+
const qs = params.toString();
|
|
270
|
+
return qs ? `${base}?${qs}` : base;
|
|
271
|
+
}
|
|
272
|
+
return `/${locale}${pathname}`;
|
|
273
|
+
}
|
|
274
|
+
function resolvePath(template, slug, locale) {
|
|
275
|
+
const relative = template.replace(SLUG_PLACEHOLDER, slug);
|
|
276
|
+
return applyLocaleToPath(relative, locale);
|
|
277
|
+
}
|
|
278
|
+
function extractSlugFromResolvedPath(template, resolvedPath) {
|
|
279
|
+
let pathname = resolvedPath;
|
|
280
|
+
if (localeRouting.strategy === "search-param") {
|
|
281
|
+
pathname = splitPathAndSearch(resolvedPath).pathname;
|
|
282
|
+
} else if (localeRouting.strategy === "path-prefix") {
|
|
283
|
+
for (const locale of config.locales) {
|
|
284
|
+
if (locale === defaultLocale && !localeRouting.prefixDefaultLocale) continue;
|
|
285
|
+
const prefix2 = `/${locale}`;
|
|
286
|
+
if (pathname === prefix2 || pathname.startsWith(`${prefix2}/`)) {
|
|
287
|
+
pathname = pathname.slice(prefix2.length) || "/";
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const prefix = pathPrefix(template);
|
|
293
|
+
const suffix = pathSuffix(template);
|
|
294
|
+
if (!pathname.startsWith(prefix)) return null;
|
|
295
|
+
if (suffix && !pathname.endsWith(suffix)) return null;
|
|
296
|
+
const slugEnd = suffix ? pathname.length - suffix.length : pathname.length;
|
|
297
|
+
const slug = pathname.slice(prefix.length, slugEnd);
|
|
298
|
+
return slug.length > 0 ? slug : null;
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
defaultLocale,
|
|
302
|
+
locales: config.locales,
|
|
303
|
+
localeRouting,
|
|
304
|
+
prefixedLocales,
|
|
305
|
+
resolvePath,
|
|
306
|
+
extractSlugFromResolvedPath,
|
|
307
|
+
applyLocaleToPath
|
|
308
|
+
};
|
|
259
309
|
}
|
|
260
310
|
|
|
261
311
|
// src/config/resolve-config.ts
|
|
@@ -278,6 +328,13 @@ function resolveConfig(input, baseDir) {
|
|
|
278
328
|
`scribe config: defaultLocale "${defaultLocale}" is not in locales [${raw.locales.join(", ")}]`
|
|
279
329
|
);
|
|
280
330
|
}
|
|
331
|
+
const localeRouting = raw.localeRouting ?? {
|
|
332
|
+
strategy: "path-prefix",
|
|
333
|
+
prefixDefaultLocale: false
|
|
334
|
+
};
|
|
335
|
+
if (localeRouting.strategy === "search-param" && !localeRouting.param) {
|
|
336
|
+
throw new Error('scribe config: localeRouting search-param requires a "param" name');
|
|
337
|
+
}
|
|
281
338
|
const projectRoot = path4.resolve(baseDir ?? process.cwd(), raw.rootDir);
|
|
282
339
|
const contentRoot = path4.resolve(projectRoot, raw.contentDir ?? "content");
|
|
283
340
|
const storePath = path4.resolve(projectRoot, raw.store ?? ".scribe/store.sqlite");
|
|
@@ -305,6 +362,7 @@ function resolveConfig(input, baseDir) {
|
|
|
305
362
|
assetsPath,
|
|
306
363
|
locales: [...raw.locales],
|
|
307
364
|
defaultLocale,
|
|
365
|
+
localeRouting,
|
|
308
366
|
localePresets: raw.localePresets,
|
|
309
367
|
translate: raw.translate,
|
|
310
368
|
types
|
|
@@ -359,10 +417,10 @@ function introspectSchema(schema, prefix = []) {
|
|
|
359
417
|
}
|
|
360
418
|
function extractByPaths(data, paths) {
|
|
361
419
|
const out = {};
|
|
362
|
-
for (const
|
|
363
|
-
const value = getAtPath(data,
|
|
420
|
+
for (const path16 of paths) {
|
|
421
|
+
const value = getAtPath(data, path16);
|
|
364
422
|
if (value !== void 0) {
|
|
365
|
-
setAtPath(out,
|
|
423
|
+
setAtPath(out, path16.filter((p) => p !== "*"), value);
|
|
366
424
|
}
|
|
367
425
|
}
|
|
368
426
|
return out;
|
|
@@ -380,13 +438,13 @@ function mergeStructuralOntoLocale(localeData, enData, schema) {
|
|
|
380
438
|
const translatable = pickTranslatable(localeData, schema);
|
|
381
439
|
return deepMerge(structural, translatable);
|
|
382
440
|
}
|
|
383
|
-
function getAtPath(obj,
|
|
441
|
+
function getAtPath(obj, path16) {
|
|
384
442
|
let current = obj;
|
|
385
|
-
for (const segment of
|
|
443
|
+
for (const segment of path16) {
|
|
386
444
|
if (segment === "*") {
|
|
387
445
|
if (!Array.isArray(current)) return void 0;
|
|
388
446
|
return current.map(
|
|
389
|
-
(item) => typeof item === "object" && item !== null ? getAtPath(item,
|
|
447
|
+
(item) => typeof item === "object" && item !== null ? getAtPath(item, path16.slice(path16.indexOf("*") + 1)) : void 0
|
|
390
448
|
);
|
|
391
449
|
}
|
|
392
450
|
if (typeof current !== "object" || current === null || Array.isArray(current)) return void 0;
|
|
@@ -394,13 +452,13 @@ function getAtPath(obj, path14) {
|
|
|
394
452
|
}
|
|
395
453
|
return current;
|
|
396
454
|
}
|
|
397
|
-
function setAtPath(obj,
|
|
398
|
-
if (
|
|
399
|
-
if (
|
|
400
|
-
obj[
|
|
455
|
+
function setAtPath(obj, path16, value) {
|
|
456
|
+
if (path16.length === 0) return;
|
|
457
|
+
if (path16.length === 1) {
|
|
458
|
+
obj[path16[0]] = value;
|
|
401
459
|
return;
|
|
402
460
|
}
|
|
403
|
-
const [head, ...rest] =
|
|
461
|
+
const [head, ...rest] = path16;
|
|
404
462
|
if (!(head in obj) || typeof obj[head] !== "object" || obj[head] === null) {
|
|
405
463
|
obj[head] = {};
|
|
406
464
|
}
|
|
@@ -451,45 +509,23 @@ var LOCALE_BUILTIN_KEYS = [
|
|
|
451
509
|
["translationOf", "translationOf is internal; remove from locale translation"],
|
|
452
510
|
["enSlug", "enSlug is internal; remove from locale translation"]
|
|
453
511
|
];
|
|
454
|
-
|
|
512
|
+
var DEPRECATED_REDIRECT_FIELDS = [
|
|
513
|
+
[
|
|
514
|
+
"aliases",
|
|
515
|
+
"aliases frontmatter is removed \u2014 add redirects to content/<type>/_redirects.json instead"
|
|
516
|
+
],
|
|
517
|
+
[
|
|
518
|
+
"redirect_to",
|
|
519
|
+
"redirect_to frontmatter is removed \u2014 add redirects to content/<type>/_redirects.json instead"
|
|
520
|
+
]
|
|
521
|
+
];
|
|
522
|
+
function extractBuiltinEnFields(data, _pathTemplate, _enSlug, _defaultLocale) {
|
|
455
523
|
const issues = [];
|
|
456
524
|
const rest = { ...data };
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
let redirectTo;
|
|
462
|
-
if (redirectRaw !== void 0 && redirectRaw !== null && redirectRaw !== "") {
|
|
463
|
-
if (typeof redirectRaw !== "string") {
|
|
464
|
-
issues.push({
|
|
465
|
-
field: "redirect_to",
|
|
466
|
-
message: "redirect_to must be a string path",
|
|
467
|
-
level: "error"
|
|
468
|
-
});
|
|
469
|
-
} else if (!pathTemplate) {
|
|
470
|
-
issues.push({
|
|
471
|
-
field: "redirect_to",
|
|
472
|
-
message: "redirect_to is not allowed on reference-only content types",
|
|
473
|
-
level: "error"
|
|
474
|
-
});
|
|
475
|
-
} else if (!extractSlugFromResolvedPath(pathTemplate, redirectRaw)) {
|
|
476
|
-
issues.push({
|
|
477
|
-
field: "redirect_to",
|
|
478
|
-
message: `redirect_to must match path template "${pathTemplate}" (prefix "${pathPrefix(pathTemplate)}"${pathSuffix(pathTemplate) ? `, suffix "${pathSuffix(pathTemplate)}"` : ""})`,
|
|
479
|
-
level: "error"
|
|
480
|
-
});
|
|
481
|
-
} else {
|
|
482
|
-
redirectTo = redirectRaw;
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
const aliases = aliasesResult.success ? aliasesResult.data : [];
|
|
486
|
-
if (!aliasesResult.success) {
|
|
487
|
-
for (const issue of aliasesResult.error.issues) {
|
|
488
|
-
issues.push({
|
|
489
|
-
field: `aliases.${issue.path.join(".")}`,
|
|
490
|
-
message: issue.message,
|
|
491
|
-
level: "error"
|
|
492
|
-
});
|
|
525
|
+
for (const [field2, message] of DEPRECATED_REDIRECT_FIELDS) {
|
|
526
|
+
if (rest[field2] !== void 0) {
|
|
527
|
+
issues.push({ field: field2, message, level: "error" });
|
|
528
|
+
delete rest[field2];
|
|
493
529
|
}
|
|
494
530
|
}
|
|
495
531
|
let publishedAt;
|
|
@@ -551,8 +587,6 @@ function extractBuiltinEnFields(data, pathTemplate, enSlug, defaultLocale) {
|
|
|
551
587
|
}
|
|
552
588
|
return {
|
|
553
589
|
builtin: {
|
|
554
|
-
aliases,
|
|
555
|
-
redirectTo,
|
|
556
590
|
publishedAt,
|
|
557
591
|
updatedAt,
|
|
558
592
|
noindex,
|
|
@@ -571,20 +605,24 @@ function validateLocaleBuiltinFields(data) {
|
|
|
571
605
|
}
|
|
572
606
|
return issues;
|
|
573
607
|
}
|
|
574
|
-
function resolveCanonicalPathname(type, doc, defaultLocale) {
|
|
608
|
+
function resolveCanonicalPathname(type, doc, defaultLocale, localeRouting) {
|
|
609
|
+
const urlBuilder = createUrlBuilder({
|
|
610
|
+
locales: [defaultLocale, ...doc.locale !== defaultLocale ? [doc.locale] : []],
|
|
611
|
+
defaultLocale,
|
|
612
|
+
localeRouting: localeRouting ?? { strategy: "path-prefix", prefixDefaultLocale: false }
|
|
613
|
+
});
|
|
575
614
|
if (doc.canonicalPathOverride) {
|
|
576
|
-
|
|
577
|
-
return `/${doc.locale}${doc.canonicalPathOverride}`;
|
|
615
|
+
return urlBuilder.applyLocaleToPath(doc.canonicalPathOverride, doc.locale);
|
|
578
616
|
}
|
|
579
617
|
if (!type.path) return `/${doc.slug}`;
|
|
580
|
-
return resolvePath(type.path, doc.slug, doc.locale
|
|
618
|
+
return urlBuilder.resolvePath(type.path, doc.slug, doc.locale);
|
|
581
619
|
}
|
|
582
|
-
function mergeBuiltinsIntoFrontmatter(frontmatter, doc, type, defaultLocale) {
|
|
620
|
+
function mergeBuiltinsIntoFrontmatter(frontmatter, doc, type, defaultLocale, localeRouting) {
|
|
583
621
|
const out = { ...frontmatter };
|
|
584
622
|
if (doc.publishedAt !== void 0) out.publishedAt = doc.publishedAt;
|
|
585
623
|
if (doc.updatedAt !== void 0) out.updatedAt = doc.updatedAt;
|
|
586
624
|
out.noindex = doc.noindex;
|
|
587
|
-
out.canonicalPath = resolveCanonicalPathname(type, doc, defaultLocale);
|
|
625
|
+
out.canonicalPath = resolveCanonicalPathname(type, doc, defaultLocale, localeRouting);
|
|
588
626
|
return out;
|
|
589
627
|
}
|
|
590
628
|
function seoFieldsFromEn(enDoc) {
|
|
@@ -592,8 +630,7 @@ function seoFieldsFromEn(enDoc) {
|
|
|
592
630
|
publishedAt: enDoc.publishedAt,
|
|
593
631
|
updatedAt: enDoc.updatedAt,
|
|
594
632
|
noindex: enDoc.noindex,
|
|
595
|
-
canonicalPathOverride: enDoc.canonicalPathOverride
|
|
596
|
-
redirectTo: enDoc.redirectTo
|
|
633
|
+
canonicalPathOverride: enDoc.canonicalPathOverride
|
|
597
634
|
};
|
|
598
635
|
}
|
|
599
636
|
|
|
@@ -668,11 +705,6 @@ function getTranslation(db, contentType, enSlug, locale) {
|
|
|
668
705
|
function listTranslationsForType(db, contentType) {
|
|
669
706
|
return db.prepare(`SELECT * FROM translations WHERE content_type = ? ORDER BY en_slug, locale`).all(contentType);
|
|
670
707
|
}
|
|
671
|
-
function listTranslationsForEnSlug(db, contentType, enSlug) {
|
|
672
|
-
return db.prepare(
|
|
673
|
-
`SELECT * FROM translations WHERE content_type = ? AND en_slug = ? ORDER BY locale`
|
|
674
|
-
).all(contentType, enSlug);
|
|
675
|
-
}
|
|
676
708
|
function listTranslationsForLocale(db, contentType, locale) {
|
|
677
709
|
return db.prepare(`SELECT * FROM translations WHERE content_type = ? AND locale = ? ORDER BY en_slug`).all(contentType, locale);
|
|
678
710
|
}
|
|
@@ -756,7 +788,8 @@ function parseEnMdx(filePath, config, type) {
|
|
|
756
788
|
locale: config.defaultLocale
|
|
757
789
|
},
|
|
758
790
|
type,
|
|
759
|
-
config.defaultLocale
|
|
791
|
+
config.defaultLocale,
|
|
792
|
+
config.localeRouting
|
|
760
793
|
);
|
|
761
794
|
const crossIssues = type.crossValidate?.(result.data, {
|
|
762
795
|
locale: config.defaultLocale,
|
|
@@ -770,8 +803,6 @@ function parseEnMdx(filePath, config, type) {
|
|
|
770
803
|
slug,
|
|
771
804
|
enSlug: slug,
|
|
772
805
|
locale: config.defaultLocale,
|
|
773
|
-
aliases: builtin.aliases,
|
|
774
|
-
redirectTo: builtin.redirectTo,
|
|
775
806
|
publishedAt: builtin.publishedAt,
|
|
776
807
|
updatedAt: builtin.updatedAt,
|
|
777
808
|
noindex: builtin.noindex,
|
|
@@ -782,7 +813,7 @@ function parseEnMdx(filePath, config, type) {
|
|
|
782
813
|
};
|
|
783
814
|
return { document, issues };
|
|
784
815
|
}
|
|
785
|
-
function buildDocumentFromTranslation(row, enDoc, type) {
|
|
816
|
+
function buildDocumentFromTranslation(row, enDoc, type, config) {
|
|
786
817
|
const localeFm = JSON.parse(row.frontmatter_json);
|
|
787
818
|
const merged = mergeStructuralOntoLocale(localeFm, enDoc.frontmatter, type.schema);
|
|
788
819
|
const seo = seoFieldsFromEn(enDoc);
|
|
@@ -790,14 +821,13 @@ function buildDocumentFromTranslation(row, enDoc, type) {
|
|
|
790
821
|
merged,
|
|
791
822
|
{ ...seo, slug: row.slug, locale: row.locale },
|
|
792
823
|
type,
|
|
793
|
-
|
|
824
|
+
config.defaultLocale,
|
|
825
|
+
config.localeRouting
|
|
794
826
|
);
|
|
795
827
|
return {
|
|
796
828
|
slug: row.slug,
|
|
797
829
|
enSlug: row.en_slug,
|
|
798
830
|
locale: row.locale,
|
|
799
|
-
aliases: [],
|
|
800
|
-
redirectTo: seo.redirectTo,
|
|
801
831
|
publishedAt: seo.publishedAt,
|
|
802
832
|
updatedAt: seo.updatedAt,
|
|
803
833
|
noindex: seo.noindex,
|
|
@@ -875,7 +905,7 @@ function createContentLoader(config, type) {
|
|
|
875
905
|
for (const row of rowsByLocale.get(locale) ?? []) {
|
|
876
906
|
const enDoc = englishBySlug.get(row.en_slug);
|
|
877
907
|
if (!enDoc) continue;
|
|
878
|
-
const doc = buildDocumentFromTranslation(row, enDoc, type);
|
|
908
|
+
const doc = buildDocumentFromTranslation(row, enDoc, type, config);
|
|
879
909
|
bySlug.set(doc.slug, doc);
|
|
880
910
|
byEnSlug.set(row.en_slug, doc);
|
|
881
911
|
}
|
|
@@ -924,56 +954,16 @@ function getTranslatablePayload(doc, type) {
|
|
|
924
954
|
|
|
925
955
|
// src/i18n/resolve-document.ts
|
|
926
956
|
init_esm_shims();
|
|
927
|
-
function
|
|
928
|
-
const
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
}
|
|
933
|
-
return null;
|
|
934
|
-
}
|
|
935
|
-
function enCanonicalDoc(doc, allDocs, defaultLocale) {
|
|
936
|
-
if (doc.locale === defaultLocale) return doc;
|
|
937
|
-
return allDocs.get(defaultLocale)?.bySlug.get(doc.enSlug) ?? doc;
|
|
938
|
-
}
|
|
939
|
-
function redirectPathForDocument(doc, locale, defaultLocale, allDocs, type) {
|
|
940
|
-
if (!type.path) return void 0;
|
|
941
|
-
const enDoc = enCanonicalDoc(doc, allDocs, defaultLocale);
|
|
942
|
-
if (!enDoc.redirectTo) return void 0;
|
|
943
|
-
const targetEnSlug = extractSlugFromResolvedPath(type.path, enDoc.redirectTo);
|
|
944
|
-
if (!targetEnSlug) return void 0;
|
|
945
|
-
const targetEnDoc = allDocs.get(defaultLocale)?.bySlug.get(targetEnSlug);
|
|
946
|
-
const targetSlug = targetEnDoc ? getSlugForLocale(targetEnDoc, defaultLocale, locale, allDocs, defaultLocale) ?? targetEnSlug : targetEnSlug;
|
|
947
|
-
return resolvePath(type.path, targetSlug, locale, defaultLocale);
|
|
948
|
-
}
|
|
949
|
-
function withRedirectTo(result, locale, defaultLocale, allDocs, type) {
|
|
950
|
-
if (result.shouldRedirectTo || !result.document) return result;
|
|
951
|
-
const redirect = redirectPathForDocument(result.document, locale, defaultLocale, allDocs, type);
|
|
952
|
-
if (!redirect) return result;
|
|
953
|
-
return {
|
|
954
|
-
document: null,
|
|
955
|
-
actualLocale: result.actualLocale,
|
|
956
|
-
shouldRedirectTo: redirect
|
|
957
|
-
};
|
|
958
|
-
}
|
|
959
|
-
function resolveLocalizedDocument(slug, locale, defaultLocale, allDocs, type) {
|
|
957
|
+
function resolveLocalizedDocument(slug, locale, defaultLocale, allDocs, type, localeRouting) {
|
|
958
|
+
const urlBuilder = createUrlBuilder({
|
|
959
|
+
locales: [defaultLocale, locale],
|
|
960
|
+
defaultLocale,
|
|
961
|
+
localeRouting: localeRouting ?? { strategy: "path-prefix", prefixDefaultLocale: false }
|
|
962
|
+
});
|
|
960
963
|
const idx = allDocs.get(locale);
|
|
961
964
|
const direct = idx?.bySlug.get(slug);
|
|
962
965
|
if (direct) {
|
|
963
|
-
return
|
|
964
|
-
}
|
|
965
|
-
const aliasDoc = findEnDocByAlias(slug, allDocs, defaultLocale);
|
|
966
|
-
if (aliasDoc && type.path) {
|
|
967
|
-
const canonicalSlug = aliasDoc.slug;
|
|
968
|
-
const localizedSlug = getSlugForLocale(aliasDoc, defaultLocale, locale, allDocs, defaultLocale);
|
|
969
|
-
const targetSlug = localizedSlug ?? canonicalSlug;
|
|
970
|
-
if (targetSlug !== slug) {
|
|
971
|
-
return {
|
|
972
|
-
document: null,
|
|
973
|
-
actualLocale: locale,
|
|
974
|
-
shouldRedirectTo: resolvePath(type.path, targetSlug, locale, defaultLocale)
|
|
975
|
-
};
|
|
976
|
-
}
|
|
966
|
+
return { document: direct, actualLocale: locale };
|
|
977
967
|
}
|
|
978
968
|
for (const [docLocale, docIdx] of allDocs) {
|
|
979
969
|
const found = docIdx.bySlug.get(slug);
|
|
@@ -986,17 +976,11 @@ function resolveLocalizedDocument(slug, locale, defaultLocale, allDocs, type) {
|
|
|
986
976
|
return {
|
|
987
977
|
document: null,
|
|
988
978
|
actualLocale: locale,
|
|
989
|
-
shouldRedirectTo: resolvePath(type.path, correctSlug, locale
|
|
979
|
+
shouldRedirectTo: urlBuilder.resolvePath(type.path, correctSlug, locale)
|
|
990
980
|
};
|
|
991
981
|
}
|
|
992
982
|
if (docLocale === defaultLocale) {
|
|
993
|
-
return
|
|
994
|
-
{ document: found, actualLocale: defaultLocale },
|
|
995
|
-
locale,
|
|
996
|
-
defaultLocale,
|
|
997
|
-
allDocs,
|
|
998
|
-
type
|
|
999
|
-
);
|
|
983
|
+
return { document: found, actualLocale: defaultLocale };
|
|
1000
984
|
}
|
|
1001
985
|
if (!type.path) {
|
|
1002
986
|
return { document: null, actualLocale: locale };
|
|
@@ -1004,39 +988,21 @@ function resolveLocalizedDocument(slug, locale, defaultLocale, allDocs, type) {
|
|
|
1004
988
|
return {
|
|
1005
989
|
document: null,
|
|
1006
990
|
actualLocale: locale,
|
|
1007
|
-
shouldRedirectTo: resolvePath(type.path, found.enSlug, locale
|
|
991
|
+
shouldRedirectTo: urlBuilder.resolvePath(type.path, found.enSlug, locale)
|
|
1008
992
|
};
|
|
1009
993
|
}
|
|
1010
994
|
if (locale !== defaultLocale) {
|
|
1011
995
|
const enDoc = allDocs.get(defaultLocale)?.bySlug.get(slug);
|
|
1012
996
|
if (enDoc && type.indexFallback === "en") {
|
|
1013
|
-
return
|
|
1014
|
-
{ document: enDoc, actualLocale: defaultLocale },
|
|
1015
|
-
locale,
|
|
1016
|
-
defaultLocale,
|
|
1017
|
-
allDocs,
|
|
1018
|
-
type
|
|
1019
|
-
);
|
|
997
|
+
return { document: enDoc, actualLocale: defaultLocale };
|
|
1020
998
|
}
|
|
1021
999
|
const byEn = idx?.byEnSlug.get(slug);
|
|
1022
1000
|
if (byEn) {
|
|
1023
|
-
return
|
|
1024
|
-
{ document: byEn, actualLocale: locale },
|
|
1025
|
-
locale,
|
|
1026
|
-
defaultLocale,
|
|
1027
|
-
allDocs,
|
|
1028
|
-
type
|
|
1029
|
-
);
|
|
1001
|
+
return { document: byEn, actualLocale: locale };
|
|
1030
1002
|
}
|
|
1031
1003
|
const fallback = allDocs.get(defaultLocale)?.bySlug.get(slug);
|
|
1032
1004
|
if (fallback && type.indexFallback === "en") {
|
|
1033
|
-
return
|
|
1034
|
-
{ document: fallback, actualLocale: defaultLocale },
|
|
1035
|
-
locale,
|
|
1036
|
-
defaultLocale,
|
|
1037
|
-
allDocs,
|
|
1038
|
-
type
|
|
1039
|
-
);
|
|
1005
|
+
return { document: fallback, actualLocale: defaultLocale };
|
|
1040
1006
|
}
|
|
1041
1007
|
}
|
|
1042
1008
|
return { document: null, actualLocale: locale };
|
|
@@ -1078,6 +1044,7 @@ function buildRuntime(config, type, getRuntime) {
|
|
|
1078
1044
|
}
|
|
1079
1045
|
return type.path;
|
|
1080
1046
|
}
|
|
1047
|
+
const urlBuilder = createUrlBuilder(config);
|
|
1081
1048
|
const runtime = {
|
|
1082
1049
|
id: type.id,
|
|
1083
1050
|
config: type,
|
|
@@ -1093,15 +1060,21 @@ function buildRuntime(config, type, getRuntime) {
|
|
|
1093
1060
|
return load().get(locale)?.bySlug.get(slug) ?? null;
|
|
1094
1061
|
},
|
|
1095
1062
|
resolve(slug, locale) {
|
|
1096
|
-
const result = resolveLocalizedDocument(
|
|
1063
|
+
const result = resolveLocalizedDocument(
|
|
1064
|
+
slug,
|
|
1065
|
+
locale,
|
|
1066
|
+
config.defaultLocale,
|
|
1067
|
+
load(),
|
|
1068
|
+
type,
|
|
1069
|
+
config.localeRouting
|
|
1070
|
+
);
|
|
1097
1071
|
if (result.document && type.path) {
|
|
1098
1072
|
return {
|
|
1099
1073
|
...result,
|
|
1100
|
-
canonicalPath: resolvePath(
|
|
1074
|
+
canonicalPath: urlBuilder.resolvePath(
|
|
1101
1075
|
type.path,
|
|
1102
1076
|
result.document.slug,
|
|
1103
|
-
result.actualLocale
|
|
1104
|
-
config.defaultLocale
|
|
1077
|
+
result.actualLocale
|
|
1105
1078
|
)
|
|
1106
1079
|
};
|
|
1107
1080
|
}
|
|
@@ -1125,10 +1098,9 @@ function buildRuntime(config, type, getRuntime) {
|
|
|
1125
1098
|
alternates(doc) {
|
|
1126
1099
|
const pathTemplate = assertRoutable("alternates");
|
|
1127
1100
|
const out = {
|
|
1128
|
-
[config.defaultLocale]: resolvePath(
|
|
1101
|
+
[config.defaultLocale]: urlBuilder.resolvePath(
|
|
1129
1102
|
pathTemplate,
|
|
1130
1103
|
doc.enSlug,
|
|
1131
|
-
config.defaultLocale,
|
|
1132
1104
|
config.defaultLocale
|
|
1133
1105
|
)
|
|
1134
1106
|
};
|
|
@@ -1137,7 +1109,7 @@ function buildRuntime(config, type, getRuntime) {
|
|
|
1137
1109
|
if (locale === config.defaultLocale) continue;
|
|
1138
1110
|
const translated = all.get(locale)?.byEnSlug.get(doc.enSlug);
|
|
1139
1111
|
if (translated) {
|
|
1140
|
-
out[locale] = resolvePath(pathTemplate, translated.slug, locale
|
|
1112
|
+
out[locale] = urlBuilder.resolvePath(pathTemplate, translated.slug, locale);
|
|
1141
1113
|
}
|
|
1142
1114
|
}
|
|
1143
1115
|
return out;
|
|
@@ -1152,7 +1124,7 @@ function buildRuntime(config, type, getRuntime) {
|
|
|
1152
1124
|
},
|
|
1153
1125
|
url(slug, locale) {
|
|
1154
1126
|
const pathTemplate = assertRoutable("url");
|
|
1155
|
-
return resolvePath(pathTemplate, slug, locale
|
|
1127
|
+
return urlBuilder.resolvePath(pathTemplate, slug, locale);
|
|
1156
1128
|
},
|
|
1157
1129
|
related(doc, fieldName, locale) {
|
|
1158
1130
|
const meta = relationFields.get(fieldName);
|
|
@@ -1221,208 +1193,141 @@ init_esm_shims();
|
|
|
1221
1193
|
// src/redirects/build-redirects.ts
|
|
1222
1194
|
init_esm_shims();
|
|
1223
1195
|
|
|
1224
|
-
// src/core/slug-aliases.ts
|
|
1225
|
-
init_esm_shims();
|
|
1226
|
-
|
|
1227
1196
|
// src/core/alias-helpers.ts
|
|
1228
1197
|
init_esm_shims();
|
|
1229
1198
|
function enFileExists(config, type, enSlug) {
|
|
1230
1199
|
const dir = path4.join(config.rootDir, type.contentDir);
|
|
1231
1200
|
return fs2.existsSync(path4.join(dir, `${enSlug}.mdx`)) || fs2.existsSync(path4.join(dir, `${enSlug}.md`));
|
|
1232
1201
|
}
|
|
1233
|
-
function sqliteHasTranslations(db, contentTypeId, enSlug) {
|
|
1234
|
-
return listTranslationsForEnSlug(db, contentTypeId, enSlug).length > 0;
|
|
1235
|
-
}
|
|
1236
|
-
function isAliasKnown(config, type, aliasEnSlug, db) {
|
|
1237
|
-
return enFileExists(config, type, aliasEnSlug) || sqliteHasTranslations(db, type.id, aliasEnSlug);
|
|
1238
|
-
}
|
|
1239
1202
|
function listEnSlugs(rootDir, contentDir) {
|
|
1240
1203
|
const dir = path4.join(rootDir, contentDir);
|
|
1241
1204
|
if (!fs2.existsSync(dir)) return [];
|
|
1242
1205
|
return fs2.readdirSync(dir).filter(isPublishableContentFile).map((f) => f.replace(/\.(md|mdx)$/, ""));
|
|
1243
1206
|
}
|
|
1244
1207
|
|
|
1245
|
-
// src/
|
|
1208
|
+
// src/i18n/translation-index.ts
|
|
1209
|
+
init_esm_shims();
|
|
1210
|
+
|
|
1211
|
+
// src/redirects/build-redirects.ts
|
|
1246
1212
|
init_sqlite();
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1213
|
+
|
|
1214
|
+
// src/redirects/build-json-redirects.ts
|
|
1215
|
+
init_esm_shims();
|
|
1216
|
+
init_sqlite();
|
|
1217
|
+
|
|
1218
|
+
// src/redirects/load-type-redirects.ts
|
|
1219
|
+
init_esm_shims();
|
|
1220
|
+
|
|
1221
|
+
// src/redirects/redirect-schema.ts
|
|
1222
|
+
init_esm_shims();
|
|
1223
|
+
var redirectFromSchema = z.union([
|
|
1224
|
+
slugPatternSchema,
|
|
1225
|
+
z.array(slugPatternSchema).min(1).max(20)
|
|
1226
|
+
]);
|
|
1227
|
+
var typeRedirectEntrySchema = z.object({
|
|
1228
|
+
from: redirectFromSchema,
|
|
1229
|
+
toSlug: slugPatternSchema.optional(),
|
|
1230
|
+
toType: z.string().min(1).optional(),
|
|
1231
|
+
toUrl: z.string().min(1).optional(),
|
|
1232
|
+
permanent: z.boolean().optional()
|
|
1233
|
+
}).superRefine((entry, ctx) => {
|
|
1234
|
+
const hasToSlug = entry.toSlug !== void 0;
|
|
1235
|
+
const hasToUrl = entry.toUrl !== void 0;
|
|
1236
|
+
const hasToType = entry.toType !== void 0;
|
|
1237
|
+
const targetCount = Number(hasToSlug) + Number(hasToUrl);
|
|
1238
|
+
if (targetCount !== 1) {
|
|
1239
|
+
ctx.addIssue({
|
|
1240
|
+
code: "custom",
|
|
1241
|
+
message: "Each redirect must specify exactly one of toSlug/toType+toSlug or toUrl",
|
|
1242
|
+
path: ["toSlug"]
|
|
1243
|
+
});
|
|
1244
|
+
return;
|
|
1256
1245
|
}
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
level: "error"
|
|
1271
|
-
});
|
|
1272
|
-
continue;
|
|
1273
|
-
}
|
|
1274
|
-
if (canonicalSlugs.has(alias)) {
|
|
1275
|
-
issues.push({
|
|
1276
|
-
contentTypeId: type.id,
|
|
1277
|
-
enSlug,
|
|
1278
|
-
field: "aliases",
|
|
1279
|
-
message: `Alias "${alias}" collides with another document's canonical slug`,
|
|
1280
|
-
level: "error"
|
|
1281
|
-
});
|
|
1282
|
-
continue;
|
|
1283
|
-
}
|
|
1284
|
-
const existing = aliasToTarget.get(alias);
|
|
1285
|
-
if (existing) {
|
|
1286
|
-
issues.push({
|
|
1287
|
-
contentTypeId: type.id,
|
|
1288
|
-
enSlug,
|
|
1289
|
-
field: "aliases",
|
|
1290
|
-
message: `Alias "${alias}" is already claimed by ${existing.contentTypeId}/${existing.canonicalSlug}`,
|
|
1291
|
-
level: "error"
|
|
1292
|
-
});
|
|
1293
|
-
continue;
|
|
1294
|
-
}
|
|
1295
|
-
aliasToTarget.set(alias, {
|
|
1296
|
-
contentTypeId: type.id,
|
|
1297
|
-
canonicalSlug: enSlug,
|
|
1298
|
-
path: type.path
|
|
1299
|
-
});
|
|
1300
|
-
allAliasSlugs.add(alias);
|
|
1301
|
-
}
|
|
1302
|
-
}
|
|
1246
|
+
if (hasToUrl && hasToType) {
|
|
1247
|
+
ctx.addIssue({
|
|
1248
|
+
code: "custom",
|
|
1249
|
+
message: "toUrl cannot be combined with toType",
|
|
1250
|
+
path: ["toUrl"]
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
if (hasToType && !hasToSlug) {
|
|
1254
|
+
ctx.addIssue({
|
|
1255
|
+
code: "custom",
|
|
1256
|
+
message: "Cross-type redirects require both toType and toSlug",
|
|
1257
|
+
path: ["toSlug"]
|
|
1258
|
+
});
|
|
1303
1259
|
}
|
|
1304
|
-
|
|
1260
|
+
});
|
|
1261
|
+
var typeRedirectsFileSchema = z.object({
|
|
1262
|
+
redirects: z.array(typeRedirectEntrySchema).max(500)
|
|
1263
|
+
});
|
|
1264
|
+
function normalizeRedirectFrom(from) {
|
|
1265
|
+
return Array.isArray(from) ? from : [from];
|
|
1305
1266
|
}
|
|
1306
|
-
function
|
|
1307
|
-
const
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
const known = isAliasKnown(config, type, alias, db);
|
|
1316
|
-
if (!known) {
|
|
1317
|
-
issues.push({
|
|
1318
|
-
contentTypeId: type.id,
|
|
1319
|
-
enSlug: target.canonicalSlug,
|
|
1320
|
-
field: "aliases",
|
|
1321
|
-
message: `Alias "${alias}" is unknown (no EN file or sqlite translations) \u2014 only EN redirect will work`,
|
|
1322
|
-
level: "warning"
|
|
1323
|
-
});
|
|
1324
|
-
}
|
|
1325
|
-
if (enFileExists(config, type, alias)) {
|
|
1326
|
-
issues.push({
|
|
1327
|
-
contentTypeId: type.id,
|
|
1328
|
-
enSlug: target.canonicalSlug,
|
|
1329
|
-
field: "aliases",
|
|
1330
|
-
message: `Alias "${alias}" still has an EN file on disk \u2014 delete it manually`,
|
|
1331
|
-
level: "warning"
|
|
1332
|
-
});
|
|
1333
|
-
}
|
|
1334
|
-
const sqliteRows = listTranslationsForEnSlug(db, type.id, alias);
|
|
1335
|
-
if (sqliteRows.length > 0) {
|
|
1336
|
-
issues.push({
|
|
1337
|
-
contentTypeId: type.id,
|
|
1338
|
-
enSlug: target.canonicalSlug,
|
|
1339
|
-
field: "aliases",
|
|
1340
|
-
message: `${sqliteRows.length} sqlite translation(s) for alias "${alias}" retained for locale redirects and history`,
|
|
1341
|
-
level: "info"
|
|
1342
|
-
});
|
|
1343
|
-
}
|
|
1344
|
-
for (const locale of config.locales) {
|
|
1345
|
-
if (locale === config.defaultLocale) continue;
|
|
1346
|
-
const canonicalRow = listTranslationsForEnSlug(db, type.id, target.canonicalSlug).find(
|
|
1347
|
-
(r) => r.locale === locale
|
|
1348
|
-
);
|
|
1349
|
-
if (!canonicalRow) continue;
|
|
1350
|
-
const aliasRow = sqliteRows.find((r) => r.locale === locale);
|
|
1351
|
-
if (!aliasRow && known) {
|
|
1352
|
-
issues.push({
|
|
1353
|
-
contentTypeId: type.id,
|
|
1354
|
-
enSlug: target.canonicalSlug,
|
|
1355
|
-
field: "aliases",
|
|
1356
|
-
message: `Alias "${alias}" has no locale slug mapping for ${locale} \u2014 old localized URL may miss redirect`,
|
|
1357
|
-
level: "warning"
|
|
1358
|
-
});
|
|
1359
|
-
}
|
|
1360
|
-
}
|
|
1361
|
-
}
|
|
1362
|
-
} finally {
|
|
1363
|
-
db.close();
|
|
1267
|
+
function parseRedirectEntry(entry) {
|
|
1268
|
+
const fromSlugs = normalizeRedirectFrom(entry.from);
|
|
1269
|
+
if (entry.toUrl !== void 0) {
|
|
1270
|
+
return {
|
|
1271
|
+
fromSlugs,
|
|
1272
|
+
kind: "anywhere",
|
|
1273
|
+
toUrl: entry.toUrl,
|
|
1274
|
+
permanent: entry.permanent ?? true
|
|
1275
|
+
};
|
|
1364
1276
|
}
|
|
1365
|
-
|
|
1277
|
+
if (entry.toType !== void 0) {
|
|
1278
|
+
return {
|
|
1279
|
+
fromSlugs,
|
|
1280
|
+
kind: "cross-type",
|
|
1281
|
+
toType: entry.toType,
|
|
1282
|
+
toSlug: entry.toSlug,
|
|
1283
|
+
permanent: entry.permanent ?? true
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
return {
|
|
1287
|
+
fromSlugs,
|
|
1288
|
+
kind: "same-type",
|
|
1289
|
+
toSlug: entry.toSlug,
|
|
1290
|
+
permanent: entry.permanent ?? true
|
|
1291
|
+
};
|
|
1366
1292
|
}
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1293
|
+
|
|
1294
|
+
// src/redirects/load-type-redirects.ts
|
|
1295
|
+
var TYPE_REDIRECTS_FILENAME = "_redirects.json";
|
|
1296
|
+
function redirectsFilePath(config, type) {
|
|
1297
|
+
return path4.join(config.rootDir, type.contentDir, TYPE_REDIRECTS_FILENAME);
|
|
1298
|
+
}
|
|
1299
|
+
function loadTypeRedirectsFile(config, type) {
|
|
1300
|
+
const filePath = redirectsFilePath(config, type);
|
|
1301
|
+
if (!fs2.existsSync(filePath)) return null;
|
|
1302
|
+
let raw;
|
|
1303
|
+
try {
|
|
1304
|
+
raw = JSON.parse(fs2.readFileSync(filePath, "utf8"));
|
|
1305
|
+
} catch (error) {
|
|
1306
|
+
throw new Error(
|
|
1307
|
+
`${type.id}: failed to parse ${TYPE_REDIRECTS_FILENAME}: ${error instanceof Error ? error.message : String(error)}`
|
|
1376
1308
|
);
|
|
1377
|
-
if (doc?.redirectTo) {
|
|
1378
|
-
issues.push({
|
|
1379
|
-
contentTypeId: target.contentTypeId,
|
|
1380
|
-
enSlug: target.canonicalSlug,
|
|
1381
|
-
field: "aliases",
|
|
1382
|
-
message: `Alias "${alias}" points at "${target.canonicalSlug}" which has redirect_to \u2014 resolve to the final canonical slug`,
|
|
1383
|
-
level: "error"
|
|
1384
|
-
});
|
|
1385
|
-
}
|
|
1386
1309
|
}
|
|
1310
|
+
const parsed = typeRedirectsFileSchema.safeParse(raw);
|
|
1311
|
+
if (!parsed.success) {
|
|
1312
|
+
const details = parsed.error.issues.map((issue) => `${issue.path.join(".") || "root"}: ${issue.message}`).join("; ");
|
|
1313
|
+
throw new Error(`${type.id}: invalid ${TYPE_REDIRECTS_FILENAME}: ${details}`);
|
|
1314
|
+
}
|
|
1315
|
+
return {
|
|
1316
|
+
contentTypeId: type.id,
|
|
1317
|
+
contentDir: type.contentDir,
|
|
1318
|
+
filePath,
|
|
1319
|
+
entries: parsed.data.redirects.map(parseRedirectEntry)
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
function loadAllTypeRedirects(config) {
|
|
1323
|
+
const out = [];
|
|
1387
1324
|
for (const type of config.types) {
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
const doc = readEnDocument(config, type, enSlug);
|
|
1391
|
-
if (!doc?.redirectTo) continue;
|
|
1392
|
-
const targetSlug = extractSlugFromResolvedPath(type.path, doc.redirectTo);
|
|
1393
|
-
if (targetSlug && allAliasSlugs.has(targetSlug)) {
|
|
1394
|
-
issues.push({
|
|
1395
|
-
contentTypeId: type.id,
|
|
1396
|
-
enSlug,
|
|
1397
|
-
field: "redirect_to",
|
|
1398
|
-
message: `redirect_to target "${targetSlug}" is an alias slug \u2014 chain detected`,
|
|
1399
|
-
level: "error"
|
|
1400
|
-
});
|
|
1401
|
-
}
|
|
1402
|
-
const targetDoc = targetSlug ? readEnDocument(config, type, targetSlug) : null;
|
|
1403
|
-
if (targetDoc?.redirectTo) {
|
|
1404
|
-
issues.push({
|
|
1405
|
-
contentTypeId: type.id,
|
|
1406
|
-
enSlug,
|
|
1407
|
-
field: "redirect_to",
|
|
1408
|
-
message: `redirect_to chain detected: "${enSlug}" \u2192 "${targetSlug}" \u2192 "${targetDoc.redirectTo}"`,
|
|
1409
|
-
level: "error"
|
|
1410
|
-
});
|
|
1411
|
-
}
|
|
1412
|
-
}
|
|
1325
|
+
const loaded = loadTypeRedirectsFile(config, type);
|
|
1326
|
+
if (loaded) out.push(loaded);
|
|
1413
1327
|
}
|
|
1414
|
-
return
|
|
1328
|
+
return out;
|
|
1415
1329
|
}
|
|
1416
1330
|
|
|
1417
|
-
// src/redirects/translation-index.ts
|
|
1418
|
-
init_esm_shims();
|
|
1419
|
-
|
|
1420
|
-
// src/i18n/translation-index.ts
|
|
1421
|
-
init_esm_shims();
|
|
1422
|
-
|
|
1423
|
-
// src/redirects/translation-index.ts
|
|
1424
|
-
init_sqlite();
|
|
1425
|
-
|
|
1426
1331
|
// src/sitemap/join-base-url.ts
|
|
1427
1332
|
init_esm_shims();
|
|
1428
1333
|
|
|
@@ -1474,6 +1379,174 @@ function loadConfigSync(options = {}) {
|
|
|
1474
1379
|
|
|
1475
1380
|
// src/validate/validate-project.ts
|
|
1476
1381
|
init_esm_shims();
|
|
1382
|
+
|
|
1383
|
+
// src/validate/validate-redirects.ts
|
|
1384
|
+
init_esm_shims();
|
|
1385
|
+
function liveDocExists(config, typeId, enSlug) {
|
|
1386
|
+
const type = config.types.find((entry) => entry.id === typeId);
|
|
1387
|
+
if (!type) return false;
|
|
1388
|
+
return enFileExists(config, type, enSlug);
|
|
1389
|
+
}
|
|
1390
|
+
function resolveRedirectTarget(project, sourceTypeId, entry) {
|
|
1391
|
+
if (entry.kind === "anywhere") return null;
|
|
1392
|
+
if (entry.kind === "cross-type") {
|
|
1393
|
+
return { typeId: entry.toType, enSlug: entry.toSlug };
|
|
1394
|
+
}
|
|
1395
|
+
return { typeId: sourceTypeId, enSlug: entry.toSlug };
|
|
1396
|
+
}
|
|
1397
|
+
function matchRoutableTarget(project, toUrl) {
|
|
1398
|
+
const urlBuilder = createUrlBuilder(project.config);
|
|
1399
|
+
for (const type of project.config.types) {
|
|
1400
|
+
if (!type.path) continue;
|
|
1401
|
+
const enSlug = urlBuilder.extractSlugFromResolvedPath(type.path, toUrl);
|
|
1402
|
+
if (enSlug && liveDocExists(project.config, type.id, enSlug)) {
|
|
1403
|
+
return { typeId: type.id, enSlug };
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
return null;
|
|
1407
|
+
}
|
|
1408
|
+
function validateTypeRedirects(project) {
|
|
1409
|
+
const issues = [];
|
|
1410
|
+
const globalFrom = /* @__PURE__ */ new Map();
|
|
1411
|
+
for (const type of project.config.types) {
|
|
1412
|
+
const filePath = path4.join(project.config.rootDir, type.contentDir, TYPE_REDIRECTS_FILENAME);
|
|
1413
|
+
if (!fs2.existsSync(filePath)) continue;
|
|
1414
|
+
let loaded;
|
|
1415
|
+
try {
|
|
1416
|
+
loaded = loadTypeRedirectsFile(project.config, type);
|
|
1417
|
+
} catch (error) {
|
|
1418
|
+
issues.push({
|
|
1419
|
+
level: "error",
|
|
1420
|
+
contentType: type.id,
|
|
1421
|
+
field: TYPE_REDIRECTS_FILENAME,
|
|
1422
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1423
|
+
});
|
|
1424
|
+
continue;
|
|
1425
|
+
}
|
|
1426
|
+
if (!loaded) continue;
|
|
1427
|
+
if (!isRoutableType(type)) {
|
|
1428
|
+
issues.push({
|
|
1429
|
+
level: "error",
|
|
1430
|
+
contentType: type.id,
|
|
1431
|
+
field: TYPE_REDIRECTS_FILENAME,
|
|
1432
|
+
message: `Redirects are only supported on routable content types (missing path)`
|
|
1433
|
+
});
|
|
1434
|
+
continue;
|
|
1435
|
+
}
|
|
1436
|
+
for (const entry of loaded.entries) {
|
|
1437
|
+
for (const from of entry.fromSlugs) {
|
|
1438
|
+
if (entry.kind === "same-type" && from === entry.toSlug) {
|
|
1439
|
+
issues.push({
|
|
1440
|
+
level: "error",
|
|
1441
|
+
contentType: type.id,
|
|
1442
|
+
enSlug: from,
|
|
1443
|
+
field: TYPE_REDIRECTS_FILENAME,
|
|
1444
|
+
message: `Redirect source "${from}" must not equal its target slug`
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
if (liveDocExists(project.config, type.id, from)) {
|
|
1448
|
+
issues.push({
|
|
1449
|
+
level: "error",
|
|
1450
|
+
contentType: type.id,
|
|
1451
|
+
enSlug: from,
|
|
1452
|
+
field: TYPE_REDIRECTS_FILENAME,
|
|
1453
|
+
message: `Redirect source "${from}" still has a live EN file \u2014 delete or rename the MDX first`
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
const existing = globalFrom.get(from);
|
|
1457
|
+
if (existing && existing.typeId !== type.id) {
|
|
1458
|
+
issues.push({
|
|
1459
|
+
level: "error",
|
|
1460
|
+
contentType: type.id,
|
|
1461
|
+
enSlug: from,
|
|
1462
|
+
field: TYPE_REDIRECTS_FILENAME,
|
|
1463
|
+
message: `Redirect source "${from}" is already claimed by ${existing.typeId}`
|
|
1464
|
+
});
|
|
1465
|
+
} else if (existing) {
|
|
1466
|
+
issues.push({
|
|
1467
|
+
level: "error",
|
|
1468
|
+
contentType: type.id,
|
|
1469
|
+
enSlug: from,
|
|
1470
|
+
field: TYPE_REDIRECTS_FILENAME,
|
|
1471
|
+
message: `Duplicate redirect source "${from}" in ${TYPE_REDIRECTS_FILENAME}`
|
|
1472
|
+
});
|
|
1473
|
+
} else {
|
|
1474
|
+
globalFrom.set(from, { typeId: type.id, filePath: loaded.filePath });
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
if (entry.kind === "cross-type") {
|
|
1478
|
+
const targetType = project.config.types.find((candidate) => candidate.id === entry.toType);
|
|
1479
|
+
if (!targetType) {
|
|
1480
|
+
issues.push({
|
|
1481
|
+
level: "error",
|
|
1482
|
+
contentType: type.id,
|
|
1483
|
+
field: TYPE_REDIRECTS_FILENAME,
|
|
1484
|
+
message: `Unknown redirect target type "${entry.toType}"`
|
|
1485
|
+
});
|
|
1486
|
+
continue;
|
|
1487
|
+
}
|
|
1488
|
+
if (!isRoutableType(targetType)) {
|
|
1489
|
+
issues.push({
|
|
1490
|
+
level: "error",
|
|
1491
|
+
contentType: type.id,
|
|
1492
|
+
field: TYPE_REDIRECTS_FILENAME,
|
|
1493
|
+
message: `Redirect target type "${entry.toType}" is not routable (missing path)`
|
|
1494
|
+
});
|
|
1495
|
+
continue;
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
const target = resolveRedirectTarget(project, type.id, entry);
|
|
1499
|
+
if (target) {
|
|
1500
|
+
if (!liveDocExists(project.config, target.typeId, target.enSlug)) {
|
|
1501
|
+
issues.push({
|
|
1502
|
+
level: "error",
|
|
1503
|
+
contentType: type.id,
|
|
1504
|
+
field: TYPE_REDIRECTS_FILENAME,
|
|
1505
|
+
message: `Redirect target ${target.typeId}/${target.enSlug} does not exist`
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
continue;
|
|
1509
|
+
}
|
|
1510
|
+
if (entry.kind === "anywhere" && entry.toUrl?.startsWith("/")) {
|
|
1511
|
+
const matched = matchRoutableTarget(project, entry.toUrl);
|
|
1512
|
+
if (matched && !liveDocExists(project.config, matched.typeId, matched.enSlug)) {
|
|
1513
|
+
issues.push({
|
|
1514
|
+
level: "error",
|
|
1515
|
+
contentType: type.id,
|
|
1516
|
+
field: TYPE_REDIRECTS_FILENAME,
|
|
1517
|
+
message: `Redirect toUrl "${entry.toUrl}" does not resolve to a live document`
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
const allLoaded = loadAllTypeRedirects(project.config);
|
|
1524
|
+
for (const file of allLoaded) {
|
|
1525
|
+
for (const entry of file.entries) {
|
|
1526
|
+
if (entry.kind === "anywhere" || !entry.toSlug) continue;
|
|
1527
|
+
const targetTypeId = entry.kind === "cross-type" ? entry.toType : file.contentTypeId;
|
|
1528
|
+
const targetType = project.config.types.find((type) => type.id === targetTypeId);
|
|
1529
|
+
if (!targetType?.path) continue;
|
|
1530
|
+
for (const from of entry.fromSlugs) {
|
|
1531
|
+
if (globalFrom.get(from)?.typeId !== file.contentTypeId) continue;
|
|
1532
|
+
const targetAsSource = globalFrom.get(entry.toSlug);
|
|
1533
|
+
if (!targetAsSource) continue;
|
|
1534
|
+
const sameSlugCrossType = entry.kind === "cross-type" && entry.fromSlugs.includes(entry.toSlug) && targetAsSource.typeId === file.contentTypeId;
|
|
1535
|
+
if (sameSlugCrossType) continue;
|
|
1536
|
+
issues.push({
|
|
1537
|
+
level: "error",
|
|
1538
|
+
contentType: file.contentTypeId,
|
|
1539
|
+
enSlug: from,
|
|
1540
|
+
field: TYPE_REDIRECTS_FILENAME,
|
|
1541
|
+
message: `Redirect chain detected: "${from}" \u2192 "${entry.toSlug}" \u2192 existing redirect source`
|
|
1542
|
+
});
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
return issues;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
// src/validate/validate-project.ts
|
|
1477
1550
|
init_sqlite();
|
|
1478
1551
|
|
|
1479
1552
|
// src/validate/validate-relations.ts
|
|
@@ -1674,7 +1747,7 @@ function validateTranslationSlugSuffixes(config, db) {
|
|
|
1674
1747
|
}
|
|
1675
1748
|
|
|
1676
1749
|
// src/validate/validate-project.ts
|
|
1677
|
-
function
|
|
1750
|
+
function listEnSlugs3(rootDir, contentDir) {
|
|
1678
1751
|
const dir = path4.join(rootDir, contentDir);
|
|
1679
1752
|
if (!fs2.existsSync(dir)) return [];
|
|
1680
1753
|
return fs2.readdirSync(dir).filter(isPublishableContentFile).map((f) => f.replace(/\.(md|mdx)$/, ""));
|
|
@@ -1692,7 +1765,7 @@ function validateProject(config) {
|
|
|
1692
1765
|
}
|
|
1693
1766
|
const db = openStore(config, "readonly");
|
|
1694
1767
|
for (const type of config.types) {
|
|
1695
|
-
const enSlugs =
|
|
1768
|
+
const enSlugs = listEnSlugs3(config.rootDir, type.contentDir);
|
|
1696
1769
|
const englishSlugs = new Set(enSlugs);
|
|
1697
1770
|
if (!type.translate && isRoutableType(type)) {
|
|
1698
1771
|
issues.push({
|
|
@@ -1774,33 +1847,8 @@ function validateProject(config) {
|
|
|
1774
1847
|
}
|
|
1775
1848
|
}
|
|
1776
1849
|
db.close();
|
|
1777
|
-
const
|
|
1778
|
-
|
|
1779
|
-
issues.push({
|
|
1780
|
-
level: issue.level,
|
|
1781
|
-
contentType: issue.contentTypeId,
|
|
1782
|
-
enSlug: issue.enSlug,
|
|
1783
|
-
field: issue.field,
|
|
1784
|
-
message: issue.message
|
|
1785
|
-
});
|
|
1786
|
-
}
|
|
1787
|
-
for (const issue of validateAliasRedirectChains(project)) {
|
|
1788
|
-
issues.push({
|
|
1789
|
-
level: issue.level,
|
|
1790
|
-
contentType: issue.contentTypeId,
|
|
1791
|
-
enSlug: issue.enSlug,
|
|
1792
|
-
field: issue.field,
|
|
1793
|
-
message: issue.message
|
|
1794
|
-
});
|
|
1795
|
-
}
|
|
1796
|
-
for (const issue of validateAliasCoverage(project)) {
|
|
1797
|
-
issues.push({
|
|
1798
|
-
level: issue.level,
|
|
1799
|
-
contentType: issue.contentTypeId,
|
|
1800
|
-
enSlug: issue.enSlug,
|
|
1801
|
-
field: issue.field,
|
|
1802
|
-
message: issue.message
|
|
1803
|
-
});
|
|
1850
|
+
for (const issue of validateTypeRedirects(project)) {
|
|
1851
|
+
issues.push(issue);
|
|
1804
1852
|
}
|
|
1805
1853
|
for (const issue of validateRelations(project)) {
|
|
1806
1854
|
issues.push({
|
|
@@ -1844,7 +1892,7 @@ function computePageEnHash(translatableFrontmatter, body) {
|
|
|
1844
1892
|
|
|
1845
1893
|
// src/translate/worklist.ts
|
|
1846
1894
|
init_sqlite();
|
|
1847
|
-
function
|
|
1895
|
+
function listEnSlugs4(rootDir, contentDir) {
|
|
1848
1896
|
const dir = path4.join(rootDir, contentDir);
|
|
1849
1897
|
if (!fs2.existsSync(dir)) return [];
|
|
1850
1898
|
return fs2.readdirSync(dir).filter(isPublishableContentFile).map((f) => f.replace(/\.(md|mdx)$/, ""));
|
|
@@ -1855,7 +1903,7 @@ function buildWorklist(config, options = {}) {
|
|
|
1855
1903
|
const locales = options.locales ?? config.locales.filter((locale) => locale !== config.defaultLocale);
|
|
1856
1904
|
for (const type of config.types) {
|
|
1857
1905
|
if (options.contentType && type.id !== options.contentType) continue;
|
|
1858
|
-
const enSlugs = options.enSlug ? [options.enSlug] :
|
|
1906
|
+
const enSlugs = options.enSlug ? [options.enSlug] : listEnSlugs4(config.rootDir, type.contentDir);
|
|
1859
1907
|
for (const enSlug of enSlugs) {
|
|
1860
1908
|
const enDoc = readEnDocument(config, type, enSlug);
|
|
1861
1909
|
if (!enDoc) continue;
|
|
@@ -2542,6 +2590,7 @@ function buildStaticRawExports(project, options = {}) {
|
|
|
2542
2590
|
const excludeNoindex = options.excludeNoindex ?? false;
|
|
2543
2591
|
const typeFilter = options.types ? new Set(options.types) : null;
|
|
2544
2592
|
const out = [];
|
|
2593
|
+
const urlBuilder = createUrlBuilder(config);
|
|
2545
2594
|
for (const type of project.listTypes()) {
|
|
2546
2595
|
if (!isRoutableType(type.config)) continue;
|
|
2547
2596
|
if (typeFilter && !typeFilter.has(type.id)) continue;
|
|
@@ -2557,7 +2606,7 @@ function buildStaticRawExports(project, options = {}) {
|
|
|
2557
2606
|
if (excludeNoindex && resolved.document.noindex) continue;
|
|
2558
2607
|
const doc = resolved.document;
|
|
2559
2608
|
const slugWithExt = `${doc.slug}${extension}`;
|
|
2560
|
-
const urlPath = resolvePath(pathTemplate, slugWithExt, locale
|
|
2609
|
+
const urlPath = urlBuilder.resolvePath(pathTemplate, slugWithExt, locale);
|
|
2561
2610
|
out.push({
|
|
2562
2611
|
relativePath: urlPath.slice(1),
|
|
2563
2612
|
urlPath,
|