@godcreator/word-report 0.4.0 → 0.5.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.
- package/README.md +1 -1
- package/dist/{assemble-D25NLmLx.mjs → assemble-BEt9_xsB.mjs} +24 -1
- package/dist/cli.d.mts +5 -2
- package/dist/cli.mjs +13 -9
- package/dist/core.d.mts +37 -2
- package/dist/core.mjs +3 -1
- package/dist/{export-DJQiBPPU.mjs → export-B01-yaAq.mjs} +72 -16
- package/dist/{export-DC764Umz.d.mts → export-DMKj4gl2.d.mts} +6 -3
- package/dist/mdx-C1RVv6E-.mjs +1031 -0
- package/dist/node.d.mts +12 -6
- package/dist/node.mjs +3 -3
- package/dist/{page-data-CJ6nCBnW.d.mts → page-data-C1veAE55.d.mts} +1 -1
- package/dist/report-files-Dfr71bRv.mjs +103 -0
- package/dist/site-server.d.mts +8 -4
- package/dist/site-server.mjs +97 -21
- package/dist/site.d.mts +2 -2
- package/dist/site.mjs +1 -1
- package/dist/{types-Bcl48xQN.d.mts → types-Ea2OT6Ac.d.mts} +7 -1
- package/engine/README.md +3 -3
- package/engine/schemas/report-content.schema.json +42 -0
- package/engine/src/docreport/__main__.py +18 -2
- package/engine/src/docreport/compose.py +51 -17
- package/engine/src/docreport/mathtype.py +3 -2
- package/engine/src/docreport/plain_math.py +53 -0
- package/engine/src/docreport/verify.py +39 -0
- package/engine/src/docreport/word.py +23 -0
- package/engine/templates/project-report.dotx +0 -0
- package/package.json +10 -3
- package/dist/report-files-CgWzLxH-.mjs +0 -72
|
@@ -7,7 +7,8 @@ Word objects and deferred MathType/field placeholders.
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
import re
|
|
9
9
|
|
|
10
|
-
from docreport.
|
|
10
|
+
from docreport.plain_math import INLINE_MATH_STYLE
|
|
11
|
+
from docreport.word import utf16_length, find_text, MarkerFinder
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
def mm(value):
|
|
@@ -179,23 +180,21 @@ class Composer:
|
|
|
179
180
|
if self._finished:
|
|
180
181
|
raise RuntimeError('Composer.finish() may only be called once.')
|
|
181
182
|
doc = self.doc
|
|
183
|
+
fields = MarkerFinder(doc)
|
|
182
184
|
for token, code, bookmark in self._pending_fields:
|
|
183
|
-
rng =
|
|
185
|
+
rng = fields.find(token)
|
|
184
186
|
field = doc.Fields.Add(rng, -1, code, False)
|
|
185
187
|
field.Code.Font.Bold, field.Code.Font.Italic = 0, 0
|
|
186
188
|
if bookmark:
|
|
187
189
|
doc.Bookmarks.Add(bookmark, field.Result)
|
|
190
|
+
numbers = MarkerFinder(doc)
|
|
188
191
|
for begin, end, bookmark in self._pending_numbers:
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if not rng.Find.Execute(FindText=begin, Wrap=0):
|
|
196
|
-
raise RuntimeError(f'Number boundary missing: {begin}')
|
|
197
|
-
start_position = rng.Start
|
|
198
|
-
rng.Delete()
|
|
192
|
+
opening = numbers.find(begin)
|
|
193
|
+
closing = numbers.find(end)
|
|
194
|
+
end_position = closing.Start
|
|
195
|
+
closing.Delete()
|
|
196
|
+
start_position = opening.Start
|
|
197
|
+
opening.Delete()
|
|
199
198
|
doc.Bookmarks.Add(bookmark, doc.Range(
|
|
200
199
|
start_position, end_position - utf16_length(begin)))
|
|
201
200
|
for table in doc.Tables:
|
|
@@ -227,6 +226,7 @@ class ContentComposer(Composer):
|
|
|
227
226
|
self.heading_lists = {}
|
|
228
227
|
self.appendix_numbering = None
|
|
229
228
|
self.appendix_count = 0
|
|
229
|
+
self._inline_math_style = None
|
|
230
230
|
|
|
231
231
|
def point(self):
|
|
232
232
|
rng = self.doc.Content.Duplicate if self.container is None else self.container.Range.Duplicate
|
|
@@ -286,7 +286,13 @@ class ContentComposer(Composer):
|
|
|
286
286
|
if kind == 'text':
|
|
287
287
|
value = node['text']
|
|
288
288
|
elif kind == 'math':
|
|
289
|
-
|
|
289
|
+
if 'plainText' not in node:
|
|
290
|
+
value = self.math_token(node, True)
|
|
291
|
+
else:
|
|
292
|
+
for run in node['plainText']:
|
|
293
|
+
spans.append((utf16_length(text), utf16_length(run['text']), ['math'] + run.get('marks', [])))
|
|
294
|
+
text += run['text']
|
|
295
|
+
continue
|
|
290
296
|
elif kind == 'ref':
|
|
291
297
|
value = self.ref_token(node)
|
|
292
298
|
elif kind == 'break':
|
|
@@ -309,14 +315,39 @@ class ContentComposer(Composer):
|
|
|
309
315
|
def rich_paragraph(self, nodes, style=None, prefix='', suffix=''):
|
|
310
316
|
text, spans = self.inline(nodes)
|
|
311
317
|
rng = self.paragraph(prefix + text + suffix, style)
|
|
318
|
+
self.apply_marks(rng, utf16_length(prefix), spans)
|
|
319
|
+
return rng
|
|
320
|
+
|
|
321
|
+
def apply_marks(self, rng, offset, spans):
|
|
312
322
|
for start, length, marks in spans:
|
|
313
|
-
|
|
323
|
+
if not marks:
|
|
324
|
+
continue
|
|
325
|
+
run = self.part(rng, offset + start, length)
|
|
314
326
|
for mark in marks:
|
|
315
327
|
if mark == 'code':
|
|
316
328
|
run.Font.Name = 'Consolas'
|
|
329
|
+
elif mark == 'math':
|
|
330
|
+
run.Style = self.inline_math_style()
|
|
317
331
|
else:
|
|
318
332
|
setattr(run.Font, {'strong': 'Bold', 'em': 'Italic', 'sup': 'Superscript', 'sub': 'Subscript'}[mark], True)
|
|
319
|
-
|
|
333
|
+
|
|
334
|
+
def inline_math_style(self):
|
|
335
|
+
"""Character style of inline formulas written as text; created in the document, the DOTX stays unchanged."""
|
|
336
|
+
if self._inline_math_style is None:
|
|
337
|
+
style = self.doc.Styles.Add(INLINE_MATH_STYLE, 2)
|
|
338
|
+
style.Font.Name = 'Times New Roman'
|
|
339
|
+
self._inline_math_style = style
|
|
340
|
+
return self._inline_math_style
|
|
341
|
+
|
|
342
|
+
def single_paragraph_cell(self, cell, node):
|
|
343
|
+
"""Write a one-paragraph cell in one assignment; the general path inserts then trims a paragraph."""
|
|
344
|
+
text, spans = self.inline(node['content'])
|
|
345
|
+
rng = cell.Range
|
|
346
|
+
rng.Text = text
|
|
347
|
+
rng = self.part(cell.Range, 0, utf16_length(text))
|
|
348
|
+
rng.Style = self.doc.Styles.Item(self.layout.get(node['id'], {}).get('style') or self.style)
|
|
349
|
+
self.apply_marks(rng, 0, spans)
|
|
350
|
+
self.mark(node['id'], rng, '_b')
|
|
320
351
|
|
|
321
352
|
def chapter_sequence(self, kind, bookmark):
|
|
322
353
|
"""「章号-序号」 for an equation, figure or table in the current top-level chapter."""
|
|
@@ -489,11 +520,14 @@ class ContentComposer(Composer):
|
|
|
489
520
|
saved = self.container, self.cell, self.style, self.width
|
|
490
521
|
for r, c, rs, cs, cell in placements:
|
|
491
522
|
self.container = cells[r, c].Cells.Item(1)
|
|
492
|
-
# Merge can concatenate the original empty cell paragraphs.
|
|
493
|
-
self.container.Range.Text = ''
|
|
494
523
|
self.cell = True
|
|
495
524
|
self.style = 'Table Header' if r < node['headerRows'] else 'Table Text'
|
|
496
525
|
self.width = sum(widths[c:c + cs]) - 8
|
|
526
|
+
if len(cell['blocks']) == 1 and cell['blocks'][0]['type'] == 'paragraph':
|
|
527
|
+
self.single_paragraph_cell(self.container, cell['blocks'][0])
|
|
528
|
+
continue
|
|
529
|
+
# Merge can concatenate the original empty cell paragraphs.
|
|
530
|
+
self.container.Range.Text = ''
|
|
497
531
|
self.blocks(cell['blocks'], depth)
|
|
498
532
|
self.trim_container()
|
|
499
533
|
self.container, self.cell, self.style, self.width = saved
|
|
@@ -10,7 +10,7 @@ import re
|
|
|
10
10
|
import struct
|
|
11
11
|
from hashlib import sha256
|
|
12
12
|
|
|
13
|
-
from .word import
|
|
13
|
+
from .word import MarkerFinder
|
|
14
14
|
|
|
15
15
|
DEFAULT_DLL = Path(r"C:\Program Files (x86)\MathType\MathPage\64\MathPage.wll")
|
|
16
16
|
|
|
@@ -138,12 +138,13 @@ def fill_and_reopen(draft, destination, pending, record, dll=DEFAULT_DLL):
|
|
|
138
138
|
word.Visible, word.DisplayAlerts, word.AutomationSecurity = False, 0, 3
|
|
139
139
|
missing = comtypes.automation.VARIANT.missing
|
|
140
140
|
doc = word.Documents.Open(str(draft), False, False, False)
|
|
141
|
+
markers = MarkerFinder(doc)
|
|
141
142
|
for item in pending:
|
|
142
143
|
equation = item["equation"]
|
|
143
144
|
tex = mathtype_tex(equation["latex"])
|
|
144
145
|
if tex != equation["latex"]:
|
|
145
146
|
record("tex-adaptation", id=equation["id"], sourceTex=equation["latex"], mathTypeTex=tex)
|
|
146
|
-
target =
|
|
147
|
+
target = markers.find(item["token"])
|
|
147
148
|
target.Text = ""
|
|
148
149
|
shape = doc.InlineShapes.AddOLEObject("Equation.DSMT4", missing, False, False,
|
|
149
150
|
missing, missing, missing, target)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Inline formulas written as Word text: their character style and the saved-package fix-up.
|
|
2
|
+
|
|
3
|
+
The report marks each such formula with <WordPlainMath> and supplies the runs; the engine
|
|
4
|
+
does not decide which TeX can become text.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
INLINE_MATH_STYLE = 'DR Inline Math'
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def clear_east_asian_hints(path):
|
|
12
|
+
"""Drop w:hint="eastAsia" from runs in the inline-math character style.
|
|
13
|
+
|
|
14
|
+
Word adds the hint when it inserts φ, ≈ or ° into a Chinese paragraph, which renders
|
|
15
|
+
them in the East Asian font; COM exposes no setter for it, so the saved package is edited.
|
|
16
|
+
"""
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import tempfile
|
|
20
|
+
import zipfile
|
|
21
|
+
|
|
22
|
+
with zipfile.ZipFile(path) as source:
|
|
23
|
+
styles = source.read('word/styles.xml').decode('utf8')
|
|
24
|
+
match = re.search(r'<w:style\b[^>]*w:styleId="([^"]+)"[^>]*>(?:(?!</w:style>).)*?<w:name w:val="'
|
|
25
|
+
+ re.escape(INLINE_MATH_STYLE) + '"', styles, re.S)
|
|
26
|
+
if not match:
|
|
27
|
+
return 0
|
|
28
|
+
marker = f'<w:rStyle w:val="{match[1]}"/>'
|
|
29
|
+
cleared = 0
|
|
30
|
+
|
|
31
|
+
def clear(run):
|
|
32
|
+
nonlocal cleared
|
|
33
|
+
text = run[0]
|
|
34
|
+
if marker not in text or 'w:hint="eastAsia"' not in text:
|
|
35
|
+
return text
|
|
36
|
+
cleared += 1
|
|
37
|
+
text = text.replace(' w:hint="eastAsia"', '')
|
|
38
|
+
return text.replace('<w:rFonts/>', '')
|
|
39
|
+
|
|
40
|
+
fd, temporary = tempfile.mkstemp(suffix='.docx', dir=os.path.dirname(path))
|
|
41
|
+
os.close(fd)
|
|
42
|
+
try:
|
|
43
|
+
with zipfile.ZipFile(temporary, 'w') as target:
|
|
44
|
+
for item in source.infolist():
|
|
45
|
+
data = source.read(item)
|
|
46
|
+
if item.filename.startswith('word/') and item.filename.endswith('.xml') and marker.encode() in data:
|
|
47
|
+
data = re.sub(r'<w:r(?:\s[^>]*)?>.*?</w:r>', clear, data.decode('utf8'), flags=re.S).encode('utf8')
|
|
48
|
+
target.writestr(item, data, compress_type=item.compress_type)
|
|
49
|
+
except BaseException:
|
|
50
|
+
os.unlink(temporary)
|
|
51
|
+
raise
|
|
52
|
+
os.replace(temporary, path)
|
|
53
|
+
return cleared
|
|
@@ -59,6 +59,39 @@ def abstract_state(doc, content):
|
|
|
59
59
|
return results
|
|
60
60
|
|
|
61
61
|
|
|
62
|
+
WORD_NS = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
|
63
|
+
# Word writes these into a field's result when a REF/SEQ target or switch is broken; readers then see them as body text.
|
|
64
|
+
FIELD_ERROR_PREFIXES = ('错误!不能识别的开关参数', '错误!未找到引用源', '错误!找不到引用源', '错误!未定义书签',
|
|
65
|
+
'error!unknownswitchargument', 'error!referencesourcenotfound', 'error!bookmarknotdefined')
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def field_results(root):
|
|
69
|
+
"""Yield (field code, displayed result) for every simple and complex field in one XML part."""
|
|
70
|
+
open_fields = [] # [code, result, past separator] per nested complex field
|
|
71
|
+
for node in root.iter():
|
|
72
|
+
if node.tag == WORD_NS + 'fldSimple':
|
|
73
|
+
yield node.get(WORD_NS + 'instr', ''), ''.join(t.text or '' for t in node.iter(WORD_NS + 't'))
|
|
74
|
+
elif node.tag == WORD_NS + 'fldChar':
|
|
75
|
+
kind = node.get(WORD_NS + 'fldCharType')
|
|
76
|
+
if kind == 'begin':
|
|
77
|
+
open_fields.append(['', '', False])
|
|
78
|
+
elif kind == 'separate' and open_fields:
|
|
79
|
+
open_fields[-1][2] = True
|
|
80
|
+
elif kind == 'end' and open_fields:
|
|
81
|
+
code, result, _ = open_fields.pop()
|
|
82
|
+
yield code, result
|
|
83
|
+
elif open_fields and node.tag == WORD_NS + 'instrText' and not open_fields[-1][2]:
|
|
84
|
+
open_fields[-1][0] += node.text or ''
|
|
85
|
+
elif open_fields and node.tag == WORD_NS + 't' and open_fields[-1][2]:
|
|
86
|
+
open_fields[-1][1] += node.text or ''
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def reject_field_error_results(root):
|
|
90
|
+
for code, result in field_results(root):
|
|
91
|
+
if re.sub(r'\s+', '', result).lower().replace('!', '!').startswith(FIELD_ERROR_PREFIXES):
|
|
92
|
+
raise RuntimeError(f'Word field result error: {code.strip()}: {result}')
|
|
93
|
+
|
|
94
|
+
|
|
62
95
|
def package_state(path, expected_equations):
|
|
63
96
|
with ZipFile(path) as archive:
|
|
64
97
|
objects, omml = [], []
|
|
@@ -66,6 +99,7 @@ def package_state(path, expected_equations):
|
|
|
66
99
|
if not part.startswith('word/') or not part.endswith('.xml'):
|
|
67
100
|
continue
|
|
68
101
|
root = ET.fromstring(archive.read(part))
|
|
102
|
+
reject_field_error_results(root)
|
|
69
103
|
found = [o for o in root.findall(".//{urn:schemas-microsoft-com:office:office}OLEObject")
|
|
70
104
|
if o.get("ProgID") == "Equation.DSMT4"]
|
|
71
105
|
objects.extend(found)
|
|
@@ -103,6 +137,11 @@ def content_state(path, content, directory):
|
|
|
103
137
|
if compact(node['text']) not in available:
|
|
104
138
|
raise RuntimeError(f"Source text missing after save: {node['text'][:100]}")
|
|
105
139
|
checked += 1
|
|
140
|
+
elif node.get('type') == 'math' and 'plainText' in node:
|
|
141
|
+
written = ''.join(run['text'] for run in node['plainText'])
|
|
142
|
+
if compact(written) not in available:
|
|
143
|
+
raise RuntimeError(f"Inline formula text missing after save: {written[:100]}")
|
|
144
|
+
checked += 1
|
|
106
145
|
elif node.get('type') == 'abstract':
|
|
107
146
|
keywords = '关键词:' + ';'.join(node['keywords'])
|
|
108
147
|
if compact(node.get('title', '摘要')) not in available or compact(keywords) not in available:
|
|
@@ -72,6 +72,29 @@ def find_text(doc, text):
|
|
|
72
72
|
raise ValueError(f"Generation marker missing: {text}")
|
|
73
73
|
|
|
74
74
|
|
|
75
|
+
class MarkerFinder:
|
|
76
|
+
"""Find generation markers written in body order without rescanning from the top each time.
|
|
77
|
+
|
|
78
|
+
The body story is searched from the previous body hit; a marker not found there
|
|
79
|
+
(notes, headers, or out of order) falls back to the full search of every story.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
MAIN_STORY = 1
|
|
83
|
+
|
|
84
|
+
def __init__(self, doc):
|
|
85
|
+
self.doc = doc
|
|
86
|
+
self.position = 0
|
|
87
|
+
|
|
88
|
+
def find(self, text):
|
|
89
|
+
target = self.doc.Range(self.position, self.doc.Content.End)
|
|
90
|
+
target.Find.ClearFormatting()
|
|
91
|
+
if not target.Find.Execute(text, True, False, False, False, False, True, 0):
|
|
92
|
+
target = find_text(self.doc, text)
|
|
93
|
+
if target.StoryType == self.MAIN_STORY:
|
|
94
|
+
self.position = target.Start
|
|
95
|
+
return target
|
|
96
|
+
|
|
97
|
+
|
|
75
98
|
def refresh(doc):
|
|
76
99
|
"""Resolve sequence fields before cross-references, then refresh TOC pages."""
|
|
77
100
|
for _ in range(2):
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@godcreator/word-report",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"author": {
|
|
5
5
|
"name": "godcreator"
|
|
6
6
|
},
|
|
7
7
|
"type": "module",
|
|
8
|
-
"description": "报告:JSON
|
|
8
|
+
"description": "报告:JSON 报告文档的格式、校验与分章组装,MDX 写法编译成它(.);读报告目录、检查、导出 Word、JSON 报告转 MDX 的命令行 report-docx(./node);报告页与报告列表(./site),两页与图片的 loader(./site/server);engine/ 是驱动本机 Word 的 Python 导出引擎与 .dotx 模板;template/ 是建站器复制进站点 app/features/word-report/ 的接线文件(两条页面路由、图片路由、侧栏入口、样式)。",
|
|
9
9
|
"license": "MIT",
|
|
10
10
|
"exports": {
|
|
11
11
|
".": {
|
|
@@ -43,7 +43,14 @@
|
|
|
43
43
|
],
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@godcreator/comments": "^0.1.3",
|
|
46
|
-
"
|
|
46
|
+
"remark-frontmatter": "^5.0.0",
|
|
47
|
+
"remark-gfm": "^4.0.1",
|
|
48
|
+
"remark-math": "^6.0.0",
|
|
49
|
+
"remark-mdx": "^3.1.0",
|
|
50
|
+
"remark-parse": "^11.0.0",
|
|
51
|
+
"sharp": "^0.35.5",
|
|
52
|
+
"unified": "^11.0.5",
|
|
53
|
+
"yaml": "^2.9.1"
|
|
47
54
|
},
|
|
48
55
|
"peerDependencies": {
|
|
49
56
|
"@fumadocs/base-ui": "^16.17.1",
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import { a as parseReportJson, n as safeRelativePath, o as ReportContentError, r as validateDocument } from "./assemble-D25NLmLx.mjs";
|
|
2
|
-
import { readFile, readdir, realpath, stat } from "node:fs/promises";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
//#region src/node/report-files.ts
|
|
5
|
-
/** 站点里的报告:站点根下 reports/<报告 id>/content.json,章节与图片在同一个报告目录里 */
|
|
6
|
-
const reportsDirName = "reports";
|
|
7
|
-
/** 报告 id 就是报告目录名,出现在网址里:小写字母、数字与 -,以字母或数字开头 */
|
|
8
|
-
const REPORT_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
9
|
-
/** 报告的入口,相对站点目录 */
|
|
10
|
-
function reportEntryOf(reportId) {
|
|
11
|
-
if (!REPORT_ID_PATTERN.test(reportId)) throw new ReportContentError(`报告 id 只能是小写字母、数字与 -,以字母或数字开头:${JSON.stringify(reportId)}`);
|
|
12
|
-
return `${reportsDirName}/${reportId}/content.json`;
|
|
13
|
-
}
|
|
14
|
-
/** 站点里的报告 id:reports/ 下有 content.json 的目录,按名字排;目录名不合 REPORT_ID_PATTERN 的直接报错,不悄悄跳过 */
|
|
15
|
-
async function reportIdsOf(siteDir) {
|
|
16
|
-
const reportsDir = path.join(siteDir, reportsDirName);
|
|
17
|
-
let entries;
|
|
18
|
-
try {
|
|
19
|
-
entries = await readdir(reportsDir, { withFileTypes: true });
|
|
20
|
-
} catch (error) {
|
|
21
|
-
if (error.code === "ENOENT") return [];
|
|
22
|
-
throw error;
|
|
23
|
-
}
|
|
24
|
-
const ids = [];
|
|
25
|
-
for (const entry of entries) {
|
|
26
|
-
if (!entry.isDirectory()) continue;
|
|
27
|
-
if (!await stat(path.join(reportsDir, entry.name, "content.json")).then((info) => info.isFile(), (error) => {
|
|
28
|
-
if (error.code === "ENOENT") return false;
|
|
29
|
-
throw error;
|
|
30
|
-
})) continue;
|
|
31
|
-
reportEntryOf(entry.name);
|
|
32
|
-
ids.push(entry.name);
|
|
33
|
-
}
|
|
34
|
-
return ids.sort();
|
|
35
|
-
}
|
|
36
|
-
/**
|
|
37
|
-
* 读一份报告组装要的全部 JSON 文件(入口与它 include 到的章节),键是相对站点目录的路径,交给 assembleDocument。
|
|
38
|
-
* 不读图片、不解码:网页显示用;检查与导出用 readReport,它还核对图片与快照
|
|
39
|
-
*/
|
|
40
|
-
async function readReportFiles(siteDir, entry) {
|
|
41
|
-
const root = await realpath(siteDir);
|
|
42
|
-
const files = {};
|
|
43
|
-
const load = async (relative) => {
|
|
44
|
-
if (Object.hasOwn(files, relative)) return;
|
|
45
|
-
const absolute = path.join(root, relative);
|
|
46
|
-
let real;
|
|
47
|
-
try {
|
|
48
|
-
real = await realpath(absolute);
|
|
49
|
-
} catch {
|
|
50
|
-
throw new ReportContentError("Missing or unreadable file", relative);
|
|
51
|
-
}
|
|
52
|
-
const inside = path.relative(root, real);
|
|
53
|
-
if (inside === ".." || inside.startsWith(".." + path.sep) || path.isAbsolute(inside)) throw new ReportContentError("File escapes report root", relative);
|
|
54
|
-
const text = await readFile(real, "utf8");
|
|
55
|
-
files[relative] = text;
|
|
56
|
-
const document = parseReportJson(text, relative);
|
|
57
|
-
validateDocument(document, relative);
|
|
58
|
-
for (const [index, block] of document.blocks.entries()) {
|
|
59
|
-
if (block.type !== "include") continue;
|
|
60
|
-
try {
|
|
61
|
-
safeRelativePath(block.src);
|
|
62
|
-
} catch (error) {
|
|
63
|
-
throw new ReportContentError(error.message, relative, `/blocks/${index}/src`);
|
|
64
|
-
}
|
|
65
|
-
await load(path.posix.join(path.posix.dirname(relative), block.src));
|
|
66
|
-
}
|
|
67
|
-
};
|
|
68
|
-
await load(entry);
|
|
69
|
-
return files;
|
|
70
|
-
}
|
|
71
|
-
//#endregion
|
|
72
|
-
export { reportsDirName as a, reportIdsOf as i, readReportFiles as n, reportEntryOf as r, REPORT_ID_PATTERN as t };
|