@lexibase/rdx-next 4.0.0 → 4.2.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/README.md CHANGED
@@ -19,6 +19,8 @@ internal `.rdx` application and run Next.js.
19
19
  Run `pnpm rdx check` to validate static documentation links, heading fragments
20
20
  and local files in public/ before deployment. It reports source locations and
21
21
  exits nonzero for problems, without preparing the runtime or rendering documents.
22
+ `rdx build` runs the same check automatically before runtime preparation and
23
+ Next.js compilation, and stops if problems are found.
22
24
 
23
25
  Run `pnpm rdx add versioning` (or `npm exec -- rdx add versioning`) from a
24
26
  documentation project to install and activate the official optional plugin.
@@ -29,6 +31,13 @@ Use `--package-manager npm|pnpm|yarn` to resolve ambiguous lockfiles. Dynamic
29
31
  configurations receive manual instructions and are not automatically edited.
30
32
  Installing a package with npm/pnpm/yarn alone never edits `rdx.config.ts`.
31
33
 
34
+ Run `pnpm rdx add i18n` to activate multilingual documentation. It reuses the
35
+ primary `lang`, asks for additional language tags and creates translation
36
+ directories with `.gitkeep` files for empty trees. `--locales en,es-ES` supplies
37
+ additional languages noninteractively. Existing configurations and authored
38
+ translations are preserved; add later languages directly to `locales` and
39
+ `locale/<folder>/docs/`. The main project creator asks only for a project name.
40
+
32
41
  First plugin installation follows the installed runtime: stable releases use
33
42
  `latest`, betas use `beta`, and release candidates use `rc`. Unknown prerelease
34
43
  channels require manual installation; missing tags do not fall back to another
@@ -8,7 +8,18 @@ import { createRdxProject, resolveConfig } from '@lexibase/rdx-core/config'
8
8
  import spawn from 'cross-spawn'
9
9
  import { createJiti } from 'jiti'
10
10
 
11
- import { activateVersioning, versioningPackage } from './plugin-config.mjs'
11
+ import {
12
+ activateVersioning,
13
+ versioningPackage,
14
+ activateI18n,
15
+ inspectI18n,
16
+ i18nPackage,
17
+ } from './plugin-config.mjs'
18
+ import {
19
+ askI18nLanguages,
20
+ initialLocales,
21
+ prepareLanguageFolders,
22
+ } from './i18n-setup.mjs'
12
23
 
13
24
  const managers = new Set(['npm', 'pnpm', 'yarn'])
14
25
  const runtimeVersion = createRequire(import.meta.url)('../package.json').version
@@ -16,10 +27,18 @@ const runtimeVersion = createRequire(import.meta.url)('../package.json').version
16
27
  // Internal installer policy; channels belong to the installed runtime release,
17
28
  // not the user's docs version, invoking command or dependency range.
18
29
  export function versioningInstallRequest(version, declared) {
30
+ return pluginInstallRequest(version, declared, versioningPackage)
31
+ }
32
+
33
+ export function i18nInstallRequest(version, declared) {
34
+ return pluginInstallRequest(version, declared, i18nPackage)
35
+ }
36
+
37
+ function pluginInstallRequest(version, declared, packageName) {
19
38
  if (declared !== undefined) {
20
39
  if (typeof declared !== 'string' || !declared.trim())
21
40
  throw new Error(
22
- 'The declared @lexibase/rdx-versioning dependency must be a non-empty string.'
41
+ `The declared ${packageName} dependency must be a non-empty string.`
23
42
  )
24
43
  return { kind: 'restore' }
25
44
  }
@@ -46,26 +65,36 @@ export function versioningInstallRequest(version, declared) {
46
65
  )
47
66
  return {
48
67
  kind: 'add',
49
- specifier: `${versioningPackage}@${prerelease ?? 'latest'}`,
68
+ specifier: `${packageName}@${prerelease ?? 'latest'}`,
50
69
  }
51
70
  }
52
71
 
53
72
  export function parseAddArguments(args) {
54
73
  const [plugin, ...rest] = args
55
- if (plugin !== 'versioning')
56
- throw new Error(
57
- 'Usage: rdx add versioning [--package-manager npm|pnpm|yarn]. Only the official versioning plugin is currently supported.'
74
+ const usage =
75
+ 'Usage: rdx add versioning|i18n [--package-manager npm|pnpm|yarn]. i18n also accepts --locales en,es-ES.'
76
+ if (!['versioning', 'i18n'].includes(plugin)) throw new Error(usage)
77
+ const options = {}
78
+ for (let index = 0; index < rest.length; index += 2) {
79
+ const flag = rest[index]
80
+ const value = rest[index + 1]
81
+ if (
82
+ flag === '--package-manager' &&
83
+ managers.has(value) &&
84
+ !options.packageManager
58
85
  )
59
- if (!rest.length) return {}
60
- if (
61
- rest.length === 2 &&
62
- rest[0] === '--package-manager' &&
63
- managers.has(rest[1])
64
- )
65
- return { packageManager: rest[1] }
66
- throw new Error(
67
- 'Usage: rdx add versioning [--package-manager npm|pnpm|yarn].'
68
- )
86
+ options.packageManager = value
87
+ else if (
88
+ plugin === 'i18n' &&
89
+ flag === '--locales' &&
90
+ value?.trim() &&
91
+ !value.startsWith('-') &&
92
+ options.locales === undefined
93
+ )
94
+ options.locales = value
95
+ else throw new Error(usage)
96
+ }
97
+ return options
69
98
  }
70
99
 
71
100
  async function exists(filename) {
@@ -160,7 +189,7 @@ export function installPackage(
160
189
  })
161
190
  }
162
191
 
163
- async function validateCandidate(filename) {
192
+ async function validateCandidate(filename, packageName) {
164
193
  const jiti = createJiti(import.meta.url, {
165
194
  moduleCache: false,
166
195
  fsCache: false,
@@ -168,27 +197,62 @@ async function validateCandidate(filename) {
168
197
  })
169
198
  const input = await jiti.import(filename, { default: true })
170
199
  const config = resolveConfig(input)
171
- // Validate paths and command registrations without running plugin setup hooks.
172
- await createRdxProject(
200
+ // Read the official language declaration; other setup hooks run during prepare.
201
+ const project = await createRdxProject(
173
202
  {
174
203
  ...config,
175
204
  plugins: config.plugins.map((plugin) => ({
176
205
  ...plugin,
177
- setup: undefined,
206
+ setup:
207
+ packageName === i18nPackage && plugin.name === i18nPackage
208
+ ? plugin.setup
209
+ : undefined,
178
210
  })),
179
211
  },
180
- { rootDir: path.dirname(filename), command: 'prepare' }
212
+ { rootDir: path.dirname(filename), command: 'runtime' }
181
213
  )
182
214
  if (
183
- config.plugins.filter((plugin) => plugin.name === versioningPackage)
184
- .length !== 1
215
+ config.plugins.filter((plugin) => plugin.name === packageName).length !== 1
185
216
  )
186
217
  throw new Error(
187
- 'Expected exactly one official versioning plugin in the resulting configuration.'
218
+ `Expected exactly one ${packageName} plugin in the resulting configuration.`
188
219
  )
220
+ return project
189
221
  }
190
222
 
191
223
  export async function addVersioning(rootDir, options = {}) {
224
+ return addPlugin(rootDir, options, {
225
+ packageName: versioningPackage,
226
+ label: 'Versioning',
227
+ activate: activateVersioning,
228
+ })
229
+ }
230
+
231
+ export async function addI18n(rootDir, options = {}) {
232
+ return addPlugin(rootDir, options, {
233
+ packageName: i18nPackage,
234
+ label: 'i18n',
235
+ async activate(source) {
236
+ const state = inspectI18n(source)
237
+ if (state.configured) {
238
+ if (options.locales !== undefined)
239
+ throw new Error(
240
+ 'i18n is already configured. Add further languages to locales in rdx.config.ts and create their locale/<folder>/docs/ directories.'
241
+ )
242
+ return source
243
+ }
244
+ const primary = Intl.getCanonicalLocales(state.lang)[0]
245
+ if (!primary) throw new Error('lang must be a valid language tag.')
246
+ const additional =
247
+ options.locales ?? (await (options.ask ?? askI18nLanguages)(primary))
248
+ if (additional === undefined) return
249
+ return activateI18n(source, initialLocales(primary, additional))
250
+ },
251
+ })
252
+ }
253
+
254
+ async function addPlugin(rootDir, options, definition) {
255
+ const { packageName, label } = definition
192
256
  const {
193
257
  install = installPackage,
194
258
  log = console.log,
@@ -209,7 +273,11 @@ export async function addVersioning(rootDir, options = {}) {
209
273
  throw new Error('rdx.config.ts must be a regular project file, not a link.')
210
274
  const original = await readFile(filename, 'utf8')
211
275
  // Refuse unsupported configurations before running the package manager.
212
- const updated = activateVersioning(original)
276
+ const updated = await definition.activate(original)
277
+ if (updated === undefined) {
278
+ log(`${label} setup cancelled.`)
279
+ return { changed: false, cancelled: true }
280
+ }
213
281
  const pkg = JSON.parse(
214
282
  await readFile(path.join(rootDir, 'package.json'), 'utf8')
215
283
  )
@@ -220,11 +288,10 @@ export async function addVersioning(rootDir, options = {}) {
220
288
  options.packageManager
221
289
  )
222
290
  const declared =
223
- pkg.dependencies?.[versioningPackage] ??
224
- pkg.devDependencies?.[versioningPackage]
291
+ pkg.dependencies?.[packageName] ?? pkg.devDependencies?.[packageName]
225
292
  let installed = false
226
293
  try {
227
- createRequire(path.join(rootDir, 'package.json')).resolve(versioningPackage)
294
+ createRequire(path.join(rootDir, 'package.json')).resolve(packageName)
228
295
  installed = true
229
296
  } catch (error) {
230
297
  // The official package exposes ESM imports only. require.resolve can find
@@ -234,21 +301,27 @@ export async function addVersioning(rootDir, options = {}) {
234
301
  throw error
235
302
  }
236
303
  if (!declared || !installed) {
237
- const request = versioningInstallRequest(runtimeVersion, declared)
304
+ const request = pluginInstallRequest(runtimeVersion, declared, packageName)
238
305
  log(
239
306
  request.kind === 'restore'
240
- ? `Restoring the declared ${versioningPackage} dependency with ${manager}...`
307
+ ? `Restoring the declared ${packageName} dependency with ${manager}...`
241
308
  : `Installing ${request.specifier} with ${manager}...`
242
309
  )
243
310
  await install(manager, rootDir, env, request)
244
- } else
245
- log(`${versioningPackage} is already installed; preserving its version.`)
311
+ } else log(`${packageName} is already installed; preserving its version.`)
246
312
 
247
313
  const temporary = path.join(rootDir, `rdx.config.add-${randomUUID()}.ts`)
314
+ let rollbackFolders
315
+ let committed = false
248
316
  try {
249
317
  await writeFile(temporary, updated, { flag: 'wx', mode: info.mode })
250
318
  try {
251
- await validateCandidate(temporary)
319
+ const project = await validateCandidate(temporary, packageName)
320
+ if (project.localization)
321
+ rollbackFolders = await prepareLanguageFolders(
322
+ rootDir,
323
+ project.localization
324
+ )
252
325
  } catch (error) {
253
326
  throw new Error(
254
327
  `Plugin configuration validation failed: ${error.message}. rdx.config.ts was not changed. Installed dependencies were kept; fix the error and retry.`
@@ -265,10 +338,13 @@ export async function addVersioning(rootDir, options = {}) {
265
338
  )
266
339
  if (updated !== original) {
267
340
  await rename(temporary, filename)
268
- log('Versioning activated in rdx.config.ts.')
341
+ committed = true
342
+ log(`${label} activated in rdx.config.ts.`)
269
343
  } else
270
- log('Versioning is already configured; no configuration changes needed.')
344
+ log(`${label} is already configured; no configuration changes needed.`)
345
+ committed = true
271
346
  } finally {
347
+ if (!committed) await rollbackFolders?.()
272
348
  await unlink(temporary).catch((error) => {
273
349
  if (error.code !== 'ENOENT') throw error
274
350
  })
package/bin/cli.mjs CHANGED
@@ -11,6 +11,7 @@ import { createJiti } from 'jiti'
11
11
  import postcssConfig from '../postcss.config.mjs'
12
12
  import { preparePublicAssets } from './public-assets.mjs'
13
13
  import { prepareProjectStyles } from './project-styles.mjs'
14
+ import { prepareLocaleRoutes } from './locale-routes.mjs'
14
15
 
15
16
  export async function runRdxCli({ templateDir } = {}) {
16
17
  const supportedCommands = new Set(['dev', 'build', 'start'])
@@ -25,7 +26,7 @@ export async function runRdxCli({ templateDir } = {}) {
25
26
  templateDir ?? path.join(packageRoot, 'templates', 'runtime')
26
27
  const runtimeDirectory = path.join(projectRoot, '.rdx')
27
28
 
28
- async function prepareRuntime() {
29
+ async function prepareRuntime(project) {
29
30
  await mkdir(runtimeDirectory, { recursive: true })
30
31
  await preparePublicAssets(projectRoot, runtimeDirectory)
31
32
  await cp(runtimeTemplate, runtimeDirectory, {
@@ -33,6 +34,7 @@ export async function runRdxCli({ templateDir } = {}) {
33
34
  recursive: true,
34
35
  })
35
36
  await prepareProjectStyles(projectRoot, runtimeDirectory)
37
+ await prepareLocaleRoutes(project, runtimeDirectory, runtimeTemplate)
36
38
  // Resolve in Node before Next processes this file: UI may be transitive,
37
39
  // and native Tailwind plugins must stay outside the Turbopack module graph.
38
40
  const resolvePlugin = createRequire(import.meta.url)
@@ -57,6 +59,9 @@ export async function runRdxCli({ templateDir } = {}) {
57
59
  ' rdx check: validate documentation links, sections and local assets'
58
60
  )
59
61
  console.log(' rdx add versioning [--package-manager npm|pnpm|yarn]')
62
+ console.log(
63
+ ' rdx add i18n [--locales en,es-ES] [--package-manager npm|pnpm|yarn]'
64
+ )
60
65
  console.log(
61
66
  'Plugin commands are registered through plugins in rdx.config.ts.'
62
67
  )
@@ -69,7 +74,7 @@ export async function runRdxCli({ templateDir } = {}) {
69
74
  ['--help', '-h'].includes(commandArgs[0])
70
75
  ) {
71
76
  console.log(
72
- 'Usage: rdx check\nValidate static MDX links, section anchors and local public/ files without building the site.'
77
+ 'Usage: rdx check\nValidate static Markdown and MDX links, section anchors and local public/ files without building the site.'
73
78
  )
74
79
  return
75
80
  }
@@ -77,10 +82,31 @@ export async function runRdxCli({ templateDir } = {}) {
77
82
  }
78
83
 
79
84
  if (command === 'add') {
80
- const { addVersioning, parseAddArguments } =
85
+ if (
86
+ (commandArgs.length === 1 &&
87
+ ['--help', '-h'].includes(commandArgs[0])) ||
88
+ (commandArgs.length === 2 &&
89
+ ['versioning', 'i18n'].includes(commandArgs[0]) &&
90
+ ['--help', '-h'].includes(commandArgs[1]))
91
+ ) {
92
+ console.log(
93
+ 'Usage: rdx add versioning|i18n [--package-manager npm|pnpm|yarn]'
94
+ )
95
+ console.log(
96
+ ' i18n: reuse lang and ask for additional languages, or pass --locales en,es-ES.'
97
+ )
98
+ console.log(
99
+ ' Existing i18n languages are edited in rdx.config.ts and locale/<folder>/docs/.'
100
+ )
101
+ return
102
+ }
103
+ const { addVersioning, addI18n, parseAddArguments } =
81
104
  await import('./add-plugin.mjs')
82
105
  const options = parseAddArguments(commandArgs)
83
- await addVersioning(projectRoot, options)
106
+ const result = await (
107
+ commandArgs[0] === 'i18n' ? addI18n : addVersioning
108
+ )(projectRoot, options)
109
+ if (result.cancelled) return
84
110
  command = 'prepare'
85
111
  }
86
112
 
@@ -114,7 +140,7 @@ export async function runRdxCli({ templateDir } = {}) {
114
140
  rootDir: projectRoot,
115
141
  command,
116
142
  })
117
- if (command === 'check') {
143
+ if (command === 'check' || command === 'build') {
118
144
  const { checkDocumentation, formatDocumentationCheck } =
119
145
  await import('@lexibase/rdx-core/check')
120
146
  const report = checkDocumentation(project)
@@ -122,11 +148,12 @@ export async function runRdxCli({ templateDir } = {}) {
122
148
  if (report.diagnostics.length) {
123
149
  console.error(output)
124
150
  process.exitCode = 1
151
+ return
125
152
  } else console.log(output)
126
- return
153
+ if (command === 'check') return
127
154
  }
128
155
  if (command === 'prepare') {
129
- await prepareRuntime()
156
+ await prepareRuntime(project)
130
157
  console.log('RDX runtime prepared in .rdx/')
131
158
  return
132
159
  }
@@ -147,7 +174,7 @@ export async function runRdxCli({ templateDir } = {}) {
147
174
  return
148
175
  }
149
176
 
150
- await prepareRuntime()
177
+ await prepareRuntime(project)
151
178
  const projectRequire = createRequire(path.join(projectRoot, 'package.json'))
152
179
  const nextCli = projectRequire.resolve('next/dist/bin/next')
153
180
  const child = spawn(
@@ -0,0 +1,115 @@
1
+ import {
2
+ lstat,
3
+ mkdir,
4
+ realpath,
5
+ rmdir,
6
+ writeFile,
7
+ readFile,
8
+ unlink,
9
+ } from 'node:fs/promises'
10
+ import path from 'node:path'
11
+ import process from 'node:process'
12
+
13
+ import { normalizeLocalization } from '@lexibase/rdx-core/config'
14
+
15
+ function languageLabel(code) {
16
+ try {
17
+ const label = new Intl.DisplayNames([code], { type: 'language' }).of(code)
18
+ return label[0].toLocaleUpperCase(code) + label.slice(1)
19
+ } catch {
20
+ // Valid tags with extensions need not have a display name in ICU.
21
+ return code
22
+ }
23
+ }
24
+
25
+ /** Additional language tags; the existing lang always remains primary. */
26
+ export function initialLocales(primary, additional) {
27
+ const lang = Intl.getCanonicalLocales(primary)[0]
28
+ if (!lang) throw new Error('lang must be a valid language tag.')
29
+ if (typeof additional !== 'string' || !additional.trim())
30
+ throw new Error('Enter at least one additional language, e.g. en, es-ES.')
31
+ const codes = additional.split(',').map((value) => {
32
+ const code = Intl.getCanonicalLocales(value.trim())[0]
33
+ if (!code) throw new Error('Enter language tags separated by commas.')
34
+ return code
35
+ })
36
+ const locales = [lang, ...codes.filter((code) => code !== lang)].map(
37
+ (code) => ({
38
+ code,
39
+ label: languageLabel(code),
40
+ })
41
+ )
42
+ // Use the same validation as manual configuration, including duplicates.
43
+ normalizeLocalization({ locales }, lang)
44
+ return locales
45
+ }
46
+
47
+ export async function askI18nLanguages(primary) {
48
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
49
+ throw new Error(
50
+ 'Interactive language setup needs a terminal. Use rdx add i18n --locales en,es-ES (additional languages; lang stays primary).'
51
+ )
52
+ const { default: prompts } = await import('prompts')
53
+ const response = await prompts({
54
+ type: 'text',
55
+ name: 'languages',
56
+ message: `Additional languages (primary: ${primary}; e.g. en, es-ES)`,
57
+ validate(value) {
58
+ try {
59
+ initialLocales(primary, value)
60
+ return true
61
+ } catch (error) {
62
+ return error.message
63
+ }
64
+ },
65
+ })
66
+ return response.languages
67
+ }
68
+
69
+ /** Create only empty translation directories; never copy or replace documents. */
70
+ export async function prepareLanguageFolders(rootDir, localization) {
71
+ const root = await realpath(rootDir)
72
+ const created = []
73
+ const markers = []
74
+ async function rollback() {
75
+ for (const marker of markers)
76
+ if ((await readFile(marker, 'utf8').catch(() => undefined)) === '')
77
+ await unlink(marker).catch((error) => {
78
+ if (error.code !== 'ENOENT') throw error
79
+ })
80
+ for (const directory of [...created].reverse())
81
+ await rmdir(directory).catch((error) => {
82
+ // A user may have started writing while installation was running.
83
+ if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error
84
+ })
85
+ }
86
+ try {
87
+ for (const locale of localization.locales) {
88
+ if (!locale.folder) continue
89
+ let directory = root
90
+ for (const part of ['locale', locale.folder, 'docs']) {
91
+ directory = path.join(directory, part)
92
+ try {
93
+ await mkdir(directory)
94
+ created.push(directory)
95
+ } catch (error) {
96
+ if (error.code !== 'EEXIST') throw error
97
+ }
98
+ const info = await lstat(directory)
99
+ if (!info.isDirectory() || info.isSymbolicLink())
100
+ throw new Error(
101
+ `Translation folders must be regular project directories: ${directory}`
102
+ )
103
+ }
104
+ if (created.includes(directory)) {
105
+ const marker = path.join(directory, '.gitkeep')
106
+ await writeFile(marker, '', { flag: 'wx' })
107
+ markers.push(marker)
108
+ }
109
+ }
110
+ } catch (error) {
111
+ await rollback()
112
+ throw error
113
+ }
114
+ return rollback
115
+ }
@@ -0,0 +1,138 @@
1
+ import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ const defaultFiles = [
5
+ 'layout.tsx',
6
+ 'page.tsx',
7
+ 'not-found.tsx',
8
+ 'docs/[[...slug]]/page.tsx',
9
+ ]
10
+ const generated = [
11
+ ...defaultFiles.map((file) => `app/(rdx-default)/${file}`),
12
+ 'app/(rdx-default)/[...notFound]/page.tsx',
13
+ 'app/(rdx-locales)/[locale]/layout.tsx',
14
+ 'app/(rdx-locales)/[locale]/not-found.tsx',
15
+ 'app/(rdx-locales)/[locale]/docs/[[...slug]]/page.tsx',
16
+ 'app/(rdx-locales)/[locale]/rdx-search/[source]/route.ts',
17
+ ]
18
+
19
+ /** Root layouts keep HTML language correct on the server, without middleware. */
20
+ export async function prepareLocaleRoutes(project, runtime, template) {
21
+ async function safe(file) {
22
+ const parts = file.split('/')
23
+ let current = runtime
24
+ for (const segment of parts) {
25
+ current = path.join(current, segment)
26
+ try {
27
+ if ((await lstat(current)).isSymbolicLink())
28
+ throw new Error(
29
+ `Generated locale routes must not contain links: ${current}`
30
+ )
31
+ } catch (error) {
32
+ if (error.code !== 'ENOENT') throw error
33
+ }
34
+ }
35
+ return current
36
+ }
37
+ const marker = await safe('.rdx-localization.json')
38
+ let owned = false
39
+ try {
40
+ owned = JSON.parse(await readFile(marker, 'utf8')).version === 1
41
+ } catch (error) {
42
+ if (error.code !== 'ENOENT') throw error
43
+ }
44
+ if (!project.localization) {
45
+ if (owned) {
46
+ for (const file of generated) await rm(await safe(file), { force: true })
47
+ await rm(marker)
48
+ }
49
+ return
50
+ }
51
+ async function emit(file, content) {
52
+ const target = await safe(file)
53
+ if (!owned) {
54
+ try {
55
+ await lstat(target)
56
+ throw new Error(
57
+ `Conflicting generated locale route: ${target}. Remove the old .rdx/ runtime and prepare again.`
58
+ )
59
+ } catch (error) {
60
+ if (error.code !== 'ENOENT') throw error
61
+ }
62
+ }
63
+ await mkdir(path.dirname(target), { recursive: true })
64
+ await writeFile(target, content)
65
+ }
66
+ const layout = await readFile(path.join(template, 'app/layout.tsx'), 'utf8')
67
+ const provider = layout.match(/from '([^']+\/runtime)'/)[1]
68
+ for (const file of defaultFiles) {
69
+ const text = await readFile(path.join(template, 'app', file), 'utf8')
70
+ await emit(
71
+ `app/(rdx-default)/${file}`,
72
+ text
73
+ .replace(/from '(\.\.\/[^']*rdx-project)'/g, "from '../$1'")
74
+ .replace("'./globals.css'", "'../globals.css'")
75
+ )
76
+ }
77
+ await emit(
78
+ 'app/(rdx-default)/[...notFound]/page.tsx',
79
+ `import { notFound } from 'next/navigation'\nexport default function Missing() { notFound() }\n`
80
+ )
81
+ await emit(
82
+ 'app/(rdx-locales)/[locale]/layout.tsx',
83
+ `import { RdxLayout, getRdxMetadata, getLocalizedProject } from '${provider}'
84
+ import { getProject } from '../../../rdx-project'
85
+ import '../../globals.css'
86
+ export async function generateStaticParams() { return (await getProject()).localization!.locales.map(locale => ({ locale: locale.path })) }
87
+ export const dynamicParams = false
88
+ async function configuration(params: Promise<{ locale: string }>) {
89
+ const project = await getProject()
90
+ const { locale } = await params
91
+ return project.localization!.locales.some(entry => entry.path === locale) ? getLocalizedProject(project, locale).config : project.config
92
+ }
93
+ export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { return getRdxMetadata(await configuration(params)) }
94
+ export default async function Layout({ children, params }: { children: React.ReactNode, params: Promise<{ locale: string }> }) {
95
+ return <RdxLayout config={await configuration(params)} bodyClassName="antialiased">{children}</RdxLayout>
96
+ }
97
+ `
98
+ )
99
+ await emit(
100
+ 'app/(rdx-locales)/[locale]/not-found.tsx',
101
+ `import { NotFoundPage } from '${provider}'
102
+ import { getProject } from '../../../rdx-project'
103
+ export default async function Missing() { return <NotFoundPage project={await getProject()} /> }
104
+ `
105
+ )
106
+ await emit(
107
+ 'app/(rdx-locales)/[locale]/docs/[[...slug]]/page.tsx',
108
+ `import { DocsPage, getDocumentMetadata, getProjectDocsPaths, getLocalizedProject } from '${provider}'
109
+ import { notFound } from 'next/navigation'
110
+ import { getProject } from '../../../../../rdx-project'
111
+ type Params = { locale: string, slug?: string[] }
112
+ async function selected(locale: string) {
113
+ const project = await getProject()
114
+ if (!project.localization!.locales.some(entry => entry.path === locale)) notFound()
115
+ return getLocalizedProject(project, locale)
116
+ }
117
+ export async function generateStaticParams({ params }: { params: { locale: string } }) { return getProjectDocsPaths(await selected(params.locale)).map(slug => ({ slug: slug.split('/') })) }
118
+ export async function generateMetadata({ params }: { params: Promise<Params> }) { const { locale, slug } = await params; return getDocumentMetadata({ project: await selected(locale), slug }) }
119
+ export default async function Page({ params }: { params: Promise<Params> }) { const { locale, slug } = await params; return <DocsPage project={await selected(locale)} slug={slug} /> }
120
+ `
121
+ )
122
+ await emit(
123
+ 'app/(rdx-locales)/[locale]/rdx-search/[source]/route.ts',
124
+ `import { getSearchResponse, getSearchSources, getLocalizedProject } from '${provider}'
125
+ import { getProject } from '../../../../../rdx-project'
126
+ export const dynamic = 'force-static'
127
+ export const dynamicParams = false
128
+ export async function generateStaticParams() {
129
+ const project = await getProject()
130
+ return project.localization!.locales.flatMap(locale => getSearchSources(getLocalizedProject(project, locale.path)).map(source => ({ locale: locale.path, ...source })))
131
+ }
132
+ export async function GET(_request: Request, { params }: { params: Promise<{ locale: string, source: string }> }) { const { locale, source } = await params; return getSearchResponse(getLocalizedProject(await getProject(), locale), source) }
133
+ `
134
+ )
135
+ await writeFile(marker, JSON.stringify({ version: 1 }) + '\n')
136
+ for (const file of defaultFiles)
137
+ await rm(await safe(`app/${file}`), { force: true })
138
+ }
@@ -1,7 +1,29 @@
1
1
  import ts from 'typescript'
2
2
 
3
3
  export const versioningPackage = '@lexibase/rdx-versioning'
4
- const manual = `Cannot safely edit rdx.config.ts. Add "import { versioning } from '${versioningPackage}'" and versioning() to plugins manually, then run rdx prepare.`
4
+ export const i18nPackage = '@lexibase/rdx-i18n'
5
+ const versioning = {
6
+ packageName: versioningPackage,
7
+ name: 'versioning',
8
+ label: 'Versioning',
9
+ }
10
+ const localization = { packageName: i18nPackage, name: 'i18n', label: 'i18n' }
11
+
12
+ export function activateVersioning(source) {
13
+ return editPluginConfig(source, versioning)
14
+ }
15
+
16
+ export function inspectI18n(source) {
17
+ return editPluginConfig(source, localization, '', true)
18
+ }
19
+
20
+ export function activateI18n(source, locales) {
21
+ return editPluginConfig(
22
+ source,
23
+ localization,
24
+ JSON.stringify({ locales }, null, 2)
25
+ )
26
+ }
5
27
 
6
28
  function unwrap(node) {
7
29
  while (
@@ -14,7 +36,9 @@ function unwrap(node) {
14
36
  }
15
37
 
16
38
  // Edit only known literal shapes; never print/reconstruct the user's entire file.
17
- export function activateVersioning(source) {
39
+ function editPluginConfig(source, plugin, argument = '', inspect = false) {
40
+ const { packageName, name: pluginName, label } = plugin
41
+ const manual = `Cannot safely edit rdx.config.ts. Add "import { ${pluginName} } from '${packageName}'" and ${pluginName}(${pluginName === 'i18n' ? '{ locales: [...] }' : ''}) to plugins manually, then run rdx prepare.`
18
42
  const file = ts.createSourceFile(
19
43
  'rdx.config.ts',
20
44
  source,
@@ -44,7 +68,7 @@ export function activateVersioning(source) {
44
68
  imported === 'defineConfig'
45
69
  )
46
70
  definitions.add(specifier.name.text)
47
- if (name === versioningPackage && imported === 'versioning') {
71
+ if (name === packageName && imported === pluginName) {
48
72
  if (factory) throw new Error(manual)
49
73
  factory = specifier.name.text
50
74
  }
@@ -52,10 +76,10 @@ export function activateVersioning(source) {
52
76
  } else if (
53
77
  bindings &&
54
78
  ts.isNamespaceImport(bindings) &&
55
- name === versioningPackage
79
+ name === packageName
56
80
  ) {
57
81
  if (factory) throw new Error(manual)
58
- factory = `${bindings.name.text}.versioning`
82
+ factory = `${bindings.name.text}.${pluginName}`
59
83
  }
60
84
  }
61
85
  const exports = file.statements.filter(ts.isExportAssignment)
@@ -100,9 +124,18 @@ export function activateVersioning(source) {
100
124
  )
101
125
  if (matches.length > 1)
102
126
  throw new Error(
103
- 'Versioning is configured more than once. Remove the duplicate plugin before retrying.'
127
+ `${label} is configured more than once. Remove the duplicate plugin before retrying.`
104
128
  )
105
- if (matches.length === 1) return source
129
+ if (matches.length === 1) return inspect ? { configured: true } : source
130
+ }
131
+ if (inspect) {
132
+ const languages = properties.filter((entry) => entry.name.text === 'lang')
133
+ if (languages.length > 1) throw new Error(manual)
134
+ const language = languages.length
135
+ ? unwrap(languages[0].initializer)
136
+ : undefined
137
+ if (language && !ts.isStringLiteral(language)) throw new Error(manual)
138
+ return { configured: false, lang: language?.text ?? 'en' }
106
139
  }
107
140
  const newline = source.includes('\r\n') ? '\r\n' : '\n'
108
141
  const edits = []
@@ -113,11 +146,12 @@ export function activateVersioning(source) {
113
146
  ts.forEachChild(node, visit)
114
147
  }
115
148
  visit(file)
116
- factory = 'versioning'
149
+ factory = pluginName
117
150
  let suffix = 1
118
- while (identifiers.has(factory)) factory = `rdxVersioning${suffix++}`
151
+ const alias = `rdx${pluginName[0].toUpperCase()}${pluginName.slice(1)}`
152
+ while (identifiers.has(factory)) factory = `${alias}${suffix++}`
119
153
  const specifier =
120
- factory === 'versioning' ? 'versioning' : `versioning as ${factory}`
154
+ factory === pluginName ? pluginName : `${pluginName} as ${factory}`
121
155
  let position = imports.length
122
156
  ? imports.at(-1).end
123
157
  : file.statements[0].getStart(file)
@@ -133,7 +167,7 @@ export function activateVersioning(source) {
133
167
  }
134
168
  edits.push({
135
169
  position,
136
- text: `${newline}import { ${specifier} } from '${versioningPackage}'${newline}`,
170
+ text: `${newline}import { ${specifier} } from '${packageName}'${newline}`,
137
171
  })
138
172
  }
139
173
  if (plugins) {
@@ -142,14 +176,14 @@ export function activateVersioning(source) {
142
176
  : plugins.getStart(file) + 1
143
177
  edits.push({
144
178
  position,
145
- text: `${plugins.elements.length ? ',' : ''} ${factory}()`,
179
+ text: `${plugins.elements.length ? ',' : ''} ${factory}(${argument.replaceAll('\n', newline)})`,
146
180
  })
147
181
  } else {
148
182
  if (properties.length && !properties.hasTrailingComma)
149
183
  edits.push({ position: properties.at(-1).end, text: ',' })
150
184
  edits.push({
151
185
  position: config.end - 1,
152
- text: `${newline} plugins: [${factory}()],${newline}`,
186
+ text: `${newline} plugins: [${factory}(${argument.replaceAll('\n', newline)})],${newline}`,
153
187
  })
154
188
  }
155
189
  for (const edit of edits.sort((a, b) => b.position - a.position))
package/config.mjs CHANGED
@@ -2,7 +2,11 @@ import { readdir, realpath, stat } from 'node:fs/promises'
2
2
  import { createRequire } from 'node:module'
3
3
  import path from 'node:path'
4
4
 
5
- import { createRdxProject, resolveProjectPath } from '@lexibase/rdx-core/config'
5
+ import {
6
+ createRdxProject,
7
+ resolveProjectPath,
8
+ getContentSources,
9
+ } from '@lexibase/rdx-core/config'
6
10
  import { createJiti } from 'jiti'
7
11
 
8
12
  /** Include the project and the real Next.js installation in Turbopack's root. */
@@ -45,7 +49,7 @@ async function contentFiles(directory) {
45
49
  const filename = path.join(directory, entry.name)
46
50
  if (entry.isDirectory()) return contentFiles(filename)
47
51
  return entry.isFile() &&
48
- (entry.name.endsWith('.mdx') || entry.name === '_category.json')
52
+ (/\.mdx?$/.test(entry.name) || entry.name === '_category.json')
49
53
  ? [filename]
50
54
  : []
51
55
  })
@@ -77,7 +81,7 @@ export async function getRdxTracingConfig({ rootDir, appDir = rootDir }) {
77
81
  const project = await createRdxProject(input, { rootDir })
78
82
  const files = (
79
83
  await Promise.all(
80
- project.sources
84
+ getContentSources(project)
81
85
  .filter((source) => source.redirectTo === undefined)
82
86
  .map((source) =>
83
87
  contentFiles(
@@ -102,11 +106,28 @@ export async function getRdxTracingConfig({ rootDir, appDir = rootDir }) {
102
106
  }
103
107
  const redirects = project.sources
104
108
  .filter((source) => source.redirectTo !== undefined)
105
- .map((source) => ({
106
- source: `/docs/${source.routePrefix}/:path*`,
107
- destination: `/docs/${source.redirectTo}/:path*`,
109
+ .flatMap((source) =>
110
+ [
111
+ '/docs',
112
+ ...(project.localization?.locales.map(
113
+ (locale) => `/${locale.path}/docs`
114
+ ) ?? []),
115
+ ].map((base) => ({
116
+ source: `${base}/${source.routePrefix}/:path*`,
117
+ destination: `${base}/${source.redirectTo}/:path*`,
118
+ permanent: true,
119
+ }))
120
+ )
121
+ if (project.localization) {
122
+ const primary = project.localization.locales.find(
123
+ (locale) => locale.code === project.localization.defaultLocale
124
+ )
125
+ redirects.push({
126
+ source: '/docs/:path*',
127
+ destination: `/${primary.path}/docs/:path*`,
108
128
  permanent: true,
109
- }))
129
+ })
130
+ }
110
131
  return {
111
132
  ...(redirects.length ? { redirects: async () => redirects } : {}),
112
133
  outputFileTracingIncludes: {
@@ -1,7 +1,7 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode } from 'react';
3
3
  import { RdxProject, RDXConfig, RdxContentSource, RdxDocPage } from '@lexibase/rdx-types';
4
- export { createRdxProject, getProjectDocsPaths } from '@lexibase/rdx-core';
4
+ export { createRdxProject, getLocalizedProject, getProjectDocsPaths } from '@lexibase/rdx-core';
5
5
  import { MetadataRoute, Metadata } from 'next';
6
6
 
7
7
  declare function getSearchSources(project: RdxProject): {
@@ -1,11 +1,17 @@
1
1
  // src/runtime/index.tsx
2
2
  import {
3
- getDocument as getDocument2,
3
+ getDocument as getDocument3,
4
4
  getDocsPaths,
5
- getSourceOptions as getSourceOptions3,
6
- resolveDocsRoute as resolveDocsRoute2
5
+ getSourceOptions as getSourceOptions4,
6
+ resolveDocsRoute as resolveDocsRoute2,
7
+ getLocalizedProject as getLocalizedProject2,
8
+ getDocsHref as getDocsHref3,
9
+ getLanguageLinks,
10
+ isFallbackPage as isFallbackPage3
7
11
  } from "@lexibase/rdx-core";
8
12
  import { Button } from "@lexibase/rdx-ui/components/button";
13
+ import { Callout } from "@lexibase/rdx-ui/components/callout";
14
+ import { LanguageDropdown } from "@lexibase/rdx-ui/components/language-dropdown";
9
15
  import { DocsSidebar } from "@lexibase/rdx-ui/components/docs-sidebar";
10
16
  import { DocsToc } from "@lexibase/rdx-ui/components/docs-toc";
11
17
  import { DocsPageTransition } from "@lexibase/rdx-ui/components/docs-page-transition";
@@ -13,12 +19,13 @@ import { DocsSearch } from "@lexibase/rdx-ui/components/docs-search";
13
19
  import { Home } from "@lexibase/rdx-ui/components/home";
14
20
  import { SkipLink } from "@lexibase/rdx-ui/components/skip-link";
15
21
  import { FooterCredit } from "@lexibase/rdx-ui/components/footer-credit";
16
- import { Logo } from "@lexibase/rdx-ui/components/logo";
22
+ import { BrandLogo } from "@lexibase/rdx-ui/components/brand-logo";
17
23
  import { getMdxComponents } from "@lexibase/rdx-ui/components/mdx-components";
18
24
  import { getMessages, formatMessage } from "@lexibase/rdx-ui/lib/i18n";
19
25
  import { LocaleProvider } from "@lexibase/rdx-ui/providers/locale-provider";
20
26
  import { Navbar } from "@lexibase/rdx-ui/components/navbar";
21
27
  import { NotFound } from "@lexibase/rdx-ui/components/not-found";
28
+ import { LocalizedNotFound } from "@lexibase/rdx-ui/components/localized-not-found";
22
29
  import {
23
30
  VersionDropdown
24
31
  } from "@lexibase/rdx-ui/components/version-dropdown";
@@ -42,6 +49,7 @@ function compileDocumentation(source, components, links) {
42
49
  blockJS: true,
43
50
  blockDangerousJS: true,
44
51
  mdxOptions: {
52
+ format: links?.sourcePath.endsWith(".md") ? "md" : "mdx",
45
53
  remarkPlugins: [
46
54
  remarkGfm,
47
55
  remarkDocumentationComponents,
@@ -54,10 +62,19 @@ function compileDocumentation(source, components, links) {
54
62
  }
55
63
 
56
64
  // src/runtime/index.tsx
57
- import { createRdxProject, getProjectDocsPaths } from "@lexibase/rdx-core";
65
+ import {
66
+ createRdxProject,
67
+ getProjectDocsPaths,
68
+ getLocalizedProject as getLocalizedProject3
69
+ } from "@lexibase/rdx-core";
58
70
 
59
71
  // src/runtime/search.ts
60
- import { getDocsIndex, getDocument, getSourceOptions } from "@lexibase/rdx-core";
72
+ import {
73
+ getDocsIndex,
74
+ getDocument,
75
+ getSourceOptions,
76
+ getDocsHref
77
+ } from "@lexibase/rdx-core";
61
78
  import { buildSearchIndex } from "@lexibase/rdx-core/search";
62
79
  function getSearchSources(project) {
63
80
  return project.config.search ? project.sources.filter((source) => source.redirectTo === void 0).map((source) => ({ source: source.routePrefix || "_" })) : [];
@@ -70,17 +87,17 @@ function getSearchResponse(project, key) {
70
87
  (entry) => entry.redirectTo === void 0 && (entry.routePrefix || "_") === key
71
88
  );
72
89
  if (!source) return new Response(null, { status: 404, headers });
73
- const options = getSourceOptions(project, source);
90
+ if (source.fallbackOnly)
91
+ return Response.json(buildSearchIndex([]), { headers });
92
+ const { docsDir } = getSourceOptions(project, source);
93
+ const options = { docsDir };
74
94
  const documents = getDocsIndex(options).flatMap(
75
95
  (category) => category.pages.map((page) => ({
76
96
  title: page.title,
77
97
  description: page.description,
78
98
  category: category.title,
79
- href: [
80
- "/docs",
81
- source.routePrefix,
82
- page.slug.split("/").map(encodeURIComponent).join("/")
83
- ].filter(Boolean).join("/"),
99
+ href: getDocsHref(project, source, page.slug),
100
+ sourcePath: page.sourcePath,
84
101
  content: getDocument(page.slug, options)?.content ?? ""
85
102
  }))
86
103
  );
@@ -88,7 +105,12 @@ function getSearchResponse(project, key) {
88
105
  }
89
106
 
90
107
  // src/runtime/indexing.ts
91
- import { getDocsIndex as getDocsIndex2, getSourceOptions as getSourceOptions2 } from "@lexibase/rdx-core";
108
+ import {
109
+ getDocsIndex as getDocsIndex2,
110
+ getSourceOptions as getSourceOptions2,
111
+ getContentSources,
112
+ isFallbackPage
113
+ } from "@lexibase/rdx-core";
92
114
  function getSourceRobots(project, source) {
93
115
  const published = project.sources.some(
94
116
  (entry) => entry.id !== "current" && entry.redirectTo === void 0 && entry.badge !== "Archived"
@@ -107,7 +129,7 @@ function getRdxRobots(config) {
107
129
  }
108
130
  function getRdxSitemap(project, {
109
131
  documentPath = (source, page) => [
110
- "/docs",
132
+ source.basePath ?? "/docs",
111
133
  source.routePrefix,
112
134
  page.slug.split("/").map(encodeURIComponent).join("/")
113
135
  ].filter(Boolean).join("/")
@@ -116,10 +138,11 @@ function getRdxSitemap(project, {
116
138
  if (!base) return [];
117
139
  const urls = /* @__PURE__ */ new Set();
118
140
  if (project.config.homepage?.enabled) urls.add(new URL("/", base).href);
119
- for (const source of project.sources) {
141
+ for (const source of getContentSources(project)) {
120
142
  if (!getSourceRobots(project, source).index) continue;
121
143
  for (const category of getDocsIndex2(getSourceOptions2(project, source))) {
122
144
  for (const page of category.pages) {
145
+ if (isFallbackPage(project, source, page.sourcePath)) continue;
123
146
  urls.add(new URL(documentPath(source, page), base).href);
124
147
  }
125
148
  }
@@ -128,21 +151,67 @@ function getRdxSitemap(project, {
128
151
  }
129
152
 
130
153
  // src/runtime/metadata.ts
131
- import { resolveDocsRoute } from "@lexibase/rdx-core";
154
+ import {
155
+ resolveDocsRoute,
156
+ getLocalizedProject,
157
+ getDocsHref as getDocsHref2,
158
+ isFallbackPage as isFallbackPage2,
159
+ getDocument as getDocument2,
160
+ getSourceOptions as getSourceOptions3
161
+ } from "@lexibase/rdx-core";
132
162
  import { notFound } from "next/navigation";
133
163
  function getDocumentMetadata({
134
164
  project,
135
165
  slug
136
166
  }) {
167
+ if (project.localization && !project.locale)
168
+ return { robots: { index: false, follow: true } };
137
169
  const route = resolveDocsRoute(project, slug);
138
170
  if (route.source.redirectTo !== void 0)
139
171
  return { robots: getSourceRobots(project, route.source) };
140
172
  if (!route.slug) return { robots: { index: false, follow: true } };
141
173
  if (!route.document) notFound();
142
174
  const page = route.categories.flatMap((category) => category.pages).find((page2) => page2.slug === route.slug);
175
+ const fallback = isFallbackPage2(project, route.source, page.sourcePath);
176
+ const primary = project.localization?.locales.find(
177
+ (locale) => locale.code === project.localization.defaultLocale
178
+ );
179
+ const canonicalProject = fallback ? getLocalizedProject(project, primary.path) : project;
180
+ const base = project.config.metadata.metadataBase;
181
+ const languages = project.localization && base ? Object.fromEntries(
182
+ project.localization.locales.flatMap((locale) => {
183
+ const view = getLocalizedProject(project, locale.path);
184
+ const source = view.sources.find(
185
+ (entry) => entry.id === route.source.id
186
+ );
187
+ return !source.fallbackOnly && getDocument2(page.slug, {
188
+ docsDir: getSourceOptions3(view, source).docsDir
189
+ }) ? [
190
+ [
191
+ locale.code,
192
+ new URL(getDocsHref2(view, source, page.slug), base).href
193
+ ]
194
+ ] : [];
195
+ })
196
+ ) : void 0;
143
197
  return {
144
198
  ...getPageMetadata(project.config, page),
145
- robots: getSourceRobots(project, route.source)
199
+ robots: fallback ? { index: false, follow: true } : getSourceRobots(project, route.source),
200
+ ...project.localization && base ? {
201
+ alternates: {
202
+ canonical: new URL(
203
+ getDocsHref2(
204
+ canonicalProject,
205
+ canonicalProject.sources.find(
206
+ (entry) => entry.id === route.source.id
207
+ ),
208
+ page.slug
209
+ ),
210
+ base
211
+ ).href,
212
+ ...fallback ? {} : { languages }
213
+ }
214
+ } : {}
146
215
  };
147
216
  }
148
217
  function getPageMetadata(config, page) {
@@ -211,16 +280,15 @@ function RdxHeader({
211
280
  const messages = getMessages(config.lang);
212
281
  const { source, slug: currentSlug } = resolveDocsRoute2(project, slug);
213
282
  const options = project.sources.filter((edition) => edition.redirectTo === void 0).map((edition) => {
214
- const docs = getSourceOptions3(project, edition);
215
- const destination = currentSlug && getDocument2(currentSlug, docs) ? currentSlug : getDocsPaths(docs)[0];
283
+ const docs = getSourceOptions4(project, edition);
284
+ const destination = currentSlug && getDocument3(currentSlug, docs) ? currentSlug : getDocsPaths(docs)[0];
216
285
  return {
217
286
  id: edition.id,
218
287
  label: edition.id === "current" ? messages.current : formatMessage(messages.version, { version: edition.label }),
219
288
  kind: edition.id === "current" ? "live" : edition.badge === "Archived" ? "archived" : "version",
220
- href: ["/docs", edition.routePrefix, destination].filter(Boolean).join("/")
289
+ href: getDocsHref3(project, edition, destination)
221
290
  };
222
291
  });
223
- const BrandLogo = config.navbar?.logo ?? Logo;
224
292
  return /* @__PURE__ */ jsx(
225
293
  Navbar,
226
294
  {
@@ -229,14 +297,23 @@ function RdxHeader({
229
297
  search: config.search ? /* @__PURE__ */ jsx(
230
298
  DocsSearch,
231
299
  {
232
- indexUrl: `/rdx-search/${encodeURIComponent(source.routePrefix || "_")}`,
300
+ indexUrl: `${project.locale ? `/${project.locale.path}` : ""}/rdx-search/${encodeURIComponent(source.routePrefix || "_")}`,
233
301
  version: source.id === "current" ? messages.current : formatMessage(messages.version, { version: source.label })
234
302
  }
235
303
  ) : void 0,
236
304
  siteTitle: config.metadata.applicationName,
237
- logo: /* @__PURE__ */ jsx(BrandLogo, { className: "size-5" }),
238
- homeHref: config.homepage?.enabled ? "/" : "/docs",
239
- docsHref: ["/docs", source.routePrefix].filter(Boolean).join("/"),
305
+ logo: /* @__PURE__ */ jsx(BrandLogo, { source: config.navbar?.logo, className: "size-5" }),
306
+ homeHref: config.homepage?.enabled ? "/" : getDocsHref3(project),
307
+ docsHref: getDocsHref3(project, source),
308
+ languageMenu: project.localization ? /* @__PURE__ */ jsx(
309
+ LanguageDropdown,
310
+ {
311
+ lang: config.lang,
312
+ current: project.locale?.code ?? project.localization.defaultLocale,
313
+ options: getLanguageLinks(project, source, currentSlug),
314
+ contributionUrl: project.localization.contributionUrl
315
+ }
316
+ ) : void 0,
240
317
  versionMenu: project.sources.length > 1 ? /* @__PURE__ */ jsx(VersionDropdown, { options, currentId: source.id }) : void 0,
241
318
  github: config.navbar?.actions?.github,
242
319
  showToggleMode: config.navbar?.actions?.showToggleMode
@@ -245,10 +322,9 @@ function RdxHeader({
245
322
  }
246
323
  function HomePage({ project }) {
247
324
  const { config } = project;
248
- if (!config.homepage?.enabled) redirect("/docs");
325
+ if (!config.homepage?.enabled) redirect(getDocsHref3(project));
249
326
  const homepage = config.homepage;
250
327
  const CustomHome = homepage.component;
251
- const BrandLogo = config.navbar?.logo ?? Logo;
252
328
  return /* @__PURE__ */ jsxs(Fragment, { children: [
253
329
  /* @__PURE__ */ jsx(RdxHeader, { project }),
254
330
  /* @__PURE__ */ jsx(
@@ -261,7 +337,13 @@ function HomePage({ project }) {
261
337
  Home,
262
338
  {
263
339
  title: homepage.title ?? config.metadata.applicationName,
264
- logo: /* @__PURE__ */ jsx(BrandLogo, { className: "size-52 hover:scale-105 hover:rotate-1 transition-[scale,rotation] duration-1000" }),
340
+ logo: /* @__PURE__ */ jsx(
341
+ BrandLogo,
342
+ {
343
+ source: config.navbar?.logo,
344
+ className: "size-52 hover:scale-105 hover:rotate-1 transition-[scale,rotation] duration-1000"
345
+ }
346
+ ),
265
347
  description: homepage.description,
266
348
  buttons: homepage.buttons
267
349
  }
@@ -271,6 +353,24 @@ function HomePage({ project }) {
271
353
  ] });
272
354
  }
273
355
  function NotFoundPage({ project }) {
356
+ if (project.localization) {
357
+ const { config, localization } = project;
358
+ return /* @__PURE__ */ jsx(
359
+ LocalizedNotFound,
360
+ {
361
+ locales: localization.locales,
362
+ defaultLocale: localization.defaultLocale,
363
+ homepage: !!config.homepage?.enabled,
364
+ brand: {
365
+ title: config.navbar?.title,
366
+ siteTitle: config.metadata.applicationName,
367
+ github: config.navbar?.actions?.github,
368
+ showToggleMode: config.navbar?.actions?.showToggleMode,
369
+ logo: /* @__PURE__ */ jsx(BrandLogo, { source: config.navbar?.logo, className: "size-5" })
370
+ }
371
+ }
372
+ );
373
+ }
274
374
  return /* @__PURE__ */ jsxs(Fragment, { children: [
275
375
  /* @__PURE__ */ jsx(RdxHeader, { project }),
276
376
  /* @__PURE__ */ jsx(NotFound, { lang: project.config.lang })
@@ -280,6 +380,8 @@ async function DocsPage({
280
380
  slug,
281
381
  project
282
382
  }) {
383
+ if (project.localization && !project.locale)
384
+ permanentRedirect(getDocsHref3(project, project.sources[0], slug?.join("/")));
283
385
  const messages = getMessages(project.config.lang);
284
386
  const {
285
387
  source,
@@ -289,11 +391,13 @@ async function DocsPage({
289
391
  } = resolveDocsRoute2(project, slug);
290
392
  if (source.redirectTo !== void 0)
291
393
  permanentRedirect(
292
- [
293
- "/docs",
294
- source.redirectTo,
295
- ...slug?.slice(1).map(encodeURIComponent) ?? []
296
- ].join("/")
394
+ getDocsHref3(
395
+ project,
396
+ project.sources.find(
397
+ (entry) => entry.routePrefix === source.redirectTo
398
+ ),
399
+ slug?.slice(1).join("/")
400
+ )
297
401
  );
298
402
  if (!currentSlug)
299
403
  return /* @__PURE__ */ jsxs(Fragment, { children: [
@@ -304,19 +408,32 @@ async function DocsPage({
304
408
  ] })
305
409
  ] });
306
410
  if (!document) notFound2();
411
+ const fallback = isFallbackPage3(project, source, document.sourcePath);
412
+ const primaryLocale = (source.localization ?? project.localization)?.locales.find(
413
+ (locale) => locale.code === project.localization.defaultLocale
414
+ );
415
+ const originalProject = fallback && primaryLocale ? getLocalizedProject2(project, primaryLocale.path) : project;
416
+ const contentSource = originalProject.sources.find(
417
+ (entry) => entry.id === source.id
418
+ );
419
+ const contentOptions = getSourceOptions4(originalProject, contentSource);
420
+ const contentCategories = fallback ? resolveDocsRoute2(originalProject, slug).categories : categories;
421
+ const contentLanguage = fallback ? primaryLocale.code : project.config.lang;
307
422
  const { content } = await compileDocumentation(
308
423
  document.content,
309
424
  getMdxComponents(project.config.lang),
310
425
  {
311
- sourceRoot: getSourceOptions3(project, source).docsDir,
426
+ sourceRoot: contentOptions.docsDir,
312
427
  sourcePath: document.sourcePath,
313
428
  routePrefix: source.routePrefix,
314
- documents: categories.flatMap((category2) => category2.pages)
429
+ basePath: contentSource.basePath,
430
+ virtualPaths: !!contentOptions.fallbackDocsDir,
431
+ documents: contentCategories.flatMap((category2) => category2.pages)
315
432
  }
316
433
  );
317
434
  const title = typeof document.frontmatter.title === "string" ? document.frontmatter.title : currentSlug;
318
435
  const description = typeof document.frontmatter.description === "string" ? document.frontmatter.description : null;
319
- const hrefFor = (pageSlug) => ["/docs", source.routePrefix, pageSlug].filter(Boolean).join("/");
436
+ const hrefFor = (pageSlug) => getDocsHref3(project, source, pageSlug);
320
437
  const sidebarCategories = categories.map((category2) => ({
321
438
  title: category2.title,
322
439
  links: category2.pages.map((page) => ({
@@ -362,9 +479,58 @@ async function DocsPage({
362
479
  "main",
363
480
  {
364
481
  id: "rdx-main",
482
+ lang: contentLanguage,
365
483
  tabIndex: -1,
366
484
  className: "w-full flex flex-col gap-5 md:mx-auto md:max-w-[860px] pt-14 md:pt-0 flex-1 outline-none",
367
485
  children: [
486
+ fallback && /* @__PURE__ */ jsxs(
487
+ Callout,
488
+ {
489
+ lang: project.config.lang,
490
+ variant: "note",
491
+ title: messages.translationUnavailable,
492
+ "data-rdx-translation-fallback": true,
493
+ children: [
494
+ /* @__PURE__ */ jsx("p", { children: formatMessage(messages.translationFallback, {
495
+ requested: project.locale.label,
496
+ original: primaryLocale.label
497
+ }) }),
498
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-x-2", children: [
499
+ /* @__PURE__ */ jsx(
500
+ Button,
501
+ {
502
+ variant: "link",
503
+ href: getDocsHref3(
504
+ originalProject,
505
+ contentSource,
506
+ currentSlug
507
+ ),
508
+ children: messages.viewOriginal
509
+ }
510
+ ),
511
+ project.localization?.contributionUrl && /* @__PURE__ */ jsxs(
512
+ Button,
513
+ {
514
+ variant: "link",
515
+ href: project.localization.contributionUrl,
516
+ target: "_blank",
517
+ rel: "noopener noreferrer",
518
+ "data-rdx-translate-page": true,
519
+ children: [
520
+ messages.translateThisPage,
521
+ /* @__PURE__ */ jsxs("span", { className: "sr-only", children: [
522
+ " ",
523
+ "(",
524
+ messages.opensInNewTab,
525
+ ")"
526
+ ] })
527
+ ]
528
+ }
529
+ )
530
+ ] })
531
+ ]
532
+ }
533
+ ),
368
534
  /* @__PURE__ */ jsxs("header", { className: "mb-6", children: [
369
535
  source.badge && /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground mb-4", children: source.badge === "Archived" ? messages.archivedVersion : source.badge }),
370
536
  /* @__PURE__ */ jsx("span", { className: "text-xs text-zinc-600 font-semibold uppercase block -mb-0.5", children: category?.title }),
@@ -425,6 +591,7 @@ export {
425
591
  RdxLayout,
426
592
  createRdxProject,
427
593
  getDocumentMetadata,
594
+ getLocalizedProject3 as getLocalizedProject,
428
595
  getPageMetadata,
429
596
  getProjectDocsPaths,
430
597
  getRdxMetadata,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lexibase/rdx-next",
3
- "version": "4.0.0",
3
+ "version": "4.2.0",
4
4
  "description": "The Next.js runtime for RDX documentation projects.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -48,14 +48,15 @@
48
48
  "dependencies": {
49
49
  "cross-spawn": "^7.0.6",
50
50
  "jiti": "^2.6.1",
51
+ "prompts": "^2.4.2",
51
52
  "next-mdx-remote": "^6.0.0",
52
53
  "remark-gfm": "^4",
53
54
  "typescript": "6.0.3",
54
- "@lexibase/rdx-types": "2.0.0",
55
- "@lexibase/rdx-core": "2.0.0"
55
+ "@lexibase/rdx-types": "2.2.0",
56
+ "@lexibase/rdx-core": "2.2.0"
56
57
  },
57
58
  "peerDependencies": {
58
- "@lexibase/rdx-ui": "^3.2.0",
59
+ "@lexibase/rdx-ui": "^3.4.0",
59
60
  "next": "^16.4.2",
60
61
  "react": "^19.3.1",
61
62
  "react-dom": "^19.3.1"
@@ -80,9 +81,9 @@
80
81
  "next": "^16.4.2",
81
82
  "react": "19.3.1",
82
83
  "react-dom": "19.3.1",
84
+ "@rdx/typescript-config": "1.0.0",
83
85
  "@rdx/eslint-config": "1.0.0",
84
- "@lexibase/rdx-ui": "3.2.1",
85
- "@rdx/typescript-config": "1.0.0"
86
+ "@lexibase/rdx-ui": "3.4.0"
86
87
  },
87
88
  "scripts": {
88
89
  "dev": "tsup src/index.ts src/runtime/index.tsx --format esm --dts --watch --external @lexibase/rdx-core --external @lexibase/rdx-types --external @lexibase/rdx-ui --external next --external react --external react-dom --external next-mdx-remote",
@@ -4,3 +4,4 @@
4
4
  @source '../../home.{ts,tsx}';
5
5
  @source '../../components/**/*.{ts,tsx}';
6
6
  @source '../../docs/**/*.mdx';
7
+ @source '../../locale/*/docs/**/*.mdx';