@uniweb/build 0.1.4 → 0.1.6

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.4",
3
+ "version": "0.1.6",
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,18 +38,21 @@
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"
43
47
  },
44
48
  "optionalDependencies": {
45
- "@uniweb/content-reader": "1.0.2"
49
+ "@uniweb/content-reader": "1.0.3"
46
50
  },
47
51
  "peerDependencies": {
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.2"
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/docs.js ADDED
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Documentation Generator
3
+ *
4
+ * Generates markdown documentation from foundation schema.json
5
+ * or directly from component meta files.
6
+ */
7
+
8
+ import { readFile, writeFile } from 'node:fs/promises'
9
+ import { existsSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import { buildSchema } from './schema.js'
12
+
13
+ /**
14
+ * Generate markdown documentation for a single component
15
+ *
16
+ * @param {string} name - Component name
17
+ * @param {Object} meta - Component metadata
18
+ * @returns {string} Markdown content
19
+ */
20
+ function generateComponentDocs(name, meta) {
21
+ const lines = []
22
+
23
+ // Component header
24
+ lines.push(`## ${name}`)
25
+ lines.push('')
26
+
27
+ // Description
28
+ if (meta.description) {
29
+ lines.push(meta.description)
30
+ lines.push('')
31
+ }
32
+
33
+ // Category badge
34
+ if (meta.category) {
35
+ lines.push(`**Category:** ${meta.category}`)
36
+ lines.push('')
37
+ }
38
+
39
+ // Content Elements
40
+ if (meta.elements && Object.keys(meta.elements).length > 0) {
41
+ lines.push('### Content Elements')
42
+ lines.push('')
43
+ lines.push('| Element | Label | Required | Description |')
44
+ lines.push('|---------|-------|----------|-------------|')
45
+
46
+ for (const [key, element] of Object.entries(meta.elements)) {
47
+ const label = element.label || key
48
+ const required = element.required ? 'Yes' : ''
49
+ const description = element.description || ''
50
+ lines.push(`| \`${key}\` | ${label} | ${required} | ${description} |`)
51
+ }
52
+ lines.push('')
53
+ }
54
+
55
+ // Parameters/Properties
56
+ if (meta.properties && Object.keys(meta.properties).length > 0) {
57
+ lines.push('### Parameters')
58
+ lines.push('')
59
+ lines.push('| Parameter | Type | Default | Description |')
60
+ lines.push('|-----------|------|---------|-------------|')
61
+
62
+ for (const [key, prop] of Object.entries(meta.properties)) {
63
+ const type = prop.type || 'string'
64
+ const defaultVal = prop.default !== undefined ? `\`${prop.default}\`` : ''
65
+ let description = prop.label || ''
66
+
67
+ // Add options for select type
68
+ if (prop.type === 'select' && prop.options) {
69
+ const optionValues = prop.options.map(o =>
70
+ typeof o === 'object' ? o.value : o
71
+ ).join(', ')
72
+ description += description ? ` (${optionValues})` : optionValues
73
+ }
74
+
75
+ lines.push(`| \`${key}\` | ${type} | ${defaultVal} | ${description} |`)
76
+ }
77
+ lines.push('')
78
+ }
79
+
80
+ // Presets
81
+ if (meta.presets && meta.presets.length > 0) {
82
+ lines.push('### Presets')
83
+ lines.push('')
84
+
85
+ for (const preset of meta.presets) {
86
+ const settings = preset.settings
87
+ ? Object.entries(preset.settings)
88
+ .map(([k, v]) => `${k}: ${v}`)
89
+ .join(', ')
90
+ : ''
91
+ lines.push(`- **${preset.name}** - ${preset.label || ''} ${settings ? `(${settings})` : ''}`)
92
+ }
93
+ lines.push('')
94
+ }
95
+
96
+ return lines.join('\n')
97
+ }
98
+
99
+ /**
100
+ * Generate full markdown documentation for a foundation
101
+ *
102
+ * @param {Object} schema - Foundation schema object
103
+ * @param {Object} options - Generation options
104
+ * @param {string} [options.title] - Document title
105
+ * @returns {string} Complete markdown documentation
106
+ */
107
+ export function generateDocsFromSchema(schema, options = {}) {
108
+ const { title = 'Foundation Components' } = options
109
+ const lines = []
110
+
111
+ // Header
112
+ lines.push(`# ${title}`)
113
+ lines.push('')
114
+
115
+ // Foundation info
116
+ const foundationMeta = schema._self
117
+ if (foundationMeta) {
118
+ if (foundationMeta.name) {
119
+ lines.push(`**${foundationMeta.name}**`)
120
+ lines.push('')
121
+ }
122
+ if (foundationMeta.description) {
123
+ lines.push(foundationMeta.description)
124
+ lines.push('')
125
+ }
126
+ }
127
+
128
+ lines.push('---')
129
+ lines.push('')
130
+
131
+ // Table of contents
132
+ const componentNames = Object.keys(schema).filter(k => k !== '_self')
133
+
134
+ if (componentNames.length > 0) {
135
+ lines.push('## Components')
136
+ lines.push('')
137
+ for (const name of componentNames) {
138
+ const meta = schema[name]
139
+ const title = meta.title || name
140
+ lines.push(`- [${title}](#${name.toLowerCase()}) - ${meta.description || ''}`)
141
+ }
142
+ lines.push('')
143
+ lines.push('---')
144
+ lines.push('')
145
+ }
146
+
147
+ // Component documentation
148
+ for (const name of componentNames) {
149
+ const meta = schema[name]
150
+ lines.push(generateComponentDocs(name, meta))
151
+ lines.push('---')
152
+ lines.push('')
153
+ }
154
+
155
+ // Footer
156
+ lines.push('*Generated from foundation schema*')
157
+
158
+ return lines.join('\n')
159
+ }
160
+
161
+ /**
162
+ * Generate documentation for a foundation directory
163
+ *
164
+ * Can read from existing schema.json or build schema from source.
165
+ *
166
+ * @param {string} foundationDir - Path to foundation directory
167
+ * @param {Object} options - Options
168
+ * @param {string} [options.output] - Output file path (default: COMPONENTS.md)
169
+ * @param {boolean} [options.fromSource] - Build schema from source instead of dist
170
+ * @returns {Promise<{outputPath: string, componentCount: number}>}
171
+ */
172
+ export async function generateDocs(foundationDir, options = {}) {
173
+ const {
174
+ output = 'COMPONENTS.md',
175
+ fromSource = false,
176
+ } = options
177
+
178
+ let schema
179
+
180
+ // Try to load schema.json from dist
181
+ const schemaPath = join(foundationDir, 'dist', 'schema.json')
182
+
183
+ if (!fromSource && existsSync(schemaPath)) {
184
+ // Load from existing schema.json
185
+ const schemaContent = await readFile(schemaPath, 'utf-8')
186
+ schema = JSON.parse(schemaContent)
187
+ } else {
188
+ // Build schema from source
189
+ const srcDir = join(foundationDir, 'src')
190
+ if (!existsSync(srcDir)) {
191
+ throw new Error(`Source directory not found: ${srcDir}`)
192
+ }
193
+ schema = await buildSchema(srcDir)
194
+ }
195
+
196
+ // Get foundation name for title
197
+ const pkgPath = join(foundationDir, 'package.json')
198
+ let title = 'Foundation Components'
199
+ if (existsSync(pkgPath)) {
200
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf-8'))
201
+ if (pkg.name) {
202
+ title = `${pkg.name} Components`
203
+ }
204
+ }
205
+
206
+ // Generate markdown
207
+ const markdown = generateDocsFromSchema(schema, { title })
208
+
209
+ // Write output
210
+ const outputPath = join(foundationDir, output)
211
+ await writeFile(outputPath, markdown)
212
+
213
+ // Count components
214
+ const componentCount = Object.keys(schema).filter(k => k !== '_self').length
215
+
216
+ return { outputPath, componentCount }
217
+ }
@@ -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
+ }