@lexibase/rdx-next 6.0.0 → 6.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/bin/cli.mjs +25 -0
- package/bin/homepage-translations.mjs +186 -23
- package/package.json +3 -3
package/bin/cli.mjs
CHANGED
|
@@ -67,6 +67,9 @@ export async function runRdxCli({ templateDir } = {}) {
|
|
|
67
67
|
console.log(
|
|
68
68
|
' rdx add i18n [--locales en,es-ES] [--package-manager npm|pnpm|yarn]'
|
|
69
69
|
)
|
|
70
|
+
console.log(
|
|
71
|
+
' rdx i18n sync: add missing phrases to homepage translation catalogs'
|
|
72
|
+
)
|
|
70
73
|
console.log(
|
|
71
74
|
'Plugin commands are registered through plugins in rdx.config.ts.'
|
|
72
75
|
)
|
|
@@ -86,6 +89,18 @@ export async function runRdxCli({ templateDir } = {}) {
|
|
|
86
89
|
if (commandArgs.length) throw new Error('Usage: rdx check')
|
|
87
90
|
}
|
|
88
91
|
|
|
92
|
+
const syncHomepage = command === 'i18n' && commandArgs[0] === 'sync'
|
|
93
|
+
if (
|
|
94
|
+
syncHomepage &&
|
|
95
|
+
commandArgs.length === 2 &&
|
|
96
|
+
['--help', '-h'].includes(commandArgs[1])
|
|
97
|
+
) {
|
|
98
|
+
console.log(
|
|
99
|
+
'Usage: rdx i18n sync\nAdd missing homepage phrases to the configured languages’ home.json files.'
|
|
100
|
+
)
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
89
104
|
if (command === 'add') {
|
|
90
105
|
if (
|
|
91
106
|
(commandArgs.length === 1 &&
|
|
@@ -145,6 +160,16 @@ export async function runRdxCli({ templateDir } = {}) {
|
|
|
145
160
|
rootDir: projectRoot,
|
|
146
161
|
command,
|
|
147
162
|
})
|
|
163
|
+
if (
|
|
164
|
+
syncHomepage &&
|
|
165
|
+
!project.commands.some((entry) => entry.name === 'i18n')
|
|
166
|
+
) {
|
|
167
|
+
if (commandArgs.length !== 1) throw new Error('Usage: rdx i18n sync')
|
|
168
|
+
const { syncHomepageTranslations } =
|
|
169
|
+
await import('./homepage-translations.mjs')
|
|
170
|
+
await syncHomepageTranslations(project)
|
|
171
|
+
return
|
|
172
|
+
}
|
|
148
173
|
let homepageTranslations
|
|
149
174
|
if (
|
|
150
175
|
project.config.homepage?.enabled &&
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import {
|
|
3
|
+
lstat,
|
|
4
|
+
readFile,
|
|
5
|
+
realpath,
|
|
6
|
+
rename,
|
|
7
|
+
rm,
|
|
8
|
+
writeFile,
|
|
9
|
+
} from 'node:fs/promises'
|
|
2
10
|
import path from 'node:path'
|
|
3
11
|
|
|
4
12
|
const sourceFile = 'components/home.tsx'
|
|
@@ -16,12 +24,17 @@ async function readOptional(file) {
|
|
|
16
24
|
}
|
|
17
25
|
}
|
|
18
26
|
|
|
19
|
-
async function sourcePhrases(rootDir) {
|
|
27
|
+
async function sourcePhrases(rootDir, report) {
|
|
20
28
|
const source = await readOptional(path.join(rootDir, sourceFile))
|
|
21
29
|
if (source === undefined)
|
|
22
30
|
throw new Error(`Create ${sourceFile} to use homepage.translations.`)
|
|
23
31
|
const { default: ts } = await import('typescript')
|
|
24
|
-
const file = ts.createSourceFile(
|
|
32
|
+
const file = ts.createSourceFile(
|
|
33
|
+
sourceFile,
|
|
34
|
+
source,
|
|
35
|
+
ts.ScriptTarget.Latest,
|
|
36
|
+
true
|
|
37
|
+
)
|
|
25
38
|
for (const statement of file.statements) {
|
|
26
39
|
if (
|
|
27
40
|
!ts.isExpressionStatement(statement) ||
|
|
@@ -34,19 +47,170 @@ async function sourcePhrases(rootDir) {
|
|
|
34
47
|
)
|
|
35
48
|
}
|
|
36
49
|
const phrases = new Set()
|
|
37
|
-
|
|
50
|
+
const warnings = []
|
|
51
|
+
const passedOn =
|
|
52
|
+
't is passed on or renamed; phrases translated elsewhere are left out of the translation report'
|
|
53
|
+
const needsLiteral =
|
|
54
|
+
't() needs a literal phrase; this call is left out of the translation report'
|
|
55
|
+
function warn(node, message) {
|
|
56
|
+
const { line } = file.getLineAndCharacterOfPosition(node.getStart(file))
|
|
57
|
+
warnings.push({ line: line + 1, message })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Bind identifiers within this file so an unrelated or shadowed t is ignored.
|
|
61
|
+
const options = { noLib: true, noResolve: true, types: [] }
|
|
62
|
+
const program = ts.createProgram([sourceFile], options, {
|
|
63
|
+
...ts.createCompilerHost(options),
|
|
64
|
+
getSourceFile: (name) => (name === sourceFile ? file : undefined),
|
|
65
|
+
})
|
|
66
|
+
const checker = program.getTypeChecker()
|
|
67
|
+
const bindings = new Set()
|
|
68
|
+
function findBindings(node) {
|
|
38
69
|
if (
|
|
39
|
-
ts.
|
|
40
|
-
ts.
|
|
41
|
-
node.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
70
|
+
ts.isBindingElement(node) &&
|
|
71
|
+
ts.isObjectBindingPattern(node.parent) &&
|
|
72
|
+
ts.isIdentifier(node.name)
|
|
73
|
+
) {
|
|
74
|
+
const key = node.propertyName ?? node.name
|
|
75
|
+
if (ts.isStringLiteralLike(key) || ts.isIdentifier(key)) {
|
|
76
|
+
if (key.text === 't') {
|
|
77
|
+
if (node.name.text === 't')
|
|
78
|
+
bindings.add(checker.getSymbolAtLocation(node.name))
|
|
79
|
+
else warn(node, passedOn)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
ts.forEachChild(node, findBindings)
|
|
84
|
+
}
|
|
85
|
+
findBindings(file)
|
|
86
|
+
|
|
87
|
+
function visit(node) {
|
|
88
|
+
if (ts.isIdentifier(node) && node.text === 't') {
|
|
89
|
+
const parent = node.parent
|
|
90
|
+
const symbol = ts.isShorthandPropertyAssignment(parent)
|
|
91
|
+
? checker.getShorthandAssignmentValueSymbol(parent)
|
|
92
|
+
: checker.getSymbolAtLocation(node)
|
|
93
|
+
if (
|
|
94
|
+
symbol &&
|
|
95
|
+
bindings.has(symbol) &&
|
|
96
|
+
!ts.isBindingElement(parent) &&
|
|
97
|
+
!ts.isTypeQueryNode(parent)
|
|
98
|
+
) {
|
|
99
|
+
if (ts.isCallExpression(parent) && parent.expression === node) {
|
|
100
|
+
const [argument] = parent.arguments
|
|
101
|
+
if (parent.arguments.length === 1 && ts.isStringLiteralLike(argument))
|
|
102
|
+
phrases.add(argument.text)
|
|
103
|
+
else warn(node, needsLiteral)
|
|
104
|
+
} else warn(node, passedOn)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
46
107
|
ts.forEachChild(node, visit)
|
|
47
108
|
}
|
|
48
109
|
visit(file)
|
|
49
|
-
|
|
110
|
+
if (report) {
|
|
111
|
+
for (const { line, message } of warnings.sort((a, b) => a.line - b.line))
|
|
112
|
+
console.warn(`[RDX] ${sourceFile}:${line}: ${message}`)
|
|
113
|
+
}
|
|
114
|
+
phrases.delete(metadataKey)
|
|
115
|
+
return { phrases, warnings }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parseCatalog(raw) {
|
|
119
|
+
const parsed = JSON.parse(raw)
|
|
120
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
121
|
+
throw new Error('Expected a JSON object.')
|
|
122
|
+
return parsed
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function syncHomepageTranslations(project) {
|
|
126
|
+
const { rootDir, config } = project
|
|
127
|
+
const locales = project.sources.find((source) => source.id === 'current')
|
|
128
|
+
?.localization?.locales
|
|
129
|
+
if (!locales)
|
|
130
|
+
throw new Error('Enable the i18n plugin before running rdx i18n sync.')
|
|
131
|
+
if (!config.homepage?.enabled || !config.homepage.translations)
|
|
132
|
+
throw new Error(
|
|
133
|
+
'Set homepage.enabled and homepage.translations to true before running rdx i18n sync.'
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
const { phrases } = await sourcePhrases(rootDir, true)
|
|
137
|
+
if (!phrases.size) {
|
|
138
|
+
console.log(`[RDX] No literal homepage phrases found in ${sourceFile}.`)
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
const root = await realpath(rootDir)
|
|
142
|
+
const updates = []
|
|
143
|
+
for (const { folder } of locales) {
|
|
144
|
+
if (!folder) continue
|
|
145
|
+
const file = `locale/${folder}/home.json`
|
|
146
|
+
const directory = await realpath(path.join(root, 'locale', folder))
|
|
147
|
+
const relative = path.relative(root, directory)
|
|
148
|
+
if (
|
|
149
|
+
!relative ||
|
|
150
|
+
relative === '..' ||
|
|
151
|
+
relative.startsWith(`..${path.sep}`) ||
|
|
152
|
+
path.isAbsolute(relative)
|
|
153
|
+
)
|
|
154
|
+
throw new Error(`${file} must remain inside the project.`)
|
|
155
|
+
const target = path.join(directory, 'home.json')
|
|
156
|
+
const info = await lstat(target).catch((error) => {
|
|
157
|
+
if (!missingFile(error)) throw error
|
|
158
|
+
})
|
|
159
|
+
if (info && !info.isFile())
|
|
160
|
+
throw new Error(`${file} must be a regular file.`)
|
|
161
|
+
const raw = info ? await readFile(target, 'utf8') : undefined
|
|
162
|
+
let messages = {}
|
|
163
|
+
if (raw !== undefined) {
|
|
164
|
+
try {
|
|
165
|
+
messages = parseCatalog(raw)
|
|
166
|
+
} catch {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`Cannot sync ${file}: expected a JSON object of homepage phrases.`
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const missing = [...phrases].filter(
|
|
173
|
+
(phrase) => !Object.hasOwn(messages, phrase)
|
|
174
|
+
)
|
|
175
|
+
if (missing.length)
|
|
176
|
+
updates.push({
|
|
177
|
+
file,
|
|
178
|
+
target,
|
|
179
|
+
info,
|
|
180
|
+
raw,
|
|
181
|
+
count: missing.length,
|
|
182
|
+
messages: Object.fromEntries([
|
|
183
|
+
...Object.entries(messages),
|
|
184
|
+
...missing.map((phrase) => [phrase, '']),
|
|
185
|
+
]),
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const { file, target, info, raw, count, messages } of updates) {
|
|
190
|
+
const content = JSON.stringify(messages, null, 2) + '\n'
|
|
191
|
+
if (raw === undefined) {
|
|
192
|
+
await writeFile(target, content, { flag: 'wx' })
|
|
193
|
+
} else {
|
|
194
|
+
const temporary = `${target}.${randomUUID()}.tmp`
|
|
195
|
+
try {
|
|
196
|
+
await writeFile(temporary, content, { flag: 'wx', mode: info.mode })
|
|
197
|
+
if (
|
|
198
|
+
!(await lstat(target)).isFile() ||
|
|
199
|
+
(await readFile(target, 'utf8')) !== raw
|
|
200
|
+
)
|
|
201
|
+
throw new Error(
|
|
202
|
+
`${file} changed during sync. Run rdx i18n sync again.`
|
|
203
|
+
)
|
|
204
|
+
await rename(temporary, target)
|
|
205
|
+
} finally {
|
|
206
|
+
await rm(temporary, { force: true })
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
console.log(
|
|
210
|
+
`[RDX] ${file}: added ${count} ${count === 1 ? 'phrase' : 'phrases'}.`
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
if (!updates.length) console.log('[RDX] Homepage catalogs are up to date.')
|
|
50
214
|
}
|
|
51
215
|
|
|
52
216
|
export async function readHomepageTranslations(
|
|
@@ -54,8 +218,7 @@ export async function readHomepageTranslations(
|
|
|
54
218
|
{ report = false } = {}
|
|
55
219
|
) {
|
|
56
220
|
const { rootDir, localization } = project
|
|
57
|
-
const phrases = await sourcePhrases(rootDir)
|
|
58
|
-
phrases.delete(metadataKey)
|
|
221
|
+
const { phrases, warnings } = await sourcePhrases(rootDir, report)
|
|
59
222
|
const catalogs = []
|
|
60
223
|
for (const locale of localization?.locales ?? []) {
|
|
61
224
|
if (!locale.folder) continue
|
|
@@ -64,10 +227,7 @@ export async function readHomepageTranslations(
|
|
|
64
227
|
let messages = {}
|
|
65
228
|
if (raw !== undefined) {
|
|
66
229
|
try {
|
|
67
|
-
|
|
68
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
69
|
-
throw new Error('Expected a JSON object.')
|
|
70
|
-
messages = parsed
|
|
230
|
+
messages = parseCatalog(raw)
|
|
71
231
|
catalogs.push({ file, locale: locale.path })
|
|
72
232
|
} catch {
|
|
73
233
|
console.warn(
|
|
@@ -86,11 +246,14 @@ export async function readHomepageTranslations(
|
|
|
86
246
|
`[RDX] ${file}: missing translation for ${JSON.stringify(phrase)}`
|
|
87
247
|
)
|
|
88
248
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
249
|
+
// An unrecognized call may use any of the remaining keys.
|
|
250
|
+
if (!warnings.length) {
|
|
251
|
+
for (const phrase of Object.keys(messages)) {
|
|
252
|
+
if (phrase !== metadataKey && !phrases.has(phrase))
|
|
253
|
+
console.warn(
|
|
254
|
+
`[RDX] ${file}: orphaned translation for ${JSON.stringify(phrase)}`
|
|
255
|
+
)
|
|
256
|
+
}
|
|
94
257
|
}
|
|
95
258
|
}
|
|
96
259
|
return { catalogs, phrases }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lexibase/rdx-next",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.1.0",
|
|
4
4
|
"description": "The Next.js runtime for RDX documentation projects.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -82,8 +82,8 @@
|
|
|
82
82
|
"react": "19.3.1",
|
|
83
83
|
"react-dom": "19.3.1",
|
|
84
84
|
"@rdx/eslint-config": "1.0.0",
|
|
85
|
-
"@rdx
|
|
86
|
-
"@
|
|
85
|
+
"@lexibase/rdx-ui": "3.5.6",
|
|
86
|
+
"@rdx/typescript-config": "1.0.0"
|
|
87
87
|
},
|
|
88
88
|
"scripts": {
|
|
89
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",
|