@uniweb/build 0.8.22 → 0.8.24

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.8.22",
3
+ "version": "0.8.24",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -54,9 +54,9 @@
54
54
  "@uniweb/theming": "0.1.2"
55
55
  },
56
56
  "optionalDependencies": {
57
+ "@uniweb/runtime": "0.6.20",
57
58
  "@uniweb/content-reader": "1.1.4",
58
- "@uniweb/schemas": "0.2.1",
59
- "@uniweb/runtime": "0.6.18"
59
+ "@uniweb/schemas": "0.2.1"
60
60
  },
61
61
  "peerDependencies": {
62
62
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -5,7 +5,7 @@
5
5
  * strings and building a manifest of translation units.
6
6
  */
7
7
 
8
- import { computeHash } from './hash.js'
8
+ import { computeHash, stripInlineTags } from './hash.js'
9
9
 
10
10
  /**
11
11
  * Extract all translatable units from site content
@@ -250,15 +250,36 @@ function extractFromList(listNode, context, units) {
250
250
  }
251
251
 
252
252
  /**
253
- * Extract all text content from a node
253
+ * Extract all text content from a node.
254
+ * When a node has multiple text children with some carrying span marks
255
+ * (e.g., `[text]{accent}`), wraps the marked spans in XLIFF-style
256
+ * `<N>...</N>` tags so translators can reposition them.
254
257
  */
255
258
  function extractTextFromNode(node) {
256
259
  if (!node.content) return ''
257
- return node.content
258
- .filter(n => n.type === 'text')
259
- .map(n => n.text || '')
260
- .join('')
261
- .trim()
260
+ const textChildren = node.content.filter(n => n.type === 'text')
261
+
262
+ // Check for mixed inline marks (span marks from `[text]{class}` syntax)
263
+ const hasInlineMarks = textChildren.length > 1 &&
264
+ textChildren.some(n => n.marks?.some(m => m.type === 'span'))
265
+
266
+ if (!hasInlineMarks) {
267
+ return textChildren.map(n => n.text || '').join('').trim()
268
+ }
269
+
270
+ // Wrap span-marked text in numbered tags
271
+ let markCounter = 0
272
+ const parts = []
273
+ for (const child of textChildren) {
274
+ const text = child.text || ''
275
+ if (child.marks?.some(m => m.type === 'span')) {
276
+ markCounter++
277
+ parts.push(`<${markCounter}>${text}</${markCounter}>`)
278
+ } else {
279
+ parts.push(text)
280
+ }
281
+ }
282
+ return parts.join('').trim()
262
283
  }
263
284
 
264
285
  /**
@@ -267,7 +288,8 @@ function extractTextFromNode(node) {
267
288
  function addUnit(units, source, field, context) {
268
289
  if (!source || source.length === 0) return
269
290
 
270
- const hash = computeHash(source)
291
+ // Hash on plain text (strip inline tags) so keys stay stable
292
+ const hash = computeHash(stripInlineTags(source))
271
293
 
272
294
  if (units[hash]) {
273
295
  // Unit exists - add context if not already present
package/src/i18n/hash.js CHANGED
@@ -28,3 +28,47 @@ export function normalizeText(text) {
28
28
  if (typeof text !== 'string') return ''
29
29
  return text.trim().replace(/\s+/g, ' ')
30
30
  }
31
+
32
+ /**
33
+ * Strip inline mark tags for hashing: "<1>text</1>" → "text"
34
+ * Used to keep hash keys stable regardless of mark tagging.
35
+ * @param {string} text
36
+ * @returns {string}
37
+ */
38
+ export function stripInlineTags(text) {
39
+ if (typeof text !== 'string') return ''
40
+ return text.replace(/<\/?(\d+)>/g, '')
41
+ }
42
+
43
+ /**
44
+ * Parse tagged translation string into segments.
45
+ * "plain <1>marked</1> more" →
46
+ * { segments: [{ text: "plain " }, { text: "marked", markIndex: 0 }, { text: " more" }], hasMarks: true }
47
+ *
48
+ * Tag numbers are 1-based in the string, markIndex is 0-based in the result.
49
+ * @param {string} text
50
+ * @returns {{ segments: Array<{ text: string, markIndex?: number }>, hasMarks: boolean }}
51
+ */
52
+ export function parseInlineTags(text) {
53
+ const regex = /<(\d+)>([\s\S]*?)<\/\1>/g
54
+ const segments = []
55
+ let lastIndex = 0
56
+ let hasMarks = false
57
+ let match
58
+
59
+ while ((match = regex.exec(text)) !== null) {
60
+ hasMarks = true
61
+ if (match.index > lastIndex) {
62
+ segments.push({ text: text.slice(lastIndex, match.index) })
63
+ }
64
+ segments.push({ text: match[2], markIndex: parseInt(match[1], 10) - 1 })
65
+ lastIndex = regex.lastIndex
66
+ }
67
+
68
+ if (lastIndex < text.length) {
69
+ segments.push({ text: text.slice(lastIndex) })
70
+ }
71
+
72
+ if (!hasMarks) return { segments: [{ text }], hasMarks: false }
73
+ return { segments, hasMarks }
74
+ }
package/src/i18n/merge.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * when no free-form translation exists.
13
13
  */
14
14
 
15
- import { computeHash } from './hash.js'
15
+ import { computeHash, stripInlineTags, parseInlineTags } from './hash.js'
16
16
  import { loadFreeformTranslation } from './freeform.js'
17
17
 
18
18
  /**
@@ -264,27 +264,88 @@ function translateProseMirrorDoc(doc, context, translations, fallbackToSource) {
264
264
  }
265
265
 
266
266
  /**
267
- * Recursively translate a node and its children
267
+ * Recursively translate a node and its children.
268
+ * For nodes with mixed marked/unmarked text children (inline span marks),
269
+ * translates the node as a whole unit using XLIFF-style inline tags,
270
+ * then rebuilds children with original marks re-applied.
268
271
  */
269
272
  function translateNode(node, context, translations, fallbackToSource) {
270
- // Translate text content
271
- if (node.content) {
272
- for (const child of node.content) {
273
- if (child.type === 'text' && child.text) {
274
- const translated = lookupTranslation(
275
- child.text,
276
- context,
277
- translations,
278
- fallbackToSource
279
- )
280
- if (translated !== child.text) {
281
- child.text = translated
282
- }
273
+ if (!node.content) return
274
+
275
+ // Check for inline span marks across multiple text children
276
+ const textChildren = node.content.filter(n => n.type === 'text')
277
+ const hasInlineMarks = textChildren.length > 1 &&
278
+ textChildren.some(n => n.marks?.some(m => m.type === 'span'))
279
+
280
+ if (hasInlineMarks) {
281
+ // Build tagged source string (mirrors extraction)
282
+ let markCounter = 0
283
+ const markMap = [] // tag index → original marks array
284
+ const parts = []
285
+ for (const child of textChildren) {
286
+ const text = child.text || ''
287
+ if (child.marks?.some(m => m.type === 'span')) {
288
+ markCounter++
289
+ markMap.push(child.marks)
290
+ parts.push(`<${markCounter}>${text}</${markCounter}>`)
283
291
  } else {
284
- // Recurse into child nodes
292
+ parts.push(text)
293
+ }
294
+ }
295
+ const plainSource = stripInlineTags(parts.join('').trim())
296
+
297
+ const translated = lookupTranslation(plainSource, context, translations, fallbackToSource)
298
+ if (translated !== plainSource) {
299
+ // Parse tagged translation and rebuild text children
300
+ const { segments } = parseInlineTags(translated)
301
+ const newTextChildren = segments.map(seg => {
302
+ if (seg.markIndex !== undefined && markMap[seg.markIndex]) {
303
+ return { type: 'text', text: seg.text, marks: [...markMap[seg.markIndex]] }
304
+ }
305
+ return { type: 'text', text: seg.text }
306
+ })
307
+
308
+ // Replace text children in content, preserve non-text children in place
309
+ const result = []
310
+ let textInserted = false
311
+ for (const child of node.content) {
312
+ if (child.type === 'text') {
313
+ if (!textInserted) {
314
+ result.push(...newTextChildren)
315
+ textInserted = true
316
+ }
317
+ // Skip remaining old text children
318
+ } else {
319
+ result.push(child)
320
+ }
321
+ }
322
+ node.content = result
323
+ }
324
+
325
+ // Recurse into non-text children
326
+ for (const child of node.content) {
327
+ if (child.type !== 'text') {
285
328
  translateNode(child, context, translations, fallbackToSource)
286
329
  }
287
330
  }
331
+ return
332
+ }
333
+
334
+ // Default: translate each child individually
335
+ for (const child of node.content) {
336
+ if (child.type === 'text' && child.text) {
337
+ const translated = lookupTranslation(
338
+ child.text,
339
+ context,
340
+ translations,
341
+ fallbackToSource
342
+ )
343
+ if (translated !== child.text) {
344
+ child.text = translated
345
+ }
346
+ } else {
347
+ translateNode(child, context, translations, fallbackToSource)
348
+ }
288
349
  }
289
350
  }
290
351
 
@@ -309,6 +309,7 @@ export function extractAllRuntimeSchemas(componentsMeta) {
309
309
  * - areas: Array of area names this layout supports
310
310
  * - transitions: View transition name mapping (stored but not acted on yet)
311
311
  * - defaults: Param default values
312
+ * - scroll: Scroll management mode ('self' or CSS selector)
312
313
  *
313
314
  * @param {Object} fullMeta - The full meta.js default export for a layout
314
315
  * @returns {Object|null} - Lean layout runtime schema or null if empty
@@ -328,6 +329,10 @@ export function extractLayoutRuntimeSchema(fullMeta) {
328
329
  runtime.transitions = fullMeta.transitions
329
330
  }
330
331
 
332
+ if (fullMeta.scroll !== undefined) {
333
+ runtime.scroll = fullMeta.scroll
334
+ }
335
+
331
336
  const defaults = extractParamDefaults(fullMeta.params)
332
337
  if (defaults) {
333
338
  runtime.defaults = defaults
@@ -942,10 +942,17 @@ export function siteContentPlugin(options = {}) {
942
942
  headInjection += ` <script type="application/json" id="${variableName}">${JSON.stringify(contentToInject).replace(/</g, '\\u003c')}</script>\n`
943
943
  }
944
944
 
945
- if (!headInjection) return html
945
+ let result = html
946
+
947
+ // Update <html lang> for non-default locales
948
+ if (activeLocale) {
949
+ result = result.replace(/<html([^>]*)\slang="[^"]*"/, `<html$1 lang="${activeLocale}"`)
950
+ }
951
+
952
+ if (!headInjection) return result
946
953
 
947
954
  // Insert before </head>
948
- return html.replace('</head>', headInjection + ' </head>')
955
+ return result.replace('</head>', headInjection + ' </head>')
949
956
  },
950
957
 
951
958
  async generateBundle() {