@lexibase/rdx-next 3.2.0 → 4.1.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
@@ -16,6 +16,12 @@ must still be trusted project-owned content, not untrusted uploads.
16
16
  It exposes the `rdx` command used by generated projects to create their
17
17
  internal `.rdx` application and run Next.js.
18
18
 
19
+ Run `pnpm rdx check` to validate static documentation links, heading fragments
20
+ and local files in public/ before deployment. It reports source locations and
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.
24
+
19
25
  Run `pnpm rdx add versioning` (or `npm exec -- rdx add versioning`) from a
20
26
  documentation project to install and activate the official optional plugin.
21
27
  The command detects the package manager, preserves config and plugin options,
@@ -25,6 +31,13 @@ Use `--package-manager npm|pnpm|yarn` to resolve ambiguous lockfiles. Dynamic
25
31
  configurations receive manual instructions and are not automatically edited.
26
32
  Installing a package with npm/pnpm/yarn alone never edits `rdx.config.ts`.
27
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
+
28
41
  First plugin installation follows the installed runtime: stable releases use
29
42
  `latest`, betas use `beta`, and release candidates use `rc`. Unknown prerelease
30
43
  channels require manual installation; missing tags do not fall back to another
@@ -89,7 +102,6 @@ export default defineConfig({
89
102
  authors: [{ name: 'My team', url: 'https://example.com' }],
90
103
  keywords: ['documentation', 'my-project'],
91
104
  icons: { icon: '/favicon.ico' },
92
- robots: { index: true, follow: true },
93
105
  openGraph: {
94
106
  type: 'website',
95
107
  images: [{ url: '/og.png', width: 1200, height: 630, alt: 'My project' }],
@@ -111,7 +123,7 @@ production domain or force localhost. Image, favicon and manifest paths refer
111
123
  to files you create in `public/`. Nothing creates those assets automatically.
112
124
 
113
125
  Supported fields are `metadataBase` (an absolute http(s) string),
114
- `applicationName`, `authors`, `keywords`, `icons`, `manifest`, `robots`,
126
+ `applicationName`, `authors`, `keywords`, `icons`, `manifest`,
115
127
  `openGraph` and `twitter`. TypeScript describes the supported nested options,
116
128
  and invalid fields fail during configuration validation. This is a portable
117
129
  subset, not an unrestricted pass-through of all Next.js metadata options.
@@ -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)
@@ -50,19 +52,61 @@ export async function runRdxCli({ templateDir } = {}) {
50
52
 
51
53
  async function main() {
52
54
  if (!command || command === 'help' || command === '--help') {
53
- console.log('Usage: rdx <dev|build|start|prepare|add|plugin-command>')
55
+ console.log(
56
+ 'Usage: rdx <dev|build|start|prepare|check|add|plugin-command>'
57
+ )
58
+ console.log(
59
+ ' rdx check: validate documentation links, sections and local assets'
60
+ )
54
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
+ )
55
65
  console.log(
56
66
  'Plugin commands are registered through plugins in rdx.config.ts.'
57
67
  )
58
68
  return
59
69
  }
60
70
 
71
+ if (command === 'check') {
72
+ if (
73
+ commandArgs.length === 1 &&
74
+ ['--help', '-h'].includes(commandArgs[0])
75
+ ) {
76
+ console.log(
77
+ 'Usage: rdx check\nValidate static MDX links, section anchors and local public/ files without building the site.'
78
+ )
79
+ return
80
+ }
81
+ if (commandArgs.length) throw new Error('Usage: rdx check')
82
+ }
83
+
61
84
  if (command === 'add') {
62
- 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 } =
63
104
  await import('./add-plugin.mjs')
64
105
  const options = parseAddArguments(commandArgs)
65
- await addVersioning(projectRoot, options)
106
+ const result = await (
107
+ commandArgs[0] === 'i18n' ? addI18n : addVersioning
108
+ )(projectRoot, options)
109
+ if (result.cancelled) return
66
110
  command = 'prepare'
67
111
  }
68
112
 
@@ -96,8 +140,20 @@ export async function runRdxCli({ templateDir } = {}) {
96
140
  rootDir: projectRoot,
97
141
  command,
98
142
  })
143
+ if (command === 'check' || command === 'build') {
144
+ const { checkDocumentation, formatDocumentationCheck } =
145
+ await import('@lexibase/rdx-core/check')
146
+ const report = checkDocumentation(project)
147
+ const output = formatDocumentationCheck(report)
148
+ if (report.diagnostics.length) {
149
+ console.error(output)
150
+ process.exitCode = 1
151
+ return
152
+ } else console.log(output)
153
+ if (command === 'check') return
154
+ }
99
155
  if (command === 'prepare') {
100
- await prepareRuntime()
156
+ await prepareRuntime(project)
101
157
  console.log('RDX runtime prepared in .rdx/')
102
158
  return
103
159
  }
@@ -107,7 +163,7 @@ export async function runRdxCli({ templateDir } = {}) {
107
163
  )
108
164
  if (!pluginCommand)
109
165
  throw new Error(
110
- `Unknown command "${command}". Available: dev, build, start, prepare, add${project.commands.map((entry) => `, ${entry.name}`).join('')}. Install and configure the plugin that provides this command.`
166
+ `Unknown command "${command}". Available: dev, build, start, prepare, check, add${project.commands.map((entry) => `, ${entry.name}`).join('')}. Install and configure the plugin that provides this command.`
111
167
  )
112
168
  await pluginCommand.run({
113
169
  rootDir: projectRoot,
@@ -118,7 +174,7 @@ export async function runRdxCli({ templateDir } = {}) {
118
174
  return
119
175
  }
120
176
 
121
- await prepareRuntime()
177
+ await prepareRuntime(project)
122
178
  const projectRequire = createRequire(path.join(projectRoot, 'package.json'))
123
179
  const nextCli = projectRequire.resolve('next/dist/bin/next')
124
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
+ }