@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.1.5",
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.4"
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
  }
@@ -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
+ }
@@ -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
+ }