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.
- package/{src/admin/ui → admin/app}/components/JobDialog.tsx +21 -2
- package/{src/admin/ui → admin/app}/components/JobPanel.tsx +1 -1
- package/{src/admin/ui → admin/app}/components/Preview.tsx +2 -5
- package/{src/admin/ui → admin/app}/lib/api.ts +18 -39
- package/admin/app/routeTree.gen.ts +68 -0
- package/admin/app/router.tsx +23 -0
- package/admin/app/routes/__root.tsx +55 -0
- package/admin/app/routes/index.tsx +416 -0
- package/{src/admin/ui → admin/app}/styles.css +36 -3
- package/admin/package.json +27 -0
- package/admin/server/functions/jobs.ts +53 -0
- package/admin/server/functions/misc.ts +84 -0
- package/{src/admin/server/routes → admin/server/functions}/models.ts +16 -29
- package/admin/server/functions/status.ts +61 -0
- package/admin/server/index.ts +35 -0
- package/admin/server/init.ts +46 -0
- package/{src/admin → admin}/server/services/job-manager.ts +39 -10
- package/{src/admin → admin}/server/services/status.ts +6 -6
- package/admin/tsconfig.json +19 -0
- package/{src/admin → admin}/vite.config.ts +8 -2
- package/dist/{assemble-7H4QCW35.js → assemble-CP2BRYQJ.js} +6 -4
- package/dist/{chunk-A3YQNPKZ.js → chunk-CLYUAWZE.js} +1 -1
- package/dist/{chunk-YN4VJHCQ.js → chunk-JHBSHTXC.js} +1 -1
- package/dist/chunk-L64GJ4OB.js +32 -0
- package/dist/{chunk-SKKZIV3L.js → chunk-PNKVD2UK.js} +1 -29
- package/dist/{chunk-XEOYZUHS.js → chunk-QKIR7RKQ.js} +4 -31
- package/dist/chunk-TRURQFP4.js +31 -0
- package/dist/cli.js +108 -23
- package/dist/index.d.ts +41 -1
- package/dist/index.js +92 -3
- package/dist/{rescan-O5D3CYC2.js → rescan-HXMWFAOC.js} +5 -3
- package/dist/{status-F4MYIAAY.js → status-AGZDXOTZ.js} +4 -2
- package/dist/{translate-ZIVKNAC4.js → translate-A5X6MX4Y.js} +14 -7
- package/dist/upload-XL6KG6S2.js +132 -0
- package/package.json +17 -15
- package/template/app/components/BlogArticle.tsx +159 -0
- package/template/app/components/BlogList.tsx +88 -0
- package/template/app/components/Breadcrumbs.tsx +81 -0
- package/template/app/components/Card.tsx +31 -0
- package/template/app/components/Doc.tsx +191 -0
- package/template/app/components/DocBreadcrumb.tsx +60 -0
- package/template/app/components/DocContainer.tsx +13 -0
- package/template/app/components/DocTitle.tsx +11 -0
- package/template/app/components/DocsLayout.tsx +715 -0
- package/template/app/components/Dropdown.tsx +116 -0
- package/template/app/components/FallbackBanner.tsx +36 -0
- package/template/app/components/Footer.tsx +29 -0
- package/template/app/components/FrameworkSelect.tsx +150 -0
- package/template/app/components/LibraryCard.tsx +178 -0
- package/template/app/components/LocaleSwitcher.tsx +43 -0
- package/template/app/components/Navbar.tsx +430 -0
- package/template/app/components/PostNotFound.tsx +20 -0
- package/template/app/components/SearchButton.tsx +32 -0
- package/template/app/components/Select.tsx +103 -0
- package/template/app/components/Spinner.tsx +18 -0
- package/template/app/components/ThemeProvider.tsx +141 -0
- package/template/app/components/ThemeToggle.tsx +31 -0
- package/template/app/components/Toc.tsx +86 -0
- package/template/app/components/VersionSelect.tsx +118 -0
- package/template/app/components/icons/BSkyIcon.tsx +27 -0
- package/template/app/components/icons/BaseballCapIcon.tsx +25 -0
- package/template/app/components/icons/BrandXIcon.tsx +28 -0
- package/template/app/components/icons/CheckCircleIcon.tsx +28 -0
- package/template/app/components/icons/CogsIcon.tsx +25 -0
- package/template/app/components/icons/DiscordIcon.tsx +24 -0
- package/template/app/components/icons/GithubIcon.tsx +24 -0
- package/template/app/components/icons/GoogleIcon.tsx +24 -0
- package/template/app/components/icons/InstagramIcon.tsx +24 -0
- package/template/app/components/icons/NpmIcon.tsx +26 -0
- package/template/app/components/icons/YinYangIcon.tsx +26 -0
- package/template/app/components/icons/YouTubeIcon.tsx +24 -0
- package/template/app/components/markdown/CodeBlock.tsx +254 -0
- package/template/app/components/markdown/FileTabs.tsx +58 -0
- package/template/app/components/markdown/FrameworkContent.tsx +76 -0
- package/template/app/components/markdown/Markdown.tsx +216 -0
- package/template/app/components/markdown/MarkdownContent.tsx +89 -0
- package/template/app/components/markdown/MarkdownFrameworkHandler.tsx +66 -0
- package/template/app/components/markdown/MarkdownHeadingContext.tsx +35 -0
- package/template/app/components/markdown/MarkdownLink.tsx +46 -0
- package/template/app/components/markdown/MarkdownTabsHandler.tsx +109 -0
- package/template/app/components/markdown/PackageManagerTabs.tsx +95 -0
- package/template/app/components/markdown/Tabs.tsx +139 -0
- package/template/app/components/markdown/index.ts +15 -0
- package/template/app/components/ui/Button.tsx +141 -0
- package/template/app/components/ui/InlineCode.tsx +16 -0
- package/template/app/components/ui/MarkdownImg.tsx +21 -0
- package/template/app/config/frameworks.ts +93 -0
- package/template/app/contexts/SearchContext.tsx +36 -0
- package/template/app/db/index.ts +17 -0
- package/template/app/db/schema.ts +74 -0
- package/template/app/hooks/useClickOutside.ts +106 -0
- package/template/app/routeTree.gen.ts +584 -0
- package/template/app/router.tsx +29 -0
- package/template/app/routes/$lang.$project.$version.docs.$.tsx +128 -0
- package/template/app/routes/$lang.$project.$version.docs.framework.$framework.$.tsx +106 -0
- package/template/app/routes/$lang.$project.$version.docs.framework.$framework.index.tsx +27 -0
- package/template/app/routes/$lang.$project.$version.docs.framework.index.tsx +44 -0
- package/template/app/routes/$lang.$project.$version.docs.index.tsx +27 -0
- package/template/app/routes/$lang.$project.$version.docs.tsx +70 -0
- package/template/app/routes/$lang.$project.$version.tsx +69 -0
- package/template/app/routes/$lang.$project.docs.$.tsx +104 -0
- package/template/app/routes/$lang.$project.docs.index.tsx +20 -0
- package/template/app/routes/$lang.$project.docs.tsx +79 -0
- package/template/app/routes/$lang.$project.tsx +89 -0
- package/template/app/routes/$lang.blog.$.tsx +82 -0
- package/template/app/routes/$lang.blog.index.tsx +56 -0
- package/template/app/routes/$lang.blog.tsx +26 -0
- package/template/app/routes/$lang.docs.$.tsx +100 -0
- package/template/app/routes/$lang.docs.framework.$framework.$.tsx +104 -0
- package/template/app/routes/$lang.docs.framework.$framework.index.tsx +32 -0
- package/template/app/routes/$lang.docs.framework.index.tsx +47 -0
- package/template/app/routes/$lang.docs.index.tsx +20 -0
- package/template/app/routes/$lang.docs.tsx +90 -0
- package/template/app/routes/$lang.tsx +16 -0
- package/template/app/routes/__root.tsx +180 -0
- package/template/app/routes/index.tsx +89 -0
- package/template/app/site.config.ts +182 -0
- package/template/app/styles/app.css +1029 -0
- package/template/app/types/index.ts +77 -0
- package/template/app/utils/blog.server.ts +193 -0
- package/template/app/utils/blog.ts +42 -0
- package/template/app/utils/config.ts +120 -0
- package/template/app/utils/content-loader.ts +400 -0
- package/template/app/utils/dates.ts +29 -0
- package/template/app/utils/docs.server.ts +150 -0
- package/template/app/utils/markdown/filterFrameworkContent.ts +233 -0
- package/template/app/utils/markdown/index.ts +2 -0
- package/template/app/utils/markdown/installCommand.ts +143 -0
- package/template/app/utils/markdown/plugins/collectHeadings.ts +104 -0
- package/template/app/utils/markdown/plugins/extractCodeMeta.ts +57 -0
- package/template/app/utils/markdown/plugins/helpers.ts +33 -0
- package/template/app/utils/markdown/plugins/index.ts +8 -0
- package/template/app/utils/markdown/plugins/parseCommentComponents.ts +103 -0
- package/template/app/utils/markdown/plugins/transformCommentComponents.ts +23 -0
- package/template/app/utils/markdown/plugins/transformFrameworkComponent.ts +217 -0
- package/template/app/utils/markdown/plugins/transformTabsComponent.ts +359 -0
- package/template/app/utils/markdown/processor.ts +75 -0
- package/template/app/utils/site-config.tsx +11 -0
- package/template/app/utils/upload.ts +232 -0
- package/template/app/utils/useLocalStorage.ts +65 -0
- package/template/app/utils/utils.ts +23 -0
- package/template/package.json +54 -0
- package/template/public/favicon.svg +1 -0
- package/template/public/fonts/Inter-latin-ext.woff2 +0 -0
- package/template/public/fonts/Inter-latin.woff2 +0 -0
- package/template/public/images/frameworks/angular-logo.svg +1 -0
- package/template/public/images/frameworks/js-logo.svg +1 -0
- package/template/public/images/frameworks/lit-logo.svg +1 -0
- package/template/public/images/frameworks/preact-logo.svg +6 -0
- package/template/public/images/frameworks/qwik-logo.svg +1 -0
- package/template/public/images/frameworks/react-logo.svg +1 -0
- package/template/public/images/frameworks/solid-logo.svg +1 -0
- package/template/public/images/frameworks/svelte-logo.svg +1 -0
- package/template/public/images/frameworks/vue-logo.svg +4 -0
- package/template/tsconfig.json +24 -0
- package/template/vite.config.ts +43 -0
- package/template/wrangler.jsonc +16 -0
- package/README.md +0 -161
- package/dist/server-73AVSOL5.js +0 -598
- package/src/admin/index.html +0 -13
- package/src/admin/server/index.ts +0 -138
- package/src/admin/server/routes/jobs.ts +0 -113
- package/src/admin/server/routes/status.ts +0 -57
- package/src/admin/ui/App.tsx +0 -332
- package/src/admin/ui/main.tsx +0 -19
- /package/{src/admin/ui → admin/app}/components/FileList.tsx +0 -0
- /package/{src/admin/ui → admin/app}/components/LangGrid.tsx +0 -0
- /package/{src/admin/ui → admin/app}/components/ProgressBar.tsx +0 -0
- /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
|
+
}
|