@intlayer/engine 9.1.2 → 9.2.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 (38) hide show
  1. package/dist/assets/initConfig/templates/cjs.txt +4 -1
  2. package/dist/assets/initConfig/templates/json.txt +4 -1
  3. package/dist/assets/initConfig/templates/mjs.txt +4 -1
  4. package/dist/assets/initConfig/templates/ts.txt +4 -1
  5. package/dist/cjs/docReview/alignBlocks.cjs +57 -8
  6. package/dist/cjs/docReview/alignBlocks.cjs.map +1 -1
  7. package/dist/cjs/docReview/rebuildDocument.cjs +37 -9
  8. package/dist/cjs/docReview/rebuildDocument.cjs.map +1 -1
  9. package/dist/cjs/docReview/segmentDocument.cjs +19 -2
  10. package/dist/cjs/docReview/segmentDocument.cjs.map +1 -1
  11. package/dist/cjs/prepareIntlayer.cjs +7 -3
  12. package/dist/cjs/prepareIntlayer.cjs.map +1 -1
  13. package/dist/cjs/utils/runOnce.cjs +129 -50
  14. package/dist/cjs/utils/runOnce.cjs.map +1 -1
  15. package/dist/cjs/writeFileIfChanged.cjs +14 -7
  16. package/dist/cjs/writeFileIfChanged.cjs.map +1 -1
  17. package/dist/esm/docReview/alignBlocks.mjs +57 -8
  18. package/dist/esm/docReview/alignBlocks.mjs.map +1 -1
  19. package/dist/esm/docReview/rebuildDocument.mjs +37 -9
  20. package/dist/esm/docReview/rebuildDocument.mjs.map +1 -1
  21. package/dist/esm/docReview/segmentDocument.mjs +19 -2
  22. package/dist/esm/docReview/segmentDocument.mjs.map +1 -1
  23. package/dist/esm/prepareIntlayer.mjs +7 -3
  24. package/dist/esm/prepareIntlayer.mjs.map +1 -1
  25. package/dist/esm/utils/runOnce.mjs +129 -50
  26. package/dist/esm/utils/runOnce.mjs.map +1 -1
  27. package/dist/esm/writeFileIfChanged.mjs +15 -8
  28. package/dist/esm/writeFileIfChanged.mjs.map +1 -1
  29. package/dist/types/docReview/alignBlocks.d.ts +4 -2
  30. package/dist/types/docReview/alignBlocks.d.ts.map +1 -1
  31. package/dist/types/docReview/rebuildDocument.d.ts.map +1 -1
  32. package/dist/types/docReview/segmentDocument.d.ts.map +1 -1
  33. package/dist/types/docReview/types.d.ts +9 -0
  34. package/dist/types/docReview/types.d.ts.map +1 -1
  35. package/dist/types/utils/index.d.ts +2 -2
  36. package/dist/types/utils/runOnce.d.ts +30 -6
  37. package/dist/types/utils/runOnce.d.ts.map +1 -1
  38. package/package.json +10 -10
@@ -1,7 +1,17 @@
1
1
  //#region src/docReview/segmentDocument.ts
2
+ const HEADING_PATTERN = /^\s*(#{1,6})\s+/;
2
3
  const isBlankLine = (line) => line.trim().length === 0;
3
4
  const isFencedCodeDelimiter = (line) => /^\s*```/.test(line);
4
- const isHeading = (line) => /^\s*#{1,6}\s+/.test(line);
5
+ /**
6
+ * Read the depth of an ATX markdown heading (`#` → 1, `######` → 6).
7
+ *
8
+ * @param line - The line to inspect.
9
+ * @returns The heading depth, or `null` when the line is not a heading.
10
+ */
11
+ const parseHeadingDepth = (line) => {
12
+ return HEADING_PATTERN.exec(line)?.[1]?.length ?? null;
13
+ };
14
+ const isHeading = (line) => parseHeadingDepth(line) !== null;
5
15
  const isFrontmatterDelimiter = (line) => /^\s*---\s*$/.test(line);
6
16
  /**
7
17
  * Split a markdown document into fine-grained blocks.
@@ -35,6 +45,7 @@ const segmentDocument = (text) => {
35
45
  if (index < lineCount) index += 1;
36
46
  units.push({
37
47
  type: "unknown",
48
+ headingDepth: null,
38
49
  startIndex,
39
50
  endIndex: index - 1
40
51
  });
@@ -47,14 +58,17 @@ const segmentDocument = (text) => {
47
58
  if (index < lineCount) index += 1;
48
59
  units.push({
49
60
  type: "code_block",
61
+ headingDepth: null,
50
62
  startIndex,
51
63
  endIndex: index - 1
52
64
  });
53
65
  continue;
54
66
  }
55
- if (isHeading(currentLine)) {
67
+ const headingDepth = parseHeadingDepth(currentLine);
68
+ if (headingDepth !== null) {
56
69
  units.push({
57
70
  type: "heading",
71
+ headingDepth,
58
72
  startIndex: index,
59
73
  endIndex: index
60
74
  });
@@ -65,6 +79,7 @@ const segmentDocument = (text) => {
65
79
  while (index < lineCount && !isBlankLine(lines[index]) && !isHeading(lines[index]) && !isFencedCodeDelimiter(lines[index])) index += 1;
66
80
  units.push({
67
81
  type: "paragraph",
82
+ headingDepth: null,
68
83
  startIndex,
69
84
  endIndex: index - 1
70
85
  });
@@ -78,6 +93,7 @@ const segmentDocument = (text) => {
78
93
  return {
79
94
  type: unit.type,
80
95
  content,
96
+ headingDepth: unit.headingDepth,
81
97
  lineStart: blockStartIndex + 1,
82
98
  lineEnd: blockEndIndex + 1
83
99
  };
@@ -112,6 +128,7 @@ const segmentSections = (text) => {
112
128
  sections.push({
113
129
  type: firstBlock.type,
114
130
  content: currentBlocks.map((block) => block.content).join(""),
131
+ headingDepth: firstBlock.headingDepth,
115
132
  lineStart: firstBlock.lineStart,
116
133
  lineEnd: lastBlock.lineEnd
117
134
  });
@@ -1 +1 @@
1
- {"version":3,"file":"segmentDocument.mjs","names":[],"sources":["../../../src/docReview/segmentDocument.ts"],"sourcesContent":["import type { Block, BlockType } from './types';\n\nconst isBlankLine = (line: string): boolean => line.trim().length === 0;\nconst isFencedCodeDelimiter = (line: string): boolean => /^\\s*```/.test(line);\nconst isHeading = (line: string): boolean => /^\\s*#{1,6}\\s+/.test(line);\nconst isFrontmatterDelimiter = (line: string): boolean =>\n /^\\s*---\\s*$/.test(line);\n\n/**\n * A content unit (heading, paragraph, code block or frontmatter) spanning a\n * 0-based, inclusive line range. Blank-line runs are not units of their own;\n * they are folded into the preceding unit as trailing separators (see\n * {@link segmentDocument}) so that the concatenation of every block's content\n * reproduces the document byte-for-byte.\n */\ntype ContentUnit = {\n type: BlockType;\n startIndex: number;\n endIndex: number;\n};\n\n/**\n * Split a markdown document into fine-grained blocks.\n *\n * Boundaries are drawn at headings, blank lines (paragraph breaks) and fenced\n * code blocks, while frontmatter and the inside of code fences are kept intact.\n * Each blank-line run is appended to the block that precedes it, so the blocks\n * form an exact partition of the document: concatenating every `content` (in\n * order) yields the original text unchanged. This is what lets the block-aware\n * review re-translate only the paragraphs/snippets that actually changed instead\n * of the whole heading section.\n *\n * @param text - The full markdown document.\n * @returns The ordered list of blocks with their 1-based line ranges.\n */\nexport const segmentDocument = (text: string): Block[] => {\n const lines = text.split('\\n');\n const lineCount = lines.length;\n\n // 1. Tokenize into content units, skipping blank-line runs (folded in below).\n const units: ContentUnit[] = [];\n let index = 0;\n\n while (index < lineCount) {\n const currentLine = lines[index];\n\n if (isBlankLine(currentLine)) {\n index += 1;\n continue;\n }\n\n // Frontmatter: only when it opens the document.\n if (units.length === 0 && isFrontmatterDelimiter(currentLine)) {\n const startIndex = index;\n index += 1;\n while (index < lineCount && !isFrontmatterDelimiter(lines[index])) {\n index += 1;\n }\n // Include the closing delimiter when present.\n if (index < lineCount) index += 1;\n units.push({ type: 'unknown', startIndex, endIndex: index - 1 });\n continue;\n }\n\n // Fenced code block: consumed whole so inner blank lines and `#` lines are\n // never treated as boundaries.\n if (isFencedCodeDelimiter(currentLine)) {\n const startIndex = index;\n index += 1;\n while (index < lineCount && !isFencedCodeDelimiter(lines[index])) {\n index += 1;\n }\n // Include the closing fence when present.\n if (index < lineCount) index += 1;\n units.push({ type: 'code_block', startIndex, endIndex: index - 1 });\n continue;\n }\n\n // Heading: a single self-contained line.\n if (isHeading(currentLine)) {\n units.push({ type: 'heading', startIndex: index, endIndex: index });\n index += 1;\n continue;\n }\n\n // Paragraph: a run of consecutive lines until a blank line, a heading or a\n // code fence. Tables and tight lists stay together (no blank line between\n // their rows/items).\n const startIndex = index;\n while (\n index < lineCount &&\n !isBlankLine(lines[index]) &&\n !isHeading(lines[index]) &&\n !isFencedCodeDelimiter(lines[index])\n ) {\n index += 1;\n }\n units.push({ type: 'paragraph', startIndex, endIndex: index - 1 });\n }\n\n if (units.length === 0) return [];\n\n // 2. Turn each unit into a block whose line range extends to just before the\n // next unit, so the trailing blank-line run is owned by it. The first block\n // also absorbs any leading blank lines, and the last block runs to EOF.\n return units.map((unit, unitIndex): Block => {\n const blockStartIndex = unitIndex === 0 ? 0 : unit.startIndex;\n const blockEndIndex =\n unitIndex === units.length - 1\n ? lineCount - 1\n : units[unitIndex + 1].startIndex - 1;\n\n const blockLines = lines.slice(blockStartIndex, blockEndIndex + 1);\n // Re-append the boundary newline dropped by `split` for every block but the\n // one ending at EOF, so concatenating all blocks rebuilds the document.\n const content =\n blockEndIndex < lineCount - 1\n ? `${blockLines.join('\\n')}\\n`\n : blockLines.join('\\n');\n\n return {\n type: unit.type,\n content,\n lineStart: blockStartIndex + 1,\n lineEnd: blockEndIndex + 1,\n };\n });\n};\n\n/**\n * Split a markdown document into coarse, heading-anchored sections.\n *\n * Built by grouping the fine blocks of {@link segmentDocument}: frontmatter and\n * each heading open a new section, and the following non-heading blocks are\n * folded into it. Because it only concatenates adjacent fine blocks, the result\n * is still an exact partition of the document (sections concatenate back to the\n * source unchanged).\n *\n * Sections are the robust alignment unit between a base document and its\n * translation — both share the same heading structure, so they align almost\n * perfectly and a translation that splits its prose into a different number of\n * paragraphs never causes a section to be dropped. Fine-grained review happens\n * within a section once it is known to have changed.\n *\n * @param text - The full markdown document.\n * @returns The ordered list of sections with their 1-based line ranges.\n */\nexport const segmentSections = (text: string): Block[] => {\n const fineBlocks = segmentDocument(text);\n const sections: Block[] = [];\n let currentBlocks: Block[] = [];\n\n const flushSection = (): void => {\n if (currentBlocks.length === 0) return;\n\n const [firstBlock] = currentBlocks;\n const lastBlock = currentBlocks[currentBlocks.length - 1];\n\n sections.push({\n type: firstBlock.type,\n content: currentBlocks.map((block) => block.content).join(''),\n lineStart: firstBlock.lineStart,\n lineEnd: lastBlock.lineEnd,\n });\n currentBlocks = [];\n };\n\n for (const block of fineBlocks) {\n // Frontmatter (a leading `unknown` block) and every heading open a section.\n const opensSection =\n block.type === 'heading' ||\n (block.type === 'unknown' && sections.length === 0);\n\n if (opensSection) flushSection();\n currentBlocks.push(block);\n if (block.type === 'unknown' && sections.length === 0) flushSection();\n }\n\n flushSection();\n\n return sections;\n};\n"],"mappings":"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"}
1
+ {"version":3,"file":"segmentDocument.mjs","names":[],"sources":["../../../src/docReview/segmentDocument.ts"],"sourcesContent":["import type { Block, BlockType } from './types';\n\nconst HEADING_PATTERN = /^\\s*(#{1,6})\\s+/;\n\nconst isBlankLine = (line: string): boolean => line.trim().length === 0;\nconst isFencedCodeDelimiter = (line: string): boolean => /^\\s*```/.test(line);\n\n/**\n * Read the depth of an ATX markdown heading (`#` → 1, `######` → 6).\n *\n * @param line - The line to inspect.\n * @returns The heading depth, or `null` when the line is not a heading.\n */\nconst parseHeadingDepth = (line: string): number | null => {\n const match = HEADING_PATTERN.exec(line);\n\n return match?.[1]?.length ?? null;\n};\n\nconst isHeading = (line: string): boolean => parseHeadingDepth(line) !== null;\nconst isFrontmatterDelimiter = (line: string): boolean =>\n /^\\s*---\\s*$/.test(line);\n\n/**\n * A content unit (heading, paragraph, code block or frontmatter) spanning a\n * 0-based, inclusive line range. Blank-line runs are not units of their own;\n * they are folded into the preceding unit as trailing separators (see\n * {@link segmentDocument}) so that the concatenation of every block's content\n * reproduces the document byte-for-byte.\n */\ntype ContentUnit = {\n type: BlockType;\n /** Depth of the ATX heading opening the unit, `null` when it is not a heading. */\n headingDepth: number | null;\n startIndex: number;\n endIndex: number;\n};\n\n/**\n * Split a markdown document into fine-grained blocks.\n *\n * Boundaries are drawn at headings, blank lines (paragraph breaks) and fenced\n * code blocks, while frontmatter and the inside of code fences are kept intact.\n * Each blank-line run is appended to the block that precedes it, so the blocks\n * form an exact partition of the document: concatenating every `content` (in\n * order) yields the original text unchanged. This is what lets the block-aware\n * review re-translate only the paragraphs/snippets that actually changed instead\n * of the whole heading section.\n *\n * @param text - The full markdown document.\n * @returns The ordered list of blocks with their 1-based line ranges.\n */\nexport const segmentDocument = (text: string): Block[] => {\n const lines = text.split('\\n');\n const lineCount = lines.length;\n\n // 1. Tokenize into content units, skipping blank-line runs (folded in below).\n const units: ContentUnit[] = [];\n let index = 0;\n\n while (index < lineCount) {\n const currentLine = lines[index];\n\n if (isBlankLine(currentLine)) {\n index += 1;\n continue;\n }\n\n // Frontmatter: only when it opens the document.\n if (units.length === 0 && isFrontmatterDelimiter(currentLine)) {\n const startIndex = index;\n index += 1;\n while (index < lineCount && !isFrontmatterDelimiter(lines[index])) {\n index += 1;\n }\n // Include the closing delimiter when present.\n if (index < lineCount) index += 1;\n units.push({\n type: 'unknown',\n headingDepth: null,\n startIndex,\n endIndex: index - 1,\n });\n continue;\n }\n\n // Fenced code block: consumed whole so inner blank lines and `#` lines are\n // never treated as boundaries.\n if (isFencedCodeDelimiter(currentLine)) {\n const startIndex = index;\n index += 1;\n while (index < lineCount && !isFencedCodeDelimiter(lines[index])) {\n index += 1;\n }\n // Include the closing fence when present.\n if (index < lineCount) index += 1;\n units.push({\n type: 'code_block',\n headingDepth: null,\n startIndex,\n endIndex: index - 1,\n });\n continue;\n }\n\n // Heading: a single self-contained line.\n const headingDepth = parseHeadingDepth(currentLine);\n\n if (headingDepth !== null) {\n units.push({\n type: 'heading',\n headingDepth,\n startIndex: index,\n endIndex: index,\n });\n index += 1;\n continue;\n }\n\n // Paragraph: a run of consecutive lines until a blank line, a heading or a\n // code fence. Tables and tight lists stay together (no blank line between\n // their rows/items).\n const startIndex = index;\n while (\n index < lineCount &&\n !isBlankLine(lines[index]) &&\n !isHeading(lines[index]) &&\n !isFencedCodeDelimiter(lines[index])\n ) {\n index += 1;\n }\n units.push({\n type: 'paragraph',\n headingDepth: null,\n startIndex,\n endIndex: index - 1,\n });\n }\n\n if (units.length === 0) return [];\n\n // 2. Turn each unit into a block whose line range extends to just before the\n // next unit, so the trailing blank-line run is owned by it. The first block\n // also absorbs any leading blank lines, and the last block runs to EOF.\n return units.map((unit, unitIndex): Block => {\n const blockStartIndex = unitIndex === 0 ? 0 : unit.startIndex;\n const blockEndIndex =\n unitIndex === units.length - 1\n ? lineCount - 1\n : units[unitIndex + 1].startIndex - 1;\n\n const blockLines = lines.slice(blockStartIndex, blockEndIndex + 1);\n // Re-append the boundary newline dropped by `split` for every block but the\n // one ending at EOF, so concatenating all blocks rebuilds the document.\n const content =\n blockEndIndex < lineCount - 1\n ? `${blockLines.join('\\n')}\\n`\n : blockLines.join('\\n');\n\n return {\n type: unit.type,\n content,\n headingDepth: unit.headingDepth,\n lineStart: blockStartIndex + 1,\n lineEnd: blockEndIndex + 1,\n };\n });\n};\n\n/**\n * Split a markdown document into coarse, heading-anchored sections.\n *\n * Built by grouping the fine blocks of {@link segmentDocument}: frontmatter and\n * each heading open a new section, and the following non-heading blocks are\n * folded into it. Because it only concatenates adjacent fine blocks, the result\n * is still an exact partition of the document (sections concatenate back to the\n * source unchanged).\n *\n * Sections are the robust alignment unit between a base document and its\n * translation — both share the same heading structure, so they align almost\n * perfectly and a translation that splits its prose into a different number of\n * paragraphs never causes a section to be dropped. Fine-grained review happens\n * within a section once it is known to have changed.\n *\n * @param text - The full markdown document.\n * @returns The ordered list of sections with their 1-based line ranges.\n */\nexport const segmentSections = (text: string): Block[] => {\n const fineBlocks = segmentDocument(text);\n const sections: Block[] = [];\n let currentBlocks: Block[] = [];\n\n const flushSection = (): void => {\n if (currentBlocks.length === 0) return;\n\n const [firstBlock] = currentBlocks;\n const lastBlock = currentBlocks[currentBlocks.length - 1];\n\n sections.push({\n type: firstBlock.type,\n content: currentBlocks.map((block) => block.content).join(''),\n // A section is identified by the heading that opens it, so it inherits its\n // depth — this is what keeps a `##` section from aligning with a `###` one.\n headingDepth: firstBlock.headingDepth,\n lineStart: firstBlock.lineStart,\n lineEnd: lastBlock.lineEnd,\n });\n currentBlocks = [];\n };\n\n for (const block of fineBlocks) {\n // Frontmatter (a leading `unknown` block) and every heading open a section.\n const opensSection =\n block.type === 'heading' ||\n (block.type === 'unknown' && sections.length === 0);\n\n if (opensSection) flushSection();\n currentBlocks.push(block);\n if (block.type === 'unknown' && sections.length === 0) flushSection();\n }\n\n flushSection();\n\n return sections;\n};\n"],"mappings":";AAEA,MAAM,kBAAkB;AAExB,MAAM,eAAe,SAA0B,KAAK,KAAK,CAAC,CAAC,WAAW;AACtE,MAAM,yBAAyB,SAA0B,UAAU,KAAK,IAAI;;;;;;;AAQ5E,MAAM,qBAAqB,SAAgC;CAGzD,OAFc,gBAAgB,KAAK,IAExB,CAAC,GAAG,EAAE,EAAE,UAAU;AAC/B;AAEA,MAAM,aAAa,SAA0B,kBAAkB,IAAI,MAAM;AACzE,MAAM,0BAA0B,SAC9B,cAAc,KAAK,IAAI;;;;;;;;;;;;;;;AA+BzB,MAAa,mBAAmB,SAA0B;CACxD,MAAM,QAAQ,KAAK,MAAM,IAAI;CAC7B,MAAM,YAAY,MAAM;CAGxB,MAAM,QAAuB,CAAC;CAC9B,IAAI,QAAQ;CAEZ,OAAO,QAAQ,WAAW;EACxB,MAAM,cAAc,MAAM;EAE1B,IAAI,YAAY,WAAW,GAAG;GAC5B,SAAS;GACT;EACF;EAGA,IAAI,MAAM,WAAW,KAAK,uBAAuB,WAAW,GAAG;GAC7D,MAAM,aAAa;GACnB,SAAS;GACT,OAAO,QAAQ,aAAa,CAAC,uBAAuB,MAAM,MAAM,GAC9D,SAAS;GAGX,IAAI,QAAQ,WAAW,SAAS;GAChC,MAAM,KAAK;IACT,MAAM;IACN,cAAc;IACd;IACA,UAAU,QAAQ;GACpB,CAAC;GACD;EACF;EAIA,IAAI,sBAAsB,WAAW,GAAG;GACtC,MAAM,aAAa;GACnB,SAAS;GACT,OAAO,QAAQ,aAAa,CAAC,sBAAsB,MAAM,MAAM,GAC7D,SAAS;GAGX,IAAI,QAAQ,WAAW,SAAS;GAChC,MAAM,KAAK;IACT,MAAM;IACN,cAAc;IACd;IACA,UAAU,QAAQ;GACpB,CAAC;GACD;EACF;EAGA,MAAM,eAAe,kBAAkB,WAAW;EAElD,IAAI,iBAAiB,MAAM;GACzB,MAAM,KAAK;IACT,MAAM;IACN;IACA,YAAY;IACZ,UAAU;GACZ,CAAC;GACD,SAAS;GACT;EACF;EAKA,MAAM,aAAa;EACnB,OACE,QAAQ,aACR,CAAC,YAAY,MAAM,MAAM,KACzB,CAAC,UAAU,MAAM,MAAM,KACvB,CAAC,sBAAsB,MAAM,MAAM,GAEnC,SAAS;EAEX,MAAM,KAAK;GACT,MAAM;GACN,cAAc;GACd;GACA,UAAU,QAAQ;EACpB,CAAC;CACH;CAEA,IAAI,MAAM,WAAW,GAAG,OAAO,CAAC;CAKhC,OAAO,MAAM,KAAK,MAAM,cAAqB;EAC3C,MAAM,kBAAkB,cAAc,IAAI,IAAI,KAAK;EACnD,MAAM,gBACJ,cAAc,MAAM,SAAS,IACzB,YAAY,IACZ,MAAM,YAAY,EAAE,CAAC,aAAa;EAExC,MAAM,aAAa,MAAM,MAAM,iBAAiB,gBAAgB,CAAC;EAGjE,MAAM,UACJ,gBAAgB,YAAY,IACxB,GAAG,WAAW,KAAK,IAAI,EAAE,MACzB,WAAW,KAAK,IAAI;EAE1B,OAAO;GACL,MAAM,KAAK;GACX;GACA,cAAc,KAAK;GACnB,WAAW,kBAAkB;GAC7B,SAAS,gBAAgB;EAC3B;CACF,CAAC;AACH;;;;;;;;;;;;;;;;;;;AAoBA,MAAa,mBAAmB,SAA0B;CACxD,MAAM,aAAa,gBAAgB,IAAI;CACvC,MAAM,WAAoB,CAAC;CAC3B,IAAI,gBAAyB,CAAC;CAE9B,MAAM,qBAA2B;EAC/B,IAAI,cAAc,WAAW,GAAG;EAEhC,MAAM,CAAC,cAAc;EACrB,MAAM,YAAY,cAAc,cAAc,SAAS;EAEvD,SAAS,KAAK;GACZ,MAAM,WAAW;GACjB,SAAS,cAAc,KAAK,UAAU,MAAM,OAAO,CAAC,CAAC,KAAK,EAAE;GAG5D,cAAc,WAAW;GACzB,WAAW,WAAW;GACtB,SAAS,UAAU;EACrB,CAAC;EACD,gBAAgB,CAAC;CACnB;CAEA,KAAK,MAAM,SAAS,YAAY;EAM9B,IAHE,MAAM,SAAS,aACd,MAAM,SAAS,aAAa,SAAS,WAAW,GAEjC,aAAa;EAC/B,cAAc,KAAK,KAAK;EACxB,IAAI,MAAM,SAAS,aAAa,SAAS,WAAW,GAAG,aAAa;CACtE;CAEA,aAAa;CAEb,OAAO;AACT"}
@@ -27,7 +27,7 @@ const prepareIntlayer = async (configuration, options) => {
27
27
  const sentinelPath = join(configuration.system.cacheDir, "intlayer-prepared.lock");
28
28
  const versionCache = cacheDisk(configuration, ["intlayer-version"]);
29
29
  const intlayerCacheVersion = await versionCache.get();
30
- const isCorrectVersion = Boolean(intlayerCacheVersion && intlayerCacheVersion === packageJson.version);
30
+ const isCorrectVersion = Boolean(intlayerCacheVersion === void 0 || intlayerCacheVersion === packageJson.version);
31
31
  const isConfigSimilar = await isCachedConfigurationUpToDate(configuration);
32
32
  const dictionariesWithStats = await listDictionariesWithStats(configuration);
33
33
  let isDictionaryChanged = false;
@@ -41,8 +41,12 @@ const prepareIntlayer = async (configuration, options) => {
41
41
  forceRun: !isCorrectVersion || !isConfigSimilar || isDictionaryChanged || hasPluginLoadDictionaries,
42
42
  ...options ?? {}
43
43
  };
44
- await runOnce(sentinelPath, async () => {
45
- if (clean || !isCorrectVersion) await cleanOutputDir(configuration);
44
+ await runOnce(sentinelPath, async ({ renewLock }) => {
45
+ const isCorrectVersionUnderLock = await versionCache.get() === packageJson.version;
46
+ if (clean || !isCorrectVersionUnderLock) {
47
+ await cleanOutputDir(configuration);
48
+ await renewLock();
49
+ }
46
50
  await versionCache.set(packageJson.version);
47
51
  const preparationStartMs = Date.now();
48
52
  appLogger(["Preparing Intlayer", colorize(`(v${packageJson.version})`, ANSIColors.GREY_DARK)]);
@@ -1 +1 @@
1
- {"version":3,"file":"prepareIntlayer.mjs","names":[],"sources":["../../src/prepareIntlayer.ts"],"sourcesContent":["import { stat } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport * as ANSIColors from '@intlayer/config/colors';\nimport { colorize, getAppLogger } from '@intlayer/config/logger';\nimport packageJson from '@intlayer/config/package.json' with { type: 'json' };\nimport { cacheDisk } from '@intlayer/config/utils';\nimport type { IntlayerConfig } from '@intlayer/types/config';\nimport { buildDictionary } from './buildIntlayerDictionary/buildIntlayerDictionary';\nimport { writeRemoteDictionary } from './buildIntlayerDictionary/writeRemoteDictionary';\nimport { cleanOutputDir } from './cleanOutputDir';\nimport { createDictionaryEntryPoint } from './createDictionaryEntryPoint/createDictionaryEntryPoint';\nimport { createModuleAugmentation, createTypes } from './createType/index';\nimport { listDictionariesWithStats } from './listDictionariesPath';\nimport { loadDictionaries } from './loadDictionaries/loadDictionaries';\nimport { runOnce } from './utils/runOnce';\nimport {\n isCachedConfigurationUpToDate,\n writeConfiguration,\n} from './writeConfiguration';\n\ntype PrepareIntlayerOptions = {\n clean?: boolean;\n env?: 'prod' | 'dev';\n format?: ('cjs' | 'esm')[];\n forceRun?: boolean;\n cacheTimeoutMs?: number;\n onIsCached?: () => void | Promise<void>;\n};\n\nconst DEFAULT_PREPARE_INTLAYER_OPTIONS = {\n clean: false,\n env: 'dev',\n format: ['cjs', 'esm'],\n cacheTimeoutMs: 1000 * 60 * 60, // 1 hour\n} satisfies PrepareIntlayerOptions;\n\nexport const prepareIntlayer = async (\n configuration: IntlayerConfig,\n options?: PrepareIntlayerOptions\n) => {\n const appLogger = getAppLogger(configuration);\n\n const sentinelPath = join(\n configuration.system.cacheDir,\n 'intlayer-prepared.lock'\n );\n // Clean output dir if the intlayer version has changed\n const versionCache = cacheDisk(configuration, ['intlayer-version']);\n const intlayerCacheVersion = await versionCache.get();\n const isCorrectVersion = Boolean(\n intlayerCacheVersion && intlayerCacheVersion === packageJson.version\n );\n\n const isConfigSimilar = await isCachedConfigurationUpToDate(configuration);\n\n // Check if any dictionary has been changed to force a new rebuild\n const dictionariesWithStats = await listDictionariesWithStats(configuration);\n let isDictionaryChanged = false;\n try {\n // Try catch as sentinel file may not exist yet\n const sentinelStats = await stat(sentinelPath);\n isDictionaryChanged = dictionariesWithStats.some(\n (dictionary) =>\n dictionary.stats.mtime.getTime() > sentinelStats.mtime.getTime()\n );\n } catch {}\n\n const resolvedPlugins = await Promise.all(configuration.plugins ?? []);\n const hasPluginLoadDictionaries = resolvedPlugins.some((plugin) =>\n Boolean(plugin.loadDictionaries)\n ); // Disable cache if any plugin because it can have custom behavior\n\n const { clean, format, forceRun, onIsCached, cacheTimeoutMs, env } = {\n ...DEFAULT_PREPARE_INTLAYER_OPTIONS,\n forceRun:\n !isCorrectVersion ||\n !isConfigSimilar ||\n isDictionaryChanged ||\n hasPluginLoadDictionaries,\n ...(options ?? {}),\n };\n\n // Skip preparation if it has already been done recently\n await runOnce(\n sentinelPath,\n async () => {\n // comment because of issue with next and webpack\n // await checkVersionsConsistency(configuration);\n\n if (clean || !isCorrectVersion) {\n await cleanOutputDir(configuration);\n }\n\n await versionCache.set(packageJson.version);\n\n const preparationStartMs = Date.now();\n\n appLogger([\n 'Preparing Intlayer',\n colorize(`(v${packageJson.version})`, ANSIColors.GREY_DARK),\n ]);\n\n await writeConfiguration(configuration);\n\n const configurationWrittenTime = Date.now();\n\n appLogger(\n [\n 'Configuration written',\n colorize(\n `(${configurationWrittenTime - preparationStartMs}ms)`,\n ANSIColors.GREY_DARK\n ),\n ],\n {\n isVerbose: true,\n }\n );\n\n const contentDeclarationPaths = dictionariesWithStats.map(\n (dictionary) => dictionary.path\n );\n\n const dictionaries = await loadDictionaries(\n contentDeclarationPaths,\n configuration\n );\n\n const dictionariesLoadedTime = Date.now();\n\n appLogger(\n [\n 'Content loaded',\n colorize(\n [\n dictionaries.remoteDictionaries.length +\n dictionaries.pluginDictionaries.length >\n 0\n ? [\n `(Total: ${dictionariesLoadedTime - configurationWrittenTime}ms`,\n dictionaries.localDictionaries.length > 0\n ? ` - Local: ${dictionaries.time.localDictionaries}ms`\n : '',\n dictionaries.remoteDictionaries.length > 0\n ? ` - Remote: ${dictionaries.time.remoteDictionaries}ms`\n : '',\n dictionaries.pluginDictionaries.length > 0\n ? ` - Plugin: ${dictionaries.time.pluginDictionaries}ms`\n : '',\n `)`,\n ].join('')\n : `(${dictionariesLoadedTime - configurationWrittenTime}ms)`,\n ].join(''),\n ANSIColors.GREY_DARK\n ),\n ],\n {\n isVerbose: true,\n }\n );\n\n // Build local dictionaries\n const dictionariesOutput = await buildDictionary(\n [\n ...dictionaries.localDictionaries,\n ...dictionaries.remoteDictionaries,\n ...dictionaries.pluginDictionaries,\n ],\n configuration,\n { formats: format, importOtherDictionaries: false, env }\n );\n\n // Write remote dictionaries\n // Used as cache for next fetch\n await writeRemoteDictionary(\n dictionaries.remoteDictionaries,\n configuration\n );\n\n const dictionariesToBuild = Object.values(\n dictionariesOutput?.mergedDictionaries ?? {}\n ).map((dictionary) => dictionary.dictionary);\n\n await createTypes(dictionariesToBuild, configuration);\n\n await createDictionaryEntryPoint(configuration, {\n formats: options?.format,\n });\n\n const dictionariesBuiltTime = Date.now();\n\n appLogger([\n 'Dictionaries built',\n colorize(\n `(${dictionariesBuiltTime - preparationStartMs}ms)`,\n ANSIColors.GREY_DARK\n ),\n ]);\n\n await createModuleAugmentation(configuration);\n\n const moduleAugmentationBuiltTime = Date.now();\n\n appLogger(\n [\n 'Module augmentation built',\n colorize(\n `(${moduleAugmentationBuiltTime - dictionariesBuiltTime}ms)`,\n ANSIColors.GREY_DARK\n ),\n ],\n {\n isVerbose: true,\n }\n );\n\n // Plugin transformation\n // Allow plugins to post-process the final build output (e.g., write back ICU JSON)\n for await (const plugin of configuration.plugins ?? []) {\n const { unmergedDictionaries, mergedDictionaries } = dictionariesOutput;\n\n try {\n await plugin.afterBuild?.({\n dictionaries: {\n unmergedDictionaries,\n mergedDictionaries,\n },\n configuration,\n });\n } catch (error) {\n // A failing write-back (e.g. transient network error on a remote\n // target) must not invalidate the dictionaries already built.\n appLogger(\n [\n `Plugin ${plugin.name} afterBuild failed:`,\n (error as Error).message,\n ],\n { level: 'error' }\n );\n }\n }\n\n const preparationElapsedMs = Date.now() - preparationStartMs;\n appLogger(\n [`Done`, colorize(`${preparationElapsedMs}ms`, ANSIColors.GREEN)],\n {\n level: 'info',\n isVerbose: true,\n }\n );\n },\n {\n forceRun,\n onIsCached,\n cacheTimeoutMs,\n }\n );\n};\n"],"mappings":"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"}
1
+ {"version":3,"file":"prepareIntlayer.mjs","names":[],"sources":["../../src/prepareIntlayer.ts"],"sourcesContent":["import { stat } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport * as ANSIColors from '@intlayer/config/colors';\nimport { colorize, getAppLogger } from '@intlayer/config/logger';\nimport packageJson from '@intlayer/config/package.json' with { type: 'json' };\nimport { cacheDisk } from '@intlayer/config/utils';\nimport type { IntlayerConfig } from '@intlayer/types/config';\nimport { buildDictionary } from './buildIntlayerDictionary/buildIntlayerDictionary';\nimport { writeRemoteDictionary } from './buildIntlayerDictionary/writeRemoteDictionary';\nimport { cleanOutputDir } from './cleanOutputDir';\nimport { createDictionaryEntryPoint } from './createDictionaryEntryPoint/createDictionaryEntryPoint';\nimport { createModuleAugmentation, createTypes } from './createType/index';\nimport { listDictionariesWithStats } from './listDictionariesPath';\nimport { loadDictionaries } from './loadDictionaries/loadDictionaries';\nimport { runOnce } from './utils/runOnce';\nimport {\n isCachedConfigurationUpToDate,\n writeConfiguration,\n} from './writeConfiguration';\n\ntype PrepareIntlayerOptions = {\n clean?: boolean;\n env?: 'prod' | 'dev';\n format?: ('cjs' | 'esm')[];\n forceRun?: boolean;\n cacheTimeoutMs?: number;\n onIsCached?: () => void | Promise<void>;\n};\n\nconst DEFAULT_PREPARE_INTLAYER_OPTIONS = {\n clean: false,\n env: 'dev',\n format: ['cjs', 'esm'],\n cacheTimeoutMs: 1000 * 60 * 60, // 1 hour\n} satisfies PrepareIntlayerOptions;\n\nexport const prepareIntlayer = async (\n configuration: IntlayerConfig,\n options?: PrepareIntlayerOptions\n) => {\n const appLogger = getAppLogger(configuration);\n\n const sentinelPath = join(\n configuration.system.cacheDir,\n 'intlayer-prepared.lock'\n );\n // Clean output dir if the intlayer version has changed\n const versionCache = cacheDisk(configuration, ['intlayer-version']);\n const intlayerCacheVersion = await versionCache.get();\n const isCorrectVersion = Boolean(\n intlayerCacheVersion === undefined ||\n intlayerCacheVersion === packageJson.version\n );\n\n const isConfigSimilar = await isCachedConfigurationUpToDate(configuration);\n\n // Check if any dictionary has been changed to force a new rebuild\n const dictionariesWithStats = await listDictionariesWithStats(configuration);\n let isDictionaryChanged = false;\n try {\n // Try catch as sentinel file may not exist yet\n const sentinelStats = await stat(sentinelPath);\n isDictionaryChanged = dictionariesWithStats.some(\n (dictionary) =>\n dictionary.stats.mtime.getTime() > sentinelStats.mtime.getTime()\n );\n } catch {}\n\n const resolvedPlugins = await Promise.all(configuration.plugins ?? []);\n const hasPluginLoadDictionaries = resolvedPlugins.some((plugin) =>\n Boolean(plugin.loadDictionaries)\n ); // Disable cache if any plugin because it can have custom behavior\n\n const { clean, format, forceRun, onIsCached, cacheTimeoutMs, env } = {\n ...DEFAULT_PREPARE_INTLAYER_OPTIONS,\n forceRun:\n !isCorrectVersion ||\n !isConfigSimilar ||\n isDictionaryChanged ||\n hasPluginLoadDictionaries,\n ...(options ?? {}),\n };\n\n // Skip preparation if it has already been done recently\n await runOnce(\n sentinelPath,\n async ({ renewLock }) => {\n // comment because of issue with next and webpack\n // await checkVersionsConsistency(configuration);\n\n // Re-read the cached version now that the lock is held: another process\n // may have upgraded the output directory while this call was queued, in\n // which case cleaning it would throw away a build that just completed.\n const isCorrectVersionUnderLock =\n (await versionCache.get()) === packageJson.version;\n\n if (clean || !isCorrectVersionUnderLock) {\n await cleanOutputDir(configuration);\n // `cleanOutputDir` wipes the cache directory the sentinel lives in.\n // Re-create it immediately, otherwise a concurrent process (e.g. the\n // `intlayer watch` CLI next to a dev server) sees no lock, starts its\n // own run, and cleans the output directory while this one writes.\n await renewLock();\n }\n\n await versionCache.set(packageJson.version);\n\n const preparationStartMs = Date.now();\n\n appLogger([\n 'Preparing Intlayer',\n colorize(`(v${packageJson.version})`, ANSIColors.GREY_DARK),\n ]);\n\n await writeConfiguration(configuration);\n\n const configurationWrittenTime = Date.now();\n\n appLogger(\n [\n 'Configuration written',\n colorize(\n `(${configurationWrittenTime - preparationStartMs}ms)`,\n ANSIColors.GREY_DARK\n ),\n ],\n {\n isVerbose: true,\n }\n );\n\n const contentDeclarationPaths = dictionariesWithStats.map(\n (dictionary) => dictionary.path\n );\n\n const dictionaries = await loadDictionaries(\n contentDeclarationPaths,\n configuration\n );\n\n const dictionariesLoadedTime = Date.now();\n\n appLogger(\n [\n 'Content loaded',\n colorize(\n [\n dictionaries.remoteDictionaries.length +\n dictionaries.pluginDictionaries.length >\n 0\n ? [\n `(Total: ${dictionariesLoadedTime - configurationWrittenTime}ms`,\n dictionaries.localDictionaries.length > 0\n ? ` - Local: ${dictionaries.time.localDictionaries}ms`\n : '',\n dictionaries.remoteDictionaries.length > 0\n ? ` - Remote: ${dictionaries.time.remoteDictionaries}ms`\n : '',\n dictionaries.pluginDictionaries.length > 0\n ? ` - Plugin: ${dictionaries.time.pluginDictionaries}ms`\n : '',\n `)`,\n ].join('')\n : `(${dictionariesLoadedTime - configurationWrittenTime}ms)`,\n ].join(''),\n ANSIColors.GREY_DARK\n ),\n ],\n {\n isVerbose: true,\n }\n );\n\n // Build local dictionaries\n const dictionariesOutput = await buildDictionary(\n [\n ...dictionaries.localDictionaries,\n ...dictionaries.remoteDictionaries,\n ...dictionaries.pluginDictionaries,\n ],\n configuration,\n { formats: format, importOtherDictionaries: false, env }\n );\n\n // Write remote dictionaries\n // Used as cache for next fetch\n await writeRemoteDictionary(\n dictionaries.remoteDictionaries,\n configuration\n );\n\n const dictionariesToBuild = Object.values(\n dictionariesOutput?.mergedDictionaries ?? {}\n ).map((dictionary) => dictionary.dictionary);\n\n await createTypes(dictionariesToBuild, configuration);\n\n await createDictionaryEntryPoint(configuration, {\n formats: options?.format,\n });\n\n const dictionariesBuiltTime = Date.now();\n\n appLogger([\n 'Dictionaries built',\n colorize(\n `(${dictionariesBuiltTime - preparationStartMs}ms)`,\n ANSIColors.GREY_DARK\n ),\n ]);\n\n await createModuleAugmentation(configuration);\n\n const moduleAugmentationBuiltTime = Date.now();\n\n appLogger(\n [\n 'Module augmentation built',\n colorize(\n `(${moduleAugmentationBuiltTime - dictionariesBuiltTime}ms)`,\n ANSIColors.GREY_DARK\n ),\n ],\n {\n isVerbose: true,\n }\n );\n\n // Plugin transformation\n // Allow plugins to post-process the final build output (e.g., write back ICU JSON)\n for await (const plugin of configuration.plugins ?? []) {\n const { unmergedDictionaries, mergedDictionaries } = dictionariesOutput;\n\n try {\n await plugin.afterBuild?.({\n dictionaries: {\n unmergedDictionaries,\n mergedDictionaries,\n },\n configuration,\n });\n } catch (error) {\n // A failing write-back (e.g. transient network error on a remote\n // target) must not invalidate the dictionaries already built.\n appLogger(\n [\n `Plugin ${plugin.name} afterBuild failed:`,\n (error as Error).message,\n ],\n { level: 'error' }\n );\n }\n }\n\n const preparationElapsedMs = Date.now() - preparationStartMs;\n appLogger(\n [`Done`, colorize(`${preparationElapsedMs}ms`, ANSIColors.GREEN)],\n {\n level: 'info',\n isVerbose: true,\n }\n );\n },\n {\n forceRun,\n onIsCached,\n cacheTimeoutMs,\n }\n );\n};\n"],"mappings":"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"}
@@ -1,90 +1,169 @@
1
1
  import { mkdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
2
2
  import { dirname } from "node:path";
3
+ import { rmSync } from "node:fs";
3
4
  import packageJson from "@intlayer/core/package.json" with { type: "json" };
4
5
 
5
6
  //#region src/utils/runOnce.ts
6
- const DEFAULT_RUN_ONCE_OPTIONS = { cacheTimeoutMs: 60 * 1e3 };
7
- const writeSentinelFile = async (sentinelFilePath, currentTimestamp) => {
8
- const data = {
9
- version: packageJson.version,
10
- timestamp: currentTimestamp
11
- };
7
+ const DEFAULT_RUN_ONCE_OPTIONS = {
8
+ cacheTimeoutMs: 60 * 1e3,
9
+ lockWaitTimeoutMs: 300 * 1e3
10
+ };
11
+ /** Delay between two reads of a sentinel owned by another process. */
12
+ const LOCK_POLL_INTERVAL_MS = 50;
13
+ const delay = (durationMs) => new Promise((resolve) => setTimeout(resolve, durationMs));
14
+ /**
15
+ * Sentinels owned by this process, released synchronously on exit so a crash or
16
+ * a Ctrl-C never leaves a `running` lock that would stall the next run.
17
+ */
18
+ const ownedSentinelFilePaths = /* @__PURE__ */ new Set();
19
+ process.on("exit", () => {
20
+ for (const sentinelFilePath of ownedSentinelFilePaths) try {
21
+ rmSync(sentinelFilePath, { force: true });
22
+ } catch {}
23
+ });
24
+ /**
25
+ * Reads the sentinel file, returning `undefined` when it does not exist or
26
+ * cannot be parsed.
27
+ *
28
+ * Sentinels written by older versions carry no `status`; they always describe a
29
+ * finished run, so they are reported as `done`.
30
+ */
31
+ const readSentinelState = async (sentinelFilePath) => {
32
+ try {
33
+ const [sentinelStats, raw] = await Promise.all([stat(sentinelFilePath), readFile(sentinelFilePath, "utf8")]);
34
+ const parsed = JSON.parse(raw);
35
+ return {
36
+ version: parsed.version ?? "",
37
+ timestamp: parsed.timestamp ?? 0,
38
+ status: parsed.status ?? "done",
39
+ pid: parsed.pid ?? 0,
40
+ mtimeMs: sentinelStats.mtime.getTime()
41
+ };
42
+ } catch {
43
+ return;
44
+ }
45
+ };
46
+ /**
47
+ * Whether the process that wrote the sentinel is still alive. An unknown PID
48
+ * (legacy sentinel) is assumed alive so the staleness timeout stays the only
49
+ * way to reclaim it.
50
+ */
51
+ const isOwnerProcessAlive = (pid) => {
52
+ if (!pid || pid === process.pid) return true;
12
53
  try {
54
+ process.kill(pid, 0);
55
+ return true;
56
+ } catch (error) {
57
+ return error.code === "EPERM";
58
+ }
59
+ };
60
+ const serializeSentinel = (timestamp, status) => JSON.stringify({
61
+ version: packageJson.version,
62
+ timestamp,
63
+ status,
64
+ pid: process.pid
65
+ });
66
+ /**
67
+ * Attempts to take ownership of the sentinel.
68
+ *
69
+ * `wx` makes the creation atomic, so exactly one process can win even when
70
+ * several start at the same moment.
71
+ *
72
+ * @returns `true` when this process now owns the sentinel, `false` when another
73
+ * process created it first.
74
+ */
75
+ const acquireSentinel = async (sentinelFilePath, timestamp) => {
76
+ const data = serializeSentinel(timestamp, "running");
77
+ for (let attempt = 0; attempt < 2; attempt++) try {
13
78
  await mkdir(dirname(sentinelFilePath), { recursive: true });
14
- await writeFile(sentinelFilePath, JSON.stringify(data), { flag: "wx" });
15
- } catch (err) {
16
- if (err.code === "EEXIST") return;
17
- if (err.code === "ENOENT") try {
18
- await mkdir(dirname(sentinelFilePath), { recursive: true });
19
- await writeFile(sentinelFilePath, JSON.stringify(data), { flag: "wx" });
20
- return;
21
- } catch (retryErr) {
22
- if (retryErr.code === "EEXIST") return;
23
- }
24
- throw err;
79
+ await writeFile(sentinelFilePath, data, { flag: "wx" });
80
+ return true;
81
+ } catch (error) {
82
+ const code = error.code;
83
+ if (code === "EEXIST") return false;
84
+ if (code === "ENOENT" && attempt === 0) continue;
85
+ throw error;
25
86
  }
87
+ return false;
88
+ };
89
+ /**
90
+ * Rewrites the sentinel owned by this process, overwriting any existing file.
91
+ */
92
+ const writeOwnedSentinel = async (sentinelFilePath, timestamp, status) => {
93
+ try {
94
+ await mkdir(dirname(sentinelFilePath), { recursive: true });
95
+ await writeFile(sentinelFilePath, serializeSentinel(timestamp, status));
96
+ } catch {}
97
+ };
98
+ const removeSentinel = async (sentinelFilePath) => {
99
+ try {
100
+ await unlink(sentinelFilePath);
101
+ } catch {}
26
102
  };
27
103
  /**
28
104
  * Ensures a callback function runs only once within a specified time window across multiple processes.
29
105
  * Uses a sentinel file to coordinate execution and prevent duplicate work.
30
106
  *
107
+ * Processes that lose the race for the sentinel wait for the owner to finish
108
+ * rather than running a competing copy of the callback — concurrent runs would
109
+ * otherwise write to (and clean) the same output directory at the same time.
110
+ *
31
111
  * @param sentinelFilePath - Path to the sentinel file used for coordination
32
112
  * @param callback - The function to execute (should be async)
33
113
  * @param options - The options for the runOnce function
34
114
  *
35
115
  * @example
36
116
  * ```typescript
37
- * await runPrepareIntlayerOnce(
117
+ * await runOnce(
38
118
  * '/tmp/intlayer-sentinel',
39
- * async () => {
40
- * // Your initialization logic here
119
+ * async ({ renewLock }) => {
120
+ * await cleanOutputDir(configuration); // may delete the sentinel
121
+ * await renewLock();
41
122
  * await prepareIntlayer();
42
123
  * },
43
- * 30 * 1000 // 30 seconds cache
124
+ * { cacheTimeoutMs: 30 * 1000 } // 30 seconds cache
44
125
  * );
45
126
  * ```
46
127
  *
47
128
  * @throws {Error} When there are unexpected filesystem errors
48
129
  */
49
130
  const runOnce = async (sentinelFilePath, callback, options) => {
50
- const { onIsCached, cacheTimeoutMs, forceRun } = {
131
+ const { onIsCached, cacheTimeoutMs, forceRun, lockWaitTimeoutMs } = {
51
132
  ...DEFAULT_RUN_ONCE_OPTIONS,
52
133
  ...options ?? {}
53
134
  };
54
135
  const currentTimestamp = Date.now();
55
- try {
56
- const sentinelAge = currentTimestamp - (await stat(sentinelFilePath)).mtime.getTime();
57
- let shouldRebuild = Boolean(forceRun) || sentinelAge > cacheTimeoutMs;
58
- if (!shouldRebuild) try {
59
- const raw = await readFile(sentinelFilePath, "utf8");
60
- let cachedVersion;
61
- try {
62
- cachedVersion = JSON.parse(raw).version;
63
- } catch {
64
- cachedVersion = void 0;
136
+ const waitDeadline = currentTimestamp + lockWaitTimeoutMs;
137
+ while (true) {
138
+ const sentinelState = await readSentinelState(sentinelFilePath);
139
+ if (sentinelState) {
140
+ const sentinelAge = Date.now() - sentinelState.mtimeMs;
141
+ if (sentinelState.status === "running") {
142
+ if (!(sentinelAge > lockWaitTimeoutMs || !isOwnerProcessAlive(sentinelState.pid)) && Date.now() < waitDeadline) {
143
+ await delay(LOCK_POLL_INTERVAL_MS);
144
+ continue;
145
+ }
146
+ await removeSentinel(sentinelFilePath);
147
+ continue;
65
148
  }
66
- if (!cachedVersion || cachedVersion !== packageJson.version) shouldRebuild = true;
67
- } catch {
68
- shouldRebuild = true;
69
- }
70
- if (shouldRebuild) try {
71
- await unlink(sentinelFilePath);
72
- } catch {}
73
- else {
74
- await onIsCached?.();
75
- return;
149
+ if (!forceRun && sentinelAge <= cacheTimeoutMs && sentinelState.version === packageJson.version) {
150
+ await onIsCached?.();
151
+ return;
152
+ }
153
+ await removeSentinel(sentinelFilePath);
76
154
  }
77
- } catch (err) {
78
- if (err.code === "ENOENT") {} else throw err;
155
+ if (await acquireSentinel(sentinelFilePath, currentTimestamp)) break;
156
+ await delay(LOCK_POLL_INTERVAL_MS);
79
157
  }
80
- await writeSentinelFile(sentinelFilePath, currentTimestamp);
158
+ ownedSentinelFilePaths.add(sentinelFilePath);
159
+ const renewLock = () => writeOwnedSentinel(sentinelFilePath, currentTimestamp, "running");
81
160
  try {
82
- await callback();
83
- await writeSentinelFile(sentinelFilePath, currentTimestamp);
161
+ await callback({ renewLock });
162
+ await writeOwnedSentinel(sentinelFilePath, currentTimestamp, "done");
84
163
  } catch {
85
- try {
86
- await unlink(sentinelFilePath);
87
- } catch {}
164
+ await removeSentinel(sentinelFilePath);
165
+ } finally {
166
+ ownedSentinelFilePaths.delete(sentinelFilePath);
88
167
  }
89
168
  };
90
169
 
@@ -1 +1 @@
1
- {"version":3,"file":"runOnce.mjs","names":[],"sources":["../../../src/utils/runOnce.ts"],"sourcesContent":["import { mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises';\nimport { dirname } from 'node:path';\nimport packageJson from '@intlayer/core/package.json' with { type: 'json' };\n\ntype RunOnceOptions = {\n /**\n * The function to execute when the sentinel is not found or is older than the cache timeout.\n */\n onIsCached?: () => void | Promise<void>;\n /**\n * The time window in milliseconds during which the sentinel is considered valid.\n *\n * @default 60000 = 1 minute\n */\n cacheTimeoutMs?: number;\n /**\n * If true, the callback will always run. If undefined, the callback will run only if the sentinel is older than the cache timeout.\n *\n * @default false\n */\n forceRun?: boolean;\n};\n\nconst DEFAULT_RUN_ONCE_OPTIONS = {\n cacheTimeoutMs: 60 * 1000, // 1 minute in milliseconds,\n} satisfies RunOnceOptions;\n\ntype SentinelData = {\n version: string;\n timestamp: number;\n};\n\nconst writeSentinelFile = async (\n sentinelFilePath: string,\n currentTimestamp: number\n) => {\n // O_EXCL ensures only the *first* process can create the file\n const data: SentinelData = {\n version: packageJson.version,\n timestamp: currentTimestamp,\n };\n\n try {\n // Ensure the directory exists before writing the file\n await mkdir(dirname(sentinelFilePath), { recursive: true });\n\n await writeFile(sentinelFilePath, JSON.stringify(data), { flag: 'wx' });\n } catch (err: any) {\n if (err.code === 'EEXIST') {\n // Another process already created it → we're done\n return;\n }\n // Optimization: If ENOENT occurs on write despite mkdir (race condition with external deletion), retry once.\n if (err.code === 'ENOENT') {\n try {\n await mkdir(dirname(sentinelFilePath), { recursive: true });\n await writeFile(sentinelFilePath, JSON.stringify(data), { flag: 'wx' });\n return;\n } catch (retryErr: any) {\n if (retryErr.code === 'EEXIST') return;\n }\n }\n throw err; // unexpected FS error\n }\n};\n\n/**\n * Ensures a callback function runs only once within a specified time window across multiple processes.\n * Uses a sentinel file to coordinate execution and prevent duplicate work.\n *\n * @param sentinelFilePath - Path to the sentinel file used for coordination\n * @param callback - The function to execute (should be async)\n * @param options - The options for the runOnce function\n *\n * @example\n * ```typescript\n * await runPrepareIntlayerOnce(\n * '/tmp/intlayer-sentinel',\n * async () => {\n * // Your initialization logic here\n * await prepareIntlayer();\n * },\n * 30 * 1000 // 30 seconds cache\n * );\n * ```\n *\n * @throws {Error} When there are unexpected filesystem errors\n */\nexport const runOnce = async (\n sentinelFilePath: string,\n callback: () => void | Promise<void>,\n options?: RunOnceOptions\n) => {\n const { onIsCached, cacheTimeoutMs, forceRun } = {\n ...DEFAULT_RUN_ONCE_OPTIONS,\n ...(options ?? {}),\n };\n const currentTimestamp = Date.now();\n\n try {\n // Check if sentinel file exists and get its stats\n const sentinelStats = await stat(sentinelFilePath);\n const sentinelAge = currentTimestamp - sentinelStats.mtime.getTime();\n\n // Determine if we should rebuild based on cache age, force flag, or version mismatch\n let shouldRebuild = Boolean(forceRun) || sentinelAge > cacheTimeoutMs!;\n\n if (!shouldRebuild) {\n try {\n const raw = await readFile(sentinelFilePath, 'utf8');\n let cachedVersion: string | undefined;\n try {\n const parsed = JSON.parse(raw) as Partial<SentinelData>;\n cachedVersion = parsed.version;\n } catch {\n // Legacy format (timestamp only). Force a rebuild once to write versioned sentinel.\n cachedVersion = undefined;\n }\n\n if (!cachedVersion || cachedVersion !== packageJson.version) {\n shouldRebuild = true;\n }\n } catch {\n // If we cannot read the file, err on the safe side and rebuild\n shouldRebuild = true;\n }\n }\n\n if (shouldRebuild) {\n try {\n await unlink(sentinelFilePath);\n } catch {}\n // Fall through to create new sentinel and rebuild\n } else {\n await onIsCached?.();\n // Sentinel is recent and versions match, no need to rebuild\n return;\n }\n } catch (err: any) {\n if (err.code === 'ENOENT') {\n // File doesn't exist, continue to create it\n } else {\n throw err; // unexpected FS error\n }\n }\n\n // Write sentinel file before to block parallel processes\n // Added await here\n await writeSentinelFile(sentinelFilePath, currentTimestamp);\n\n try {\n await callback();\n\n // Write sentinel file after to ensure the first one has not been removed with cleanOutputDir\n // Added await here\n await writeSentinelFile(sentinelFilePath, currentTimestamp);\n } catch {\n try {\n await unlink(sentinelFilePath); // Remove sentinel file if an error occurs\n } catch {}\n }\n};\n"],"mappings":";;;;;AAuBA,MAAM,2BAA2B,EAC/B,gBAAgB,KAAK,IACvB;AAOA,MAAM,oBAAoB,OACxB,kBACA,qBACG;CAEH,MAAM,OAAqB;EACzB,SAAS,YAAY;EACrB,WAAW;CACb;CAEA,IAAI;EAEF,MAAM,MAAM,QAAQ,gBAAgB,GAAG,EAAE,WAAW,KAAK,CAAC;EAE1D,MAAM,UAAU,kBAAkB,KAAK,UAAU,IAAI,GAAG,EAAE,MAAM,KAAK,CAAC;CACxE,SAAS,KAAU;EACjB,IAAI,IAAI,SAAS,UAEf;EAGF,IAAI,IAAI,SAAS,UACf,IAAI;GACF,MAAM,MAAM,QAAQ,gBAAgB,GAAG,EAAE,WAAW,KAAK,CAAC;GAC1D,MAAM,UAAU,kBAAkB,KAAK,UAAU,IAAI,GAAG,EAAE,MAAM,KAAK,CAAC;GACtE;EACF,SAAS,UAAe;GACtB,IAAI,SAAS,SAAS,UAAU;EAClC;EAEF,MAAM;CACR;AACF;;;;;;;;;;;;;;;;;;;;;;;AAwBA,MAAa,UAAU,OACrB,kBACA,UACA,YACG;CACH,MAAM,EAAE,YAAY,gBAAgB,aAAa;EAC/C,GAAG;EACH,GAAI,WAAW,CAAC;CAClB;CACA,MAAM,mBAAmB,KAAK,IAAI;CAElC,IAAI;EAGF,MAAM,cAAc,oBAAmB,MADX,KAAK,gBAAgB,EACG,CAAC,MAAM,QAAQ;EAGnE,IAAI,gBAAgB,QAAQ,QAAQ,KAAK,cAAc;EAEvD,IAAI,CAAC,eACH,IAAI;GACF,MAAM,MAAM,MAAM,SAAS,kBAAkB,MAAM;GACnD,IAAI;GACJ,IAAI;IAEF,gBADe,KAAK,MAAM,GACL,CAAC,CAAC;GACzB,QAAQ;IAEN,gBAAgB;GAClB;GAEA,IAAI,CAAC,iBAAiB,kBAAkB,YAAY,SAClD,gBAAgB;EAEpB,QAAQ;GAEN,gBAAgB;EAClB;EAGF,IAAI,eACF,IAAI;GACF,MAAM,OAAO,gBAAgB;EAC/B,QAAQ,CAAC;OAEJ;GACL,MAAM,aAAa;GAEnB;EACF;CACF,SAAS,KAAU;EACjB,IAAI,IAAI,SAAS,UAAU,CAE3B,OACE,MAAM;CAEV;CAIA,MAAM,kBAAkB,kBAAkB,gBAAgB;CAE1D,IAAI;EACF,MAAM,SAAS;EAIf,MAAM,kBAAkB,kBAAkB,gBAAgB;CAC5D,QAAQ;EACN,IAAI;GACF,MAAM,OAAO,gBAAgB;EAC/B,QAAQ,CAAC;CACX;AACF"}
1
+ {"version":3,"file":"runOnce.mjs","names":[],"sources":["../../../src/utils/runOnce.ts"],"sourcesContent":["import { rmSync } from 'node:fs';\nimport { mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises';\nimport { dirname } from 'node:path';\nimport packageJson from '@intlayer/core/package.json' with { type: 'json' };\n\n/**\n * Lifecycle of the run described by a sentinel file.\n *\n * - `running` — a process owns the sentinel and its callback is still in flight.\n * Other processes must wait instead of starting a concurrent run.\n * - `done` — the callback completed; the sentinel is now a plain cache marker.\n */\ntype SentinelStatus = 'running' | 'done';\n\ntype SentinelData = {\n version: string;\n timestamp: number;\n status: SentinelStatus;\n /** PID of the process that owns the sentinel, used to detect abandoned locks. */\n pid: number;\n};\n\n/** Sentinel state as read from disk, enriched with the file's modification time. */\ntype SentinelState = SentinelData & { mtimeMs: number };\n\n/**\n * Context handed to the callback so it can interact with the lock it runs under.\n */\nexport type RunOnceContext = {\n /**\n * Re-create the sentinel file after an operation that may have deleted it —\n * typically cleaning the output directory, which wipes the cache directory the\n * sentinel lives in. Without this, concurrent processes would see no lock and\n * start a competing run while this one is still writing.\n */\n renewLock: () => Promise<void>;\n};\n\ntype RunOnceOptions = {\n /**\n * The function to execute when the sentinel is not found or is older than the cache timeout.\n */\n onIsCached?: () => void | Promise<void>;\n /**\n * The time window in milliseconds during which the sentinel is considered valid.\n *\n * @default 60000 = 1 minute\n */\n cacheTimeoutMs?: number;\n /**\n * If true, the callback will always run. If undefined, the callback will run only if the sentinel is older than the cache timeout.\n *\n * @default false\n */\n forceRun?: boolean;\n /**\n * How long to wait for another process to release the sentinel before\n * considering its run abandoned and taking the lock over.\n *\n * @default 300000 = 5 minutes\n */\n lockWaitTimeoutMs?: number;\n};\n\nconst DEFAULT_RUN_ONCE_OPTIONS = {\n cacheTimeoutMs: 60 * 1000, // 1 minute in milliseconds,\n lockWaitTimeoutMs: 5 * 60 * 1000, // 5 minutes in milliseconds\n} satisfies RunOnceOptions;\n\n/** Delay between two reads of a sentinel owned by another process. */\nconst LOCK_POLL_INTERVAL_MS = 50;\n\nconst delay = (durationMs: number): Promise<void> =>\n new Promise((resolve) => setTimeout(resolve, durationMs));\n\n/**\n * Sentinels owned by this process, released synchronously on exit so a crash or\n * a Ctrl-C never leaves a `running` lock that would stall the next run.\n */\nconst ownedSentinelFilePaths = new Set<string>();\n\nprocess.on('exit', () => {\n for (const sentinelFilePath of ownedSentinelFilePaths) {\n try {\n rmSync(sentinelFilePath, { force: true });\n } catch {}\n }\n});\n\n/**\n * Reads the sentinel file, returning `undefined` when it does not exist or\n * cannot be parsed.\n *\n * Sentinels written by older versions carry no `status`; they always describe a\n * finished run, so they are reported as `done`.\n */\nconst readSentinelState = async (\n sentinelFilePath: string\n): Promise<SentinelState | undefined> => {\n try {\n const [sentinelStats, raw] = await Promise.all([\n stat(sentinelFilePath),\n readFile(sentinelFilePath, 'utf8'),\n ]);\n\n const parsed = JSON.parse(raw) as Partial<SentinelData>;\n\n return {\n version: parsed.version ?? '',\n timestamp: parsed.timestamp ?? 0,\n status: parsed.status ?? 'done',\n pid: parsed.pid ?? 0,\n mtimeMs: sentinelStats.mtime.getTime(),\n };\n } catch {\n return undefined;\n }\n};\n\n/**\n * Whether the process that wrote the sentinel is still alive. An unknown PID\n * (legacy sentinel) is assumed alive so the staleness timeout stays the only\n * way to reclaim it.\n */\nconst isOwnerProcessAlive = (pid: number): boolean => {\n if (!pid || pid === process.pid) return true;\n\n try {\n // Signal 0 performs an existence check without delivering a signal.\n process.kill(pid, 0);\n return true;\n } catch (error) {\n // EPERM means the process exists but belongs to another user.\n return (error as NodeJS.ErrnoException).code === 'EPERM';\n }\n};\n\nconst serializeSentinel = (timestamp: number, status: SentinelStatus): string =>\n JSON.stringify({\n version: packageJson.version,\n timestamp,\n status,\n pid: process.pid,\n } satisfies SentinelData);\n\n/**\n * Attempts to take ownership of the sentinel.\n *\n * `wx` makes the creation atomic, so exactly one process can win even when\n * several start at the same moment.\n *\n * @returns `true` when this process now owns the sentinel, `false` when another\n * process created it first.\n */\nconst acquireSentinel = async (\n sentinelFilePath: string,\n timestamp: number\n): Promise<boolean> => {\n const data = serializeSentinel(timestamp, 'running');\n\n for (let attempt = 0; attempt < 2; attempt++) {\n try {\n // Ensure the directory exists before writing the file\n await mkdir(dirname(sentinelFilePath), { recursive: true });\n\n await writeFile(sentinelFilePath, data, { flag: 'wx' });\n return true;\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code;\n\n if (code === 'EEXIST') return false;\n // The directory was removed between the mkdir and the write (e.g. a\n // concurrent output-directory clean); retry once.\n if (code === 'ENOENT' && attempt === 0) continue;\n\n throw error;\n }\n }\n\n return false;\n};\n\n/**\n * Rewrites the sentinel owned by this process, overwriting any existing file.\n */\nconst writeOwnedSentinel = async (\n sentinelFilePath: string,\n timestamp: number,\n status: SentinelStatus\n): Promise<void> => {\n try {\n await mkdir(dirname(sentinelFilePath), { recursive: true });\n await writeFile(sentinelFilePath, serializeSentinel(timestamp, status));\n } catch {}\n};\n\nconst removeSentinel = async (sentinelFilePath: string): Promise<void> => {\n try {\n await unlink(sentinelFilePath);\n } catch {}\n};\n\n/**\n * Ensures a callback function runs only once within a specified time window across multiple processes.\n * Uses a sentinel file to coordinate execution and prevent duplicate work.\n *\n * Processes that lose the race for the sentinel wait for the owner to finish\n * rather than running a competing copy of the callback — concurrent runs would\n * otherwise write to (and clean) the same output directory at the same time.\n *\n * @param sentinelFilePath - Path to the sentinel file used for coordination\n * @param callback - The function to execute (should be async)\n * @param options - The options for the runOnce function\n *\n * @example\n * ```typescript\n * await runOnce(\n * '/tmp/intlayer-sentinel',\n * async ({ renewLock }) => {\n * await cleanOutputDir(configuration); // may delete the sentinel\n * await renewLock();\n * await prepareIntlayer();\n * },\n * { cacheTimeoutMs: 30 * 1000 } // 30 seconds cache\n * );\n * ```\n *\n * @throws {Error} When there are unexpected filesystem errors\n */\nexport const runOnce = async (\n sentinelFilePath: string,\n callback: (context: RunOnceContext) => void | Promise<void>,\n options?: RunOnceOptions\n) => {\n const { onIsCached, cacheTimeoutMs, forceRun, lockWaitTimeoutMs } = {\n ...DEFAULT_RUN_ONCE_OPTIONS,\n ...(options ?? {}),\n };\n const currentTimestamp = Date.now();\n const waitDeadline = currentTimestamp + lockWaitTimeoutMs;\n\n // Acquisition loop: read the sentinel, then either return early (fresh cache),\n // wait for the current owner, or take the lock. Every branch either returns or\n // makes progress, so the loop always terminates.\n while (true) {\n const sentinelState = await readSentinelState(sentinelFilePath);\n\n if (sentinelState) {\n const sentinelAge = Date.now() - sentinelState.mtimeMs;\n\n if (sentinelState.status === 'running') {\n const isAbandoned =\n sentinelAge > lockWaitTimeoutMs ||\n !isOwnerProcessAlive(sentinelState.pid);\n\n if (!isAbandoned && Date.now() < waitDeadline) {\n await delay(LOCK_POLL_INTERVAL_MS);\n continue;\n }\n\n // The owner died or overran the timeout: reclaim the sentinel.\n await removeSentinel(sentinelFilePath);\n continue;\n }\n\n const isCacheValid =\n !forceRun &&\n sentinelAge <= cacheTimeoutMs &&\n sentinelState.version === packageJson.version;\n\n if (isCacheValid) {\n await onIsCached?.();\n return;\n }\n\n await removeSentinel(sentinelFilePath);\n }\n\n const hasAcquiredSentinel = await acquireSentinel(\n sentinelFilePath,\n currentTimestamp\n );\n\n if (hasAcquiredSentinel) break;\n\n // Another process won the race in the meantime: loop back and wait for it.\n // The delay also guarantees the loop yields, so a sentinel being repeatedly\n // created and removed can never turn into a busy wait.\n await delay(LOCK_POLL_INTERVAL_MS);\n }\n\n ownedSentinelFilePaths.add(sentinelFilePath);\n\n const renewLock = () =>\n writeOwnedSentinel(sentinelFilePath, currentTimestamp, 'running');\n\n try {\n await callback({ renewLock });\n\n // Mark the run as finished, re-creating the sentinel if the callback\n // deleted it (e.g. by cleaning the output directory).\n await writeOwnedSentinel(sentinelFilePath, currentTimestamp, 'done');\n } catch {\n await removeSentinel(sentinelFilePath); // Remove sentinel file if an error occurs\n } finally {\n ownedSentinelFilePaths.delete(sentinelFilePath);\n }\n};\n"],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
2
- import { basename, join } from "node:path";
2
+ import { basename, dirname, join } from "node:path";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import { rmSync } from "node:fs";
5
5
 
@@ -49,14 +49,21 @@ const writeFileIfChanged = async (path, data, { encoding = "utf8", tempDir, atom
49
49
  const tempPath = tempDir ? join(tempDir, tempFileName) : `${path}.${tempFileName}`;
50
50
  activeTempFiles.add(tempPath);
51
51
  try {
52
- await writeFile(tempPath, newData);
53
- if (modeToRestore !== void 0) {
54
- if (defaultFileMode === void 0) try {
55
- defaultFileMode = (await stat(tempPath)).mode & 511;
56
- } catch {}
57
- if (modeToRestore !== defaultFileMode) await chmod(tempPath, modeToRestore);
52
+ for (let attempt = 0;; attempt++) try {
53
+ await writeFile(tempPath, newData);
54
+ if (modeToRestore !== void 0) {
55
+ if (defaultFileMode === void 0) try {
56
+ defaultFileMode = (await stat(tempPath)).mode & 511;
57
+ } catch {}
58
+ if (modeToRestore !== defaultFileMode) await chmod(tempPath, modeToRestore);
59
+ }
60
+ await rename(tempPath, path);
61
+ break;
62
+ } catch (error) {
63
+ if (!(error.code === "ENOENT") || attempt > 0) throw error;
64
+ await mkdir(dirname(path), { recursive: true });
65
+ if (tempDir) await mkdir(tempDir, { recursive: true });
58
66
  }
59
- await rename(tempPath, path);
60
67
  } catch (error) {
61
68
  try {
62
69
  await rm(tempPath, { force: true });
@@ -1 +1 @@
1
- {"version":3,"file":"writeFileIfChanged.mjs","names":[],"sources":["../../src/writeFileIfChanged.ts"],"sourcesContent":["import { randomBytes } from 'node:crypto';\nimport { rmSync } from 'node:fs';\nimport {\n chmod,\n mkdir,\n readFile,\n rename,\n rm,\n stat,\n writeFile,\n} from 'node:fs/promises';\nimport { basename, join } from 'node:path';\n\nconst activeTempFiles = new Set<string>();\n\n// Synchronous cleanup on process exit\nprocess.on('exit', () => {\n for (const file of activeTempFiles) {\n try {\n rmSync(file, { force: true });\n } catch {}\n }\n});\n\n/**\n * Permission bits a freshly created file receives on this system. Detected once\n * from the first temp file we actually write (`process.umask()` with no\n * argument is deprecated and thread-unsafe) and cached, so the atomic path can\n * skip restoring the mode whenever the source file already uses the default\n * mode — which is the case for essentially every generated file.\n */\nlet defaultFileMode: number | undefined;\n\n/**\n * Options for {@link writeFileIfChanged}.\n */\nexport type WriteFileIfChangedOptions = {\n /** Encoding used to turn `data` into bytes. Defaults to `'utf8'`. */\n encoding?: BufferEncoding;\n /** Directory to hold the temporary file used for the atomic swap. */\n tempDir?: string;\n /**\n * Write atomically via a temp file + `rename` so readers never observe a\n * half-written file. This creates a fresh inode on every write, which roughly\n * doubles the cost of a changed write compared to overwriting in place. Set to\n * `false` for callers whose consumers tolerate torn reads to write directly.\n * Defaults to `true`.\n */\n atomic?: boolean;\n};\n\n/**\n * Write `data` to `path` only when it differs from the file already on disk,\n * preserving the existing file's permission mode.\n *\n * The whole file is read back and byte-compared because the files written here\n * (dictionaries, entry points, types) are small, so reading beats streaming a\n * hash. When the content is unchanged the write is skipped entirely, which both\n * avoids inode churn and prevents downstream watchers from rebuilding.\n *\n * @returns `true` when the file was written, `false` when it was already up to date.\n */\nexport const writeFileIfChanged = async (\n path: string,\n data: string,\n { encoding = 'utf8', tempDir, atomic = true }: WriteFileIfChangedOptions = {}\n): Promise<boolean> => {\n const newData = Buffer.from(data, encoding);\n\n // Read the current content first: the common case is an unchanged file, and\n // this single read is the whole fast path (no stat/chmod needed to bail out).\n let existingData: Buffer | null = null;\n try {\n existingData = await readFile(path);\n } catch {}\n\n if (existingData?.equals(newData)) {\n return false;\n }\n\n // Fast path: overwrite in place. Truncating an existing file keeps its inode,\n // so its permission mode is preserved automatically (no chmod needed). This\n // skips the temp-file inode churn at the cost of exposing readers to a\n // partially written file.\n if (!atomic) {\n await writeFile(path, newData);\n return true;\n }\n\n // The atomic swap replaces the inode, so the existing mode is not preserved\n // and must be reapplied. Only a file that already exists has a mode to keep.\n let modeToRestore: number | undefined;\n if (existingData !== null) {\n try {\n modeToRestore = (await stat(path)).mode & 0o777;\n } catch {}\n }\n\n if (tempDir) {\n await mkdir(tempDir, { recursive: true });\n }\n\n const tempFileName = `${basename(path)}.${Date.now()}-${randomBytes(4).toString('hex')}.tmp`;\n const tempPath = tempDir\n ? join(tempDir, tempFileName)\n : `${path}.${tempFileName}`;\n activeTempFiles.add(tempPath);\n\n try {\n await writeFile(tempPath, newData);\n\n if (modeToRestore !== undefined) {\n // Learn the ambient default mode once from a real temp file, then only\n // chmod when the source file used a non-default mode.\n if (defaultFileMode === undefined) {\n try {\n defaultFileMode = (await stat(tempPath)).mode & 0o777;\n } catch {}\n }\n if (modeToRestore !== defaultFileMode) {\n await chmod(tempPath, modeToRestore);\n }\n }\n\n await rename(tempPath, path);\n } catch (error) {\n try {\n await rm(tempPath, { force: true });\n } catch {}\n throw error;\n } finally {\n activeTempFiles.delete(tempPath);\n }\n\n return true;\n};\n"],"mappings":";;;;;;AAaA,MAAM,kCAAkB,IAAI,IAAY;AAGxC,QAAQ,GAAG,cAAc;CACvB,KAAK,MAAM,QAAQ,iBACjB,IAAI;EACF,OAAO,MAAM,EAAE,OAAO,KAAK,CAAC;CAC9B,QAAQ,CAAC;AAEb,CAAC;;;;;;;;AASD,IAAI;;;;;;;;;;;;AA+BJ,MAAa,qBAAqB,OAChC,MACA,MACA,EAAE,WAAW,QAAQ,SAAS,SAAS,SAAoC,CAAC,MACvD;CACrB,MAAM,UAAU,OAAO,KAAK,MAAM,QAAQ;CAI1C,IAAI,eAA8B;CAClC,IAAI;EACF,eAAe,MAAM,SAAS,IAAI;CACpC,QAAQ,CAAC;CAET,IAAI,cAAc,OAAO,OAAO,GAC9B,OAAO;CAOT,IAAI,CAAC,QAAQ;EACX,MAAM,UAAU,MAAM,OAAO;EAC7B,OAAO;CACT;CAIA,IAAI;CACJ,IAAI,iBAAiB,MACnB,IAAI;EACF,iBAAiB,MAAM,KAAK,IAAI,EAAC,CAAE,OAAO;CAC5C,QAAQ,CAAC;CAGX,IAAI,SACF,MAAM,MAAM,SAAS,EAAE,WAAW,KAAK,CAAC;CAG1C,MAAM,eAAe,GAAG,SAAS,IAAI,EAAE,GAAG,KAAK,IAAI,EAAE,GAAG,YAAY,CAAC,CAAC,CAAC,SAAS,KAAK,EAAE;CACvF,MAAM,WAAW,UACb,KAAK,SAAS,YAAY,IAC1B,GAAG,KAAK,GAAG;CACf,gBAAgB,IAAI,QAAQ;CAE5B,IAAI;EACF,MAAM,UAAU,UAAU,OAAO;EAEjC,IAAI,kBAAkB,QAAW;GAG/B,IAAI,oBAAoB,QACtB,IAAI;IACF,mBAAmB,MAAM,KAAK,QAAQ,EAAC,CAAE,OAAO;GAClD,QAAQ,CAAC;GAEX,IAAI,kBAAkB,iBACpB,MAAM,MAAM,UAAU,aAAa;EAEvC;EAEA,MAAM,OAAO,UAAU,IAAI;CAC7B,SAAS,OAAO;EACd,IAAI;GACF,MAAM,GAAG,UAAU,EAAE,OAAO,KAAK,CAAC;EACpC,QAAQ,CAAC;EACT,MAAM;CACR,UAAU;EACR,gBAAgB,OAAO,QAAQ;CACjC;CAEA,OAAO;AACT"}
1
+ {"version":3,"file":"writeFileIfChanged.mjs","names":[],"sources":["../../src/writeFileIfChanged.ts"],"sourcesContent":["import { randomBytes } from 'node:crypto';\nimport { rmSync } from 'node:fs';\nimport {\n chmod,\n mkdir,\n readFile,\n rename,\n rm,\n stat,\n writeFile,\n} from 'node:fs/promises';\nimport { basename, dirname, join } from 'node:path';\n\nconst activeTempFiles = new Set<string>();\n\n// Synchronous cleanup on process exit\nprocess.on('exit', () => {\n for (const file of activeTempFiles) {\n try {\n rmSync(file, { force: true });\n } catch {}\n }\n});\n\n/**\n * Permission bits a freshly created file receives on this system. Detected once\n * from the first temp file we actually write (`process.umask()` with no\n * argument is deprecated and thread-unsafe) and cached, so the atomic path can\n * skip restoring the mode whenever the source file already uses the default\n * mode — which is the case for essentially every generated file.\n */\nlet defaultFileMode: number | undefined;\n\n/**\n * Options for {@link writeFileIfChanged}.\n */\nexport type WriteFileIfChangedOptions = {\n /** Encoding used to turn `data` into bytes. Defaults to `'utf8'`. */\n encoding?: BufferEncoding;\n /** Directory to hold the temporary file used for the atomic swap. */\n tempDir?: string;\n /**\n * Write atomically via a temp file + `rename` so readers never observe a\n * half-written file. This creates a fresh inode on every write, which roughly\n * doubles the cost of a changed write compared to overwriting in place. Set to\n * `false` for callers whose consumers tolerate torn reads to write directly.\n * Defaults to `true`.\n */\n atomic?: boolean;\n};\n\n/**\n * Write `data` to `path` only when it differs from the file already on disk,\n * preserving the existing file's permission mode.\n *\n * The whole file is read back and byte-compared because the files written here\n * (dictionaries, entry points, types) are small, so reading beats streaming a\n * hash. When the content is unchanged the write is skipped entirely, which both\n * avoids inode churn and prevents downstream watchers from rebuilding.\n *\n * @returns `true` when the file was written, `false` when it was already up to date.\n */\nexport const writeFileIfChanged = async (\n path: string,\n data: string,\n { encoding = 'utf8', tempDir, atomic = true }: WriteFileIfChangedOptions = {}\n): Promise<boolean> => {\n const newData = Buffer.from(data, encoding);\n\n // Read the current content first: the common case is an unchanged file, and\n // this single read is the whole fast path (no stat/chmod needed to bail out).\n let existingData: Buffer | null = null;\n try {\n existingData = await readFile(path);\n } catch {}\n\n if (existingData?.equals(newData)) {\n return false;\n }\n\n // Fast path: overwrite in place. Truncating an existing file keeps its inode,\n // so its permission mode is preserved automatically (no chmod needed). This\n // skips the temp-file inode churn at the cost of exposing readers to a\n // partially written file.\n if (!atomic) {\n await writeFile(path, newData);\n return true;\n }\n\n // The atomic swap replaces the inode, so the existing mode is not preserved\n // and must be reapplied. Only a file that already exists has a mode to keep.\n let modeToRestore: number | undefined;\n if (existingData !== null) {\n try {\n modeToRestore = (await stat(path)).mode & 0o777;\n } catch {}\n }\n\n if (tempDir) {\n await mkdir(tempDir, { recursive: true });\n }\n\n const tempFileName = `${basename(path)}.${Date.now()}-${randomBytes(4).toString('hex')}.tmp`;\n const tempPath = tempDir\n ? join(tempDir, tempFileName)\n : `${path}.${tempFileName}`;\n activeTempFiles.add(tempPath);\n\n try {\n for (let attempt = 0; ; attempt++) {\n try {\n await writeFile(tempPath, newData);\n\n if (modeToRestore !== undefined) {\n // Learn the ambient default mode once from a real temp file, then only\n // chmod when the source file used a non-default mode.\n if (defaultFileMode === undefined) {\n try {\n defaultFileMode = (await stat(tempPath)).mode & 0o777;\n } catch {}\n }\n if (modeToRestore !== defaultFileMode) {\n await chmod(tempPath, modeToRestore);\n }\n }\n\n await rename(tempPath, path);\n break;\n } catch (error) {\n const isMissingDirectory =\n (error as NodeJS.ErrnoException).code === 'ENOENT';\n\n if (!isMissingDirectory || attempt > 0) throw error;\n\n await mkdir(dirname(path), { recursive: true });\n if (tempDir) await mkdir(tempDir, { recursive: true });\n }\n }\n } catch (error) {\n try {\n await rm(tempPath, { force: true });\n } catch {}\n throw error;\n } finally {\n activeTempFiles.delete(tempPath);\n }\n\n return true;\n};\n"],"mappings":";;;;;;AAaA,MAAM,kCAAkB,IAAI,IAAY;AAGxC,QAAQ,GAAG,cAAc;CACvB,KAAK,MAAM,QAAQ,iBACjB,IAAI;EACF,OAAO,MAAM,EAAE,OAAO,KAAK,CAAC;CAC9B,QAAQ,CAAC;AAEb,CAAC;;;;;;;;AASD,IAAI;;;;;;;;;;;;AA+BJ,MAAa,qBAAqB,OAChC,MACA,MACA,EAAE,WAAW,QAAQ,SAAS,SAAS,SAAoC,CAAC,MACvD;CACrB,MAAM,UAAU,OAAO,KAAK,MAAM,QAAQ;CAI1C,IAAI,eAA8B;CAClC,IAAI;EACF,eAAe,MAAM,SAAS,IAAI;CACpC,QAAQ,CAAC;CAET,IAAI,cAAc,OAAO,OAAO,GAC9B,OAAO;CAOT,IAAI,CAAC,QAAQ;EACX,MAAM,UAAU,MAAM,OAAO;EAC7B,OAAO;CACT;CAIA,IAAI;CACJ,IAAI,iBAAiB,MACnB,IAAI;EACF,iBAAiB,MAAM,KAAK,IAAI,EAAC,CAAE,OAAO;CAC5C,QAAQ,CAAC;CAGX,IAAI,SACF,MAAM,MAAM,SAAS,EAAE,WAAW,KAAK,CAAC;CAG1C,MAAM,eAAe,GAAG,SAAS,IAAI,EAAE,GAAG,KAAK,IAAI,EAAE,GAAG,YAAY,CAAC,CAAC,CAAC,SAAS,KAAK,EAAE;CACvF,MAAM,WAAW,UACb,KAAK,SAAS,YAAY,IAC1B,GAAG,KAAK,GAAG;CACf,gBAAgB,IAAI,QAAQ;CAE5B,IAAI;EACF,KAAK,IAAI,UAAU,IAAK,WACtB,IAAI;GACF,MAAM,UAAU,UAAU,OAAO;GAEjC,IAAI,kBAAkB,QAAW;IAG/B,IAAI,oBAAoB,QACtB,IAAI;KACF,mBAAmB,MAAM,KAAK,QAAQ,EAAC,CAAE,OAAO;IAClD,QAAQ,CAAC;IAEX,IAAI,kBAAkB,iBACpB,MAAM,MAAM,UAAU,aAAa;GAEvC;GAEA,MAAM,OAAO,UAAU,IAAI;GAC3B;EACF,SAAS,OAAO;GAId,IAAI,EAFD,MAAgC,SAAS,aAEjB,UAAU,GAAG,MAAM;GAE9C,MAAM,MAAM,QAAQ,IAAI,GAAG,EAAE,WAAW,KAAK,CAAC;GAC9C,IAAI,SAAS,MAAM,MAAM,SAAS,EAAE,WAAW,KAAK,CAAC;EACvD;CAEJ,SAAS,OAAO;EACd,IAAI;GACF,MAAM,GAAG,UAAU,EAAE,OAAO,KAAK,CAAC;EACpC,QAAQ,CAAC;EACT,MAAM;CACR,UAAU;EACR,gBAAgB,OAAO,QAAQ;CACjC;CAEA,OAAO;AACT"}
@@ -2,10 +2,12 @@ import { AlignmentPair, FingerprintedBlock } from "./types.js";
2
2
  //#region src/docReview/alignBlocks.d.ts
3
3
  /**
4
4
  * Align the blocks of a base document with the blocks of its translation using a
5
- * Needleman–Wunsch global alignment over anchor similarity and block type.
5
+ * Needleman–Wunsch global alignment over heading depth, anchor similarity and
6
+ * block type.
6
7
  *
7
8
  * Because prose differs across languages, the score is weighted toward the
8
- * structural anchor (digits and symbols) rather than the words themselves.
9
+ * structural signals — heading depth first, then the anchor (digits and symbols)
10
+ * — rather than the words themselves.
9
11
  *
10
12
  * @param baseBlocks - Blocks of the base (source) document.
11
13
  * @param targetBlocks - Blocks of the target (translated) document.
@@ -1 +1 @@
1
- {"version":3,"file":"alignBlocks.d.ts","names":[],"sources":["../../../src/docReview/alignBlocks.ts"],"mappings":";;;;;;;;;;;;;cAca,2BAAwB,YACvB,sBAAoB,cAClB,yBACb"}
1
+ {"version":3,"file":"alignBlocks.d.ts","names":[],"sources":["../../../src/docReview/alignBlocks.ts"],"mappings":";;;;;;;;;;;;;;;cAgEa,2BAAwB,YACvB,sBAAoB,cAClB,yBACb"}
@@ -1 +1 @@
1
- {"version":3,"file":"rebuildDocument.d.ts","names":[],"sources":["../../../src/docReview/rebuildDocument.ts"],"mappings":";;;;;;KAMY;;EAEV,WAAW;;EAEX;;EAEA;;KAGU;EACV,YAAY;EACZ,cAAc;EACd,MAAM;;KAGI;EACV,kBAAkB;;;;;;;;;;cAWP,6BAAwB,YAAA,cAAA,QAIlC,iBAAe;;;;;;;;;;cAmCL,wBAAqB,MAC1B,eAAa,cACL,sBAAoB,kBAChB"}
1
+ {"version":3,"file":"rebuildDocument.d.ts","names":[],"sources":["../../../src/docReview/rebuildDocument.ts"],"mappings":";;;;;;KAMY;;EAEV,WAAW;;EAEX;;EAEA;;KAGU;EACV,YAAY;EACZ,cAAc;EACd,MAAM;;KAGI;EACV,kBAAkB;;;;;;;;;;cAWP,6BAAwB,YAAA,cAAA,QAIlC,iBAAe;;;;;;;;;;cAmDL,wBAAqB,MAC1B,eAAa,cACL,sBAAoB,kBAChB"}
@@ -1 +1 @@
1
- {"version":3,"file":"segmentDocument.d.ts","names":[],"sources":["../../../src/docReview/segmentDocument.ts"],"mappings":";;;;;;;;;;;;;;;;cAmCa,kBAAe,iBAAmB;;;;;;;;;;;;;;;;;;;cAgHlC,kBAAe,iBAAmB"}
1
+ {"version":3,"file":"segmentDocument.d.ts","names":[],"sources":["../../../src/docReview/segmentDocument.ts"],"mappings":";;;;;;;;;;;;;;;;cAoDa,kBAAe,iBAAmB;;;;;;;;;;;;;;;;;;;cAuIlC,kBAAe,iBAAmB"}
@@ -11,6 +11,15 @@ type BlockType = 'heading' | 'paragraph' | 'list_item' | 'code_block' | 'blockqu
11
11
  type Block = {
12
12
  type: BlockType;
13
13
  content: string;
14
+ /**
15
+ * Depth of the ATX heading opening the block (`#` → 1, `######` → 6), or
16
+ * `null` when the block does not start with a heading.
17
+ *
18
+ * Heading depth is the one structural signal that survives translation, so it
19
+ * is used as decisive evidence when aligning a document with its translation:
20
+ * a `##` section is never the counterpart of a `###` section.
21
+ */
22
+ headingDepth: number | null;
14
23
  /** 1-based line number where the block starts (inclusive). */
15
24
  lineStart: number;
16
25
  /** 1-based line number where the block ends (inclusive). */
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","names":[],"sources":["../../../src/docReview/types.ts"],"mappings":";;;;;;KAKY;;;;KAcA;EACV,MAAM;EACN;;EAEA;;EAEA;;;;;KAMU,kBAAkB;;EAE5B;;EAEA;;;;;KAMU,qBAAqB;EAC/B;EACA;EACA;EACA;;;;;;;;KASU;EACV;EACA;EACA;;;;;KAMU;EACN;EAAe;EAAmB;;EAClC;EAAgB;EAAmB;;EACnC;EAAoB;;EACpB;EAAgB;;KAEV;EACV,SAAS;;;KAIC;KAEA;;EAEV;;EAEA"}
1
+ {"version":3,"file":"types.d.ts","names":[],"sources":["../../../src/docReview/types.ts"],"mappings":";;;;;;KAKY;;;;KAcA;EACV,MAAM;EACN;;;;;;;;;EASA;;EAEA;;EAEA;;;;;KAMU,kBAAkB;;EAE5B;;EAEA;;;;;KAMU,qBAAqB;EAC/B;EACA;EACA;EACA;;;;;;;;KASU;EACV;EACA;EACA;;;;;KAMU;EACN;EAAe;EAAmB;;EAClC;EAAgB;EAAmB;;EACnC;EAAoB;;EACpB;EAAgB;;KAEV;EACV,SAAS;;;KAIC;KAEA;;EAEV;;EAEA"}
@@ -13,9 +13,9 @@ import { getGlobalLimiter, getTaskLimiter, parallelizeGlobal } from "./paralleli
13
13
  import { Primitive, Recursive, excludeObjectFormat, reduceObjectFormat } from "./reduceObjectFormat.js";
14
14
  import { resolveObjectPromises } from "./resolveObjectPromises.js";
15
15
  import { resolveRelativePath } from "./resolveRelativePath.js";
16
- import { runOnce } from "./runOnce.js";
16
+ import { RunOnceContext, runOnce } from "./runOnce.js";
17
17
  import { ParallelHandle, runParallel } from "./runParallel/index.js";
18
18
  import { sortAlphabetically } from "./sortAlphabetically.js";
19
19
  import { splitTextByLines } from "./splitTextByLine.js";
20
20
  import { verifyIdenticObjectFormat } from "./verifyIdenticObjectFormat.js";
21
- export { Extension, Format, JSONObject, JsonChunk, ParallelHandle, Primitive, Queue, Recursive, assembleJSON, autoDecorateContent, buildComponentFilesList, chunkJSON, excludeObjectFormat, formatLocale, formatPath, getChunk, getContentExtension, getExtensionFromFormat, getFormatFromExtension, getGlobalLimiter, getPathHash, getTaskLimiter, mergeChunks, pLimit, parallelize, parallelizeGlobal, reconstructFromSingleChunk, reduceObjectFormat, resolveObjectPromises, resolveRelativePath, runOnce, runParallel, sortAlphabetically, splitTextByLines, verifyIdenticObjectFormat };
21
+ export { Extension, Format, JSONObject, JsonChunk, ParallelHandle, Primitive, Queue, Recursive, RunOnceContext, assembleJSON, autoDecorateContent, buildComponentFilesList, chunkJSON, excludeObjectFormat, formatLocale, formatPath, getChunk, getContentExtension, getExtensionFromFormat, getFormatFromExtension, getGlobalLimiter, getPathHash, getTaskLimiter, mergeChunks, pLimit, parallelize, parallelizeGlobal, reconstructFromSingleChunk, reduceObjectFormat, resolveObjectPromises, resolveRelativePath, runOnce, runParallel, sortAlphabetically, splitTextByLines, verifyIdenticObjectFormat };