scribe-cms 0.0.10 → 0.0.12

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;
@@ -2075,6 +2207,20 @@ function listEnSlugs3(rootDir, contentDir) {
2075
2207
  if (!fs2.existsSync(dir)) return [];
2076
2208
  return fs2.readdirSync(dir).filter(isPublishableContentFile).map((f) => f.replace(/\.(md|mdx)$/, ""));
2077
2209
  }
2210
+ function validateDocumentMdxBody(issues, input) {
2211
+ const preparedBody = prepareTranslatedMdxBody(input.body).body;
2212
+ const mdxValidation = validateMdxBody(preparedBody);
2213
+ if (!mdxValidation.ok) {
2214
+ issues.push({
2215
+ level: "error",
2216
+ contentType: input.contentType,
2217
+ enSlug: input.enSlug,
2218
+ locale: input.locale,
2219
+ field: "body",
2220
+ message: `Invalid MDX: ${mdxValidation.error}`
2221
+ });
2222
+ }
2223
+ }
2078
2224
  function validateProject(config) {
2079
2225
  const issues = [];
2080
2226
  const project = createProject(config);
@@ -2133,6 +2279,12 @@ function validateProject(config) {
2133
2279
  })) {
2134
2280
  issues.push(issue);
2135
2281
  }
2282
+ validateDocumentMdxBody(issues, {
2283
+ contentType: type.id,
2284
+ enSlug,
2285
+ locale: config.defaultLocale,
2286
+ body: enDoc.content
2287
+ });
2136
2288
  for (const locale of config.locales) {
2137
2289
  if (locale === config.defaultLocale) continue;
2138
2290
  const row = getTranslation(db, type.id, enSlug, locale);
@@ -2148,15 +2300,22 @@ function validateProject(config) {
2148
2300
  message: issue.message
2149
2301
  });
2150
2302
  }
2303
+ const preparedBody = prepareTranslatedMdxBody(row.body).body;
2151
2304
  for (const issue of validateDocumentAssets(config, {
2152
2305
  contentType: type.id,
2153
2306
  enSlug,
2154
2307
  locale,
2155
2308
  frontmatter: localeFm,
2156
- body: row.body
2309
+ body: preparedBody
2157
2310
  })) {
2158
2311
  issues.push(issue);
2159
2312
  }
2313
+ validateDocumentMdxBody(issues, {
2314
+ contentType: type.id,
2315
+ enSlug,
2316
+ locale,
2317
+ body: row.body
2318
+ });
2160
2319
  }
2161
2320
  }
2162
2321
  try {
@@ -2305,12 +2464,20 @@ function normalizeGeminiDisplayName(model) {
2305
2464
  // src/translate/gemini-client.ts
2306
2465
  var DEFAULT_MODEL = DEFAULT_GEMINI_MODEL;
2307
2466
  function extractJson(text) {
2308
- const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
2467
+ const trimmed = text.trim();
2468
+ const fenced = trimmed.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
2309
2469
  if (fenced?.[1]) return fenced[1].trim();
2310
- const start = text.indexOf("{");
2311
- const end = text.lastIndexOf("}");
2312
- if (start >= 0 && end > start) return text.slice(start, end + 1);
2313
- return text.trim();
2470
+ const start = trimmed.indexOf("{");
2471
+ const end = trimmed.lastIndexOf("}");
2472
+ if (start >= 0 && end > start) return trimmed.slice(start, end + 1);
2473
+ return trimmed;
2474
+ }
2475
+ function parseGeminiResponse(text) {
2476
+ try {
2477
+ return JSON.parse(text.trim());
2478
+ } catch {
2479
+ return JSON.parse(extractJson(text));
2480
+ }
2314
2481
  }
2315
2482
  async function translatePageWithGemini(input) {
2316
2483
  const apiKey = input.apiKey ?? process.env.GEMINI_API_KEY;
@@ -2331,7 +2498,7 @@ async function translatePageWithGemini(input) {
2331
2498
  }
2332
2499
  });
2333
2500
  const raw = response.text ?? "";
2334
- const parsed = JSON.parse(extractJson(raw));
2501
+ const parsed = parseGeminiResponse(raw);
2335
2502
  if (!parsed.frontmatter || typeof parsed.body !== "string") {
2336
2503
  throw new Error("Gemini response missing frontmatter/body");
2337
2504
  }
@@ -2488,24 +2655,21 @@ function buildGeminiResponseSchema(schema, slugStrategy) {
2488
2655
  }
2489
2656
 
2490
2657
  // src/translate/resolve-translate-config.ts
2491
- function slugStrategyRules(slugStrategy, preserveTerms) {
2658
+ var BUILTIN_TRANSLATE_RULES = [
2659
+ "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.",
2660
+ "Return the MDX body with real line breaks; do not use JSON escape sequences like \\n or \\t in the body string.",
2661
+ '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 \\".'
2662
+ ];
2663
+ function slugStrategyRules(slugStrategy) {
2492
2664
  if (slugStrategy === "localized") {
2493
- const rules = [
2665
+ return [
2494
2666
  "Provide a URL slug in JSON field `slug` that is Localized into the target language.",
2495
2667
  "Base the slug on the meaning of the translated title \u2014 do NOT reuse the English slug words.",
2496
2668
  "Slug MUST be ASCII only: a-z, 0-9, hyphens. No uppercase, accents, underscores, or spaces.",
2497
2669
  "For non-Latin languages, write the words in the target language and transliterate them into Latin script (e.g. Russian -> romanized Russian, not English).",
2498
2670
  "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.",
2499
- '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 \\".'
2671
+ "Preserve 4-digit years and proper nouns in slugs."
2500
2672
  ];
2501
- if (preserveTerms?.length) {
2502
- rules.push(
2503
- `Preserve these terms verbatim in slugs (lowercase): ${preserveTerms.join(", ")}.`
2504
- );
2505
- } else {
2506
- rules.push("Preserve 4-digit years and proper nouns in slugs.");
2507
- }
2508
- return rules;
2509
2673
  }
2510
2674
  return ["Do NOT output a slug \u2014 slugStrategy is fixed."];
2511
2675
  }
@@ -2518,9 +2682,10 @@ function mergeTranslateConfig(project, type, slugStrategy) {
2518
2682
  promptOverride: type?.prompt ?? project?.prompt,
2519
2683
  context: mergeContext(project, type),
2520
2684
  rules: [
2685
+ ...BUILTIN_TRANSLATE_RULES,
2521
2686
  ...project?.rules ?? [],
2522
2687
  ...type?.rules ?? [],
2523
- ...slugStrategyRules(slugStrategy, project?.slugPreserveTerms)
2688
+ ...slugStrategyRules(slugStrategy)
2524
2689
  ],
2525
2690
  model: type?.model ?? project?.defaultModel
2526
2691
  };
@@ -2529,95 +2694,6 @@ function resolveTranslateConfig(config, type) {
2529
2694
  return mergeTranslateConfig(config.translate, type.translate, type.slugStrategy);
2530
2695
  }
2531
2696
 
2532
- // src/translate/sanitize-mdx-jsx.ts
2533
- function sanitizeMdxJsxAttributeQuotes(body) {
2534
- let adjusted = false;
2535
- let out = "";
2536
- let i = 0;
2537
- while (i < body.length) {
2538
- if (body[i] !== "<" || !/[\w.]/.test(body[i + 1] ?? "")) {
2539
- out += body[i];
2540
- i += 1;
2541
- continue;
2542
- }
2543
- const tagStart = i;
2544
- i += 1;
2545
- while (i < body.length && /[\w.-]/.test(body[i] ?? "")) i += 1;
2546
- const tagName = body.slice(tagStart + 1, i);
2547
- let tagOut = `<${tagName}`;
2548
- let tagAdjusted = false;
2549
- while (i < body.length && body[i] !== ">" && body[i] !== "/") {
2550
- while (i < body.length && /\s/.test(body[i] ?? "")) {
2551
- tagOut += body[i];
2552
- i += 1;
2553
- }
2554
- if (i >= body.length || body[i] === ">" || body[i] === "/") break;
2555
- const attrStart = i;
2556
- while (i < body.length && /[\w:.-]/.test(body[i] ?? "")) i += 1;
2557
- body.slice(attrStart, i);
2558
- while (i < body.length && /\s/.test(body[i] ?? "")) i += 1;
2559
- if (body[i] !== "=") {
2560
- tagOut += body.slice(attrStart, i);
2561
- continue;
2562
- }
2563
- tagOut += body.slice(attrStart, i);
2564
- tagOut += "=";
2565
- i += 1;
2566
- while (i < body.length && /\s/.test(body[i] ?? "")) i += 1;
2567
- const quote = body[i];
2568
- if (quote !== '"' && quote !== "'") {
2569
- continue;
2570
- }
2571
- if (quote === "'") {
2572
- const valStart2 = i + 1;
2573
- i += 1;
2574
- while (i < body.length) {
2575
- if (body[i] === "\\") {
2576
- i += 2;
2577
- continue;
2578
- }
2579
- if (body[i] === "'") break;
2580
- i += 1;
2581
- }
2582
- tagOut += body.slice(valStart2 - 1, i + 1);
2583
- i += 1;
2584
- continue;
2585
- }
2586
- const valStart = i + 1;
2587
- i += 1;
2588
- let closeIdx = -1;
2589
- for (let scan = valStart; scan < body.length; scan += 1) {
2590
- if (body[scan] !== '"') continue;
2591
- let j = scan + 1;
2592
- while (j < body.length && /\s/.test(body[j] ?? "")) j += 1;
2593
- if (j < body.length && (body[j] === ">" || body[j] === "/" || /[a-zA-Z]/.test(body[j] ?? ""))) {
2594
- closeIdx = scan;
2595
- break;
2596
- }
2597
- }
2598
- if (closeIdx === -1) {
2599
- tagOut += body.slice(valStart - 1, i);
2600
- break;
2601
- }
2602
- const value = body.slice(valStart, closeIdx);
2603
- const hasInternalQuote = value.includes('"');
2604
- if (hasInternalQuote) {
2605
- const escaped = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
2606
- tagOut += `'${escaped}'`;
2607
- tagAdjusted = true;
2608
- } else {
2609
- tagOut += `"${value}"`;
2610
- }
2611
- i = closeIdx + 1;
2612
- }
2613
- if (tagAdjusted) adjusted = true;
2614
- tagOut += body[i] ?? "";
2615
- out += tagOut;
2616
- i += 1;
2617
- }
2618
- return { body: out, adjusted };
2619
- }
2620
-
2621
2697
  // src/translate/validate-translation.ts
2622
2698
  function formatZodIssues(error) {
2623
2699
  return error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
@@ -2751,7 +2827,7 @@ async function translatePage(config, item, options = {}) {
2751
2827
  if (!validated.ok) {
2752
2828
  throw new Error(`Translation validation failed: ${validated.error}`);
2753
2829
  }
2754
- const { body: translatedBody, adjusted: mdxAdjusted } = sanitizeMdxJsxAttributeQuotes(
2830
+ const { body: translatedBody, adjusted: mdxAdjusted } = assertValidTranslatedMdxBody(
2755
2831
  result.parsed.body
2756
2832
  );
2757
2833
  const writeDb = openStore(config, "readwrite");