@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 +14 -2
- package/bin/add-plugin.mjs +111 -35
- package/bin/cli.mjs +63 -7
- package/bin/i18n-setup.mjs +115 -0
- package/bin/locale-routes.mjs +138 -0
- package/bin/plugin-config.mjs +47 -13
- package/config.mjs +27 -6
- package/dist/runtime/index.d.ts +16 -4
- package/dist/runtime/index.js +246 -34
- package/package.json +6 -5
- package/templates/runtime/app/globals.css +1 -0
- package/templates/runtime/app/robots.ts +6 -0
- package/templates/runtime/app/sitemap.ts +6 -0
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`,
|
|
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.
|
package/bin/add-plugin.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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: `${
|
|
68
|
+
specifier: `${packageName}@${prerelease ?? 'latest'}`,
|
|
50
69
|
}
|
|
51
70
|
}
|
|
52
71
|
|
|
53
72
|
export function parseAddArguments(args) {
|
|
54
73
|
const [plugin, ...rest] = args
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
//
|
|
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:
|
|
206
|
+
setup:
|
|
207
|
+
packageName === i18nPackage && plugin.name === i18nPackage
|
|
208
|
+
? plugin.setup
|
|
209
|
+
: undefined,
|
|
178
210
|
})),
|
|
179
211
|
},
|
|
180
|
-
{ rootDir: path.dirname(filename), command: '
|
|
212
|
+
{ rootDir: path.dirname(filename), command: 'runtime' }
|
|
181
213
|
)
|
|
182
214
|
if (
|
|
183
|
-
config.plugins.filter((plugin) => plugin.name ===
|
|
184
|
-
.length !== 1
|
|
215
|
+
config.plugins.filter((plugin) => plugin.name === packageName).length !== 1
|
|
185
216
|
)
|
|
186
217
|
throw new Error(
|
|
187
|
-
|
|
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 =
|
|
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?.[
|
|
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(
|
|
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 =
|
|
304
|
+
const request = pluginInstallRequest(runtimeVersion, declared, packageName)
|
|
238
305
|
log(
|
|
239
306
|
request.kind === 'restore'
|
|
240
|
-
? `Restoring the declared ${
|
|
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
|
-
|
|
341
|
+
committed = true
|
|
342
|
+
log(`${label} activated in rdx.config.ts.`)
|
|
269
343
|
} else
|
|
270
|
-
log(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|