docs-i18n 0.6.2 → 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 -23
  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
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Content loading abstraction — filesystem for dev, D1 for production.
3
+ *
4
+ * The ContentLoader interface provides a uniform API for loading documents,
5
+ * docs config, and listing docs regardless of whether content lives on disk
6
+ * or in a D1 database.
7
+ */
8
+
9
+ import type { LoadedDoc, DocsConfig } from '~/types'
10
+
11
+ export interface ContentLoader {
12
+ /** Load a single document with i18n fallback. */
13
+ loadDoc(
14
+ project: string,
15
+ version: string,
16
+ lang: string,
17
+ slug: string,
18
+ ): Promise<LoadedDoc>
19
+
20
+ /** Load sidebar config for a project/version. */
21
+ loadDocsConfig(
22
+ project: string,
23
+ version: string,
24
+ ): Promise<DocsConfig | null>
25
+
26
+ /** List available doc slugs for a project/version/lang. */
27
+ listDocs(
28
+ project: string,
29
+ version: string,
30
+ lang: string,
31
+ dir?: string,
32
+ ): Promise<string[]>
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Filesystem loader (local development)
37
+ // ---------------------------------------------------------------------------
38
+
39
+ import {
40
+ readFileSync,
41
+ existsSync,
42
+ readdirSync,
43
+ statSync,
44
+ } from 'node:fs'
45
+ import { resolve, relative, join } from 'node:path'
46
+ import matter from 'gray-matter'
47
+ import { TranslationCache, assemble } from 'docs-i18n'
48
+
49
+ /** Singleton cache instance (lazily created, shared across requests) */
50
+ let _translationCache: TranslationCache | null = null
51
+
52
+ function getTranslationCache(projectRoot: string): TranslationCache | null {
53
+ if (_translationCache) return _translationCache
54
+ const cacheDir = resolve(projectRoot, '.cache')
55
+ const dbPath = resolve(cacheDir, 'translations.db')
56
+ if (!existsSync(dbPath)) return null
57
+ try {
58
+ _translationCache = new TranslationCache(cacheDir)
59
+ return _translationCache
60
+ } catch {
61
+ return null
62
+ }
63
+ }
64
+
65
+ export function createFsLoader(projectRoot: string): ContentLoader {
66
+ /**
67
+ * Read a raw .md file from one of the candidate base directories.
68
+ * Returns the raw file content and resolved path, or null.
69
+ */
70
+ function readRawFile(
71
+ project: string,
72
+ version: string,
73
+ lang: string,
74
+ slug: string,
75
+ ): { raw: string; filePath: string } | null {
76
+ const candidates = [
77
+ resolve(projectRoot, 'content', project, version, lang, `${slug}.md`),
78
+ resolve(projectRoot, 'content', project, lang, `${slug}.md`),
79
+ resolve(projectRoot, 'content', lang, `${slug}.md`),
80
+ ]
81
+ for (const filePath of candidates) {
82
+ if (existsSync(filePath)) {
83
+ return { raw: readFileSync(filePath, 'utf-8'), filePath }
84
+ }
85
+ }
86
+ return null
87
+ }
88
+
89
+ function parseRawDoc(
90
+ raw: string,
91
+ filePath: string,
92
+ locale: string,
93
+ isFallback: boolean,
94
+ ): LoadedDoc {
95
+ const { data, content } = matter(raw)
96
+ return {
97
+ content,
98
+ meta: { title: (data.title as string) || '', ...data },
99
+ locale,
100
+ isFallback,
101
+ filePath,
102
+ }
103
+ }
104
+
105
+ function tryLoadConfig(configPath: string): DocsConfig | null {
106
+ if (existsSync(configPath)) {
107
+ return JSON.parse(readFileSync(configPath, 'utf-8'))
108
+ }
109
+ return null
110
+ }
111
+
112
+ function scanDirectory(dir: string, base: string): string[] {
113
+ const slugs: string[] = []
114
+ try {
115
+ const entries = readdirSync(dir, { withFileTypes: true })
116
+ for (const entry of entries) {
117
+ if (entry.isFile() && entry.name.endsWith('.md')) {
118
+ slugs.push(relative(base, join(dir, entry.name)).replace(/\.md$/, ''))
119
+ } else if (entry.isDirectory() && !entry.name.startsWith('.')) {
120
+ slugs.push(...scanDirectory(join(dir, entry.name), base))
121
+ }
122
+ }
123
+ } catch {
124
+ // ignore read errors
125
+ }
126
+ return slugs
127
+ }
128
+
129
+ return {
130
+ async loadDoc(project, version, lang, slug) {
131
+ // For EN, just read the file directly
132
+ if (lang === 'en') {
133
+ const en = readRawFile(project, version, 'en', slug)
134
+ if (en) return parseRawDoc(en.raw, en.filePath, 'en', false)
135
+ throw new Error(
136
+ `Document not found: ${project}/${version}/en/${slug}`,
137
+ )
138
+ }
139
+
140
+ // For non-EN: always read the EN source first
141
+ const en = readRawFile(project, version, 'en', slug)
142
+ if (!en) {
143
+ throw new Error(
144
+ `Document not found: ${project}/${version}/${lang}/${slug}`,
145
+ )
146
+ }
147
+
148
+ // Try to assemble from translation cache
149
+ const cache = getTranslationCache(projectRoot)
150
+ if (cache) {
151
+ try {
152
+ const sourceFilePath = `${slug}.md`
153
+ const result = assemble(
154
+ en.raw,
155
+ lang,
156
+ cache,
157
+ sourceFilePath,
158
+ true, // fallbackToSource — use EN text for untranslated nodes
159
+ )
160
+
161
+ if (result.cachedCount > 0) {
162
+ // At least some translations found — return assembled content
163
+ const { data, content } = matter(result.content)
164
+ return {
165
+ content,
166
+ meta: { title: (data.title as string) || '', ...data },
167
+ locale: lang,
168
+ isFallback: false,
169
+ filePath: en.filePath,
170
+ }
171
+ }
172
+ } catch {
173
+ // Cache read failed, fall through to EN fallback
174
+ }
175
+ }
176
+
177
+ // No translations available — fall back to EN content
178
+ return parseRawDoc(en.raw, en.filePath, 'en', true)
179
+ },
180
+
181
+ async loadDocsConfig(project, version) {
182
+ // Try versioned config
183
+ const v = tryLoadConfig(
184
+ resolve(projectRoot, 'content', project, version, 'en', 'config.json'),
185
+ )
186
+ if (v) return v
187
+
188
+ // Try project-level config (docs.config.json)
189
+ const p = tryLoadConfig(
190
+ resolve(projectRoot, 'content', project, 'docs.config.json'),
191
+ )
192
+ if (p) return p
193
+
194
+ // Try project-level config (config.json)
195
+ const p2 = tryLoadConfig(
196
+ resolve(projectRoot, 'content', project, 'config.json'),
197
+ )
198
+ if (p2) return p2
199
+
200
+ // Try legacy single-project config
201
+ const l = tryLoadConfig(
202
+ resolve(projectRoot, 'content', 'docs.config.json'),
203
+ )
204
+ if (l) return l
205
+
206
+ return null
207
+ },
208
+
209
+ async listDocs(project, version, lang, dir) {
210
+ // Try requested lang first, then fall back to EN
211
+ // (translated content comes from the cache, so non-EN dirs may not exist on disk)
212
+ const langsToTry = lang === 'en' ? ['en'] : [lang, 'en']
213
+
214
+ for (const tryLang of langsToTry) {
215
+ const candidates = [
216
+ resolve(projectRoot, 'content', project, version, tryLang),
217
+ resolve(projectRoot, 'content', project, tryLang),
218
+ resolve(projectRoot, 'content', tryLang),
219
+ ]
220
+
221
+ for (const base of candidates) {
222
+ const searchDir = dir ? resolve(base, dir) : base
223
+ if (existsSync(searchDir) && statSync(searchDir).isDirectory()) {
224
+ const slugs = scanDirectory(searchDir, base)
225
+ if (slugs.length > 0) return slugs
226
+ }
227
+ }
228
+ }
229
+
230
+ return []
231
+ },
232
+ }
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // D1 loader (production)
237
+ // ---------------------------------------------------------------------------
238
+
239
+ import { eq, and, like } from 'drizzle-orm'
240
+ import type { Db } from '~/db'
241
+ import { createDb, schema } from '~/db'
242
+
243
+ export function createD1Loader(db: Db): ContentLoader {
244
+ return {
245
+ async loadDoc(project, version, lang, slug) {
246
+ const path = `${project}/${version}/${lang}/${slug}.md`
247
+
248
+ // Try requested language
249
+ const row = await db
250
+ .select()
251
+ .from(schema.content)
252
+ .where(eq(schema.content.path, path))
253
+ .get()
254
+
255
+ if (row) {
256
+ const { data, content } = matter(row.body)
257
+ return {
258
+ content,
259
+ meta: { title: (data.title as string) || '', ...data },
260
+ locale: lang,
261
+ isFallback: false,
262
+ filePath: path,
263
+ }
264
+ }
265
+
266
+ // Fallback to English
267
+ if (lang !== 'en') {
268
+ const enPath = `${project}/${version}/en/${slug}.md`
269
+ const enRow = await db
270
+ .select()
271
+ .from(schema.content)
272
+ .where(eq(schema.content.path, enPath))
273
+ .get()
274
+
275
+ if (enRow) {
276
+ const { data, content } = matter(enRow.body)
277
+ return {
278
+ content,
279
+ meta: { title: (data.title as string) || '', ...data },
280
+ locale: 'en',
281
+ isFallback: true,
282
+ filePath: enPath,
283
+ }
284
+ }
285
+ }
286
+
287
+ throw new Error(
288
+ `Document not found: ${project}/${version}/${lang}/${slug}`,
289
+ )
290
+ },
291
+
292
+ async loadDocsConfig(project, version) {
293
+ // Config stored as a special content entry
294
+ const configPath = `${project}/${version}/en/config.json`
295
+ const row = await db
296
+ .select()
297
+ .from(schema.content)
298
+ .where(eq(schema.content.path, configPath))
299
+ .get()
300
+
301
+ if (row) {
302
+ return JSON.parse(row.body)
303
+ }
304
+
305
+ // Try project-level docs.config.json
306
+ const docsConfigPath = `${project}/docs.config.json`
307
+ const row1b = await db
308
+ .select()
309
+ .from(schema.content)
310
+ .where(eq(schema.content.path, docsConfigPath))
311
+ .get()
312
+
313
+ if (row1b) {
314
+ return JSON.parse(row1b.body)
315
+ }
316
+
317
+ // Try project-level config.json
318
+ const projectConfigPath = `${project}/config.json`
319
+ const row2 = await db
320
+ .select()
321
+ .from(schema.content)
322
+ .where(eq(schema.content.path, projectConfigPath))
323
+ .get()
324
+
325
+ if (row2) {
326
+ return JSON.parse(row2.body)
327
+ }
328
+
329
+ return null
330
+ },
331
+
332
+ async listDocs(project, version, lang, dir) {
333
+ const prefix = dir
334
+ ? `${project}/${version}/${lang}/${dir}/`
335
+ : `${project}/${version}/${lang}/`
336
+
337
+ const rows = await db
338
+ .select({ path: schema.content.path })
339
+ .from(schema.content)
340
+ .where(
341
+ and(
342
+ like(schema.content.path, `${prefix}%`),
343
+ eq(schema.content.project, project),
344
+ eq(schema.content.version, version),
345
+ eq(schema.content.lang, lang),
346
+ ),
347
+ )
348
+ .all()
349
+
350
+ return rows
351
+ .map((r) => r.path.replace(prefix, '').replace(/\.md$/, ''))
352
+ .filter((s) => s.length > 0)
353
+ },
354
+ }
355
+ }
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // Factory — selects loader based on environment
359
+ // ---------------------------------------------------------------------------
360
+
361
+ /**
362
+ * Get the project root directory.
363
+ * Uses the same pattern as docs-i18n admin:
364
+ * 1. DOCS_I18N_PROJECT_ROOT env var
365
+ * 2. Temp file written by the CLI
366
+ * 3. Falls back to cwd
367
+ */
368
+ function getProjectRoot(): string {
369
+ if (process.env.DOCS_I18N_PROJECT_ROOT) {
370
+ return process.env.DOCS_I18N_PROJECT_ROOT
371
+ }
372
+ // Prefer cwd if it has a content directory (avoids stale temp file from another project)
373
+ const cwd = process.cwd()
374
+ if (existsSync(resolve(cwd, 'content'))) {
375
+ return cwd
376
+ }
377
+ try {
378
+ const tmpDir = process.env.TMPDIR || process.env.TEMP || '/tmp'
379
+ return readFileSync(resolve(tmpDir, 'docs-i18n-project-root'), 'utf-8').trim()
380
+ } catch {
381
+ // ignore
382
+ }
383
+ return cwd
384
+ }
385
+
386
+ /**
387
+ * Create a content loader based on the current environment.
388
+ *
389
+ * - In production with a D1 binding: uses D1Loader
390
+ * - Otherwise: uses FsLoader reading from the project root
391
+ *
392
+ * For D1 in production, pass the D1 database instance.
393
+ * When no D1 is available, falls back to filesystem.
394
+ */
395
+ export function getContentLoader(d1?: D1Database): ContentLoader {
396
+ if (d1) {
397
+ return createD1Loader(createDb(d1))
398
+ }
399
+ return createFsLoader(getProjectRoot())
400
+ }
@@ -0,0 +1,29 @@
1
+ // Native date formatting utilities (ported from tanstack.com)
2
+ // Uses Intl.DateTimeFormat — no external dependencies
3
+
4
+ export function format(date: Date | number, formatStr: string): string {
5
+ const d = typeof date === 'number' ? new Date(date) : date
6
+
7
+ switch (formatStr) {
8
+ case 'MMMM d, yyyy':
9
+ return d.toLocaleDateString('en-US', {
10
+ year: 'numeric',
11
+ month: 'long',
12
+ day: 'numeric',
13
+ })
14
+
15
+ case 'MMM d, yyyy':
16
+ return d.toLocaleDateString('en-US', {
17
+ year: 'numeric',
18
+ month: 'short',
19
+ day: 'numeric',
20
+ })
21
+
22
+ default:
23
+ return d.toLocaleDateString('en-US', {
24
+ year: 'numeric',
25
+ month: 'short',
26
+ day: 'numeric',
27
+ })
28
+ }
29
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Server-side content loading functions.
3
+ *
4
+ * Uses the ContentLoader abstraction to support both filesystem (dev)
5
+ * and D1 (production) content sources.
6
+ */
7
+
8
+ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'
9
+ import { resolve, relative, join } from 'node:path'
10
+ import matter from 'gray-matter'
11
+ import type { LoadedDoc, DocsConfig, DocsConfigItem } from '~/types'
12
+ import { type ContentLoader, createFsLoader } from './content-loader'
13
+
14
+ /**
15
+ * Get the project root directory.
16
+ * Uses the same pattern as docs-i18n admin:
17
+ * 1. DOCS_I18N_PROJECT_ROOT env var
18
+ * 2. Temp file written by the CLI
19
+ * 3. Falls back to cwd
20
+ */
21
+ function getProjectRoot(): string {
22
+ if (process.env.DOCS_I18N_PROJECT_ROOT) {
23
+ return process.env.DOCS_I18N_PROJECT_ROOT
24
+ }
25
+ // Prefer cwd if it has a content directory (avoids stale temp file from another project)
26
+ const cwd = process.cwd()
27
+ if (existsSync(resolve(cwd, 'content'))) {
28
+ return cwd
29
+ }
30
+ try {
31
+ const tmpDir = process.env.TMPDIR || process.env.TEMP || '/tmp'
32
+ return readFileSync(resolve(tmpDir, 'docs-i18n-project-root'), 'utf-8').trim()
33
+ } catch {
34
+ // ignore
35
+ }
36
+ return cwd
37
+ }
38
+
39
+ /** Cached content loader instance. */
40
+ let _loader: ContentLoader | null = null
41
+
42
+ /**
43
+ * Get or create the content loader.
44
+ * In dev mode, uses filesystem. In production with D1, uses D1.
45
+ * The D1 loader is activated by calling `setD1Loader()` from the server entry.
46
+ */
47
+ export function getLoader(): ContentLoader {
48
+ if (!_loader) {
49
+ _loader = createFsLoader(getProjectRoot())
50
+ }
51
+ return _loader
52
+ }
53
+
54
+ /**
55
+ * Set a custom content loader (e.g., D1-backed for production).
56
+ * Called from the server entry point when a D1 binding is available.
57
+ */
58
+ export function setContentLoader(loader: ContentLoader) {
59
+ _loader = loader
60
+ }
61
+
62
+ /**
63
+ * Load a document with i18n fallback.
64
+ * Delegates to the active ContentLoader.
65
+ */
66
+ export async function loadDoc(
67
+ project: string,
68
+ version: string,
69
+ lang: string,
70
+ slug: string,
71
+ ): Promise<LoadedDoc> {
72
+ return getLoader().loadDoc(project, version, lang, slug)
73
+ }
74
+
75
+ /**
76
+ * Load docs config (config.json for sidebar structure).
77
+ * Delegates to the active ContentLoader, with auto-scan fallback.
78
+ */
79
+ export async function loadDocsConfig(
80
+ project: string,
81
+ version: string,
82
+ ): Promise<DocsConfig> {
83
+ const config = await getLoader().loadDocsConfig(project, version)
84
+ if (config) return config
85
+
86
+ // Auto-scan fallback (filesystem only)
87
+ return autoScanDocs(getProjectRoot(), project, version)
88
+ }
89
+
90
+ /**
91
+ * Auto-generate sidebar config by scanning .md files in the content directory.
92
+ */
93
+ function autoScanDocs(
94
+ root: string,
95
+ project: string,
96
+ version: string,
97
+ ): DocsConfig {
98
+ const candidates = [
99
+ resolve(root, 'content', project, version, 'en'),
100
+ resolve(root, 'content', project, 'en'),
101
+ resolve(root, 'content', 'en'),
102
+ ]
103
+
104
+ for (const dir of candidates) {
105
+ if (existsSync(dir) && statSync(dir).isDirectory()) {
106
+ const items = scanDirectory(dir, dir)
107
+ if (items.length > 0) {
108
+ return {
109
+ sections: [
110
+ {
111
+ label: 'Documentation',
112
+ children: items,
113
+ },
114
+ ],
115
+ }
116
+ }
117
+ }
118
+ }
119
+
120
+ return { sections: [] }
121
+ }
122
+
123
+ function scanDirectory(dir: string, base: string): DocsConfigItem[] {
124
+ const items: DocsConfigItem[] = []
125
+
126
+ try {
127
+ const entries = readdirSync(dir, { withFileTypes: true })
128
+ for (const entry of entries) {
129
+ if (entry.isFile() && entry.name.endsWith('.md')) {
130
+ const relativePath = relative(base, join(dir, entry.name))
131
+ const slug = relativePath.replace(/\.md$/, '')
132
+ // Read frontmatter for title
133
+ const filePath = join(dir, entry.name)
134
+ const raw = readFileSync(filePath, 'utf-8')
135
+ const { data } = matter(raw)
136
+ items.push({
137
+ label: (data.title as string) || slug,
138
+ to: slug,
139
+ })
140
+ } else if (entry.isDirectory() && !entry.name.startsWith('.')) {
141
+ const subItems = scanDirectory(join(dir, entry.name), base)
142
+ items.push(...subItems)
143
+ }
144
+ }
145
+ } catch {
146
+ // ignore read errors
147
+ }
148
+
149
+ return items
150
+ }