scratchblocks-plus 1.0.0

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.
Files changed (119) hide show
  1. package/LICENSE +19 -0
  2. package/README.md +193 -0
  3. package/browser.es.js +8 -0
  4. package/browser.js +8 -0
  5. package/build/scratchblocks-plus.min.es.js +12 -0
  6. package/build/scratchblocks-plus.min.es.js.map +1 -0
  7. package/build/scratchblocks-plus.min.js +12 -0
  8. package/build/scratchblocks-plus.min.js.map +1 -0
  9. package/build/translations-all-es.js +11 -0
  10. package/build/translations-all-es.js.map +1 -0
  11. package/build/translations-all.js +11 -0
  12. package/build/translations-all.js.map +1 -0
  13. package/build/translations-es.js +11 -0
  14. package/build/translations-es.js.map +1 -0
  15. package/build/translations.js +11 -0
  16. package/build/translations.js.map +1 -0
  17. package/index.d.ts +297 -0
  18. package/index.js +229 -0
  19. package/locales/ab.json +1630 -0
  20. package/locales/af.json +1630 -0
  21. package/locales/all.d.ts +108 -0
  22. package/locales/all.js +161 -0
  23. package/locales/am.json +1925 -0
  24. package/locales/an.json +1630 -0
  25. package/locales/ar.json +1924 -0
  26. package/locales/ast.json +1630 -0
  27. package/locales/az.json +1925 -0
  28. package/locales/be.json +1630 -0
  29. package/locales/bg.json +1924 -0
  30. package/locales/bn.json +1630 -0
  31. package/locales/ca.json +1930 -0
  32. package/locales/ckb.json +1630 -0
  33. package/locales/cs.json +1930 -0
  34. package/locales/cy.json +1929 -0
  35. package/locales/da.json +1924 -0
  36. package/locales/de.json +1929 -0
  37. package/locales/el.json +1931 -0
  38. package/locales/eo.json +1630 -0
  39. package/locales/es-419.json +1924 -0
  40. package/locales/es.json +1929 -0
  41. package/locales/et.json +1924 -0
  42. package/locales/eu.json +1924 -0
  43. package/locales/fa.json +1929 -0
  44. package/locales/fi.json +1924 -0
  45. package/locales/fil.json +1631 -0
  46. package/locales/forums.js +37 -0
  47. package/locales/fr.json +1929 -0
  48. package/locales/fy.json +1630 -0
  49. package/locales/ga.json +1924 -0
  50. package/locales/gd.json +1929 -0
  51. package/locales/gl.json +1924 -0
  52. package/locales/ha.json +1630 -0
  53. package/locales/he.json +1929 -0
  54. package/locales/hi.json +1635 -0
  55. package/locales/hr.json +1929 -0
  56. package/locales/ht.json +1630 -0
  57. package/locales/hu.json +1930 -0
  58. package/locales/hy.json +1630 -0
  59. package/locales/id.json +1929 -0
  60. package/locales/is.json +1924 -0
  61. package/locales/it.json +1929 -0
  62. package/locales/ja-Hira.json +1637 -0
  63. package/locales/ja.json +1931 -0
  64. package/locales/ka.json +1630 -0
  65. package/locales/kk.json +1632 -0
  66. package/locales/km.json +1630 -0
  67. package/locales/ko.json +1924 -0
  68. package/locales/ku.json +1632 -0
  69. package/locales/lt.json +1924 -0
  70. package/locales/lv.json +1924 -0
  71. package/locales/mi.json +1924 -0
  72. package/locales/mn.json +1631 -0
  73. package/locales/nb.json +1929 -0
  74. package/locales/nl.json +1929 -0
  75. package/locales/nn.json +1630 -0
  76. package/locales/nso.json +1630 -0
  77. package/locales/oc.json +1630 -0
  78. package/locales/or.json +1631 -0
  79. package/locales/pl.json +1929 -0
  80. package/locales/pt-br.json +1924 -0
  81. package/locales/pt.json +1929 -0
  82. package/locales/qu.json +1630 -0
  83. package/locales/rap.json +1632 -0
  84. package/locales/ro.json +1929 -0
  85. package/locales/ru.json +1929 -0
  86. package/locales/sk.json +1924 -0
  87. package/locales/sl.json +1929 -0
  88. package/locales/sr.json +1924 -0
  89. package/locales/sv.json +1924 -0
  90. package/locales/sw.json +1630 -0
  91. package/locales/th.json +1924 -0
  92. package/locales/tn.json +1630 -0
  93. package/locales/tr.json +1932 -0
  94. package/locales/uk.json +1924 -0
  95. package/locales/uz.json +1631 -0
  96. package/locales/vi.json +1925 -0
  97. package/locales/xh.json +1630 -0
  98. package/locales/zh-cn.json +1930 -0
  99. package/locales/zh-tw.json +1930 -0
  100. package/locales/zu.json +1918 -0
  101. package/package.json +81 -0
  102. package/scratch2/blocks.js +1000 -0
  103. package/scratch2/draw.js +452 -0
  104. package/scratch2/filter.js +78 -0
  105. package/scratch2/index.js +12 -0
  106. package/scratch2/style.css.js +148 -0
  107. package/scratch2/style.js +214 -0
  108. package/scratch3/blocks.js +1134 -0
  109. package/scratch3/draw.js +334 -0
  110. package/scratch3/index.js +12 -0
  111. package/scratch3/style.css.js +280 -0
  112. package/scratch3/style.js +877 -0
  113. package/syntax/blocks.js +921 -0
  114. package/syntax/commands.js +1755 -0
  115. package/syntax/dropdowns.js +688 -0
  116. package/syntax/extensions.js +34 -0
  117. package/syntax/index.js +17 -0
  118. package/syntax/model.js +566 -0
  119. package/syntax/syntax.js +1091 -0
@@ -0,0 +1,34 @@
1
+ /*
2
+ When a new extension is added:
3
+ 1) Add it to extensions object
4
+ 2) Add its blocks to commands.js
5
+ 3) Add icon width/height to scratch3/blocks.js IconView
6
+ 4) Add icon to scratch3/style.js
7
+ */
8
+
9
+ // Moved extensions: key is scratch3, value is scratch2
10
+ export const movedExtensions = {
11
+ pen: "pen",
12
+ video: "sensing",
13
+ music: "sound",
14
+ }
15
+
16
+ export const extensions = {
17
+ ...movedExtensions,
18
+ faceSensing: "faceSensing",
19
+ tts: "tts",
20
+ translate: "translate",
21
+ microbit: "microbit",
22
+ gdxfor: "gdxfor",
23
+ wedo: "wedo",
24
+ makeymakey: "makeymakey",
25
+ ev3: "ev3",
26
+ boost: "boost",
27
+ }
28
+
29
+ // Alias extensions: unlike movedExtensions, this is handled for both scratch2 and scratch3.
30
+ // Key is alias, value is real extension name
31
+ export const aliasExtensions = {
32
+ wedo2: "wedo",
33
+ text2speech: "tts",
34
+ }
@@ -0,0 +1,17 @@
1
+ export { parse } from "./syntax.js"
2
+
3
+ export {
4
+ Label,
5
+ Icon,
6
+ Input,
7
+ Matrix,
8
+ Block,
9
+ Comment,
10
+ Glow,
11
+ Script,
12
+ Document,
13
+ } from "./model.js"
14
+
15
+ export { allLanguages, loadLanguages } from "./blocks.js"
16
+
17
+ export { extensions, movedExtensions, aliasExtensions } from "./extensions.js"
@@ -0,0 +1,566 @@
1
+ function assert(bool, message) {
2
+ if (!bool) {
3
+ throw new Error(`Assertion failed! ${message || ""}`)
4
+ }
5
+ }
6
+
7
+ function indent(text) {
8
+ return text
9
+ .split("\n")
10
+ .map(line => {
11
+ return ` ${line}`
12
+ })
13
+ .join("\n")
14
+ }
15
+
16
+ // Compute display width of a string, counting common CJK / fullwidth
17
+ // characters as width 2 so alignment in mixed-language text looks correct.
18
+ function displayWidth(str) {
19
+ if (!str) {
20
+ return 0
21
+ }
22
+ str = String(str)
23
+ let w = 0
24
+ // Rough classification: common CJK ranges and fullwidth block
25
+ const wideRe =
26
+ /[\u2E80-\u2EFF\u2F00-\u2FDF\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\uFF00-\uFFEF]/u
27
+ for (const ch of str) {
28
+ w += wideRe.test(ch) ? 2 : 1
29
+ }
30
+ return w
31
+ }
32
+
33
+ import {
34
+ parseSpec,
35
+ inputPat,
36
+ parseInputNumber,
37
+ iconPat,
38
+ rtlLanguages,
39
+ unicodeIcons,
40
+ hexColorPat,
41
+ } from "./blocks.js"
42
+
43
+ export class Label {
44
+ constructor(value, cls) {
45
+ this.value = value
46
+ this.cls = cls || ""
47
+ this.el = null
48
+ this.height = 12
49
+ this.metrics = null
50
+ this.x = 0
51
+ }
52
+ get isLabel() {
53
+ return true
54
+ }
55
+
56
+ stringify() {
57
+ if (this.value === "<" || this.value === ">") {
58
+ return this.value
59
+ }
60
+ return this.value
61
+ .replace(/([<>[\](){}\\])/g, "\\$1")
62
+ .replace(/:{2,}/g, m => ":" + "\\:".repeat(m.length - 1))
63
+ }
64
+ }
65
+
66
+ export class Icon {
67
+ constructor(name) {
68
+ this.name = name
69
+ this.isArrow = name === "loopArrow"
70
+
71
+ assert(Icon.icons[name], `no info for icon ${name}`)
72
+ }
73
+ get isIcon() {
74
+ return true
75
+ }
76
+
77
+ static get icons() {
78
+ return {
79
+ greenFlag: true,
80
+ stopSign: true,
81
+ turnLeft: true,
82
+ turnRight: true,
83
+ loopArrow: true,
84
+ addInput: true,
85
+ delInput: true,
86
+ list: true,
87
+ }
88
+ }
89
+
90
+ stringify() {
91
+ return unicodeIcons[`@${this.name}`] || ""
92
+ }
93
+ }
94
+
95
+ export class Matrix {
96
+ constructor(rows) {
97
+ // rows should already be 2D array of booleans from parsing
98
+ const inputRows = Array.isArray(rows) ? rows : []
99
+ // Make shallow copies and coerce non-array entries into arrays
100
+ this.rows = inputRows.map(row => (Array.isArray(row) ? row.slice() : [row]))
101
+
102
+ // Determine maximum row length
103
+ const maxLen = this.rows.reduce((m, r) => Math.max(m, r.length), 0)
104
+
105
+ // Pad short rows with false to make all rows the same length
106
+ for (let i = 0; i < this.rows.length; i++) {
107
+ const r = this.rows[i]
108
+ while (r.length < maxLen) {
109
+ r.push(false)
110
+ }
111
+ }
112
+ }
113
+
114
+ get isMatrix() {
115
+ return true
116
+ }
117
+
118
+ stringify() {
119
+ // Format as {row1,row2,row3,...} where each cell is 0 or 1
120
+ const rowStrings = this.rows.map(row =>
121
+ row.map(cell => (cell ? "1" : "0")).join(""),
122
+ )
123
+ return `{${rowStrings.join(",")}}`
124
+ }
125
+
126
+ translate() {
127
+ // Matrix doesn't need translation
128
+ }
129
+ }
130
+
131
+ export class Input {
132
+ constructor(shape, value) {
133
+ this.shape = shape
134
+ this.value = value
135
+
136
+ this.isRound = shape === "number" || shape === "number-dropdown"
137
+ this.isBoolean = shape === "boolean"
138
+ this.isStack = shape === "stack"
139
+ this.isInset =
140
+ shape === "boolean" || shape === "stack" || shape === "reporter"
141
+ this.isColor = shape === "color"
142
+ this.isMatrix = shape === "matrix"
143
+ this.hasArrow = shape === "dropdown" || shape === "number-dropdown"
144
+ this.isDarker =
145
+ shape === "boolean" || shape === "stack" || shape === "dropdown"
146
+ this.isSquare =
147
+ shape === "string" || shape === "color" || shape === "dropdown"
148
+
149
+ // Check if value is a Matrix object
150
+ const isMatrixValue = value && typeof value === "object" && value.isMatrix
151
+
152
+ this.hasLabel = !(
153
+ this.isColor ||
154
+ this.isInset ||
155
+ this.isMatrix ||
156
+ isMatrixValue
157
+ )
158
+ this.label = this.hasLabel
159
+ ? new Label(value, `literal-${this.shape}`)
160
+ : null
161
+ this.x = 0
162
+ }
163
+ get isInput() {
164
+ return true
165
+ }
166
+
167
+ setMenu(value) {
168
+ this.menu = value
169
+ }
170
+
171
+ setMenu(value) {
172
+ this.menu = value
173
+ }
174
+
175
+ stringify(parentPrefix = "") {
176
+ if (this.isColor) {
177
+ assert(this.value[0] === "#")
178
+ return `[${this.value}]`
179
+ }
180
+
181
+ // Handle Matrix values
182
+ if (this.value && typeof this.value === "object" && this.value.isMatrix) {
183
+ const matrixStr = this.value.stringify()
184
+ // Calculate the indentation needed for alignment
185
+ // parentPrefix is the text before this input on the same line
186
+ // We need to account for: parentPrefix + "(" + "{"
187
+ const indentSpaces = Math.max(0, displayWidth(parentPrefix) + 2)
188
+ const indent = " ".repeat(indentSpaces)
189
+
190
+ // Split matrix string and add indentation to each line after the first
191
+ const parts = matrixStr.slice(1, -1).split(",") // Remove outer braces and split by comma
192
+ const formattedMatrix = parts
193
+ .map((part, index) => {
194
+ if (index === 0) {
195
+ return part
196
+ }
197
+ return `\n${indent}${part}`
198
+ })
199
+ .join(",")
200
+
201
+ return `({${formattedMatrix}} v)`
202
+ }
203
+
204
+ // Order sensitive; see #439
205
+ let text = (this.value ? String(this.value) : "")
206
+ .replace(/([\]\\])/g, "\\$1")
207
+ .replace(this.isRound ? /([<>])/g : /$^/, "\\$1")
208
+ .replace(/ v$/, " \\v")
209
+ if (this.hasArrow) {
210
+ text += " v"
211
+ } else if (hexColorPat.test(text)) {
212
+ text = "\\" + text // Escape hex colors
213
+ }
214
+ return this.isRound
215
+ ? `(${text})`
216
+ : this.isSquare
217
+ ? `[${text}]`
218
+ : this.isBoolean
219
+ ? "<>"
220
+ : this.isStack
221
+ ? "{}"
222
+ : text
223
+ }
224
+
225
+ translate(lang) {
226
+ if (this.hasArrow) {
227
+ // Don't create label for Matrix values
228
+ if (this.value && typeof this.value === "object" && this.value.isMatrix) {
229
+ return
230
+ }
231
+ if (this.menu) {
232
+ this.value = lang.dropdowns[this.menu].value
233
+ }
234
+ this.label = new Label(this.value, `literal-${this.shape}`)
235
+ }
236
+ }
237
+ }
238
+
239
+ export class Block {
240
+ constructor(info, children, comment) {
241
+ assert(info)
242
+ this.info = { ...info }
243
+ this.children = children
244
+ this.comment = comment || null
245
+ this.diff = null
246
+
247
+ // Block path for identification (e.g., "1.2.1" = Script 1, Block 2, Child 1)
248
+ this.blockPath = null
249
+
250
+ const shape = this.info.shape
251
+ this.isHat = shape === "hat" || shape === "cat" || shape === "define-hat"
252
+ this.hasPuzzle =
253
+ shape === "stack" ||
254
+ shape === "hat" ||
255
+ shape === "cat" ||
256
+ shape === "c-block" ||
257
+ shape === "define-hat"
258
+ this.isFinal = /cap/.test(shape)
259
+ this.isCommand = shape === "stack" || shape === "cap" || /block/.test(shape)
260
+ this.isOutline = shape === "outline"
261
+ this.isReporter = shape === "reporter"
262
+ this.isBoolean = shape === "boolean"
263
+
264
+ this.isRing = shape === "ring"
265
+ this.hasScript = /block/.test(shape)
266
+ this.isElse = shape === "celse"
267
+ this.isEnd = shape === "cend"
268
+ }
269
+ get isBlock() {
270
+ return true
271
+ }
272
+
273
+ stringify(extras) {
274
+ let firstInput = null
275
+ let checkAlias = false
276
+ let currentLinePrefix = ""
277
+ const parts = []
278
+
279
+ for (const child of this.children) {
280
+ if (child.isIcon) {
281
+ checkAlias = true
282
+ }
283
+ if (!firstInput && !(child.isLabel || child.isIcon)) {
284
+ firstInput = child
285
+ }
286
+
287
+ if (child.isScript) {
288
+ parts.push(`\n${indent(child.stringify())}\n`)
289
+ currentLinePrefix = ""
290
+ } else {
291
+ // Pass the current line prefix to child's stringify for alignment
292
+ const childStr = child.isInput
293
+ ? child.stringify(currentLinePrefix)
294
+ : child.stringify()
295
+
296
+ const trimmed = childStr.trim()
297
+ parts.push(trimmed)
298
+
299
+ // Update prefix for next child
300
+ if (!childStr.includes("\n")) {
301
+ currentLinePrefix += trimmed + " "
302
+ } else {
303
+ // If there's a newline, extract the last line as new prefix
304
+ const lines = childStr.split("\n")
305
+ currentLinePrefix = lines[lines.length - 1].trim() + " "
306
+ }
307
+ }
308
+ }
309
+
310
+ let text = parts.join(" ").trim().replace(/ +\n/g, "\n")
311
+
312
+ if (this.info.shape === "reporter" && hexColorPat.test(text)) {
313
+ return `(\\${text})`
314
+ }
315
+
316
+ const lang = this.info.language
317
+ if (checkAlias && lang && this.info.selector) {
318
+ const aliases = lang.nativeAliases[this.info.id]
319
+ if (aliases && aliases.length) {
320
+ let alias = aliases[0]
321
+ // TODO make translate() not in-place, and use that
322
+ if (inputPat.test(alias) && firstInput) {
323
+ alias = alias.replace(inputPat, firstInput.stringify())
324
+ }
325
+ return alias
326
+ }
327
+ }
328
+
329
+ let overrides = extras || ""
330
+ if (
331
+ this.info.categoryIsDefault === false ||
332
+ (this.info.category === "custom-arg" &&
333
+ (this.isReporter || this.isBoolean)) ||
334
+ (this.info.category === "custom" && this.info.shape === "stack")
335
+ ) {
336
+ if (overrides) {
337
+ overrides += " "
338
+ }
339
+ if (this.info.isReset && this.info.category === "obsolete") {
340
+ overrides += "reset"
341
+ } else {
342
+ overrides += this.info.category
343
+ }
344
+ }
345
+ if (this.info.shapeIsDefault === false) {
346
+ if (overrides) {
347
+ overrides += " "
348
+ }
349
+ overrides += this.info.shape
350
+ }
351
+ if (overrides) {
352
+ text += ` :: ${overrides}`
353
+ }
354
+ return this.hasScript
355
+ ? text +
356
+ "\n" +
357
+ (Object.keys(lang.aliases).find(
358
+ key => lang.aliases[key] === "scratchblocks:end",
359
+ ) || "end")
360
+ : this.info.shape === "reporter"
361
+ ? `(${text})`
362
+ : this.info.shape === "boolean"
363
+ ? `<${text}>`
364
+ : text
365
+ }
366
+
367
+ translate(lang, isShallow) {
368
+ if (!lang) {
369
+ throw new Error("Missing language")
370
+ }
371
+
372
+ const id = this.info.id
373
+ if (!id) {
374
+ return
375
+ }
376
+
377
+ if (id === "PROCEDURES_DEFINITION") {
378
+ // Find the first 'outline' child (there should be exactly one).
379
+ const outline = this.children.find(child => child.isOutline)
380
+
381
+ this.children = []
382
+ for (const word of lang.definePrefix) {
383
+ this.children.push(new Label(word))
384
+ }
385
+ this.children.push(outline)
386
+ for (const word of lang.defineSuffix) {
387
+ this.children.push(new Label(word))
388
+ }
389
+ return
390
+ } else if (id === "PROCEDURES_CALL") {
391
+ this.children.forEach(child => {
392
+ if (!child.isLabel && !child.isIcon) {
393
+ child.translate(lang)
394
+ }
395
+ })
396
+ return
397
+ }
398
+
399
+ const oldSpec = this.info.language.commands[id]
400
+
401
+ const nativeSpec = lang.commands[id]
402
+ if (!nativeSpec) {
403
+ return
404
+ }
405
+ const nativeInfo = parseSpec(nativeSpec)
406
+
407
+ const rawArgs = this.children.filter(
408
+ child => !child.isLabel && !child.isIcon,
409
+ )
410
+
411
+ if (!isShallow) {
412
+ rawArgs.forEach(child => child.translate(lang))
413
+ }
414
+
415
+ // Work out indexes of existing children
416
+ const oldParts = parseSpec(oldSpec).parts
417
+ const oldInputOrder = oldParts
418
+ .map(part => parseInputNumber(part))
419
+ .filter(x => x)
420
+
421
+ let highestNumber = 0
422
+ const args = oldInputOrder.map(number => {
423
+ highestNumber = Math.max(highestNumber, number)
424
+ return rawArgs[number - 1]
425
+ })
426
+ const remainingArgs = rawArgs.slice(highestNumber)
427
+
428
+ // Get new children by index
429
+ this.children = nativeInfo.parts
430
+ .map(part => {
431
+ part = part.trim()
432
+ if (!part) {
433
+ return
434
+ }
435
+ const number = parseInputNumber(part)
436
+ if (number) {
437
+ return args[number - 1]
438
+ }
439
+ return iconPat.test(part) ? new Icon(part.slice(1)) : new Label(part)
440
+ })
441
+ .filter(x => x)
442
+
443
+ // Push any remaining children, so we pick up C block bodies
444
+ remainingArgs.forEach((arg, index) => {
445
+ if (index === 1 && this.info.id === "CONTROL_IF") {
446
+ this.children.push(new Label(lang.commands.CONTROL_ELSE))
447
+ }
448
+ this.children.push(arg)
449
+ })
450
+
451
+ this.info.language = lang
452
+ this.info.isRTL = rtlLanguages.includes(lang.code)
453
+ this.info.categoryIsDefault = true
454
+ }
455
+ }
456
+
457
+ export class Comment {
458
+ constructor(value, hasBlock) {
459
+ this.label = new Label(value, "comment-label")
460
+ this.width = null
461
+ this.hasBlock = hasBlock
462
+ }
463
+ get isComment() {
464
+ return true
465
+ }
466
+
467
+ stringify() {
468
+ return `// ${this.label.value.trim()}`
469
+ }
470
+ }
471
+
472
+ export class Glow {
473
+ constructor(child) {
474
+ assert(child)
475
+ this.child = child
476
+ if (child.isBlock) {
477
+ this.shape = child.info.shape
478
+ this.info = child.info
479
+ } else {
480
+ this.shape = "stack"
481
+ }
482
+ }
483
+ get isGlow() {
484
+ return true
485
+ }
486
+
487
+ stringify() {
488
+ if (this.child.isBlock) {
489
+ return this.child.stringify("+")
490
+ }
491
+ const lines = this.child.stringify().split("\n")
492
+ return lines.map(line => `+ ${line}`).join("\n")
493
+ }
494
+
495
+ translate(lang) {
496
+ this.child.translate(lang)
497
+ }
498
+ }
499
+
500
+ export class Script {
501
+ constructor(blocks) {
502
+ this.blocks = blocks
503
+ this.isEmpty = !blocks.length
504
+ this.isFinal = !this.isEmpty && blocks[blocks.length - 1].isFinal
505
+ // Script index for block path generation
506
+ this.scriptIndex = null
507
+ }
508
+ get isScript() {
509
+ return true
510
+ }
511
+
512
+ stringify() {
513
+ return this.blocks
514
+ .map(block => {
515
+ let line = block.stringify()
516
+ if (block.comment) {
517
+ // If this block contains a script (multi-line), insert the
518
+ // comment on the first line (the opening line) instead of
519
+ // appending it after the whole multi-line block (which would
520
+ // place it after the trailing "end").
521
+ if (block.isBlock && block.hasScript) {
522
+ const commentText = ` ${block.comment.stringify()}`
523
+ const nl = line.indexOf("\n")
524
+ if (nl !== -1) {
525
+ line = line.slice(0, nl) + commentText + line.slice(nl)
526
+ } else {
527
+ line += commentText
528
+ }
529
+ } else {
530
+ line += ` ${block.comment.stringify()}`
531
+ }
532
+ }
533
+ return line
534
+ })
535
+ .join("\n")
536
+ }
537
+
538
+ translate(lang) {
539
+ this.blocks.forEach(block => block.translate && block.translate(lang))
540
+ }
541
+ }
542
+
543
+ export class Document {
544
+ constructor(scripts) {
545
+ this.scripts = scripts
546
+ // Map of blockPath -> Block for quick lookup
547
+ this.blockMap = new Map()
548
+ }
549
+
550
+ stringify() {
551
+ return this.scripts.map(script => script.stringify()).join("\n\n")
552
+ }
553
+
554
+ translate(lang) {
555
+ this.scripts.forEach(script => script.translate(lang))
556
+ }
557
+
558
+ /**
559
+ * Get a block by its path (e.g., "1.2.1")
560
+ * @param {string} path - The block path
561
+ * @returns {Block|null}
562
+ */
563
+ getBlockByPath(path) {
564
+ return this.blockMap.get(path) || null
565
+ }
566
+ }