scribe-cms 0.0.9 → 0.0.11

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/dist/index.js CHANGED
@@ -3,6 +3,9 @@ import path3 from 'path';
3
3
  import fs2 from 'fs';
4
4
  import matter from 'gray-matter';
5
5
  import Database from 'better-sqlite3';
6
+ import remarkMdx from 'remark-mdx';
7
+ import remarkParse from 'remark-parse';
8
+ import { unified } from 'unified';
6
9
  import { createJiti } from 'jiti';
7
10
  import { createHash } from 'crypto';
8
11
  import { GoogleGenAI } from '@google/genai';
@@ -687,6 +690,135 @@ function bulkLoadTranslations(db) {
687
690
  return db.prepare(`SELECT * FROM translations ORDER BY content_type, en_slug, locale`).all();
688
691
  }
689
692
 
693
+ // src/translate/normalize-mdx-body.ts
694
+ function needsMdxEscapeNormalization(body) {
695
+ return /\\n[ \t/>]|<[\w.-][^>]*\\n/.test(body);
696
+ }
697
+ function normalizeTranslatedMdxBody(body) {
698
+ if (!needsMdxEscapeNormalization(body)) {
699
+ return { body, adjusted: false };
700
+ }
701
+ const normalized = body.replace(/\\r\\n/g, "\n").replace(/\\n/g, "\n").replace(/\\t/g, " ").replace(/\\"/g, '"').replace(/\\\\/g, "\\");
702
+ return { body: normalized, adjusted: normalized !== body };
703
+ }
704
+
705
+ // src/translate/sanitize-mdx-jsx.ts
706
+ function sanitizeMdxJsxAttributeQuotes(body) {
707
+ let adjusted = false;
708
+ let out = "";
709
+ let i = 0;
710
+ while (i < body.length) {
711
+ if (body[i] !== "<" || !/[\w.]/.test(body[i + 1] ?? "")) {
712
+ out += body[i];
713
+ i += 1;
714
+ continue;
715
+ }
716
+ const tagStart = i;
717
+ i += 1;
718
+ while (i < body.length && /[\w.-]/.test(body[i] ?? "")) i += 1;
719
+ const tagName = body.slice(tagStart + 1, i);
720
+ let tagOut = `<${tagName}`;
721
+ let tagAdjusted = false;
722
+ while (i < body.length && body[i] !== ">" && body[i] !== "/") {
723
+ while (i < body.length && /\s/.test(body[i] ?? "")) {
724
+ tagOut += body[i];
725
+ i += 1;
726
+ }
727
+ if (i >= body.length || body[i] === ">" || body[i] === "/") break;
728
+ const attrStart = i;
729
+ while (i < body.length && /[\w:.-]/.test(body[i] ?? "")) i += 1;
730
+ body.slice(attrStart, i);
731
+ while (i < body.length && /\s/.test(body[i] ?? "")) i += 1;
732
+ if (body[i] !== "=") {
733
+ tagOut += body.slice(attrStart, i);
734
+ continue;
735
+ }
736
+ tagOut += body.slice(attrStart, i);
737
+ tagOut += "=";
738
+ i += 1;
739
+ while (i < body.length && /\s/.test(body[i] ?? "")) i += 1;
740
+ const quote = body[i];
741
+ if (quote !== '"' && quote !== "'") {
742
+ continue;
743
+ }
744
+ if (quote === "'") {
745
+ const valStart2 = i + 1;
746
+ i += 1;
747
+ while (i < body.length) {
748
+ if (body[i] === "\\") {
749
+ i += 2;
750
+ continue;
751
+ }
752
+ if (body[i] === "'") break;
753
+ i += 1;
754
+ }
755
+ tagOut += body.slice(valStart2 - 1, i + 1);
756
+ i += 1;
757
+ continue;
758
+ }
759
+ const valStart = i + 1;
760
+ i += 1;
761
+ let closeIdx = -1;
762
+ for (let scan = valStart; scan < body.length; scan += 1) {
763
+ if (body[scan] !== '"') continue;
764
+ let j = scan + 1;
765
+ while (j < body.length && /\s/.test(body[j] ?? "")) j += 1;
766
+ if (j < body.length && (body[j] === ">" || body[j] === "/" || /[a-zA-Z]/.test(body[j] ?? ""))) {
767
+ closeIdx = scan;
768
+ break;
769
+ }
770
+ }
771
+ if (closeIdx === -1) {
772
+ tagOut += body.slice(valStart - 1, i);
773
+ break;
774
+ }
775
+ const value = body.slice(valStart, closeIdx);
776
+ const hasInternalQuote = value.includes('"');
777
+ if (hasInternalQuote) {
778
+ const escaped = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
779
+ tagOut += `'${escaped}'`;
780
+ tagAdjusted = true;
781
+ } else {
782
+ tagOut += `"${value}"`;
783
+ }
784
+ i = closeIdx + 1;
785
+ }
786
+ if (tagAdjusted) adjusted = true;
787
+ tagOut += body[i] ?? "";
788
+ out += tagOut;
789
+ i += 1;
790
+ }
791
+ return { body: out, adjusted };
792
+ }
793
+
794
+ // src/translate/validate-mdx-body.ts
795
+ var parser = unified().use(remarkParse).use(remarkMdx);
796
+ function prepareTranslatedMdxBody(body) {
797
+ const normalized = normalizeTranslatedMdxBody(body);
798
+ const sanitized = sanitizeMdxJsxAttributeQuotes(normalized.body);
799
+ return {
800
+ body: sanitized.body,
801
+ adjusted: normalized.adjusted || sanitized.adjusted
802
+ };
803
+ }
804
+ function validateMdxBody(body) {
805
+ try {
806
+ parser.parse(body);
807
+ return { ok: true };
808
+ } catch (error) {
809
+ const message = error instanceof Error ? error.message : String(error);
810
+ return { ok: false, error: message };
811
+ }
812
+ }
813
+ function assertValidTranslatedMdxBody(body) {
814
+ const prepared = prepareTranslatedMdxBody(body);
815
+ const validated = validateMdxBody(prepared.body);
816
+ if (!validated.ok) {
817
+ throw new Error(`MDX validation failed: ${validated.error}`);
818
+ }
819
+ return prepared;
820
+ }
821
+
690
822
  // src/loader/normalize-en.ts
691
823
  function normalizeEnFrontmatter(data) {
692
824
  const out = { ...data };
@@ -807,7 +939,7 @@ function buildDocumentFromTranslation(row, enDoc, type, config) {
807
939
  noindex: seo.noindex,
808
940
  canonicalPathOverride: seo.canonicalPathOverride,
809
941
  frontmatter,
810
- content: row.body
942
+ content: prepareTranslatedMdxBody(row.body).body
811
943
  };
812
944
  }
813
945
  var DEV_REVALIDATE_MS = 1500;
@@ -1771,7 +1903,7 @@ function validateTypeRedirects(project) {
1771
1903
  }
1772
1904
  for (const entry of loaded.entries) {
1773
1905
  for (const from of entry.fromSlugs) {
1774
- if (from === entry.toSlug) {
1906
+ if (entry.kind === "same-type" && from === entry.toSlug) {
1775
1907
  issues.push({
1776
1908
  level: "error",
1777
1909
  contentType: type.id,
@@ -1865,16 +1997,17 @@ function validateTypeRedirects(project) {
1865
1997
  if (!targetType?.path) continue;
1866
1998
  for (const from of entry.fromSlugs) {
1867
1999
  if (globalFrom.get(from)?.typeId !== file.contentTypeId) continue;
1868
- const chainTarget = globalFrom.get(entry.toSlug);
1869
- if (chainTarget) {
1870
- issues.push({
1871
- level: "error",
1872
- contentType: file.contentTypeId,
1873
- enSlug: from,
1874
- field: TYPE_REDIRECTS_FILENAME,
1875
- message: `Redirect chain detected: "${from}" \u2192 "${entry.toSlug}" \u2192 existing redirect source`
1876
- });
1877
- }
2000
+ const targetAsSource = globalFrom.get(entry.toSlug);
2001
+ if (!targetAsSource) continue;
2002
+ const sameSlugCrossType = entry.kind === "cross-type" && entry.fromSlugs.includes(entry.toSlug) && targetAsSource.typeId === file.contentTypeId;
2003
+ if (sameSlugCrossType) continue;
2004
+ issues.push({
2005
+ level: "error",
2006
+ contentType: file.contentTypeId,
2007
+ enSlug: from,
2008
+ field: TYPE_REDIRECTS_FILENAME,
2009
+ message: `Redirect chain detected: "${from}" \u2192 "${entry.toSlug}" \u2192 existing redirect source`
2010
+ });
1878
2011
  }
1879
2012
  }
1880
2013
  }
@@ -2147,15 +2280,27 @@ function validateProject(config) {
2147
2280
  message: issue.message
2148
2281
  });
2149
2282
  }
2283
+ const preparedBody = prepareTranslatedMdxBody(row.body).body;
2150
2284
  for (const issue of validateDocumentAssets(config, {
2151
2285
  contentType: type.id,
2152
2286
  enSlug,
2153
2287
  locale,
2154
2288
  frontmatter: localeFm,
2155
- body: row.body
2289
+ body: preparedBody
2156
2290
  })) {
2157
2291
  issues.push(issue);
2158
2292
  }
2293
+ const mdxValidation = validateMdxBody(preparedBody);
2294
+ if (!mdxValidation.ok) {
2295
+ issues.push({
2296
+ level: "error",
2297
+ contentType: type.id,
2298
+ enSlug,
2299
+ locale,
2300
+ field: "body",
2301
+ message: `Invalid MDX: ${mdxValidation.error}`
2302
+ });
2303
+ }
2159
2304
  }
2160
2305
  }
2161
2306
  try {
@@ -2487,24 +2632,21 @@ function buildGeminiResponseSchema(schema, slugStrategy) {
2487
2632
  }
2488
2633
 
2489
2634
  // src/translate/resolve-translate-config.ts
2490
- function slugStrategyRules(slugStrategy, preserveTerms) {
2635
+ var BUILTIN_TRANSLATE_RULES = [
2636
+ "Do not translate brand or product names unless the brand has a well-known localized name in the target market (rare). Keep the original spelling and capitalization.",
2637
+ "Return the MDX body with real line breaks; do not use JSON escape sequences like \\n or \\t in the body string.",
2638
+ 'In JSX attributes (e.g. FaqItem question="..."), use single quotes when the value contains double-quote characters (e.g. Hebrew \u05D3\u05D5\u05D0"\u05DC), or escape them as \\".'
2639
+ ];
2640
+ function slugStrategyRules(slugStrategy) {
2491
2641
  if (slugStrategy === "localized") {
2492
- const rules = [
2642
+ return [
2493
2643
  "Provide a URL slug in JSON field `slug` that is Localized into the target language.",
2494
2644
  "Base the slug on the meaning of the translated title \u2014 do NOT reuse the English slug words.",
2495
2645
  "Slug MUST be ASCII only: a-z, 0-9, hyphens. No uppercase, accents, underscores, or spaces.",
2496
2646
  "For non-Latin languages, write the words in the target language and transliterate them into Latin script (e.g. Russian -> romanized Russian, not English).",
2497
2647
  "Do NOT append locale codes to the slug (e.g. -fr, -he, -zh-cn). Locale routing is handled by the URL prefix, not the slug.",
2498
- 'In JSX attributes (e.g. FaqItem question="..."), use single quotes when the value contains double-quote characters (e.g. Hebrew \u05D3\u05D5\u05D0"\u05DC), or escape them as \\".'
2648
+ "Preserve 4-digit years and proper nouns in slugs."
2499
2649
  ];
2500
- if (preserveTerms?.length) {
2501
- rules.push(
2502
- `Preserve these terms verbatim in slugs (lowercase): ${preserveTerms.join(", ")}.`
2503
- );
2504
- } else {
2505
- rules.push("Preserve 4-digit years and proper nouns in slugs.");
2506
- }
2507
- return rules;
2508
2650
  }
2509
2651
  return ["Do NOT output a slug \u2014 slugStrategy is fixed."];
2510
2652
  }
@@ -2517,9 +2659,10 @@ function mergeTranslateConfig(project, type, slugStrategy) {
2517
2659
  promptOverride: type?.prompt ?? project?.prompt,
2518
2660
  context: mergeContext(project, type),
2519
2661
  rules: [
2662
+ ...BUILTIN_TRANSLATE_RULES,
2520
2663
  ...project?.rules ?? [],
2521
2664
  ...type?.rules ?? [],
2522
- ...slugStrategyRules(slugStrategy, project?.slugPreserveTerms)
2665
+ ...slugStrategyRules(slugStrategy)
2523
2666
  ],
2524
2667
  model: type?.model ?? project?.defaultModel
2525
2668
  };
@@ -2528,95 +2671,6 @@ function resolveTranslateConfig(config, type) {
2528
2671
  return mergeTranslateConfig(config.translate, type.translate, type.slugStrategy);
2529
2672
  }
2530
2673
 
2531
- // src/translate/sanitize-mdx-jsx.ts
2532
- function sanitizeMdxJsxAttributeQuotes(body) {
2533
- let adjusted = false;
2534
- let out = "";
2535
- let i = 0;
2536
- while (i < body.length) {
2537
- if (body[i] !== "<" || !/[\w.]/.test(body[i + 1] ?? "")) {
2538
- out += body[i];
2539
- i += 1;
2540
- continue;
2541
- }
2542
- const tagStart = i;
2543
- i += 1;
2544
- while (i < body.length && /[\w.-]/.test(body[i] ?? "")) i += 1;
2545
- const tagName = body.slice(tagStart + 1, i);
2546
- let tagOut = `<${tagName}`;
2547
- let tagAdjusted = false;
2548
- while (i < body.length && body[i] !== ">" && body[i] !== "/") {
2549
- while (i < body.length && /\s/.test(body[i] ?? "")) {
2550
- tagOut += body[i];
2551
- i += 1;
2552
- }
2553
- if (i >= body.length || body[i] === ">" || body[i] === "/") break;
2554
- const attrStart = i;
2555
- while (i < body.length && /[\w:.-]/.test(body[i] ?? "")) i += 1;
2556
- body.slice(attrStart, i);
2557
- while (i < body.length && /\s/.test(body[i] ?? "")) i += 1;
2558
- if (body[i] !== "=") {
2559
- tagOut += body.slice(attrStart, i);
2560
- continue;
2561
- }
2562
- tagOut += body.slice(attrStart, i);
2563
- tagOut += "=";
2564
- i += 1;
2565
- while (i < body.length && /\s/.test(body[i] ?? "")) i += 1;
2566
- const quote = body[i];
2567
- if (quote !== '"' && quote !== "'") {
2568
- continue;
2569
- }
2570
- if (quote === "'") {
2571
- const valStart2 = i + 1;
2572
- i += 1;
2573
- while (i < body.length) {
2574
- if (body[i] === "\\") {
2575
- i += 2;
2576
- continue;
2577
- }
2578
- if (body[i] === "'") break;
2579
- i += 1;
2580
- }
2581
- tagOut += body.slice(valStart2 - 1, i + 1);
2582
- i += 1;
2583
- continue;
2584
- }
2585
- const valStart = i + 1;
2586
- i += 1;
2587
- let closeIdx = -1;
2588
- for (let scan = valStart; scan < body.length; scan += 1) {
2589
- if (body[scan] !== '"') continue;
2590
- let j = scan + 1;
2591
- while (j < body.length && /\s/.test(body[j] ?? "")) j += 1;
2592
- if (j < body.length && (body[j] === ">" || body[j] === "/" || /[a-zA-Z]/.test(body[j] ?? ""))) {
2593
- closeIdx = scan;
2594
- break;
2595
- }
2596
- }
2597
- if (closeIdx === -1) {
2598
- tagOut += body.slice(valStart - 1, i);
2599
- break;
2600
- }
2601
- const value = body.slice(valStart, closeIdx);
2602
- const hasInternalQuote = value.includes('"');
2603
- if (hasInternalQuote) {
2604
- const escaped = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
2605
- tagOut += `'${escaped}'`;
2606
- tagAdjusted = true;
2607
- } else {
2608
- tagOut += `"${value}"`;
2609
- }
2610
- i = closeIdx + 1;
2611
- }
2612
- if (tagAdjusted) adjusted = true;
2613
- tagOut += body[i] ?? "";
2614
- out += tagOut;
2615
- i += 1;
2616
- }
2617
- return { body: out, adjusted };
2618
- }
2619
-
2620
2674
  // src/translate/validate-translation.ts
2621
2675
  function formatZodIssues(error) {
2622
2676
  return error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
@@ -2750,7 +2804,7 @@ async function translatePage(config, item, options = {}) {
2750
2804
  if (!validated.ok) {
2751
2805
  throw new Error(`Translation validation failed: ${validated.error}`);
2752
2806
  }
2753
- const { body: translatedBody, adjusted: mdxAdjusted } = sanitizeMdxJsxAttributeQuotes(
2807
+ const { body: translatedBody, adjusted: mdxAdjusted } = assertValidTranslatedMdxBody(
2754
2808
  result.parsed.body
2755
2809
  );
2756
2810
  const writeDb = openStore(config, "readwrite");