@uniweb/build 0.1.5 → 0.1.7
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/package.json +10 -3
- package/src/generate-entry.js +40 -1
- package/src/i18n/extract.js +259 -0
- package/src/i18n/hash.js +30 -0
- package/src/i18n/index.js +301 -0
- package/src/i18n/merge.js +192 -0
- package/src/i18n/sync.js +174 -0
- package/src/prerender.js +115 -23
- package/src/site/content-collector.js +150 -24
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"./vite-plugin": "./src/vite-foundation-plugin.js",
|
|
12
12
|
"./site": "./src/site/index.js",
|
|
13
13
|
"./dev": "./src/dev/index.js",
|
|
14
|
-
"./prerender": "./src/prerender.js"
|
|
14
|
+
"./prerender": "./src/prerender.js",
|
|
15
|
+
"./i18n": "./src/i18n/index.js"
|
|
15
16
|
},
|
|
16
17
|
"files": [
|
|
17
18
|
"src"
|
|
@@ -37,6 +38,9 @@
|
|
|
37
38
|
"engines": {
|
|
38
39
|
"node": ">=20.19"
|
|
39
40
|
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"jest": "^29.7.0"
|
|
43
|
+
},
|
|
40
44
|
"dependencies": {
|
|
41
45
|
"js-yaml": "^4.1.0",
|
|
42
46
|
"sharp": "^0.33.2"
|
|
@@ -48,7 +52,7 @@
|
|
|
48
52
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
|
49
53
|
"react": "^18.0.0 || ^19.0.0",
|
|
50
54
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
51
|
-
"@uniweb/core": "0.1.
|
|
55
|
+
"@uniweb/core": "0.1.5"
|
|
52
56
|
},
|
|
53
57
|
"peerDependenciesMeta": {
|
|
54
58
|
"vite": {
|
|
@@ -63,5 +67,8 @@
|
|
|
63
67
|
"@uniweb/core": {
|
|
64
68
|
"optional": true
|
|
65
69
|
}
|
|
70
|
+
},
|
|
71
|
+
"scripts": {
|
|
72
|
+
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
|
|
66
73
|
}
|
|
67
74
|
}
|
package/src/generate-entry.js
CHANGED
|
@@ -14,11 +14,31 @@ import {
|
|
|
14
14
|
extractRuntimeConfig,
|
|
15
15
|
} from './schema.js'
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Detect site configuration file (for custom Layout, etc.)
|
|
19
|
+
* Looks for: src/site.js, src/site.jsx, src/site/index.js, src/site/index.jsx
|
|
20
|
+
*/
|
|
21
|
+
function detectSiteConfig(srcDir) {
|
|
22
|
+
const candidates = [
|
|
23
|
+
{ path: 'site.js', ext: 'js' },
|
|
24
|
+
{ path: 'site.jsx', ext: 'jsx' },
|
|
25
|
+
{ path: 'site/index.js', ext: 'js' },
|
|
26
|
+
{ path: 'site/index.jsx', ext: 'jsx' },
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
for (const { path, ext } of candidates) {
|
|
30
|
+
if (existsSync(join(srcDir, path))) {
|
|
31
|
+
return { path: `./${path.replace(/\/index\.(js|jsx)$/, '')}`, ext }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
|
|
17
37
|
/**
|
|
18
38
|
* Generate the entry point source code
|
|
19
39
|
*/
|
|
20
40
|
function generateEntrySource(componentNames, runtimeConfig, options = {}) {
|
|
21
|
-
const { includeCss = true, cssPath = './index.css', componentExtensions = {} } = options
|
|
41
|
+
const { includeCss = true, cssPath = './index.css', componentExtensions = {}, siteConfig = null } = options
|
|
22
42
|
|
|
23
43
|
const imports = []
|
|
24
44
|
const exports = []
|
|
@@ -28,6 +48,11 @@ function generateEntrySource(componentNames, runtimeConfig, options = {}) {
|
|
|
28
48
|
imports.push(`import '${cssPath}'`)
|
|
29
49
|
}
|
|
30
50
|
|
|
51
|
+
// Site config import (for custom Layout, etc.)
|
|
52
|
+
if (siteConfig) {
|
|
53
|
+
imports.push(`import { site } from '${siteConfig.path}'`)
|
|
54
|
+
}
|
|
55
|
+
|
|
31
56
|
// Component imports (use detected extension or default to .js)
|
|
32
57
|
for (const name of componentNames) {
|
|
33
58
|
const ext = componentExtensions[name] || 'js'
|
|
@@ -105,6 +130,11 @@ export function getSchema(name) {
|
|
|
105
130
|
? `\n// Named exports for direct imports\nexport { ${componentNames.join(', ')} }`
|
|
106
131
|
: ''
|
|
107
132
|
|
|
133
|
+
// Site config export (for custom Layout, etc.)
|
|
134
|
+
const siteExport = siteConfig
|
|
135
|
+
? `\n// Site configuration (Layout, etc.)\nexport { site }`
|
|
136
|
+
: `\n// No site configuration provided\nexport const site = null`
|
|
137
|
+
|
|
108
138
|
return `// Auto-generated foundation entry point
|
|
109
139
|
// DO NOT EDIT - This file is regenerated during build
|
|
110
140
|
|
|
@@ -114,6 +144,7 @@ ${componentsObj}
|
|
|
114
144
|
${runtimeConfigBlock}
|
|
115
145
|
${exportFunctions}
|
|
116
146
|
${namedExports}
|
|
147
|
+
${siteExport}
|
|
117
148
|
`
|
|
118
149
|
}
|
|
119
150
|
|
|
@@ -166,10 +197,14 @@ export async function generateEntryPoint(srcDir, outputPath = null) {
|
|
|
166
197
|
// Check if CSS exists
|
|
167
198
|
const cssExists = existsSync(join(srcDir, 'index.css'))
|
|
168
199
|
|
|
200
|
+
// Check for site config (custom Layout, etc.)
|
|
201
|
+
const siteConfig = detectSiteConfig(srcDir)
|
|
202
|
+
|
|
169
203
|
// Generate source
|
|
170
204
|
const source = generateEntrySource(componentNames, runtimeConfig, {
|
|
171
205
|
includeCss: cssExists,
|
|
172
206
|
componentExtensions,
|
|
207
|
+
siteConfig,
|
|
173
208
|
})
|
|
174
209
|
|
|
175
210
|
// Write to file
|
|
@@ -179,11 +214,15 @@ export async function generateEntryPoint(srcDir, outputPath = null) {
|
|
|
179
214
|
|
|
180
215
|
console.log(`Generated entry point: ${output}`)
|
|
181
216
|
console.log(` - ${componentNames.length} components: ${componentNames.join(', ')}`)
|
|
217
|
+
if (siteConfig) {
|
|
218
|
+
console.log(` - Site config found: ${siteConfig.path}`)
|
|
219
|
+
}
|
|
182
220
|
|
|
183
221
|
return {
|
|
184
222
|
outputPath: output,
|
|
185
223
|
componentNames,
|
|
186
224
|
runtimeConfig,
|
|
225
|
+
siteConfig,
|
|
187
226
|
}
|
|
188
227
|
}
|
|
189
228
|
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract translatable content from site-content.json
|
|
3
|
+
*
|
|
4
|
+
* Walks through all pages and sections, extracting translatable
|
|
5
|
+
* strings and building a manifest of translation units.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { computeHash } from './hash.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Extract all translatable units from site content
|
|
12
|
+
* @param {Object} siteContent - Parsed site-content.json
|
|
13
|
+
* @returns {Object} Manifest with translation units
|
|
14
|
+
*/
|
|
15
|
+
export function extractTranslatableContent(siteContent) {
|
|
16
|
+
const units = {}
|
|
17
|
+
|
|
18
|
+
for (const page of siteContent.pages || []) {
|
|
19
|
+
const pageRoute = page.route || '/'
|
|
20
|
+
|
|
21
|
+
// Extract page metadata (title, description, keywords from page.yml)
|
|
22
|
+
extractFromPageMeta(page, pageRoute, units)
|
|
23
|
+
|
|
24
|
+
// Extract section content
|
|
25
|
+
for (const section of page.sections || []) {
|
|
26
|
+
extractFromSection(section, pageRoute, units)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
version: '1.0',
|
|
32
|
+
defaultLocale: siteContent.config?.defaultLanguage || 'en',
|
|
33
|
+
extracted: new Date().toISOString(),
|
|
34
|
+
units
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Extract translatable page metadata
|
|
40
|
+
* @param {Object} page - Page data
|
|
41
|
+
* @param {string} pageRoute - Page route
|
|
42
|
+
* @param {Object} units - Units accumulator
|
|
43
|
+
*/
|
|
44
|
+
function extractFromPageMeta(page, pageRoute, units) {
|
|
45
|
+
// Use special section identifier for page-level metadata
|
|
46
|
+
const context = { page: pageRoute, section: '_meta' }
|
|
47
|
+
|
|
48
|
+
// Page title
|
|
49
|
+
if (page.title && typeof page.title === 'string') {
|
|
50
|
+
addUnit(units, page.title, 'page.title', context)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Page description
|
|
54
|
+
if (page.description && typeof page.description === 'string') {
|
|
55
|
+
addUnit(units, page.description, 'page.description', context)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// SEO-specific fields (if present)
|
|
59
|
+
if (page.seo) {
|
|
60
|
+
// og:title, og:description might be different from page title/description
|
|
61
|
+
if (page.seo.ogTitle && typeof page.seo.ogTitle === 'string') {
|
|
62
|
+
addUnit(units, page.seo.ogTitle, 'page.seo.ogTitle', context)
|
|
63
|
+
}
|
|
64
|
+
if (page.seo.ogDescription && typeof page.seo.ogDescription === 'string') {
|
|
65
|
+
addUnit(units, page.seo.ogDescription, 'page.seo.ogDescription', context)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Keywords (if array, join for translation context)
|
|
70
|
+
if (page.keywords) {
|
|
71
|
+
if (Array.isArray(page.keywords)) {
|
|
72
|
+
// Each keyword as separate unit for flexibility
|
|
73
|
+
page.keywords.forEach((keyword, index) => {
|
|
74
|
+
if (keyword && typeof keyword === 'string') {
|
|
75
|
+
addUnit(units, keyword, `page.keyword.${index}`, context)
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
} else if (typeof page.keywords === 'string') {
|
|
79
|
+
addUnit(units, page.keywords, 'page.keywords', context)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Extract translatable content from a section
|
|
86
|
+
* @param {Object} section - Section data
|
|
87
|
+
* @param {string} pageRoute - Parent page route
|
|
88
|
+
* @param {Object} units - Units accumulator
|
|
89
|
+
*/
|
|
90
|
+
function extractFromSection(section, pageRoute, units) {
|
|
91
|
+
const sectionId = section.id || 'unknown'
|
|
92
|
+
const context = { page: pageRoute, section: sectionId }
|
|
93
|
+
|
|
94
|
+
// Extract from parsed semantic content if available
|
|
95
|
+
// The section.content is ProseMirror doc, but we need parsed content
|
|
96
|
+
// For now, we'll extract from the raw ProseMirror structure
|
|
97
|
+
// In practice, this should use semantic-parser output
|
|
98
|
+
|
|
99
|
+
if (section.content?.type === 'doc') {
|
|
100
|
+
extractFromProseMirrorDoc(section.content, context, units)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Recursively process subsections
|
|
104
|
+
for (const subsection of section.subsections || []) {
|
|
105
|
+
extractFromSection(subsection, pageRoute, units)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Extract translatable strings from ProseMirror document
|
|
111
|
+
* @param {Object} doc - ProseMirror document
|
|
112
|
+
* @param {Object} context - Current context (page, section)
|
|
113
|
+
* @param {Object} units - Units accumulator
|
|
114
|
+
*/
|
|
115
|
+
function extractFromProseMirrorDoc(doc, context, units) {
|
|
116
|
+
if (!doc.content) return
|
|
117
|
+
|
|
118
|
+
let headingIndex = { h1: 0, h2: 0, h3: 0, h4: 0 }
|
|
119
|
+
let paragraphIndex = 0
|
|
120
|
+
let linkIndex = 0
|
|
121
|
+
|
|
122
|
+
for (const node of doc.content) {
|
|
123
|
+
if (node.type === 'heading') {
|
|
124
|
+
const text = extractTextFromNode(node)
|
|
125
|
+
if (!text) continue
|
|
126
|
+
|
|
127
|
+
const level = node.attrs?.level || 1
|
|
128
|
+
const field = getHeadingField(level, headingIndex)
|
|
129
|
+
headingIndex[`h${level}`]++
|
|
130
|
+
|
|
131
|
+
addUnit(units, text, field, context)
|
|
132
|
+
} else if (node.type === 'paragraph') {
|
|
133
|
+
const result = extractFromParagraph(node, context, units, linkIndex)
|
|
134
|
+
linkIndex = result.linkIndex
|
|
135
|
+
|
|
136
|
+
// Add paragraph text if it's substantial (not just links/buttons)
|
|
137
|
+
const plainText = extractPlainTextFromParagraph(node)
|
|
138
|
+
if (plainText && plainText.length > 0) {
|
|
139
|
+
const field = paragraphIndex === 0 ? 'paragraph' : `paragraph.${paragraphIndex}`
|
|
140
|
+
addUnit(units, plainText, field, context)
|
|
141
|
+
paragraphIndex++
|
|
142
|
+
}
|
|
143
|
+
} else if (node.type === 'bulletList' || node.type === 'orderedList') {
|
|
144
|
+
extractFromList(node, context, units)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Determine field name for heading based on level and index
|
|
151
|
+
*/
|
|
152
|
+
function getHeadingField(level, index) {
|
|
153
|
+
// First H1 is title, first H2 is subtitle, H3 before H1 could be pretitle
|
|
154
|
+
// This is simplified - semantic-parser does this more intelligently
|
|
155
|
+
if (level === 1) return index.h1 === 0 ? 'title' : `heading.${index.h1}`
|
|
156
|
+
if (level === 2) return index.h2 === 0 ? 'subtitle' : `heading.h2.${index.h2}`
|
|
157
|
+
if (level === 3) return `heading.h3.${index.h3}`
|
|
158
|
+
return `heading.h${level}.${index[`h${level}`]}`
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Extract from paragraph node, handling links specially
|
|
163
|
+
*/
|
|
164
|
+
function extractFromParagraph(node, context, units, linkIndex) {
|
|
165
|
+
if (!node.content) return { linkIndex }
|
|
166
|
+
|
|
167
|
+
for (const child of node.content) {
|
|
168
|
+
if (child.type === 'text' && child.marks) {
|
|
169
|
+
const linkMark = child.marks.find(m => m.type === 'link')
|
|
170
|
+
if (linkMark && child.text) {
|
|
171
|
+
const field = linkIndex === 0 ? 'link.label' : `link.${linkIndex}.label`
|
|
172
|
+
addUnit(units, child.text, field, context)
|
|
173
|
+
linkIndex++
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { linkIndex }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Extract plain text from paragraph, excluding link text
|
|
183
|
+
*/
|
|
184
|
+
function extractPlainTextFromParagraph(node) {
|
|
185
|
+
if (!node.content) return ''
|
|
186
|
+
|
|
187
|
+
const parts = []
|
|
188
|
+
for (const child of node.content) {
|
|
189
|
+
if (child.type === 'text') {
|
|
190
|
+
// Skip if it's a link
|
|
191
|
+
const isLink = child.marks?.some(m => m.type === 'link')
|
|
192
|
+
if (!isLink && child.text && child.text.trim()) {
|
|
193
|
+
parts.push(child.text)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return parts.join('').trim()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Extract from list items
|
|
203
|
+
*/
|
|
204
|
+
function extractFromList(listNode, context, units) {
|
|
205
|
+
if (!listNode.content) return
|
|
206
|
+
|
|
207
|
+
listNode.content.forEach((listItem, index) => {
|
|
208
|
+
if (listItem.type === 'listItem' && listItem.content) {
|
|
209
|
+
for (const child of listItem.content) {
|
|
210
|
+
if (child.type === 'paragraph') {
|
|
211
|
+
const text = extractTextFromNode(child)
|
|
212
|
+
if (text) {
|
|
213
|
+
addUnit(units, text, `list.${index}`, context)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Extract all text content from a node
|
|
223
|
+
*/
|
|
224
|
+
function extractTextFromNode(node) {
|
|
225
|
+
if (!node.content) return ''
|
|
226
|
+
return node.content
|
|
227
|
+
.filter(n => n.type === 'text')
|
|
228
|
+
.map(n => n.text || '')
|
|
229
|
+
.join('')
|
|
230
|
+
.trim()
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Add a translation unit to the accumulator
|
|
235
|
+
*/
|
|
236
|
+
function addUnit(units, source, field, context) {
|
|
237
|
+
if (!source || source.length === 0) return
|
|
238
|
+
|
|
239
|
+
const hash = computeHash(source)
|
|
240
|
+
|
|
241
|
+
if (units[hash]) {
|
|
242
|
+
// Unit exists - add context if not already present
|
|
243
|
+
const existingContexts = units[hash].contexts
|
|
244
|
+
const contextKey = `${context.page}:${context.section}`
|
|
245
|
+
const exists = existingContexts.some(
|
|
246
|
+
c => `${c.page}:${c.section}` === contextKey
|
|
247
|
+
)
|
|
248
|
+
if (!exists) {
|
|
249
|
+
existingContexts.push({ ...context })
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
// New unit
|
|
253
|
+
units[hash] = {
|
|
254
|
+
source,
|
|
255
|
+
field,
|
|
256
|
+
contexts: [{ ...context }]
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
package/src/i18n/hash.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hash utilities for i18n translation units
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createHash } from 'crypto'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Compute an 8-character hash for translation unit identification
|
|
9
|
+
* @param {string} text - Source text to hash
|
|
10
|
+
* @returns {string} 8-character hex hash
|
|
11
|
+
*/
|
|
12
|
+
export function computeHash(text) {
|
|
13
|
+
const normalized = normalizeText(text)
|
|
14
|
+
return createHash('sha256')
|
|
15
|
+
.update(normalized)
|
|
16
|
+
.digest('hex')
|
|
17
|
+
.slice(0, 8)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Normalize text for consistent hashing
|
|
22
|
+
* - Trim whitespace
|
|
23
|
+
* - Normalize internal whitespace to single spaces
|
|
24
|
+
* @param {string} text
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
export function normalizeText(text) {
|
|
28
|
+
if (typeof text !== 'string') return ''
|
|
29
|
+
return text.trim().replace(/\s+/g, ' ')
|
|
30
|
+
}
|