wovn-nextjs 0.0.4
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 +1 -0
- package/bin/wovn-nextjs +422 -0
- package/dist/cjs/index.d.ts +62 -0
- package/dist/cjs/index.js +267 -0
- package/dist/cjs/server.d.ts +1 -0
- package/dist/cjs/server.js +6 -0
- package/dist/esm/index.d.ts +62 -0
- package/dist/esm/index.js +255 -0
- package/dist/esm/server.d.ts +1 -0
- package/dist/esm/server.js +2 -0
- package/package.json +57 -0
package/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Wovn Next.js plugin
|
package/bin/wovn-nextjs
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const path = require('node:path')
|
|
5
|
+
const https = require('node:https')
|
|
6
|
+
const fs = require('node:fs')
|
|
7
|
+
const fsp = fs.promises
|
|
8
|
+
const parse = require('typescript-eslint').parser.parseForESLint
|
|
9
|
+
|
|
10
|
+
const command = process.argv[2]
|
|
11
|
+
const options = process.argv.slice(3)
|
|
12
|
+
const targetRegexp = /\.(?:jsx|tsx)$/
|
|
13
|
+
const ignoreRegexp = /^\..*|node_modules$/
|
|
14
|
+
const buildDir = options[0] || '.'
|
|
15
|
+
const parseOption = {
|
|
16
|
+
ecmaFeatures: {
|
|
17
|
+
jsx: true,
|
|
18
|
+
},
|
|
19
|
+
sourceType: 'module'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function main() {
|
|
23
|
+
const context = {
|
|
24
|
+
uvs: new Map(), // insertion order is expected for wovn-nextjs.test.js
|
|
25
|
+
pathname: '',
|
|
26
|
+
components: [],
|
|
27
|
+
wovnJsData: {},
|
|
28
|
+
projectToken: process.env.WOVN_PROJECT_TOKEN,
|
|
29
|
+
hostname: process.env.WOVN_HOSTNAME
|
|
30
|
+
}
|
|
31
|
+
if (command === 'build' && context.projectToken && context.hostname) {
|
|
32
|
+
context.wovnJsData = await httpsRequest(`https://data.wovn.io/js_data/json/1/${context.projectToken}/?u=http%3A%2F%2F${context.hostname}`).then(body => JSON.parse(body.toString()))
|
|
33
|
+
await overwriteTsxAndJsxFiles(context, undo) // Make sure to collect translatable texts
|
|
34
|
+
await overwriteTsxAndJsxFiles(context, transform).then(() => reportToWovn(context))
|
|
35
|
+
await undoIndexJavaScriptFiles()
|
|
36
|
+
await overwriteInitializationProcess(context)
|
|
37
|
+
await createMiddlewareFile(context)
|
|
38
|
+
if (process.env.WOVN_DEBUG) {
|
|
39
|
+
console.dir(context, {depth: null})
|
|
40
|
+
}
|
|
41
|
+
} else if (command === 'build-config' && context.projectToken && context.hostname) {
|
|
42
|
+
context.wovnJsData = await httpsRequest(`https://data.wovn.io/js_data/json/1/${context.projectToken}/?u=http%3A%2F%2F${context.hostname}`).then(body => JSON.parse(body.toString()))
|
|
43
|
+
await undoIndexJavaScriptFiles()
|
|
44
|
+
await overwriteInitializationProcess(context)
|
|
45
|
+
await createMiddlewareFile(context)
|
|
46
|
+
if (process.env.WOVN_DEBUG) {
|
|
47
|
+
console.dir(context, {depth: null})
|
|
48
|
+
}
|
|
49
|
+
} else if (command === 'undo') {
|
|
50
|
+
await overwriteTsxAndJsxFiles(context, undo)
|
|
51
|
+
await undoIndexJavaScriptFiles()
|
|
52
|
+
await unlinkMiddlewareFileSafely()
|
|
53
|
+
} else {
|
|
54
|
+
console .log(`Usage:
|
|
55
|
+
WOVN_PROJECT_TOKEN=<token> WOVN_HOSTNAME=<host> wovn-nextjs build overwrite jsx and tsx files
|
|
56
|
+
wovn-nextjs undo restore overwritten jsx and tsx files`)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (require.main === module) {
|
|
61
|
+
main()
|
|
62
|
+
} else {
|
|
63
|
+
module.exports = { transform, toReport, undo }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// the following are helper functions
|
|
67
|
+
|
|
68
|
+
const singleContentTags = new Set('title'.split(' '))
|
|
69
|
+
const inlineTags = new Set('a abbr b bdi bdo button cite code data dfn em i kbd label legend mark meter q rb rp rt rtc ruby s samp small span strong sub sup time u var'.split(' ')) // option tag as a blcok
|
|
70
|
+
const voidElementTags = new Set('area base br col embed hr img input link meta param source track wbr'.split(' '))
|
|
71
|
+
function transform(context, source) {
|
|
72
|
+
const tokens = parse(source, parseOption).ast.tokens
|
|
73
|
+
const component = {
|
|
74
|
+
pathname: context.pathname,
|
|
75
|
+
uvs: new Set(),
|
|
76
|
+
attributes: []
|
|
77
|
+
}
|
|
78
|
+
let prev = { type: '', value: '' }
|
|
79
|
+
let prev2 = { type: '', value: '' }
|
|
80
|
+
let tag = ''
|
|
81
|
+
let currentLevel = 0
|
|
82
|
+
let startedLevel = 0
|
|
83
|
+
function normalizeText(s) {
|
|
84
|
+
return s.replace(/[\n \t\u0020\u0009\u000C\u200B\u000D\u000A]+/g, ' ').replace(/^[\s\u00A0\uFEFF\u1680\u180E\u2000-\u200A\u202F\u205F\u3000]+|[\s\u00A0\uFEFF\u1680\u180E\u2000-\u200A\u202F\u205F\u3000]+$/g, '')
|
|
85
|
+
}
|
|
86
|
+
function reportAttribute(attr, s) {
|
|
87
|
+
component.attributes.push({tag, attr, value: normalizeText(s)})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Scrape translatable texts from tokens
|
|
91
|
+
const groups = [[]]
|
|
92
|
+
const len = tokens.length
|
|
93
|
+
for (let i=0; i<len; i++) {
|
|
94
|
+
const token = tokens[i]
|
|
95
|
+
const isTagName = token.type === 'JSXIdentifier' || (token.type === 'Keyword' && token.value === 'var')
|
|
96
|
+
if (prev.type === 'Punctuator' && prev.value === '<' && isTagName) {
|
|
97
|
+
tag = token.value
|
|
98
|
+
const headPos = i - 1
|
|
99
|
+
const attrs = {}
|
|
100
|
+
for (i++; i<len; i++) {
|
|
101
|
+
// Scrape attributes in the tag
|
|
102
|
+
const t = tokens[i]
|
|
103
|
+
if (t.type === 'Punctuator' && t.value === '>') {
|
|
104
|
+
break
|
|
105
|
+
} else if (t.type === 'JSXText' && t.value.trim().length && prev.type === 'Punctuator' && prev.value === '=') {
|
|
106
|
+
const value = t.value.slice(1, -1) // Strip leading and trailing double quotes
|
|
107
|
+
if(isTranslatableAttribute(tag, prev2.value, attrs)) {
|
|
108
|
+
if (prev2.value === 'srcset') {
|
|
109
|
+
for (const src of value.split(',')) {
|
|
110
|
+
reportAttribute(prev2.value, src.trim().split(' ')[0])
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
reportAttribute(prev2.value, value)
|
|
114
|
+
}
|
|
115
|
+
if (value.length) {
|
|
116
|
+
t.wovnCode = `{ wovnInternalT(${ JSON.stringify(value) }) }`
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
attrs[prev2.value] = value
|
|
120
|
+
}
|
|
121
|
+
prev2 = prev
|
|
122
|
+
prev = t
|
|
123
|
+
}
|
|
124
|
+
const isSelfClosing = prev.type === 'Punctuator' && prev.value === '/' && tokens[i].type === 'Punctuator' && tokens[i].value === '>'
|
|
125
|
+
currentLevel += isSelfClosing ? 0: 1
|
|
126
|
+
if (inlineTags.has(tag)) {
|
|
127
|
+
startedLevel = startedLevel === 0 ? currentLevel : startedLevel
|
|
128
|
+
groups.at(-1).push({value: `<${tag}>`, headPos})
|
|
129
|
+
} else if (voidElementTags.has(tag)) {
|
|
130
|
+
groups.at(-1).push({value: `<${tag}>`})
|
|
131
|
+
} else {
|
|
132
|
+
if (startedLevel) {
|
|
133
|
+
groups.push([])
|
|
134
|
+
}
|
|
135
|
+
startedLevel = 0
|
|
136
|
+
}
|
|
137
|
+
} else if (prev.type === 'Punctuator' && prev.value === '/' && isTagName) {
|
|
138
|
+
if (startedLevel) {
|
|
139
|
+
groups.at(-1).push({value: `</${token.value}>`, headPos: i - 2, tailPos: i + 1})
|
|
140
|
+
}
|
|
141
|
+
if (startedLevel === currentLevel) {
|
|
142
|
+
groups.push([])
|
|
143
|
+
startedLevel = 0
|
|
144
|
+
}
|
|
145
|
+
currentLevel--
|
|
146
|
+
tag = ''
|
|
147
|
+
} else if (token.type === 'JSXText') {
|
|
148
|
+
if (prev.value === '=') {
|
|
149
|
+
// the `token` could be value of attribute
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
if (startedLevel === 0) {
|
|
153
|
+
if (token.value.trim().length) {
|
|
154
|
+
if (singleContentTags.has(tag)) {
|
|
155
|
+
token.wovnCode = `{ wovnInternalT(${ JSON.stringify(token.value) }) }`
|
|
156
|
+
} else {
|
|
157
|
+
groups.at(-1).push(token)
|
|
158
|
+
groups.push([])
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
groups.at(-1).push(token)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
prev2 = prev
|
|
166
|
+
prev = token
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Set wovnCode for each token to modify the tsx or jsx file
|
|
170
|
+
function internalComponent(major, minor, source) {
|
|
171
|
+
return `<WovnInternalComponent major={${ major }} minor={${ minor }} source={${ JSON.stringify(source) }} />`
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const group of groups.filter(tokens => tokens.some(t => t.type === 'JSXText' && t.value.trim().length))) {
|
|
175
|
+
const normalizedUv = group.map(token => token.value.startsWith('<') ? token.value : normalizeText(token.value)).join('')
|
|
176
|
+
const major = context.uvs.has(normalizedUv) ? context.uvs.get(normalizedUv) : context.uvs.size
|
|
177
|
+
let minor = 0
|
|
178
|
+
context.uvs.set(normalizedUv, major)
|
|
179
|
+
component.uvs.add(normalizedUv)
|
|
180
|
+
let isPrevTag = false
|
|
181
|
+
if ('headPos' in group[0]) {
|
|
182
|
+
tokens[group[0].headPos].wovnCode = '<>{/* wovn-nextjs-command */}' + internalComponent(major, minor++, '') + tokens[group[0].headPos].value
|
|
183
|
+
}
|
|
184
|
+
for (const token of group) {
|
|
185
|
+
if (token.value.startsWith('<')) {
|
|
186
|
+
if (isPrevTag && token.headPos) {
|
|
187
|
+
tokens[token.headPos].wovnCode = internalComponent(major, minor++, '') + tokens[token.headPos].value
|
|
188
|
+
}
|
|
189
|
+
isPrevTag = true
|
|
190
|
+
} else {
|
|
191
|
+
token.wovnCode = internalComponent(major, minor++, token.value)
|
|
192
|
+
isPrevTag = false
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if ('tailPos' in group.at(-1)) {
|
|
196
|
+
tokens[group.at(-1).tailPos].wovnCode = tokens[group.at(-1).tailPos].value + internalComponent(major, minor++, '') + '{/* wovn-nextjs-command */}</>'
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Return modified code
|
|
201
|
+
let pos = 0
|
|
202
|
+
let code = ''
|
|
203
|
+
let isModified = false
|
|
204
|
+
for (const token of tokens) {
|
|
205
|
+
isModified ||= !!token.wovnCode
|
|
206
|
+
code += token.wovnCode || source.slice(pos, token.range[1])
|
|
207
|
+
pos = token.range[1]
|
|
208
|
+
}
|
|
209
|
+
code += source.match(/\s*$/)[0] // Keep white spaces at tail
|
|
210
|
+
if (isModified) {
|
|
211
|
+
const importer = 'import { WovnInternalComponent, wovnInternalT } from "wovn-nextjs" // wovn-nextjs-command-appended\n'
|
|
212
|
+
code = code.replaceAll(/^(import Link from ["'`]next\/link.*)/mg, 'import { Link } from "wovn-nextjs" //$1 wovn-nextjs-command-comment')
|
|
213
|
+
code = code.replaceAll(/^(import { *useRouter *} from ["'`]next\/router.*)/mg, 'import { usePageRouter as useRouter } from "wovn-nextjs" //$1 wovn-nextjs-command-comment')
|
|
214
|
+
code = code.replaceAll(/^(import { *useRouter *} from ["'`]next\/navigation.*)/mg, 'import { useAppRouter as useRouter } from "wovn-nextjs" //$1 wovn-nextjs-command-comment')
|
|
215
|
+
code = code.match(/^['"]use /) ? code.replace(/\n/, '\n' + importer) : importer + code
|
|
216
|
+
}
|
|
217
|
+
context.components.push(component)
|
|
218
|
+
return code
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function undo(context, code) {
|
|
222
|
+
code = code.replaceAll(/^import [^\n]* from "wovn-nextjs" \/\/ wovn-nextjs-command-appended\n/mg, '')
|
|
223
|
+
code = code.replaceAll(/^(?:.*?)\/\/(.*?) wovn-nextjs-command-comment$/mg, '$1')
|
|
224
|
+
code = code.replaceAll(/(?:<>\{\/\* wovn-nextjs-command \*\/\})?<WovnInternalComponent major={[0-9.]*} minor={[0-9.]*} source={(".*?")} \/>(?:\{\/\* wovn-nextjs-command \*\/\}<\/>)?/g, (...m) => JSON.parse(m[1]))
|
|
225
|
+
code = code.replaceAll(/=\s*{ wovnInternalT\((".+")\) }/g, (...m) => '="' + JSON.parse(m[1]) + '"') // for attribute
|
|
226
|
+
code = code.replaceAll(/{ wovnInternalT\((".+")\) }/g, (...m) => JSON.parse(m[1])) // for single contet tag like <title>
|
|
227
|
+
return code
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const appendMarker = '\n// wovn-nextjs-command-appended'
|
|
231
|
+
|
|
232
|
+
async function overwriteInitializationProcess(context) {
|
|
233
|
+
const sourceLanguage = context.wovnJsData.language
|
|
234
|
+
const targetLanguages = context.wovnJsData.convert_langs?.map(({code}) => code).filter(l => l !== sourceLanguage)
|
|
235
|
+
const extraCode = `${appendMarker}
|
|
236
|
+
state.projectToken = ${ JSON.stringify(context.projectToken) }
|
|
237
|
+
state.sources = ${ JSON.stringify([...context.uvs.keys()]) }
|
|
238
|
+
wovnMiddlewareConfig.matcher = ${ JSON.stringify(targetLanguages.map(language => '/' + language + '/:path*')) }
|
|
239
|
+
setTranslations(${ JSON.stringify(context.wovnJsData) })`
|
|
240
|
+
await overwriteIndexJavaScriptFiles(code => code.split(appendMarker)[0] + extraCode)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function undoIndexJavaScriptFiles() {
|
|
244
|
+
await overwriteIndexJavaScriptFiles(code => code.split(appendMarker)[0])
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function overwriteIndexJavaScriptFiles(convert) {
|
|
248
|
+
const promises = []
|
|
249
|
+
for (const dir of ['cjs', 'esm']) {
|
|
250
|
+
const filepath = path.join(__dirname, '..', 'dist', dir, 'index.js')
|
|
251
|
+
const source = await fsp.readFile(filepath, 'utf-8')
|
|
252
|
+
const convertedSource = convert(source)
|
|
253
|
+
if (source !== convertedSource) {
|
|
254
|
+
promises.push(fsp.writeFile(filepath, convertedSource))
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return Promise.all(promises)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function overwriteTsxAndJsxFiles(context, convert) {
|
|
261
|
+
async function* targetFiles(dir) {
|
|
262
|
+
const dirents = await fsp.readdir(dir, { withFileTypes: true });
|
|
263
|
+
for (const dirent of dirents) {
|
|
264
|
+
const res = path.resolve(dir, dirent.name);
|
|
265
|
+
if (dirent.isDirectory()) {
|
|
266
|
+
if (!ignoreRegexp.test(dirent.name)) {
|
|
267
|
+
yield* targetFiles(res);
|
|
268
|
+
}
|
|
269
|
+
} else if (targetRegexp.test(dirent.name)) {
|
|
270
|
+
yield res;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const promises = []
|
|
275
|
+
const pwd = process.env.PWD
|
|
276
|
+
for await (const pathname of targetFiles(buildDir)) {
|
|
277
|
+
context.pathname = pathname.slice(pwd.length + buildDir.length)
|
|
278
|
+
const source = await fsp.readFile(pathname, 'utf-8')
|
|
279
|
+
const convertedSource = convert(context, source)
|
|
280
|
+
if (source !== convertedSource) {
|
|
281
|
+
promises.push(fsp.writeFile(pathname, convertedSource))
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return Promise.all(promises)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function reportToWovn(context) {
|
|
288
|
+
function toBody(context, id, uvs, attributes) {
|
|
289
|
+
const form = []
|
|
290
|
+
for (const [k, v] of Object.entries(toReport(context, id, uvs, attributes))) {
|
|
291
|
+
form.push(`${ encodeURIComponent(k) }=${ encodeURIComponent(typeof v === 'object' ? JSON.stringify(v) : v) }`)
|
|
292
|
+
}
|
|
293
|
+
return form.join('&')
|
|
294
|
+
}
|
|
295
|
+
await httpsRequest(`https://ee.wovn.io/report_values/${ encodeURIComponent(context.projectToken) }`, 'post', toBody(context, context.wovnJsData.id, context.uvs, context.components.flatMap(c => c.attributes)))
|
|
296
|
+
for (const component of context.components) {
|
|
297
|
+
const { id } = await httpsRequest(`https://data.wovn.io/js_data/json/1/${context.projectToken}/?u=http%3A%2F%2F${ encodeURIComponent(context.hostname + '/' + component.pathname ) }`).then(body => JSON.parse(body.toString()))
|
|
298
|
+
await httpsRequest(`https://ee.wovn.io/report_values/${ encodeURIComponent(context.projectToken) }`, 'post', toBody(context, id, component.uvs, component.attributes))
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function httpsRequest(url, method='get', body='') {
|
|
303
|
+
return new Promise((resolve, reject) => {
|
|
304
|
+
try {
|
|
305
|
+
const req = https.request(url, { method }, res => {
|
|
306
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
307
|
+
const chunks = []
|
|
308
|
+
res.on('data', chunk => chunks.push(chunk))
|
|
309
|
+
res.on('end', () => resolve(Buffer.concat(chunks)))
|
|
310
|
+
} else {
|
|
311
|
+
reject(new Error(`${url} returns ${res.statusCode}`))
|
|
312
|
+
}
|
|
313
|
+
})
|
|
314
|
+
req.on('error', reject)
|
|
315
|
+
body.length > 0 && req.write(body)
|
|
316
|
+
req.end()
|
|
317
|
+
} catch (e) {
|
|
318
|
+
reject(e)
|
|
319
|
+
}
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function toReport(context, pageId, uvs, attributes) {
|
|
324
|
+
return {
|
|
325
|
+
page_id: pageId,
|
|
326
|
+
url: `http://${context.hostname}`,
|
|
327
|
+
no_record_vals: [...uvs.keys()].map(src => ({
|
|
328
|
+
src,
|
|
329
|
+
xpath: '/html/body/text()',
|
|
330
|
+
unified: true,
|
|
331
|
+
exists: true,
|
|
332
|
+
scrape_number: 0
|
|
333
|
+
})).concat(attributes.map(a => ({
|
|
334
|
+
src: a.value,
|
|
335
|
+
xpath: `/html/body/${a.tag}[@${a.attr}]`,
|
|
336
|
+
unified: false,
|
|
337
|
+
exists: true,
|
|
338
|
+
scrape_number: 0
|
|
339
|
+
}))),
|
|
340
|
+
links: [],
|
|
341
|
+
diagnostics: {},
|
|
342
|
+
report_count: 1,
|
|
343
|
+
source: 'custom',
|
|
344
|
+
page_metadata: {}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const attributes = {
|
|
349
|
+
option: ['label'],
|
|
350
|
+
a: ['title'],
|
|
351
|
+
optgroup: ['label'],
|
|
352
|
+
img: ['alt', 'srcset', 'src'],
|
|
353
|
+
textarea: ['placeholder'],
|
|
354
|
+
source: ['srcset']
|
|
355
|
+
}
|
|
356
|
+
const inputAttributes = {
|
|
357
|
+
search: ['value', 'placeholder'],
|
|
358
|
+
button: ['value', 'placeholder', 'data-confirm'],
|
|
359
|
+
submit: ['value', 'placeholder', 'data-confirm'],
|
|
360
|
+
image: ['src', 'alt', 'placeholder', 'data-confirm'],
|
|
361
|
+
reset: ['value']
|
|
362
|
+
}
|
|
363
|
+
const metaNameAttributes = new Set([
|
|
364
|
+
'description',
|
|
365
|
+
'title',
|
|
366
|
+
'og:description',
|
|
367
|
+
'og:title',
|
|
368
|
+
'twitter:description',
|
|
369
|
+
'twitter:title'
|
|
370
|
+
])
|
|
371
|
+
const metaPropertyAttributes = new Set([
|
|
372
|
+
'og:description',
|
|
373
|
+
'og:title',
|
|
374
|
+
'og:site_name',
|
|
375
|
+
'twitter:description',
|
|
376
|
+
'twitter:title'
|
|
377
|
+
])
|
|
378
|
+
function isTranslatableAttribute(tag, attr, attrs) {
|
|
379
|
+
if (tag === 'meta' && (metaNameAttributes.has(attr) || (attr === 'content' && (metaNameAttributes.has(attrs.name) || metaPropertyAttributes.has(attrs.property))))) {
|
|
380
|
+
return true
|
|
381
|
+
}
|
|
382
|
+
const targets = tag === 'input' ? inputAttributes[attrs.type] : attributes[tag]
|
|
383
|
+
return targets && targets.includes(attr)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const middlewarePath = path.join(buildDir, 'middleware.js')
|
|
387
|
+
const targetMiddlewarePaths = ['', 'src'].flatMap(dir => ['.js', '.ts'].flatMap(ext => path.join(buildDir, dir, 'middleware' + ext)))
|
|
388
|
+
|
|
389
|
+
async function canMiddlewareDelete() {
|
|
390
|
+
try {
|
|
391
|
+
return (await fsp.readFile(middlewarePath, 'utf-8')).startsWith('// wovn-nextjs-command-appended')
|
|
392
|
+
} catch (e) {
|
|
393
|
+
if (e.message.startsWith('ENOENT:')) {
|
|
394
|
+
return true
|
|
395
|
+
} else {
|
|
396
|
+
throw e
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function createMiddlewareFile(context) {
|
|
402
|
+
const found = targetMiddlewarePaths.find(pathname => fs.existsSync(pathname))
|
|
403
|
+
if (found && found !== middlewarePath) {
|
|
404
|
+
return
|
|
405
|
+
}
|
|
406
|
+
if (await canMiddlewareDelete(middlewarePath)) {
|
|
407
|
+
await fsp.writeFile(middlewarePath, `// wovn-nextjs-command-appended
|
|
408
|
+
import { wovnMiddleware as middleware, wovnMiddlewareConfig as config } from 'wovn-nextjs';
|
|
409
|
+
export { middleware, config }
|
|
410
|
+
`)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async function unlinkMiddlewareFileSafely() {
|
|
415
|
+
if (await canMiddlewareDelete(middlewarePath)) {
|
|
416
|
+
try {
|
|
417
|
+
await fsp.unlink(middlewarePath)
|
|
418
|
+
} catch {
|
|
419
|
+
// ignore the error
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { UrlObject } from "url";
|
|
2
|
+
import { type MiddlewareConfig, NextRequest, NextResponse } from "next/server";
|
|
3
|
+
import { AppContext, AppProps } from "next/app";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { NextRouter } from "next/router";
|
|
6
|
+
import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
|
|
7
|
+
export declare function t(source: string): string;
|
|
8
|
+
export declare function currentLanguage(): string;
|
|
9
|
+
export declare function useTranslation(): {
|
|
10
|
+
t: typeof t;
|
|
11
|
+
i18n: {
|
|
12
|
+
changeLanguage(language: string): void;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
export declare const Link: React.ForwardRefExoticComponent<Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof {
|
|
16
|
+
href: string | UrlObject;
|
|
17
|
+
as?: string | UrlObject;
|
|
18
|
+
replace?: boolean;
|
|
19
|
+
scroll?: boolean;
|
|
20
|
+
shallow?: boolean;
|
|
21
|
+
passHref?: boolean;
|
|
22
|
+
prefetch?: boolean;
|
|
23
|
+
locale?: string | false;
|
|
24
|
+
legacyBehavior?: boolean;
|
|
25
|
+
onMouseEnter?: any;
|
|
26
|
+
onTouchStart?: any;
|
|
27
|
+
onClick?: any;
|
|
28
|
+
}> & {
|
|
29
|
+
href: string | UrlObject;
|
|
30
|
+
as?: string | UrlObject;
|
|
31
|
+
replace?: boolean;
|
|
32
|
+
scroll?: boolean;
|
|
33
|
+
shallow?: boolean;
|
|
34
|
+
passHref?: boolean;
|
|
35
|
+
prefetch?: boolean;
|
|
36
|
+
locale?: string | false;
|
|
37
|
+
legacyBehavior?: boolean;
|
|
38
|
+
onMouseEnter?: any;
|
|
39
|
+
onTouchStart?: any;
|
|
40
|
+
onClick?: any;
|
|
41
|
+
} & {
|
|
42
|
+
children?: React.ReactNode | undefined;
|
|
43
|
+
} & React.RefAttributes<HTMLAnchorElement>>;
|
|
44
|
+
export declare function usePageRouter(): NextRouter;
|
|
45
|
+
export declare function useAppRouter(): AppRouterInstance;
|
|
46
|
+
declare const wovnMiddlewareConfig: MiddlewareConfig & {
|
|
47
|
+
matcher: string[];
|
|
48
|
+
};
|
|
49
|
+
export { wovnMiddlewareConfig };
|
|
50
|
+
export declare function wovnMiddleware(request: NextRequest): NextResponse<unknown>;
|
|
51
|
+
export declare function appWithWovn(TargetComponent: React.FunctionComponent<any> | React.ComponentClass<any, any>): {
|
|
52
|
+
(props: AppProps): React.ReactElement<any, string | React.JSXElementConstructor<any>>;
|
|
53
|
+
getInitialProps(appContext: AppContext): Promise<{
|
|
54
|
+
pageProps: any;
|
|
55
|
+
}>;
|
|
56
|
+
};
|
|
57
|
+
export declare function WovnInternalComponent({ major, minor, source }: {
|
|
58
|
+
major: number;
|
|
59
|
+
minor: number;
|
|
60
|
+
source: string;
|
|
61
|
+
}): string;
|
|
62
|
+
export declare function wovnInternalT(source: string): string;
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
'use client';
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.wovnMiddlewareConfig = exports.Link = void 0;
|
|
5
|
+
exports.t = t;
|
|
6
|
+
exports.currentLanguage = currentLanguage;
|
|
7
|
+
exports.useTranslation = useTranslation;
|
|
8
|
+
exports.usePageRouter = usePageRouter;
|
|
9
|
+
exports.useAppRouter = useAppRouter;
|
|
10
|
+
exports.wovnMiddleware = wovnMiddleware;
|
|
11
|
+
exports.appWithWovn = appWithWovn;
|
|
12
|
+
exports.WovnInternalComponent = WovnInternalComponent;
|
|
13
|
+
exports.wovnInternalT = wovnInternalT;
|
|
14
|
+
const server_1 = require("next/server");
|
|
15
|
+
const app_1 = require("next/app");
|
|
16
|
+
const React = require("react");
|
|
17
|
+
const link_1 = require("next/link");
|
|
18
|
+
const server_2 = require("./server");
|
|
19
|
+
const format_url_1 = require("next/dist/shared/lib/router/utils/format-url");
|
|
20
|
+
const router_1 = require("next/router");
|
|
21
|
+
const navigation_1 = require("next/navigation");
|
|
22
|
+
function t(source) {
|
|
23
|
+
return wovnInternalT(source);
|
|
24
|
+
}
|
|
25
|
+
function currentLanguage() {
|
|
26
|
+
if (typeof window === 'undefined') {
|
|
27
|
+
return getLanguageInServer() || state.sourceLanguage;
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
const p = location.pathname;
|
|
31
|
+
return state.languages.find(language => p === `/${language}` || p.startsWith(`/${language}/`)) || state.sourceLanguage;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function useTranslation() {
|
|
35
|
+
const pageRouter = usePageRouter();
|
|
36
|
+
const appRouter = useAppRouter();
|
|
37
|
+
const router = pageRouter || appRouter;
|
|
38
|
+
return {
|
|
39
|
+
t,
|
|
40
|
+
i18n: { changeLanguage(language) {
|
|
41
|
+
const href = link(undefined, language);
|
|
42
|
+
log('changeLanguage', language, href);
|
|
43
|
+
console.log(pageRouter, appRouter);
|
|
44
|
+
router.push(href);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function link(href, locale) {
|
|
50
|
+
const newPathname = linkImpl(href, locale);
|
|
51
|
+
log('link', locale, href, ' -> ', newPathname);
|
|
52
|
+
return newPathname;
|
|
53
|
+
}
|
|
54
|
+
function linkImpl(href, locale) {
|
|
55
|
+
const language = currentLanguage();
|
|
56
|
+
const pathnameWithoutLanguage = typeof window === 'undefined' ? getPathWithoutLanguage() : language === state.sourceLanguage ? location.pathname : location.pathname.slice(language.length + 1);
|
|
57
|
+
let targetHref = '';
|
|
58
|
+
if (typeof href === 'string') {
|
|
59
|
+
if (/^https?:\.\./.test(href)) {
|
|
60
|
+
return href;
|
|
61
|
+
}
|
|
62
|
+
if (!locale && state.languages.find(language => href === `/${language}` || href.startsWith(`/${language}/`))) {
|
|
63
|
+
return href;
|
|
64
|
+
}
|
|
65
|
+
targetHref = href;
|
|
66
|
+
}
|
|
67
|
+
else if (typeof href === 'object') {
|
|
68
|
+
targetHref = href.pathname || '';
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
targetHref = pathnameWithoutLanguage;
|
|
72
|
+
}
|
|
73
|
+
const absPathnameWithoutLanguage = new URL(targetHref, 'http://wovn.io' + pathnameWithoutLanguage).pathname;
|
|
74
|
+
const targetLanguage = locale || language;
|
|
75
|
+
const newPathname = (targetLanguage === state.sourceLanguage ? '' : '/' + targetLanguage) + absPathnameWithoutLanguage;
|
|
76
|
+
if (typeof href === 'object') {
|
|
77
|
+
return (0, format_url_1.formatUrl)({ ...href, pathname: newPathname });
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
return newPathname;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
exports.Link = React.forwardRef(function LinkComponent(props, ref) {
|
|
84
|
+
fetchJsonDataIfNeeded();
|
|
85
|
+
const newProps = { ...props, ref, locale: undefined, href: link(props.href, props.locale) };
|
|
86
|
+
return React.createElement(link_1.default, newProps, props.children);
|
|
87
|
+
});
|
|
88
|
+
function usePageRouter() {
|
|
89
|
+
const target = (0, router_1.useRouter)();
|
|
90
|
+
return new Proxy(target, {
|
|
91
|
+
get(target, prop, receiver) {
|
|
92
|
+
switch (prop) {
|
|
93
|
+
case 'push':
|
|
94
|
+
return (url, as, options) => target.push(link(url), as ? link(as) : as, options);
|
|
95
|
+
case 'replace':
|
|
96
|
+
return (url, as, options) => target.replace(link(url), as ? link(as) : as, options);
|
|
97
|
+
case 'prefetch':
|
|
98
|
+
return (url, asPath, options) => target.prefetch(link(url), asPath ? link(asPath) : asPath, options);
|
|
99
|
+
default:
|
|
100
|
+
return Reflect.get(target, prop, receiver);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
function useAppRouter() {
|
|
106
|
+
return new Proxy((0, navigation_1.useRouter)(), {
|
|
107
|
+
get(target, prop, receiver) {
|
|
108
|
+
switch (prop) {
|
|
109
|
+
case 'push':
|
|
110
|
+
return (url, options) => target.push(link(url), options);
|
|
111
|
+
case 'replace':
|
|
112
|
+
return (url, options) => target.replace(link(url), options);
|
|
113
|
+
case 'prefetch':
|
|
114
|
+
return (url) => target.prefetch(link(url));
|
|
115
|
+
default:
|
|
116
|
+
return Reflect.get(target, prop, receiver);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const wovnMiddlewareConfig = { matcher: [] }; // this values will be override by wovn-nextjs build command
|
|
122
|
+
exports.wovnMiddlewareConfig = wovnMiddlewareConfig;
|
|
123
|
+
function wovnMiddleware(request) {
|
|
124
|
+
return rewritePath(state.languages.filter(l => l !== state.sourceLanguage), request);
|
|
125
|
+
}
|
|
126
|
+
function appWithWovn(TargetComponent) {
|
|
127
|
+
function AppWithWovn(props) {
|
|
128
|
+
wovnAsyncLocalStorage.enterWith(props.pageProps.wovn);
|
|
129
|
+
return React.createElement(TargetComponent, props);
|
|
130
|
+
}
|
|
131
|
+
AppWithWovn.getInitialProps = async (appContext) => {
|
|
132
|
+
const appProps = await app_1.default.getInitialProps(appContext);
|
|
133
|
+
const headers = appContext.ctx.req?.headers || {};
|
|
134
|
+
return {
|
|
135
|
+
...appProps,
|
|
136
|
+
pageProps: {
|
|
137
|
+
...appProps.pageProps,
|
|
138
|
+
wovn: {
|
|
139
|
+
'x-wovn-nextjs-language': headers['x-wovn-nextjs-language'] || '',
|
|
140
|
+
'x-wovn-nextjs-path-without-language': headers['x-wovn-nextjs-path-without-language'] || ''
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
return AppWithWovn;
|
|
146
|
+
}
|
|
147
|
+
// the followings are exported but for internal purpose
|
|
148
|
+
function WovnInternalComponent({ major, minor, source }) {
|
|
149
|
+
const text = state.translations[currentLanguage()]?.[major]?.[minor] ?? unescapeHtml(source);
|
|
150
|
+
log("internal component", major, minor, state.translations[currentLanguage()]?.[major]?.[minor], ' <- ', source);
|
|
151
|
+
fetchJsonDataIfNeeded();
|
|
152
|
+
return text;
|
|
153
|
+
}
|
|
154
|
+
function wovnInternalT(source) {
|
|
155
|
+
fetchJsonDataIfNeeded();
|
|
156
|
+
return translate(source);
|
|
157
|
+
}
|
|
158
|
+
// the followings are helper functions
|
|
159
|
+
function unescapeHtml(s) {
|
|
160
|
+
return s.replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'");
|
|
161
|
+
}
|
|
162
|
+
function fetchJsonDataIfNeeded() {
|
|
163
|
+
if (typeof window === 'undefined') {
|
|
164
|
+
return; // do nothing when calling by server component
|
|
165
|
+
}
|
|
166
|
+
if (!state.projectToken.length) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (!state.fetched) {
|
|
170
|
+
window.wovnnextjs = { state, t, currentLanguage };
|
|
171
|
+
state.fetched = true;
|
|
172
|
+
fetch(`https://wovn.global.ssl.fastly.net/js_data/json/1/${encodeURIComponent(state.projectToken)}?u=` + encodeURIComponent(`${location.protocol}//${location.hostname}`)).then(res => {
|
|
173
|
+
if (res.status === 200) {
|
|
174
|
+
res.json().then(setTranslations);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function translate(source) {
|
|
180
|
+
const language = currentLanguage();
|
|
181
|
+
const key = source.replace(/ \/>/g, '>').replace(/ +</g, '<').replace(/> +/g, '>').replace(/ +/g, ' ');
|
|
182
|
+
log('try to translate', language, key, state.rawTranslations?.[key]?.[language]?.[0]?.data);
|
|
183
|
+
if (language === state.sourceLanguage) {
|
|
184
|
+
return source;
|
|
185
|
+
}
|
|
186
|
+
const translation = state.rawTranslations?.[key]?.[language]?.[0]?.data;
|
|
187
|
+
return translation || source;
|
|
188
|
+
}
|
|
189
|
+
const wovnAsyncLocalStorage = typeof window === 'undefined' ? new global.AsyncLocalStorage() : {
|
|
190
|
+
enterWith(_) { },
|
|
191
|
+
getStore() { return null; }
|
|
192
|
+
};
|
|
193
|
+
function get(key) {
|
|
194
|
+
if (typeof window === 'undefined') {
|
|
195
|
+
try {
|
|
196
|
+
const s = wovnAsyncLocalStorage.getStore();
|
|
197
|
+
return s ? s[key] : (0, server_2.headers)().get(key);
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
return e instanceof Error ? `(${e.message})` : '';
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
throw new Error('This function should not be called in client');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function getLanguageInServer() { return get('x-wovn-nextjs-language'); }
|
|
208
|
+
function getPathWithoutLanguage() { return get('x-wovn-nextjs-path-without-language'); }
|
|
209
|
+
function rewritePath(targetLanguages, request) {
|
|
210
|
+
if (request.nextUrl.pathname.startsWith('/_next') || request.nextUrl.pathname.startsWith('/favicon')) {
|
|
211
|
+
return server_1.NextResponse.next();
|
|
212
|
+
}
|
|
213
|
+
for (const language of targetLanguages) {
|
|
214
|
+
const url = new URL(request.url);
|
|
215
|
+
if (url.pathname === '/' + language) {
|
|
216
|
+
const headers = new Headers(request.headers);
|
|
217
|
+
headers.set("x-wovn-nextjs-language", language);
|
|
218
|
+
headers.set("x-wovn-nextjs-path-without-language", '/');
|
|
219
|
+
log('rewrite', language, request.nextUrl.pathname);
|
|
220
|
+
return server_1.NextResponse.rewrite(new URL("/" + url.search, url), { headers });
|
|
221
|
+
}
|
|
222
|
+
if (url.pathname.startsWith('/' + language + '/')) {
|
|
223
|
+
const headers = new Headers(request.headers);
|
|
224
|
+
headers.set("x-wovn-nextjs-language", language);
|
|
225
|
+
headers.set("x-wovn-nextjs-path-without-language", url.pathname.slice(language.length + 1));
|
|
226
|
+
log('rewrite', language, request.nextUrl.pathname);
|
|
227
|
+
return server_1.NextResponse.rewrite(new URL(url.pathname.slice(language.length + 1) + url.search, url), { headers });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
log('do not rewrite', request.nextUrl.pathname);
|
|
231
|
+
return server_1.NextResponse.next();
|
|
232
|
+
}
|
|
233
|
+
const state = {
|
|
234
|
+
fetched: false,
|
|
235
|
+
debug: !!process.env.WOVN_DEBUG,
|
|
236
|
+
projectToken: '',
|
|
237
|
+
sourceLanguage: '',
|
|
238
|
+
languages: [],
|
|
239
|
+
sources: [], // [major] == source text
|
|
240
|
+
translations: {}, // [language][major][minor] == translation
|
|
241
|
+
rawTranslations: {} // [source]?.[currentLanguage()]?.[0]?.data == translation
|
|
242
|
+
};
|
|
243
|
+
function setTranslations(root) {
|
|
244
|
+
state.sourceLanguage = root.language;
|
|
245
|
+
state.rawTranslations = root.html_text_vals;
|
|
246
|
+
state.translations = {};
|
|
247
|
+
state.languages = root.convert_langs?.map((lang) => lang.code) || [];
|
|
248
|
+
const sourceLength = state.sources.length;
|
|
249
|
+
for (const language of state.languages) {
|
|
250
|
+
const translations = state.translations[language] = [];
|
|
251
|
+
for (let i = 0; i < sourceLength; i++) {
|
|
252
|
+
const source = state.sources[i];
|
|
253
|
+
const translation = state.rawTranslations[source]?.[language]?.[0]?.data;
|
|
254
|
+
translations.push(translation ? translation.split(/<.*?>/) : []);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (typeof window !== 'undefined') {
|
|
258
|
+
window.wovnnextjs = { state, t };
|
|
259
|
+
}
|
|
260
|
+
log('set translation');
|
|
261
|
+
}
|
|
262
|
+
// for debug
|
|
263
|
+
const log = function (...args) {
|
|
264
|
+
if (state.debug) {
|
|
265
|
+
console.log('wovn-nextjs', ...args);
|
|
266
|
+
}
|
|
267
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { headers } from "next/headers";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.headers = void 0;
|
|
4
|
+
// To avoid Next.js errors because index.ts has 'use client' and can not import next/headers
|
|
5
|
+
var headers_1 = require("next/headers");
|
|
6
|
+
Object.defineProperty(exports, "headers", { enumerable: true, get: function () { return headers_1.headers; } });
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { UrlObject } from "url";
|
|
2
|
+
import { type MiddlewareConfig, NextRequest, NextResponse } from "next/server";
|
|
3
|
+
import { AppContext, AppProps } from "next/app";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { NextRouter } from "next/router";
|
|
6
|
+
import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
|
|
7
|
+
export declare function t(source: string): string;
|
|
8
|
+
export declare function currentLanguage(): string;
|
|
9
|
+
export declare function useTranslation(): {
|
|
10
|
+
t: typeof t;
|
|
11
|
+
i18n: {
|
|
12
|
+
changeLanguage(language: string): void;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
export declare const Link: React.ForwardRefExoticComponent<Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof {
|
|
16
|
+
href: string | UrlObject;
|
|
17
|
+
as?: string | UrlObject;
|
|
18
|
+
replace?: boolean;
|
|
19
|
+
scroll?: boolean;
|
|
20
|
+
shallow?: boolean;
|
|
21
|
+
passHref?: boolean;
|
|
22
|
+
prefetch?: boolean;
|
|
23
|
+
locale?: string | false;
|
|
24
|
+
legacyBehavior?: boolean;
|
|
25
|
+
onMouseEnter?: React.MouseEventHandler<HTMLAnchorElement>;
|
|
26
|
+
onTouchStart?: React.TouchEventHandler<HTMLAnchorElement>;
|
|
27
|
+
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
|
|
28
|
+
}> & {
|
|
29
|
+
href: string | UrlObject;
|
|
30
|
+
as?: string | UrlObject;
|
|
31
|
+
replace?: boolean;
|
|
32
|
+
scroll?: boolean;
|
|
33
|
+
shallow?: boolean;
|
|
34
|
+
passHref?: boolean;
|
|
35
|
+
prefetch?: boolean;
|
|
36
|
+
locale?: string | false;
|
|
37
|
+
legacyBehavior?: boolean;
|
|
38
|
+
onMouseEnter?: React.MouseEventHandler<HTMLAnchorElement>;
|
|
39
|
+
onTouchStart?: React.TouchEventHandler<HTMLAnchorElement>;
|
|
40
|
+
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
|
|
41
|
+
} & {
|
|
42
|
+
children?: React.ReactNode | undefined;
|
|
43
|
+
} & React.RefAttributes<HTMLAnchorElement>>;
|
|
44
|
+
export declare function usePageRouter(): NextRouter;
|
|
45
|
+
export declare function useAppRouter(): AppRouterInstance;
|
|
46
|
+
declare const wovnMiddlewareConfig: MiddlewareConfig & {
|
|
47
|
+
matcher: string[];
|
|
48
|
+
};
|
|
49
|
+
export { wovnMiddlewareConfig };
|
|
50
|
+
export declare function wovnMiddleware(request: NextRequest): NextResponse<unknown>;
|
|
51
|
+
export declare function appWithWovn(TargetComponent: React.FunctionComponent<any> | React.ComponentClass<any, any>): {
|
|
52
|
+
(props: AppProps): React.ReactElement<any, string | React.JSXElementConstructor<any>>;
|
|
53
|
+
getInitialProps(appContext: AppContext): Promise<{
|
|
54
|
+
pageProps: any;
|
|
55
|
+
}>;
|
|
56
|
+
};
|
|
57
|
+
export declare function WovnInternalComponent({ major, minor, source }: {
|
|
58
|
+
major: number;
|
|
59
|
+
minor: number;
|
|
60
|
+
source: string;
|
|
61
|
+
}): string;
|
|
62
|
+
export declare function wovnInternalT(source: string): string;
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { NextResponse } from "next/server";
|
|
3
|
+
import App from "next/app";
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import NextLink from "next/link";
|
|
6
|
+
import { headers } from "./server";
|
|
7
|
+
import { formatUrl } from "next/dist/shared/lib/router/utils/format-url";
|
|
8
|
+
import { useRouter as useInternalPageRouter } from "next/router";
|
|
9
|
+
import { useRouter as useInternalAppRouter } from "next/navigation";
|
|
10
|
+
export function t(source) {
|
|
11
|
+
return wovnInternalT(source);
|
|
12
|
+
}
|
|
13
|
+
export function currentLanguage() {
|
|
14
|
+
if (typeof window === 'undefined') {
|
|
15
|
+
return getLanguageInServer() || state.sourceLanguage;
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
const p = location.pathname;
|
|
19
|
+
return state.languages.find(language => p === `/${language}` || p.startsWith(`/${language}/`)) || state.sourceLanguage;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function useTranslation() {
|
|
23
|
+
const pageRouter = usePageRouter();
|
|
24
|
+
const appRouter = useAppRouter();
|
|
25
|
+
const router = pageRouter || appRouter;
|
|
26
|
+
return {
|
|
27
|
+
t,
|
|
28
|
+
i18n: { changeLanguage(language) {
|
|
29
|
+
const href = link(undefined, language);
|
|
30
|
+
log('changeLanguage', language, href);
|
|
31
|
+
console.log(pageRouter, appRouter);
|
|
32
|
+
router.push(href);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function link(href, locale) {
|
|
38
|
+
const newPathname = linkImpl(href, locale);
|
|
39
|
+
log('link', locale, href, ' -> ', newPathname);
|
|
40
|
+
return newPathname;
|
|
41
|
+
}
|
|
42
|
+
function linkImpl(href, locale) {
|
|
43
|
+
const language = currentLanguage();
|
|
44
|
+
const pathnameWithoutLanguage = typeof window === 'undefined' ? getPathWithoutLanguage() : language === state.sourceLanguage ? location.pathname : location.pathname.slice(language.length + 1);
|
|
45
|
+
let targetHref = '';
|
|
46
|
+
if (typeof href === 'string') {
|
|
47
|
+
if (/^https?:\.\./.test(href)) {
|
|
48
|
+
return href;
|
|
49
|
+
}
|
|
50
|
+
if (!locale && state.languages.find(language => href === `/${language}` || href.startsWith(`/${language}/`))) {
|
|
51
|
+
return href;
|
|
52
|
+
}
|
|
53
|
+
targetHref = href;
|
|
54
|
+
}
|
|
55
|
+
else if (typeof href === 'object') {
|
|
56
|
+
targetHref = href.pathname || '';
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
targetHref = pathnameWithoutLanguage;
|
|
60
|
+
}
|
|
61
|
+
const absPathnameWithoutLanguage = new URL(targetHref, 'http://wovn.io' + pathnameWithoutLanguage).pathname;
|
|
62
|
+
const targetLanguage = locale || language;
|
|
63
|
+
const newPathname = (targetLanguage === state.sourceLanguage ? '' : '/' + targetLanguage) + absPathnameWithoutLanguage;
|
|
64
|
+
if (typeof href === 'object') {
|
|
65
|
+
return formatUrl({ ...href, pathname: newPathname });
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
return newPathname;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export const Link = React.forwardRef(function LinkComponent(props, ref) {
|
|
72
|
+
fetchJsonDataIfNeeded();
|
|
73
|
+
const newProps = { ...props, ref, locale: undefined, href: link(props.href, props.locale) };
|
|
74
|
+
return React.createElement(NextLink, newProps, props.children);
|
|
75
|
+
});
|
|
76
|
+
export function usePageRouter() {
|
|
77
|
+
const target = useInternalPageRouter();
|
|
78
|
+
return new Proxy(target, {
|
|
79
|
+
get(target, prop, receiver) {
|
|
80
|
+
switch (prop) {
|
|
81
|
+
case 'push':
|
|
82
|
+
return (url, as, options) => target.push(link(url), as ? link(as) : as, options);
|
|
83
|
+
case 'replace':
|
|
84
|
+
return (url, as, options) => target.replace(link(url), as ? link(as) : as, options);
|
|
85
|
+
case 'prefetch':
|
|
86
|
+
return (url, asPath, options) => target.prefetch(link(url), asPath ? link(asPath) : asPath, options);
|
|
87
|
+
default:
|
|
88
|
+
return Reflect.get(target, prop, receiver);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
export function useAppRouter() {
|
|
94
|
+
return new Proxy(useInternalAppRouter(), {
|
|
95
|
+
get(target, prop, receiver) {
|
|
96
|
+
switch (prop) {
|
|
97
|
+
case 'push':
|
|
98
|
+
return (url, options) => target.push(link(url), options);
|
|
99
|
+
case 'replace':
|
|
100
|
+
return (url, options) => target.replace(link(url), options);
|
|
101
|
+
case 'prefetch':
|
|
102
|
+
return (url) => target.prefetch(link(url));
|
|
103
|
+
default:
|
|
104
|
+
return Reflect.get(target, prop, receiver);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const wovnMiddlewareConfig = { matcher: [] }; // this values will be override by wovn-nextjs build command
|
|
110
|
+
export { wovnMiddlewareConfig };
|
|
111
|
+
export function wovnMiddleware(request) {
|
|
112
|
+
return rewritePath(state.languages.filter(l => l !== state.sourceLanguage), request);
|
|
113
|
+
}
|
|
114
|
+
export function appWithWovn(TargetComponent) {
|
|
115
|
+
function AppWithWovn(props) {
|
|
116
|
+
wovnAsyncLocalStorage.enterWith(props.pageProps.wovn);
|
|
117
|
+
return React.createElement(TargetComponent, props);
|
|
118
|
+
}
|
|
119
|
+
AppWithWovn.getInitialProps = async (appContext) => {
|
|
120
|
+
const appProps = await App.getInitialProps(appContext);
|
|
121
|
+
const headers = appContext.ctx.req?.headers || {};
|
|
122
|
+
return {
|
|
123
|
+
...appProps,
|
|
124
|
+
pageProps: {
|
|
125
|
+
...appProps.pageProps,
|
|
126
|
+
wovn: {
|
|
127
|
+
'x-wovn-nextjs-language': headers['x-wovn-nextjs-language'] || '',
|
|
128
|
+
'x-wovn-nextjs-path-without-language': headers['x-wovn-nextjs-path-without-language'] || ''
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
};
|
|
133
|
+
return AppWithWovn;
|
|
134
|
+
}
|
|
135
|
+
// the followings are exported but for internal purpose
|
|
136
|
+
export function WovnInternalComponent({ major, minor, source }) {
|
|
137
|
+
const text = state.translations[currentLanguage()]?.[major]?.[minor] ?? unescapeHtml(source);
|
|
138
|
+
log("internal component", major, minor, state.translations[currentLanguage()]?.[major]?.[minor], ' <- ', source);
|
|
139
|
+
fetchJsonDataIfNeeded();
|
|
140
|
+
return text;
|
|
141
|
+
}
|
|
142
|
+
export function wovnInternalT(source) {
|
|
143
|
+
fetchJsonDataIfNeeded();
|
|
144
|
+
return translate(source);
|
|
145
|
+
}
|
|
146
|
+
// the followings are helper functions
|
|
147
|
+
function unescapeHtml(s) {
|
|
148
|
+
return s.replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'");
|
|
149
|
+
}
|
|
150
|
+
function fetchJsonDataIfNeeded() {
|
|
151
|
+
if (typeof window === 'undefined') {
|
|
152
|
+
return; // do nothing when calling by server component
|
|
153
|
+
}
|
|
154
|
+
if (!state.projectToken.length) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (!state.fetched) {
|
|
158
|
+
window.wovnnextjs = { state, t, currentLanguage };
|
|
159
|
+
state.fetched = true;
|
|
160
|
+
fetch(`https://wovn.global.ssl.fastly.net/js_data/json/1/${encodeURIComponent(state.projectToken)}?u=` + encodeURIComponent(`${location.protocol}//${location.hostname}`)).then(res => {
|
|
161
|
+
if (res.status === 200) {
|
|
162
|
+
res.json().then(setTranslations);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function translate(source) {
|
|
168
|
+
const language = currentLanguage();
|
|
169
|
+
const key = source.replace(/ \/>/g, '>').replace(/ +</g, '<').replace(/> +/g, '>').replace(/ +/g, ' ');
|
|
170
|
+
log('try to translate', language, key, state.rawTranslations?.[key]?.[language]?.[0]?.data);
|
|
171
|
+
if (language === state.sourceLanguage) {
|
|
172
|
+
return source;
|
|
173
|
+
}
|
|
174
|
+
const translation = state.rawTranslations?.[key]?.[language]?.[0]?.data;
|
|
175
|
+
return translation || source;
|
|
176
|
+
}
|
|
177
|
+
const wovnAsyncLocalStorage = typeof window === 'undefined' ? new global.AsyncLocalStorage() : {
|
|
178
|
+
enterWith(_) { },
|
|
179
|
+
getStore() { return null; }
|
|
180
|
+
};
|
|
181
|
+
function get(key) {
|
|
182
|
+
if (typeof window === 'undefined') {
|
|
183
|
+
try {
|
|
184
|
+
const s = wovnAsyncLocalStorage.getStore();
|
|
185
|
+
return s ? s[key] : headers().get(key);
|
|
186
|
+
}
|
|
187
|
+
catch (e) {
|
|
188
|
+
return e instanceof Error ? `(${e.message})` : '';
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
throw new Error('This function should not be called in client');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function getLanguageInServer() { return get('x-wovn-nextjs-language'); }
|
|
196
|
+
function getPathWithoutLanguage() { return get('x-wovn-nextjs-path-without-language'); }
|
|
197
|
+
function rewritePath(targetLanguages, request) {
|
|
198
|
+
if (request.nextUrl.pathname.startsWith('/_next') || request.nextUrl.pathname.startsWith('/favicon')) {
|
|
199
|
+
return NextResponse.next();
|
|
200
|
+
}
|
|
201
|
+
for (const language of targetLanguages) {
|
|
202
|
+
const url = new URL(request.url);
|
|
203
|
+
if (url.pathname === '/' + language) {
|
|
204
|
+
const headers = new Headers(request.headers);
|
|
205
|
+
headers.set("x-wovn-nextjs-language", language);
|
|
206
|
+
headers.set("x-wovn-nextjs-path-without-language", '/');
|
|
207
|
+
log('rewrite', language, request.nextUrl.pathname);
|
|
208
|
+
return NextResponse.rewrite(new URL("/" + url.search, url), { headers });
|
|
209
|
+
}
|
|
210
|
+
if (url.pathname.startsWith('/' + language + '/')) {
|
|
211
|
+
const headers = new Headers(request.headers);
|
|
212
|
+
headers.set("x-wovn-nextjs-language", language);
|
|
213
|
+
headers.set("x-wovn-nextjs-path-without-language", url.pathname.slice(language.length + 1));
|
|
214
|
+
log('rewrite', language, request.nextUrl.pathname);
|
|
215
|
+
return NextResponse.rewrite(new URL(url.pathname.slice(language.length + 1) + url.search, url), { headers });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
log('do not rewrite', request.nextUrl.pathname);
|
|
219
|
+
return NextResponse.next();
|
|
220
|
+
}
|
|
221
|
+
const state = {
|
|
222
|
+
fetched: false,
|
|
223
|
+
debug: !!process.env.WOVN_DEBUG,
|
|
224
|
+
projectToken: '',
|
|
225
|
+
sourceLanguage: '',
|
|
226
|
+
languages: [],
|
|
227
|
+
sources: [], // [major] == source text
|
|
228
|
+
translations: {}, // [language][major][minor] == translation
|
|
229
|
+
rawTranslations: {} // [source]?.[currentLanguage()]?.[0]?.data == translation
|
|
230
|
+
};
|
|
231
|
+
function setTranslations(root) {
|
|
232
|
+
state.sourceLanguage = root.language;
|
|
233
|
+
state.rawTranslations = root.html_text_vals;
|
|
234
|
+
state.translations = {};
|
|
235
|
+
state.languages = root.convert_langs?.map((lang) => lang.code) || [];
|
|
236
|
+
const sourceLength = state.sources.length;
|
|
237
|
+
for (const language of state.languages) {
|
|
238
|
+
const translations = state.translations[language] = [];
|
|
239
|
+
for (let i = 0; i < sourceLength; i++) {
|
|
240
|
+
const source = state.sources[i];
|
|
241
|
+
const translation = state.rawTranslations[source]?.[language]?.[0]?.data;
|
|
242
|
+
translations.push(translation ? translation.split(/<.*?>/) : []);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (typeof window !== 'undefined') {
|
|
246
|
+
window.wovnnextjs = { state, t };
|
|
247
|
+
}
|
|
248
|
+
log('set translation');
|
|
249
|
+
}
|
|
250
|
+
// for debug
|
|
251
|
+
const log = function (...args) {
|
|
252
|
+
if (state.debug) {
|
|
253
|
+
console.log('wovn-nextjs', ...args);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { headers } from "next/headers";
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wovn-nextjs",
|
|
3
|
+
"version": "0.0.4",
|
|
4
|
+
"description": "Localize the Next.js web application without changing the code",
|
|
5
|
+
"main": "./dist/cjs/index.js",
|
|
6
|
+
"module": "./dist/esm/index.js",
|
|
7
|
+
"types": "./dist/esm/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/esm/index.d.ts",
|
|
11
|
+
"require": "./dist/cjs/index.js",
|
|
12
|
+
"import": "./dist/esm/index.js",
|
|
13
|
+
"default": "./dist/cjs/index.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"bin": {
|
|
17
|
+
"wovn-nextjs": "bin/wovn-nextjs"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test --watch",
|
|
21
|
+
"build": "npx tsc src/index.ts --declaration --target esnext --module esnext --moduleResolution bundler --strict true --skipLibCheck true --outDir dist/esm && npx tsc src/index.ts --declaration --target esnext --module CommonJS --strict true --skipLibCheck true --outDir dist/cjs"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"./bin/wovn-nextjs",
|
|
25
|
+
"./dist"
|
|
26
|
+
],
|
|
27
|
+
"directories": {
|
|
28
|
+
"example": "example"
|
|
29
|
+
},
|
|
30
|
+
"author": "",
|
|
31
|
+
"license": "UNLICENSED",
|
|
32
|
+
"volta": {
|
|
33
|
+
"node": "20.16.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@testing-library/dom": "^10.4.0",
|
|
37
|
+
"@testing-library/react": "^16.0.1",
|
|
38
|
+
"@types/react": "^18.3.7",
|
|
39
|
+
"@types/react-dom": "^18.3.0",
|
|
40
|
+
"jsdom": "^25.0.0",
|
|
41
|
+
"tsx": "^4.19.1",
|
|
42
|
+
"typescript": "5.5.4",
|
|
43
|
+
"typescript-eslint": "^8.0.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@types/node": "^20.0.0",
|
|
47
|
+
"@types/react": "^18.0.0",
|
|
48
|
+
"@types/react-dom": "^18.0.0",
|
|
49
|
+
"next": "^14.0.0",
|
|
50
|
+
"react": "^18.0.0",
|
|
51
|
+
"react-dom": "^18.0.0",
|
|
52
|
+
"typescript-eslint": "^8.0.0"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"wovn-nextjs": "file:wovn-nextjs-0.0.1.tgz"
|
|
56
|
+
}
|
|
57
|
+
}
|