@uniweb/build 0.3.2 → 0.4.1

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.3.2",
3
+ "version": "0.4.1",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -50,8 +50,8 @@
50
50
  "sharp": "^0.33.2"
51
51
  },
52
52
  "optionalDependencies": {
53
- "@uniweb/content-reader": "1.0.8",
54
- "@uniweb/runtime": "0.4.3"
53
+ "@uniweb/content-reader": "1.1.1",
54
+ "@uniweb/runtime": "0.5.0"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -60,7 +60,7 @@
60
60
  "@tailwindcss/vite": "^4.0.0",
61
61
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
62
62
  "vite-plugin-svgr": "^4.0.0",
63
- "@uniweb/core": "0.2.5"
63
+ "@uniweb/core": "0.3.1"
64
64
  },
65
65
  "peerDependenciesMeta": {
66
66
  "vite": {
@@ -9,6 +9,7 @@ import { readFile, writeFile, readdir, mkdir } from 'fs/promises'
9
9
  import { existsSync } from 'fs'
10
10
  import { join } from 'path'
11
11
  import { computeHash } from './hash.js'
12
+ import { loadFreeformCollectionItem } from './freeform.js'
12
13
 
13
14
  export const COLLECTIONS_DIR = 'collections'
14
15
 
@@ -198,13 +199,16 @@ function addUnit(units, source, field, context) {
198
199
  * Merge translations into collection data and write locale-specific files
199
200
  * @param {string} siteRoot - Site root directory
200
201
  * @param {Object} options - Options
202
+ * @param {boolean} [options.freeformEnabled=true] - Enable free-form translation support
201
203
  * @returns {Promise<Object>} Map of locale to output paths
202
204
  */
203
205
  export async function buildLocalizedCollections(siteRoot, options = {}) {
204
206
  const {
205
207
  locales = [],
206
208
  outputDir = join(siteRoot, 'dist'),
207
- collectionsLocalesDir = join(siteRoot, 'locales', COLLECTIONS_DIR)
209
+ collectionsLocalesDir = join(siteRoot, 'locales', COLLECTIONS_DIR),
210
+ localesDir = join(siteRoot, 'locales'),
211
+ freeformEnabled = true
208
212
  } = options
209
213
 
210
214
  const dataDir = join(siteRoot, 'public', 'data')
@@ -240,6 +244,10 @@ export async function buildLocalizedCollections(siteRoot, options = {}) {
240
244
  }
241
245
  }
242
246
 
247
+ // Check if free-form translations exist for this locale
248
+ const freeformDir = join(localesDir, 'freeform', locale)
249
+ const hasFreeform = freeformEnabled && existsSync(freeformDir)
250
+
243
251
  // Create locale data directory
244
252
  const localeDataDir = join(outputDir, locale, 'data')
245
253
  await mkdir(localeDataDir, { recursive: true })
@@ -262,9 +270,15 @@ export async function buildLocalizedCollections(siteRoot, options = {}) {
262
270
  continue
263
271
  }
264
272
 
265
- // Translate each item
266
- const translatedItems = items.map(item =>
267
- translateItem(item, collectionName, translations)
273
+ // Translate each item (with free-form support)
274
+ const translatedItems = await Promise.all(
275
+ items.map(item =>
276
+ translateItemAsync(item, collectionName, translations, {
277
+ locale,
278
+ localesDir,
279
+ freeformEnabled: hasFreeform
280
+ })
281
+ )
268
282
  )
269
283
 
270
284
  const destPath = join(localeDataDir, file)
@@ -280,13 +294,69 @@ export async function buildLocalizedCollections(siteRoot, options = {}) {
280
294
  }
281
295
 
282
296
  /**
283
- * Apply translations to a collection item
297
+ * Apply translations to a collection item (async, with free-form support)
298
+ *
299
+ * Resolution order:
300
+ * 1. Check for free-form translation (complete or partial replacement)
301
+ * 2. Fall back to hash-based translation
302
+ *
303
+ * @param {Object} item - Collection item
304
+ * @param {string} collectionName - Name of the collection
305
+ * @param {Object} translations - Hash-based translations
306
+ * @param {Object} options - Options
307
+ * @returns {Promise<Object>} Translated item
284
308
  */
285
- function translateItem(item, collectionName, translations) {
309
+ async function translateItemAsync(item, collectionName, translations, options = {}) {
310
+ const { locale, localesDir, freeformEnabled } = options
286
311
  const translated = { ...item }
287
312
  const slug = item.slug || 'unknown'
288
313
  const context = { collection: collectionName, item: slug }
289
314
 
315
+ // Check for free-form translation first
316
+ if (freeformEnabled && locale && localesDir) {
317
+ const freeform = await loadFreeformCollectionItem(item, collectionName, locale, localesDir)
318
+
319
+ if (freeform) {
320
+ // Merge free-form data (supports partial: frontmatter only, body only, or both)
321
+ if (freeform.frontmatter) {
322
+ // Merge frontmatter fields from free-form
323
+ Object.assign(translated, freeform.frontmatter)
324
+ }
325
+ if (freeform.content) {
326
+ // Replace content entirely with free-form content
327
+ translated.content = freeform.content
328
+ // Skip hash-based content translation since we have free-form
329
+ return translateItemFields(translated, context, translations)
330
+ }
331
+ }
332
+ }
333
+
334
+ // Fall back to hash-based translation (or continue after partial free-form)
335
+ return translateItemSync(translated, collectionName, translations)
336
+ }
337
+
338
+ /**
339
+ * Apply translations to a collection item (sync, hash-based only)
340
+ */
341
+ function translateItemSync(item, collectionName, translations) {
342
+ const translated = { ...item }
343
+ const slug = item.slug || 'unknown'
344
+ const context = { collection: collectionName, item: slug }
345
+
346
+ // Translate all fields including content
347
+ return translateItemFields(translated, context, translations, true)
348
+ }
349
+
350
+ /**
351
+ * Translate item fields (frontmatter and optionally content)
352
+ *
353
+ * @param {Object} translated - Item being translated
354
+ * @param {Object} context - Translation context
355
+ * @param {Object} translations - Hash-based translations
356
+ * @param {boolean} includeContent - Whether to translate ProseMirror content
357
+ * @returns {Object} Translated item
358
+ */
359
+ function translateItemFields(translated, context, translations, includeContent = false) {
290
360
  // Translate frontmatter fields
291
361
  const translatableFields = ['title', 'description', 'excerpt', 'summary', 'subtitle']
292
362
 
@@ -320,8 +390,8 @@ function translateItem(item, collectionName, translations) {
320
390
  })
321
391
  }
322
392
 
323
- // Translate ProseMirror content
324
- if (translated.content?.type === 'doc') {
393
+ // Translate ProseMirror content (only if requested)
394
+ if (includeContent && translated.content?.type === 'doc') {
325
395
  translated.content = translateProseMirrorDoc(
326
396
  translated.content,
327
397
  context,
@@ -332,6 +402,14 @@ function translateItem(item, collectionName, translations) {
332
402
  return translated
333
403
  }
334
404
 
405
+ /**
406
+ * Apply translations to a collection item (legacy sync function)
407
+ * @deprecated Use translateItemAsync for free-form support
408
+ */
409
+ function translateItem(item, collectionName, translations) {
410
+ return translateItemSync(item, collectionName, translations)
411
+ }
412
+
335
413
  /**
336
414
  * Translate a ProseMirror document
337
415
  */
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Free-form Translation Manifest Management
3
+ *
4
+ * Tracks source content hashes for free-form translations to detect
5
+ * when source content changes (staleness). The manifest lives at:
6
+ * locales/freeform/{locale}/.manifest.json
7
+ *
8
+ * Manifest format:
9
+ * {
10
+ * "pages/about/hero.md": { "hash": "a1b2c3d4", "recorded": "2025-01-28" },
11
+ * "page-ids/installation/intro.md": { "hash": "b2c3d4e5", "recorded": "2025-01-25" },
12
+ * "collections/articles/getting-started.md": { "hash": "c3d4e5f6", "recorded": "2025-01-27" }
13
+ * }
14
+ */
15
+
16
+ import { readFile, writeFile, mkdir } from 'fs/promises'
17
+ import { existsSync } from 'fs'
18
+ import { join, dirname } from 'path'
19
+ import { createHash } from 'crypto'
20
+
21
+ const MANIFEST_FILENAME = '.manifest.json'
22
+
23
+ /**
24
+ * Compute a hash of a ProseMirror document for staleness detection
25
+ *
26
+ * Extracts text content and computes a hash that changes when
27
+ * the content changes (ignoring formatting-only changes).
28
+ *
29
+ * @param {Object} doc - ProseMirror document
30
+ * @returns {string} 8-character hex hash
31
+ */
32
+ export function computeSourceHash(doc) {
33
+ if (!doc) return ''
34
+
35
+ // Extract all text content from the document
36
+ const textContent = extractTextFromDoc(doc)
37
+
38
+ // Hash the normalized text
39
+ return createHash('sha256')
40
+ .update(textContent)
41
+ .digest('hex')
42
+ .slice(0, 8)
43
+ }
44
+
45
+ /**
46
+ * Recursively extract text from a ProseMirror document
47
+ * @param {Object} node - ProseMirror node
48
+ * @returns {string} Concatenated text content
49
+ */
50
+ function extractTextFromDoc(node) {
51
+ if (!node) return ''
52
+
53
+ if (node.type === 'text') {
54
+ return node.text || ''
55
+ }
56
+
57
+ if (!node.content || !Array.isArray(node.content)) {
58
+ return ''
59
+ }
60
+
61
+ return node.content.map(extractTextFromDoc).join(' ')
62
+ }
63
+
64
+ /**
65
+ * Get the manifest path for a locale
66
+ * @param {string} localeDir - Path to locale's freeform directory
67
+ * @returns {string} Path to manifest file
68
+ */
69
+ function getManifestPath(localeDir) {
70
+ return join(localeDir, MANIFEST_FILENAME)
71
+ }
72
+
73
+ /**
74
+ * Load the manifest for a locale
75
+ *
76
+ * @param {string} localeDir - Path to locale's freeform directory (locales/freeform/{locale})
77
+ * @returns {Promise<Object>} Manifest object (empty if not found)
78
+ */
79
+ export async function loadManifest(localeDir) {
80
+ const manifestPath = getManifestPath(localeDir)
81
+
82
+ if (!existsSync(manifestPath)) {
83
+ return {}
84
+ }
85
+
86
+ try {
87
+ const content = await readFile(manifestPath, 'utf-8')
88
+ return JSON.parse(content)
89
+ } catch (err) {
90
+ console.warn(`[i18n] Failed to load manifest ${manifestPath}: ${err.message}`)
91
+ return {}
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Save the manifest for a locale
97
+ *
98
+ * @param {string} localeDir - Path to locale's freeform directory
99
+ * @param {Object} manifest - Manifest object to save
100
+ */
101
+ export async function saveManifest(localeDir, manifest) {
102
+ const manifestPath = getManifestPath(localeDir)
103
+
104
+ // Ensure directory exists
105
+ if (!existsSync(localeDir)) {
106
+ await mkdir(localeDir, { recursive: true })
107
+ }
108
+
109
+ await writeFile(manifestPath, JSON.stringify(manifest, null, 2))
110
+ }
111
+
112
+ /**
113
+ * Record a source hash in the manifest
114
+ *
115
+ * Called when a new free-form translation is created to record
116
+ * the hash of the source content at that time.
117
+ *
118
+ * @param {string} localeDir - Path to locale's freeform directory
119
+ * @param {string} relativePath - Path relative to locale dir (e.g., 'pages/about/hero.md')
120
+ * @param {string} sourceHash - Hash of the source content
121
+ * @returns {Promise<void>}
122
+ */
123
+ export async function recordHash(localeDir, relativePath, sourceHash) {
124
+ const manifest = await loadManifest(localeDir)
125
+
126
+ manifest[relativePath] = {
127
+ hash: sourceHash,
128
+ recorded: new Date().toISOString().split('T')[0] // YYYY-MM-DD
129
+ }
130
+
131
+ await saveManifest(localeDir, manifest)
132
+ }
133
+
134
+ /**
135
+ * Check if a translation is stale (source content changed)
136
+ *
137
+ * @param {string} localeDir - Path to locale's freeform directory
138
+ * @param {string} relativePath - Path relative to locale dir
139
+ * @param {string} currentSourceHash - Current hash of source content
140
+ * @returns {Promise<Object>} { isStale, recordedHash, recordedDate, currentHash }
141
+ */
142
+ export async function checkStaleness(localeDir, relativePath, currentSourceHash) {
143
+ const manifest = await loadManifest(localeDir)
144
+ const entry = manifest[relativePath]
145
+
146
+ if (!entry) {
147
+ // No recorded hash - translation was never registered
148
+ return {
149
+ isStale: false, // Not stale, just unregistered
150
+ isNew: true,
151
+ recordedHash: null,
152
+ recordedDate: null,
153
+ currentHash: currentSourceHash
154
+ }
155
+ }
156
+
157
+ const isStale = entry.hash !== currentSourceHash
158
+
159
+ return {
160
+ isStale,
161
+ isNew: false,
162
+ recordedHash: entry.hash,
163
+ recordedDate: entry.recorded,
164
+ currentHash: currentSourceHash
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Update the hash for a translation (after translator reviews changes)
170
+ *
171
+ * @param {string} localeDir - Path to locale's freeform directory
172
+ * @param {string} relativePath - Path relative to locale dir
173
+ * @param {string} newHash - New hash to record
174
+ * @returns {Promise<void>}
175
+ */
176
+ export async function updateHash(localeDir, relativePath, newHash) {
177
+ return recordHash(localeDir, relativePath, newHash)
178
+ }
179
+
180
+ /**
181
+ * Remove entries from the manifest
182
+ *
183
+ * Used when translations are deleted.
184
+ *
185
+ * @param {string} localeDir - Path to locale's freeform directory
186
+ * @param {string[]} paths - Paths to remove
187
+ * @returns {Promise<number>} Number of entries removed
188
+ */
189
+ export async function removeManifestEntries(localeDir, paths) {
190
+ const manifest = await loadManifest(localeDir)
191
+ let removed = 0
192
+
193
+ for (const path of paths) {
194
+ if (manifest[path]) {
195
+ delete manifest[path]
196
+ removed++
197
+ }
198
+ }
199
+
200
+ if (removed > 0) {
201
+ await saveManifest(localeDir, manifest)
202
+ }
203
+
204
+ return removed
205
+ }
206
+
207
+ /**
208
+ * Rename entries in the manifest
209
+ *
210
+ * Used when translations are moved/renamed.
211
+ *
212
+ * @param {string} localeDir - Path to locale's freeform directory
213
+ * @param {string[]} oldPaths - Old paths
214
+ * @param {string[]} newPaths - New paths (same order as oldPaths)
215
+ * @returns {Promise<number>} Number of entries renamed
216
+ */
217
+ export async function renameManifestEntries(localeDir, oldPaths, newPaths) {
218
+ if (oldPaths.length !== newPaths.length) {
219
+ throw new Error('oldPaths and newPaths must have the same length')
220
+ }
221
+
222
+ const manifest = await loadManifest(localeDir)
223
+ let renamed = 0
224
+
225
+ for (let i = 0; i < oldPaths.length; i++) {
226
+ const oldPath = oldPaths[i]
227
+ const newPath = newPaths[i]
228
+
229
+ if (manifest[oldPath]) {
230
+ manifest[newPath] = manifest[oldPath]
231
+ delete manifest[oldPath]
232
+ renamed++
233
+ }
234
+ }
235
+
236
+ if (renamed > 0) {
237
+ await saveManifest(localeDir, manifest)
238
+ }
239
+
240
+ return renamed
241
+ }
242
+
243
+ /**
244
+ * Get all stale translations for a locale
245
+ *
246
+ * Compares manifest hashes against current source content.
247
+ *
248
+ * @param {string} localeDir - Path to locale's freeform directory
249
+ * @param {Object} sourceHashes - Map of relativePath → currentHash
250
+ * @returns {Promise<Object[]>} Array of { path, recordedHash, recordedDate, currentHash }
251
+ */
252
+ export async function getStaleTranslations(localeDir, sourceHashes) {
253
+ const manifest = await loadManifest(localeDir)
254
+ const stale = []
255
+
256
+ for (const [path, entry] of Object.entries(manifest)) {
257
+ const currentHash = sourceHashes[path]
258
+
259
+ // If we have a current hash and it differs, it's stale
260
+ if (currentHash && currentHash !== entry.hash) {
261
+ stale.push({
262
+ path,
263
+ recordedHash: entry.hash,
264
+ recordedDate: entry.recorded,
265
+ currentHash
266
+ })
267
+ }
268
+ }
269
+
270
+ return stale
271
+ }
272
+
273
+ /**
274
+ * Get orphaned translations (translations without source)
275
+ *
276
+ * @param {string} localeDir - Path to locale's freeform directory
277
+ * @param {Set<string>} validPaths - Set of valid translation paths that have source content
278
+ * @returns {Promise<Object[]>} Array of { path, recordedHash, recordedDate }
279
+ */
280
+ export async function getOrphanedTranslations(localeDir, validPaths) {
281
+ const manifest = await loadManifest(localeDir)
282
+ const orphaned = []
283
+
284
+ for (const [path, entry] of Object.entries(manifest)) {
285
+ if (!validPaths.has(path)) {
286
+ orphaned.push({
287
+ path,
288
+ recordedHash: entry.hash,
289
+ recordedDate: entry.recorded
290
+ })
291
+ }
292
+ }
293
+
294
+ return orphaned
295
+ }
296
+
297
+ /**
298
+ * Get unregistered translations (translations without manifest entry)
299
+ *
300
+ * @param {string} localeDir - Path to locale's freeform directory
301
+ * @param {string[]} translationPaths - All translation file paths found
302
+ * @returns {Promise<string[]>} Paths that exist as files but not in manifest
303
+ */
304
+ export async function getUnregisteredTranslations(localeDir, translationPaths) {
305
+ const manifest = await loadManifest(localeDir)
306
+ return translationPaths.filter(path => !manifest[path])
307
+ }