docs-i18n 0.6.3 → 0.7.0

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.
Files changed (169) hide show
  1. package/{src/admin/ui → admin/app}/components/JobDialog.tsx +21 -2
  2. package/{src/admin/ui → admin/app}/components/JobPanel.tsx +1 -1
  3. package/{src/admin/ui → admin/app}/components/Preview.tsx +2 -5
  4. package/{src/admin/ui → admin/app}/lib/api.ts +18 -39
  5. package/admin/app/routeTree.gen.ts +68 -0
  6. package/admin/app/router.tsx +23 -0
  7. package/admin/app/routes/__root.tsx +55 -0
  8. package/admin/app/routes/index.tsx +416 -0
  9. package/{src/admin/ui → admin/app}/styles.css +36 -3
  10. package/admin/package.json +27 -0
  11. package/admin/server/functions/jobs.ts +53 -0
  12. package/admin/server/functions/misc.ts +84 -0
  13. package/{src/admin/server/routes → admin/server/functions}/models.ts +16 -29
  14. package/admin/server/functions/status.ts +61 -0
  15. package/admin/server/index.ts +35 -0
  16. package/admin/server/init.ts +46 -0
  17. package/{src/admin → admin}/server/services/job-manager.ts +39 -10
  18. package/{src/admin → admin}/server/services/status.ts +6 -6
  19. package/admin/tsconfig.json +19 -0
  20. package/{src/admin → admin}/vite.config.ts +8 -2
  21. package/dist/{assemble-7H4QCW35.js → assemble-CP2BRYQJ.js} +6 -4
  22. package/dist/{chunk-A3YQNPKZ.js → chunk-CLYUAWZE.js} +1 -1
  23. package/dist/{chunk-YN4VJHCQ.js → chunk-JHBSHTXC.js} +1 -1
  24. package/dist/chunk-L64GJ4OB.js +32 -0
  25. package/dist/{chunk-SKKZIV3L.js → chunk-PNKVD2UK.js} +1 -29
  26. package/dist/{chunk-XEOYZUHS.js → chunk-QKIR7RKQ.js} +4 -31
  27. package/dist/chunk-TRURQFP4.js +31 -0
  28. package/dist/cli.js +108 -7
  29. package/dist/index.d.ts +41 -1
  30. package/dist/index.js +92 -3
  31. package/dist/{rescan-O5D3CYC2.js → rescan-HXMWFAOC.js} +5 -3
  32. package/dist/{status-F4MYIAAY.js → status-AGZDXOTZ.js} +4 -2
  33. package/dist/{translate-ZIVKNAC4.js → translate-A5X6MX4Y.js} +14 -7
  34. package/dist/upload-XL6KG6S2.js +132 -0
  35. package/package.json +17 -15
  36. package/template/app/components/BlogArticle.tsx +159 -0
  37. package/template/app/components/BlogList.tsx +88 -0
  38. package/template/app/components/Breadcrumbs.tsx +81 -0
  39. package/template/app/components/Card.tsx +31 -0
  40. package/template/app/components/Doc.tsx +191 -0
  41. package/template/app/components/DocBreadcrumb.tsx +60 -0
  42. package/template/app/components/DocContainer.tsx +13 -0
  43. package/template/app/components/DocTitle.tsx +11 -0
  44. package/template/app/components/DocsLayout.tsx +715 -0
  45. package/template/app/components/Dropdown.tsx +116 -0
  46. package/template/app/components/FallbackBanner.tsx +36 -0
  47. package/template/app/components/Footer.tsx +29 -0
  48. package/template/app/components/FrameworkSelect.tsx +150 -0
  49. package/template/app/components/LibraryCard.tsx +178 -0
  50. package/template/app/components/LocaleSwitcher.tsx +43 -0
  51. package/template/app/components/Navbar.tsx +430 -0
  52. package/template/app/components/PostNotFound.tsx +20 -0
  53. package/template/app/components/SearchButton.tsx +32 -0
  54. package/template/app/components/Select.tsx +103 -0
  55. package/template/app/components/Spinner.tsx +18 -0
  56. package/template/app/components/ThemeProvider.tsx +141 -0
  57. package/template/app/components/ThemeToggle.tsx +31 -0
  58. package/template/app/components/Toc.tsx +86 -0
  59. package/template/app/components/VersionSelect.tsx +118 -0
  60. package/template/app/components/icons/BSkyIcon.tsx +27 -0
  61. package/template/app/components/icons/BaseballCapIcon.tsx +25 -0
  62. package/template/app/components/icons/BrandXIcon.tsx +28 -0
  63. package/template/app/components/icons/CheckCircleIcon.tsx +28 -0
  64. package/template/app/components/icons/CogsIcon.tsx +25 -0
  65. package/template/app/components/icons/DiscordIcon.tsx +24 -0
  66. package/template/app/components/icons/GithubIcon.tsx +24 -0
  67. package/template/app/components/icons/GoogleIcon.tsx +24 -0
  68. package/template/app/components/icons/InstagramIcon.tsx +24 -0
  69. package/template/app/components/icons/NpmIcon.tsx +26 -0
  70. package/template/app/components/icons/YinYangIcon.tsx +26 -0
  71. package/template/app/components/icons/YouTubeIcon.tsx +24 -0
  72. package/template/app/components/markdown/CodeBlock.tsx +254 -0
  73. package/template/app/components/markdown/FileTabs.tsx +58 -0
  74. package/template/app/components/markdown/FrameworkContent.tsx +76 -0
  75. package/template/app/components/markdown/Markdown.tsx +216 -0
  76. package/template/app/components/markdown/MarkdownContent.tsx +89 -0
  77. package/template/app/components/markdown/MarkdownFrameworkHandler.tsx +66 -0
  78. package/template/app/components/markdown/MarkdownHeadingContext.tsx +35 -0
  79. package/template/app/components/markdown/MarkdownLink.tsx +46 -0
  80. package/template/app/components/markdown/MarkdownTabsHandler.tsx +109 -0
  81. package/template/app/components/markdown/PackageManagerTabs.tsx +95 -0
  82. package/template/app/components/markdown/Tabs.tsx +139 -0
  83. package/template/app/components/markdown/index.ts +15 -0
  84. package/template/app/components/ui/Button.tsx +141 -0
  85. package/template/app/components/ui/InlineCode.tsx +16 -0
  86. package/template/app/components/ui/MarkdownImg.tsx +21 -0
  87. package/template/app/config/frameworks.ts +93 -0
  88. package/template/app/contexts/SearchContext.tsx +36 -0
  89. package/template/app/db/index.ts +17 -0
  90. package/template/app/db/schema.ts +74 -0
  91. package/template/app/hooks/useClickOutside.ts +106 -0
  92. package/template/app/routeTree.gen.ts +584 -0
  93. package/template/app/router.tsx +29 -0
  94. package/template/app/routes/$lang.$project.$version.docs.$.tsx +128 -0
  95. package/template/app/routes/$lang.$project.$version.docs.framework.$framework.$.tsx +106 -0
  96. package/template/app/routes/$lang.$project.$version.docs.framework.$framework.index.tsx +27 -0
  97. package/template/app/routes/$lang.$project.$version.docs.framework.index.tsx +44 -0
  98. package/template/app/routes/$lang.$project.$version.docs.index.tsx +27 -0
  99. package/template/app/routes/$lang.$project.$version.docs.tsx +70 -0
  100. package/template/app/routes/$lang.$project.$version.tsx +69 -0
  101. package/template/app/routes/$lang.$project.docs.$.tsx +104 -0
  102. package/template/app/routes/$lang.$project.docs.index.tsx +20 -0
  103. package/template/app/routes/$lang.$project.docs.tsx +79 -0
  104. package/template/app/routes/$lang.$project.tsx +89 -0
  105. package/template/app/routes/$lang.blog.$.tsx +82 -0
  106. package/template/app/routes/$lang.blog.index.tsx +56 -0
  107. package/template/app/routes/$lang.blog.tsx +26 -0
  108. package/template/app/routes/$lang.docs.$.tsx +100 -0
  109. package/template/app/routes/$lang.docs.framework.$framework.$.tsx +104 -0
  110. package/template/app/routes/$lang.docs.framework.$framework.index.tsx +32 -0
  111. package/template/app/routes/$lang.docs.framework.index.tsx +47 -0
  112. package/template/app/routes/$lang.docs.index.tsx +20 -0
  113. package/template/app/routes/$lang.docs.tsx +90 -0
  114. package/template/app/routes/$lang.tsx +16 -0
  115. package/template/app/routes/__root.tsx +180 -0
  116. package/template/app/routes/index.tsx +89 -0
  117. package/template/app/site.config.ts +182 -0
  118. package/template/app/styles/app.css +1029 -0
  119. package/template/app/types/index.ts +77 -0
  120. package/template/app/utils/blog.server.ts +193 -0
  121. package/template/app/utils/blog.ts +42 -0
  122. package/template/app/utils/config.ts +120 -0
  123. package/template/app/utils/content-loader.ts +400 -0
  124. package/template/app/utils/dates.ts +29 -0
  125. package/template/app/utils/docs.server.ts +150 -0
  126. package/template/app/utils/markdown/filterFrameworkContent.ts +233 -0
  127. package/template/app/utils/markdown/index.ts +2 -0
  128. package/template/app/utils/markdown/installCommand.ts +143 -0
  129. package/template/app/utils/markdown/plugins/collectHeadings.ts +104 -0
  130. package/template/app/utils/markdown/plugins/extractCodeMeta.ts +57 -0
  131. package/template/app/utils/markdown/plugins/helpers.ts +33 -0
  132. package/template/app/utils/markdown/plugins/index.ts +8 -0
  133. package/template/app/utils/markdown/plugins/parseCommentComponents.ts +103 -0
  134. package/template/app/utils/markdown/plugins/transformCommentComponents.ts +23 -0
  135. package/template/app/utils/markdown/plugins/transformFrameworkComponent.ts +217 -0
  136. package/template/app/utils/markdown/plugins/transformTabsComponent.ts +359 -0
  137. package/template/app/utils/markdown/processor.ts +75 -0
  138. package/template/app/utils/site-config.tsx +11 -0
  139. package/template/app/utils/upload.ts +232 -0
  140. package/template/app/utils/useLocalStorage.ts +65 -0
  141. package/template/app/utils/utils.ts +23 -0
  142. package/template/package.json +54 -0
  143. package/template/public/favicon.svg +1 -0
  144. package/template/public/fonts/Inter-latin-ext.woff2 +0 -0
  145. package/template/public/fonts/Inter-latin.woff2 +0 -0
  146. package/template/public/images/frameworks/angular-logo.svg +1 -0
  147. package/template/public/images/frameworks/js-logo.svg +1 -0
  148. package/template/public/images/frameworks/lit-logo.svg +1 -0
  149. package/template/public/images/frameworks/preact-logo.svg +6 -0
  150. package/template/public/images/frameworks/qwik-logo.svg +1 -0
  151. package/template/public/images/frameworks/react-logo.svg +1 -0
  152. package/template/public/images/frameworks/solid-logo.svg +1 -0
  153. package/template/public/images/frameworks/svelte-logo.svg +1 -0
  154. package/template/public/images/frameworks/vue-logo.svg +4 -0
  155. package/template/tsconfig.json +24 -0
  156. package/template/vite.config.ts +43 -0
  157. package/template/wrangler.jsonc +16 -0
  158. package/README.md +0 -161
  159. package/dist/server-73AVSOL5.js +0 -598
  160. package/src/admin/index.html +0 -13
  161. package/src/admin/server/index.ts +0 -138
  162. package/src/admin/server/routes/jobs.ts +0 -113
  163. package/src/admin/server/routes/status.ts +0 -57
  164. package/src/admin/ui/App.tsx +0 -332
  165. package/src/admin/ui/main.tsx +0 -19
  166. /package/{src/admin/ui → admin/app}/components/FileList.tsx +0 -0
  167. /package/{src/admin/ui → admin/app}/components/LangGrid.tsx +0 -0
  168. /package/{src/admin/ui → admin/app}/components/ProgressBar.tsx +0 -0
  169. /package/{src/admin/ui → admin/app}/lib/flags.ts +0 -0
package/dist/index.d.ts CHANGED
@@ -1,3 +1,7 @@
1
+ import * as unified from 'unified';
2
+ import * as mdast from 'mdast';
3
+ import { Root } from 'hast';
4
+
1
5
  /**
2
6
  * docs-i18n configuration schema.
3
7
  * Each project provides a `docs-i18n.config.ts` file.
@@ -248,6 +252,36 @@ declare function extractTranslatableFields(frontmatterText: string): Record<stri
248
252
  */
249
253
  declare function reconstructFrontmatter(frontmatterText: string, translatedFields: Record<string, string>): string;
250
254
 
255
+ interface Heading {
256
+ id: string;
257
+ text: string;
258
+ level: number;
259
+ }
260
+ interface RenderedMarkdown {
261
+ html: string;
262
+ headings: Heading[];
263
+ }
264
+ /**
265
+ * The base unified processor for markdown → HTML rendering.
266
+ *
267
+ * Pipeline: remarkParse → remarkGfm → remarkRehype → rehypeRaw →
268
+ * rehypeCallouts → rehypeSlug → rehypeAutolinkHeadings →
269
+ * rehypeCollectHeadings → rehypeStringify
270
+ *
271
+ * Exported so consumers can extend it with additional plugins via `.use()`.
272
+ */
273
+ declare const processor: unified.Processor<mdast.Root, mdast.Root, Root, Root, string>;
274
+ /**
275
+ * Synchronous markdown → HTML rendering.
276
+ * Suitable for use inside React `useMemo` or server-side rendering.
277
+ */
278
+ declare function renderMarkdown(markdown: string): RenderedMarkdown;
279
+ /**
280
+ * Extract headings from an already-rendered HTML string.
281
+ * This is an alternative to the plugin approach — useful when you only have HTML.
282
+ */
283
+ declare function extractHeadings(html: string): Heading[];
284
+
251
285
  /**
252
286
  * Preprocess MDX content to ensure JSX tags (<AppOnly>, <PagesOnly>, <details>, <div>)
253
287
  * are separated from surrounding content by blank lines.
@@ -256,4 +290,10 @@ declare function reconstructFrontmatter(frontmatterText: string, translatedField
256
290
  */
257
291
  declare function normalize(content: string): string;
258
292
 
259
- export { type DocsI18nConfig, type ProjectConfig, TranslationCache, assemble, defineConfig, extractTranslatableFields, flattenSources, loadConfig, normalize, parseMdx, reconstructFrontmatter };
293
+ /**
294
+ * Walk up from the current file to find the docs-i18n package.json and return its version.
295
+ * Falls back to 'unknown' if not found.
296
+ */
297
+ declare function getPackageVersion(): string;
298
+
299
+ export { type DocsI18nConfig, type Heading, type ProjectConfig, type RenderedMarkdown, TranslationCache, assemble, defineConfig, extractHeadings, extractTranslatableFields, flattenSources, getPackageVersion, loadConfig, normalize, parseMdx, processor, reconstructFrontmatter, renderMarkdown };
package/dist/index.js CHANGED
@@ -10,14 +10,15 @@ function defineConfig(config) {
10
10
  }
11
11
  async function loadConfig(path2) {
12
12
  const configPath = path2 ?? "docs-i18n.config.ts";
13
+ const fullPath = configPath.startsWith("/") ? configPath : `${process.cwd()}/${configPath}`;
13
14
  try {
14
15
  const mod = await import(
15
16
  /* @vite-ignore */
16
- `${process.cwd()}/${configPath}`
17
+ fullPath
17
18
  );
18
19
  return mod.default ?? mod;
19
20
  } catch {
20
- throw new Error(`Cannot load config from ${configPath}. Create a docs-i18n.config.ts file.`);
21
+ throw new Error(`Cannot load config from ${fullPath}. Create a docs-i18n.config.ts file.`);
21
22
  }
22
23
  }
23
24
  function flattenSources(config) {
@@ -532,14 +533,102 @@ function reconstructFrontmatter(frontmatterText, translatedFields) {
532
533
  ${doc.toString().trimEnd()}
533
534
  ---`;
534
535
  }
536
+
537
+ // src/core/markdown.ts
538
+ import { unified } from "unified";
539
+ import remarkParse from "remark-parse";
540
+ import remarkGfm from "remark-gfm";
541
+ import remarkRehype from "remark-rehype";
542
+ import rehypeRaw from "rehype-raw";
543
+ import rehypeCallouts from "rehype-callouts";
544
+ import rehypeSlug from "rehype-slug";
545
+ import rehypeAutolinkHeadings from "rehype-autolink-headings";
546
+ import rehypeStringify from "rehype-stringify";
547
+ function getTextContent(node) {
548
+ if (node.type === "text") {
549
+ return node.value;
550
+ }
551
+ if ("children" in node) {
552
+ return node.children.map((child) => getTextContent(child)).join("");
553
+ }
554
+ return "";
555
+ }
556
+ function rehypeCollectHeadings() {
557
+ return (tree, file) => {
558
+ const headings = [];
559
+ function visit(node) {
560
+ if (node.type === "element") {
561
+ const match = node.tagName.match(/^h([1-6])$/);
562
+ if (match) {
563
+ const level = Number.parseInt(match[1], 10);
564
+ const id = node.properties?.id ?? "";
565
+ const text = getTextContent(node);
566
+ headings.push({ id, text, level });
567
+ }
568
+ }
569
+ if ("children" in node) {
570
+ for (const child of node.children) {
571
+ if (child.type === "element") {
572
+ visit(child);
573
+ }
574
+ }
575
+ }
576
+ }
577
+ visit(tree);
578
+ file.data.headings = headings;
579
+ };
580
+ }
581
+ var processor = unified().use(remarkParse).use(remarkGfm).use(remarkRehype, { allowDangerousHtml: true }).use(rehypeRaw).use(rehypeCallouts).use(rehypeSlug).use(rehypeAutolinkHeadings).use(rehypeCollectHeadings).use(rehypeStringify);
582
+ function renderMarkdown(markdown) {
583
+ const file = processor.processSync(markdown);
584
+ const headings = file.data.headings ?? [];
585
+ const html = String(file);
586
+ return { html, headings };
587
+ }
588
+ function extractHeadings(html) {
589
+ const headings = [];
590
+ const regex = /<h([1-6])(?:\s[^>]*?\bid="([^"]*)"[^>]*?)?>([\s\S]*?)<\/h\1>/gi;
591
+ let match;
592
+ while ((match = regex.exec(html)) !== null) {
593
+ const level = Number.parseInt(match[1], 10);
594
+ const id = match[2] ?? "";
595
+ const text = match[3].replace(/<[^>]+>/g, "").trim();
596
+ headings.push({ id, text, level });
597
+ }
598
+ return headings;
599
+ }
600
+
601
+ // src/core/version.ts
602
+ import { readFileSync } from "fs";
603
+ import { resolve, dirname } from "path";
604
+ import { fileURLToPath } from "url";
605
+ function getPackageVersion() {
606
+ try {
607
+ let dir = dirname(fileURLToPath(import.meta.url));
608
+ for (let i = 0; i < 5; i++) {
609
+ try {
610
+ const pkg = JSON.parse(readFileSync(resolve(dir, "package.json"), "utf-8"));
611
+ if (pkg.name === "docs-i18n") return pkg.version;
612
+ } catch {
613
+ }
614
+ dir = dirname(dir);
615
+ }
616
+ } catch {
617
+ }
618
+ return "unknown";
619
+ }
535
620
  export {
536
621
  TranslationCache,
537
622
  assemble,
538
623
  defineConfig,
624
+ extractHeadings,
539
625
  extractTranslatableFields,
540
626
  flattenSources,
627
+ getPackageVersion,
541
628
  loadConfig,
542
629
  normalize,
543
630
  parseMdx,
544
- reconstructFrontmatter
631
+ processor,
632
+ reconstructFrontmatter,
633
+ renderMarkdown
545
634
  };
@@ -1,13 +1,15 @@
1
1
  import {
2
2
  init_parser,
3
3
  parseMdx
4
- } from "./chunk-YN4VJHCQ.js";
4
+ } from "./chunk-JHBSHTXC.js";
5
5
  import {
6
6
  TranslationCache
7
- } from "./chunk-XEOYZUHS.js";
7
+ } from "./chunk-QKIR7RKQ.js";
8
8
  import {
9
9
  flattenSources
10
- } from "./chunk-SKKZIV3L.js";
10
+ } from "./chunk-TRURQFP4.js";
11
+ import "./chunk-L64GJ4OB.js";
12
+ import "./chunk-PNKVD2UK.js";
11
13
 
12
14
  // src/commands/rescan.ts
13
15
  import { existsSync, readdirSync, readFileSync } from "fs";
@@ -1,9 +1,11 @@
1
1
  import {
2
2
  TranslationCache
3
- } from "./chunk-XEOYZUHS.js";
3
+ } from "./chunk-QKIR7RKQ.js";
4
4
  import {
5
5
  flattenSources
6
- } from "./chunk-SKKZIV3L.js";
6
+ } from "./chunk-TRURQFP4.js";
7
+ import "./chunk-L64GJ4OB.js";
8
+ import "./chunk-PNKVD2UK.js";
7
9
 
8
10
  // src/commands/status.ts
9
11
  import { resolve } from "path";
@@ -1,17 +1,20 @@
1
- import "./chunk-A3YQNPKZ.js";
1
+ import "./chunk-CLYUAWZE.js";
2
2
  import {
3
3
  FRONTMATTER_TRANSLATABLE_FIELDS,
4
4
  init_parser
5
- } from "./chunk-YN4VJHCQ.js";
5
+ } from "./chunk-JHBSHTXC.js";
6
6
  import {
7
7
  TranslationCache
8
- } from "./chunk-XEOYZUHS.js";
8
+ } from "./chunk-QKIR7RKQ.js";
9
+ import {
10
+ flattenSources
11
+ } from "./chunk-TRURQFP4.js";
12
+ import "./chunk-L64GJ4OB.js";
9
13
  import {
10
14
  __esm,
11
15
  __export,
12
- __toCommonJS,
13
- flattenSources
14
- } from "./chunk-SKKZIV3L.js";
16
+ __toCommonJS
17
+ } from "./chunk-PNKVD2UK.js";
15
18
 
16
19
  // src/core/frontmatter.ts
17
20
  var frontmatter_exports = {};
@@ -262,7 +265,6 @@ function buildJsonUserMessage(uncached, nodeTypes) {
262
265
  const type = nodeTypes[md5] ?? "paragraph";
263
266
  if (type === "frontmatter") {
264
267
  const fields = extractTranslatableFields2(text);
265
- if (Object.keys(fields).length === 0) continue;
266
268
  const fieldKeys = {};
267
269
  for (const [field, value] of Object.entries(fields)) {
268
270
  const virtualKey = `fm:${md5}:${field}`;
@@ -477,6 +479,11 @@ ${choice.message.content}
477
479
  log(
478
480
  `\u2705 Frontmatter ${fmMd5.substring(0, 8)}: ${Object.keys(fields).join(", ")}`
479
481
  );
482
+ } else if (Object.keys(info.fieldKeys).length === 0) {
483
+ translations[fmMd5] = info.source;
484
+ log(
485
+ `\u2705 Frontmatter ${fmMd5.substring(0, 8)}: no translatable fields, cached as-is`
486
+ );
480
487
  }
481
488
  if (!allFound) {
482
489
  const vKeys = new Set(Object.values(info.fieldKeys));
@@ -0,0 +1,132 @@
1
+ import {
2
+ openDatabase
3
+ } from "./chunk-L64GJ4OB.js";
4
+ import "./chunk-PNKVD2UK.js";
5
+
6
+ // src/commands/upload.ts
7
+ import { readFileSync, readdirSync, existsSync } from "fs";
8
+ import { resolve, relative, join } from "path";
9
+ function collectContentFiles(projectRoot) {
10
+ const contentDir = resolve(projectRoot, "content");
11
+ if (!existsSync(contentDir)) {
12
+ throw new Error(`Content directory not found: ${contentDir}`);
13
+ }
14
+ const rows = [];
15
+ walkDir(contentDir, contentDir, rows);
16
+ return rows;
17
+ }
18
+ function walkDir(dir, contentRoot, rows) {
19
+ const entries = readdirSync(dir, { withFileTypes: true });
20
+ for (const entry of entries) {
21
+ const fullPath = join(dir, entry.name);
22
+ if (entry.isDirectory() && !entry.name.startsWith(".")) {
23
+ walkDir(fullPath, contentRoot, rows);
24
+ } else if (entry.isFile() && (entry.name.endsWith(".md") || entry.name.endsWith(".json"))) {
25
+ const relativePath = relative(contentRoot, fullPath);
26
+ const body = readFileSync(fullPath, "utf-8");
27
+ const parts = relativePath.split("/");
28
+ const { project, version, lang } = parseContentPath(parts);
29
+ rows.push({
30
+ path: relativePath,
31
+ body,
32
+ project,
33
+ version,
34
+ lang,
35
+ updated_at: Math.floor(Date.now() / 1e3)
36
+ });
37
+ }
38
+ }
39
+ }
40
+ function parseContentPath(parts) {
41
+ if (parts.length === 2) {
42
+ return { project: "default", version: "latest", lang: parts[0] };
43
+ }
44
+ if (parts.length === 3) {
45
+ if (isLangCode(parts[0])) {
46
+ return { project: "default", version: "latest", lang: parts[0] };
47
+ }
48
+ return { project: parts[0], version: "latest", lang: parts[1] };
49
+ }
50
+ if (parts.length >= 4) {
51
+ return { project: parts[0], version: parts[1], lang: parts[2] };
52
+ }
53
+ return { project: "default", version: "latest", lang: "en" };
54
+ }
55
+ function isLangCode(s) {
56
+ return /^[a-z]{2}(-[a-z]{2,})?$/.test(s);
57
+ }
58
+ function generateContentSql(rows) {
59
+ const statements = [];
60
+ statements.push(
61
+ `CREATE TABLE IF NOT EXISTS content (
62
+ path TEXT PRIMARY KEY NOT NULL,
63
+ body TEXT NOT NULL,
64
+ project TEXT NOT NULL,
65
+ version TEXT NOT NULL,
66
+ lang TEXT NOT NULL,
67
+ updated_at INTEGER
68
+ );`
69
+ );
70
+ for (const row of rows) {
71
+ const escapedBody = row.body.replace(/'/g, "''");
72
+ const escapedPath = row.path.replace(/'/g, "''");
73
+ statements.push(
74
+ `INSERT OR REPLACE INTO content (path, body, project, version, lang, updated_at) VALUES ('${escapedPath}', '${escapedBody}', '${row.project}', '${row.version}', '${row.lang}', ${row.updated_at});`
75
+ );
76
+ }
77
+ return statements;
78
+ }
79
+ function collectTranslations(projectRoot) {
80
+ const dbPath = resolve(projectRoot, ".docs-i18n", "translations.db");
81
+ if (!existsSync(dbPath)) {
82
+ return { sources: [], translations: [] };
83
+ }
84
+ const db = openDatabase(dbPath);
85
+ try {
86
+ const sources = db.prepare("SELECT key, text, type FROM sources").all();
87
+ const translations = db.prepare("SELECT lang, key, value FROM translations").all();
88
+ return { sources, translations };
89
+ } finally {
90
+ db.close();
91
+ }
92
+ }
93
+ function generateTranslationSql(data) {
94
+ const statements = [];
95
+ statements.push(
96
+ `CREATE TABLE IF NOT EXISTS sources (
97
+ key TEXT PRIMARY KEY NOT NULL,
98
+ text TEXT NOT NULL,
99
+ type TEXT NOT NULL DEFAULT 'paragraph'
100
+ );`
101
+ );
102
+ statements.push(
103
+ `CREATE TABLE IF NOT EXISTS translations (
104
+ lang TEXT NOT NULL,
105
+ key TEXT NOT NULL,
106
+ value TEXT NOT NULL,
107
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
108
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
109
+ PRIMARY KEY (lang, key)
110
+ );
111
+ CREATE INDEX IF NOT EXISTS idx_translations_lang ON translations(lang);`
112
+ );
113
+ for (const s of data.sources) {
114
+ const escapedText = s.text.replace(/'/g, "''");
115
+ statements.push(
116
+ `INSERT OR REPLACE INTO sources (key, text, type) VALUES ('${s.key}', '${escapedText}', '${s.type}');`
117
+ );
118
+ }
119
+ for (const t of data.translations) {
120
+ const escapedValue = t.value.replace(/'/g, "''");
121
+ statements.push(
122
+ `INSERT INTO translations (lang, key, value) VALUES ('${t.lang}', '${t.key}', '${escapedValue}') ON CONFLICT(lang, key) DO UPDATE SET value = excluded.value, updated_at = unixepoch();`
123
+ );
124
+ }
125
+ return statements;
126
+ }
127
+ export {
128
+ collectContentFiles,
129
+ collectTranslations,
130
+ generateContentSql,
131
+ generateTranslationSql
132
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docs-i18n",
3
- "version": "0.6.3",
3
+ "version": "0.7.0",
4
4
  "description": "Universal documentation translation engine — parse, translate, cache, assemble, manage.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,7 +14,8 @@
14
14
  },
15
15
  "files": [
16
16
  "dist",
17
- "src/admin",
17
+ "admin",
18
+ "template",
18
19
  "README.md"
19
20
  ],
20
21
  "scripts": {
@@ -22,8 +23,8 @@
22
23
  "dev": "tsup --watch",
23
24
  "test": "vitest run",
24
25
  "typecheck": "tsc --noEmit",
25
- "lint": "biome check .",
26
- "lint:fix": "biome check --write .",
26
+ "prepack": "rm -rf admin template && cp -r ../admin admin && cp -r ../template template && rm -rf admin/node_modules template/node_modules",
27
+ "postpack": "rm -rf admin template",
27
28
  "prepublishOnly": "rm -rf dist && tsup && chmod +x dist/cli.js"
28
29
  },
29
30
  "dependencies": {
@@ -31,25 +32,26 @@
31
32
  "glob": "^11.0.2",
32
33
  "hono": "^4.12.0",
33
34
  "openai": "^5.1.1",
35
+ "rehype-autolink-headings": "^7.1.0",
36
+ "rehype-callouts": "^2.0.0",
37
+ "rehype-raw": "^7.0.0",
38
+ "rehype-slug": "^6.0.0",
39
+ "rehype-stringify": "^10.0.1",
34
40
  "remark": "^15.0.1",
35
- "yaml": "^2.8.2",
36
- "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
37
- "@vitejs/plugin-react": "^4.0.0 || ^5.0.0 || ^6.0.0",
38
- "react": "^18.0.0 || ^19.0.0",
39
- "react-dom": "^18.0.0 || ^19.0.0",
40
- "@tanstack/react-query": "^5.0.0"
41
+ "remark-gfm": "^4.0.1",
42
+ "remark-parse": "^11.0.0",
43
+ "remark-rehype": "^11.1.2",
44
+ "unified": "^11.0.5",
45
+ "yaml": "^2.8.2"
41
46
  },
42
47
  "devDependencies": {
43
48
  "@biomejs/biome": "^2.0.0",
44
49
  "@types/better-sqlite3": "^7.6.13",
45
- "@types/bun": "^1.3.11",
50
+ "@types/hast": "^3.0.0",
46
51
  "@types/node": "^22.0.0",
47
- "@types/react": "^19.2.14",
48
- "@types/react-dom": "^19.2.3",
49
- "@vitejs/plugin-react": "^6.0.1",
50
52
  "tsup": "^8.5.0",
51
53
  "typescript": "^5.8.0",
52
- "vite": "^8.0.1",
54
+ "vfile": "^6.0.0",
53
55
  "vitest": "^3.2.0"
54
56
  },
55
57
  "publishConfig": {
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Blog article component.
3
+ *
4
+ * Renders a single blog post with:
5
+ * - Author and date byline prepended to markdown
6
+ * - Full markdown content with TOC when headings > 1
7
+ * - Breadcrumb navigation back to blog list
8
+ * - Fallback banner when showing untranslated content
9
+ *
10
+ * Layout matches tanstack.com blog article page.
11
+ */
12
+
13
+ import * as React from 'react'
14
+ import { Link } from '@tanstack/react-router'
15
+ import { renderMarkdown } from '~/utils/markdown'
16
+ import { format } from '~/utils/dates'
17
+ import { formatAuthors } from '~/utils/blog'
18
+ import { MarkdownContent } from '~/components/markdown'
19
+ import { Toc } from '~/components/Toc'
20
+ import { Breadcrumbs } from '~/components/Breadcrumbs'
21
+ import { FallbackBanner } from '~/components/FallbackBanner'
22
+ import type { LoadedBlogPost } from '~/utils/blog'
23
+
24
+ type BlogArticleProps = {
25
+ post: LoadedBlogPost
26
+ lang: string
27
+ /** Locale display name for fallback banner */
28
+ locale?: string
29
+ }
30
+
31
+ export function BlogArticle({ post, lang, locale }: BlogArticleProps) {
32
+ const { title, content, authors, published, filePath, isFallback } = post
33
+
34
+ // Prepend byline to content (matches tanstack.com pattern)
35
+ const blogContent = `<small>_by ${formatAuthors(authors)} on ${format(
36
+ new Date(published || 0),
37
+ 'MMMM d, yyyy',
38
+ )}._</small>
39
+
40
+ ${content}`
41
+
42
+ const { headings, markup } = React.useMemo(
43
+ () => renderMarkdown(blogContent),
44
+ [blogContent],
45
+ )
46
+
47
+ const isTocVisible = headings.length > 1
48
+
49
+ const markdownContainerRef = React.useRef<HTMLDivElement>(null)
50
+ const [activeHeadings, setActiveHeadings] = React.useState<Array<string>>([])
51
+
52
+ const headingElementRefs = React.useRef<
53
+ Record<string, IntersectionObserverEntry>
54
+ >({})
55
+
56
+ React.useEffect(() => {
57
+ const callback = (headingsList: Array<IntersectionObserverEntry>) => {
58
+ headingElementRefs.current = headingsList.reduce(
59
+ (map, headingElement) => {
60
+ map[headingElement.target.id] = headingElement
61
+ return map
62
+ },
63
+ headingElementRefs.current,
64
+ )
65
+
66
+ const visibleHeadings: Array<IntersectionObserverEntry> = []
67
+ Object.keys(headingElementRefs.current).forEach((key) => {
68
+ const headingElement = headingElementRefs.current[key]
69
+ if (headingElement.isIntersecting) {
70
+ visibleHeadings.push(headingElement)
71
+ }
72
+ })
73
+
74
+ if (visibleHeadings.length >= 1) {
75
+ setActiveHeadings(visibleHeadings.map((h) => h.target.id))
76
+ }
77
+ }
78
+
79
+ const observer = new IntersectionObserver(callback, {
80
+ rootMargin: '0px',
81
+ threshold: 0.2,
82
+ })
83
+
84
+ const headingElements = Array.from(
85
+ markdownContainerRef.current?.querySelectorAll(
86
+ 'h2[id], h3[id], h4[id], h5[id], h6[id]',
87
+ ) ?? [],
88
+ )
89
+ headingElements.forEach((el) => observer.observe(el))
90
+
91
+ return () => observer.disconnect()
92
+ }, [headings])
93
+
94
+ return (
95
+ <div
96
+ className={`
97
+ min-h-[calc(100dvh-var(--navbar-height))]
98
+ flex flex-col
99
+ w-full transition-all duration-300`}
100
+ >
101
+ <div className="flex flex-col max-w-full min-w-0 w-full min-h-0 relative mb-8">
102
+ <div className="min-w-0 flex justify-center w-full min-h-[88dvh] lg:min-h-0 mx-auto">
103
+ <div className="flex-1 flex flex-col w-full min-w-0">
104
+ <div className="px-4 pt-4 lg:pt-6">
105
+ <div className="w-full max-w-[1100px] mx-auto">
106
+ {isFallback && locale && <FallbackBanner locale={locale} />}
107
+ <div className="flex-1 min-h-0 flex flex-col">
108
+ <div className="w-full flex justify-center">
109
+ <div
110
+ className={[
111
+ 'w-full p-2 lg:p-4 xl:p-6',
112
+ isTocVisible ? 'max-w-full' : 'max-w-[768px]',
113
+ ].join(' ')}
114
+ >
115
+ <Breadcrumbs
116
+ section="Blog"
117
+ sectionTo={`/${lang}/blog`}
118
+ headings={isTocVisible ? headings : undefined}
119
+ tocHiddenBreakpoint="lg"
120
+ />
121
+ </div>
122
+ {isTocVisible && (
123
+ <div className="pl-4 w-32 lg:w-36 xl:w-44 2xl:w-56 3xl:w-64 shrink-0 hidden lg:block" />
124
+ )}
125
+ </div>
126
+ <div
127
+ className={[
128
+ 'w-full flex justify-center mx-auto',
129
+ isTocVisible ? 'max-w-full' : 'max-w-[768px]',
130
+ ].join(' ')}
131
+ >
132
+ <div className="flex overflow-auto flex-col w-full p-2 lg:p-4 xl:p-6 pt-0">
133
+ <MarkdownContent
134
+ title={title}
135
+ htmlMarkup={markup}
136
+ repo=""
137
+ branch=""
138
+ filePath={filePath}
139
+ containerRef={markdownContainerRef}
140
+ />
141
+ </div>
142
+ {isTocVisible && (
143
+ <div className="pl-4 w-32 lg:w-36 xl:w-44 2xl:w-56 3xl:w-64 shrink-0 hidden lg:block py-4 transition-all">
144
+ <Toc
145
+ headings={headings}
146
+ activeHeadings={activeHeadings}
147
+ />
148
+ </div>
149
+ )}
150
+ </div>
151
+ </div>
152
+ </div>
153
+ </div>
154
+ </div>
155
+ </div>
156
+ </div>
157
+ </div>
158
+ )
159
+ }