dsh-ensp 0.7.0 → 0.8.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.
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Writes .docx files without any runtime dependency.
3
+ *
4
+ * A .docx is a zip holding OOXML parts. This module builds the parts a plain
5
+ * report needs (content types, package relationships, document, styles,
6
+ * numbering) and zips them itself, so the package installs nothing and works
7
+ * offline.
8
+ *
9
+ * Blocks are plain objects; see {@link renderBlocks} for the shapes. Text may
10
+ * carry two inline markers, `**bold**` and `` `code` ``, which become runs.
11
+ *
12
+ * @module core/docx
13
+ */
14
+
15
+ import { deflateRawSync } from 'node:zlib'
16
+
17
+ const CRC_TABLE = (() => {
18
+ const table = new Int32Array(256)
19
+ for (let i = 0; i < 256; i += 1) {
20
+ let c = i
21
+ for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
22
+ table[i] = c
23
+ }
24
+ return table
25
+ })()
26
+
27
+ /** CRC32, needed by both the local and the central zip records. */
28
+ function crc32(buffer) {
29
+ let c = -1
30
+ for (let i = 0; i < buffer.length; i += 1) c = (c >>> 8) ^ CRC_TABLE[(c ^ buffer[i]) & 0xff]
31
+ return (c ^ -1) >>> 0
32
+ }
33
+
34
+ /**
35
+ * Build a zip from `{name, data}` entries. Every entry is deflated when that
36
+ * actually saves space and stored otherwise, which keeps tiny parts readable in
37
+ * a hex dump while still compressing the document body.
38
+ */
39
+ export function zip(entries) {
40
+ const chunks = []
41
+ const central = []
42
+ let offset = 0
43
+
44
+ for (const entry of entries) {
45
+ const name = Buffer.from(entry.name, 'utf8')
46
+ const data = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data, 'utf8')
47
+ const deflated = deflateRawSync(data, { level: 9 })
48
+ const compressed = deflated.length < data.length
49
+ const body = compressed ? deflated : data
50
+ const method = compressed ? 8 : 0
51
+ const crc = crc32(data)
52
+
53
+ const local = Buffer.alloc(30)
54
+ local.writeUInt32LE(0x04034b50, 0)
55
+ local.writeUInt16LE(20, 4)
56
+ local.writeUInt16LE(0, 6)
57
+ local.writeUInt16LE(method, 8)
58
+ local.writeUInt16LE(0, 10)
59
+ local.writeUInt16LE(0x0021, 12)
60
+ local.writeUInt32LE(crc, 14)
61
+ local.writeUInt32LE(body.length, 18)
62
+ local.writeUInt32LE(data.length, 22)
63
+ local.writeUInt16LE(name.length, 26)
64
+ local.writeUInt16LE(0, 28)
65
+
66
+ chunks.push(local, name, body)
67
+ central.push({ name, crc, body: body.length, size: data.length, method, offset })
68
+ offset += local.length + name.length + body.length
69
+ }
70
+
71
+ const centralStart = offset
72
+ for (const entry of central) {
73
+ const head = Buffer.alloc(46)
74
+ head.writeUInt32LE(0x02014b50, 0)
75
+ head.writeUInt16LE(20, 4)
76
+ head.writeUInt16LE(20, 6)
77
+ head.writeUInt16LE(0, 8)
78
+ head.writeUInt16LE(entry.method, 10)
79
+ head.writeUInt16LE(0, 12)
80
+ head.writeUInt16LE(0x0021, 14)
81
+ head.writeUInt32LE(entry.crc, 16)
82
+ head.writeUInt32LE(entry.body, 20)
83
+ head.writeUInt32LE(entry.size, 24)
84
+ head.writeUInt16LE(entry.name.length, 28)
85
+ head.writeUInt16LE(0, 30)
86
+ head.writeUInt16LE(0, 32)
87
+ head.writeUInt16LE(0, 34)
88
+ head.writeUInt16LE(0, 36)
89
+ head.writeUInt32LE(0, 38)
90
+ head.writeUInt32LE(entry.offset, 42)
91
+ chunks.push(head, entry.name)
92
+ offset += head.length + entry.name.length
93
+ }
94
+
95
+ const end = Buffer.alloc(22)
96
+ end.writeUInt32LE(0x06054b50, 0)
97
+ end.writeUInt16LE(0, 4)
98
+ end.writeUInt16LE(0, 6)
99
+ end.writeUInt16LE(central.length, 8)
100
+ end.writeUInt16LE(central.length, 10)
101
+ end.writeUInt32LE(offset - centralStart, 12)
102
+ end.writeUInt32LE(centralStart, 16)
103
+ end.writeUInt16LE(0, 20)
104
+ chunks.push(end)
105
+
106
+ return Buffer.concat(chunks)
107
+ }
108
+
109
+ /** XML-escape text and drop control characters, which OOXML rejects. */
110
+ export function escapeXml(value) {
111
+ return String(value ?? '')
112
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '')
113
+ .replace(/&/g, '&amp;')
114
+ .replace(/</g, '&lt;')
115
+ .replace(/>/g, '&gt;')
116
+ .replace(/"/g, '&quot;')
117
+ }
118
+
119
+ const RUN_FONTS = '<w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:eastAsia="\u5b8b\u4f53"/>'
120
+ const CODE_FONTS = '<w:rFonts w:ascii="Consolas" w:hAnsi="Consolas" w:eastAsia="Consolas"/>'
121
+
122
+ /** Turn `**bold**` and backtick spans into runs. */
123
+ export function inlineRuns(text, { size = 24, bold = false, color = null } = {}) {
124
+ const parts = String(text ?? '').split(/(\*\*[^*]+\*\*|`[^`]+`)/)
125
+ const runs = []
126
+ for (const part of parts) {
127
+ if (!part) continue
128
+ let body = part
129
+ let isBold = bold
130
+ let isCode = false
131
+ if (part.startsWith('**') && part.endsWith('**') && part.length > 4) {
132
+ body = part.slice(2, -2)
133
+ isBold = true
134
+ } else if (part.startsWith('`') && part.endsWith('`') && part.length > 2) {
135
+ body = part.slice(1, -1)
136
+ isCode = true
137
+ }
138
+ const props = [
139
+ isCode ? CODE_FONTS : RUN_FONTS,
140
+ `<w:sz w:val="${isCode ? Math.max(16, size - 2) : size}"/>`,
141
+ `<w:szCs w:val="${isCode ? Math.max(16, size - 2) : size}"/>`,
142
+ isBold ? '<w:b/>' : '',
143
+ color ? `<w:color w:val="${color}"/>` : '',
144
+ ].join('')
145
+ runs.push(`<w:r><w:rPr>${props}</w:rPr><w:t xml:space="preserve">${escapeXml(body)}</w:t></w:r>`)
146
+ }
147
+ if (!runs.length) runs.push(`<w:r><w:rPr>${RUN_FONTS}</w:rPr><w:t></w:t></w:r>`)
148
+ return runs.join('')
149
+ }
150
+
151
+ function paragraph(text, { style = null, align = null, size = 24, bold = false, spaceAfter = 80, indent = null } = {}) {
152
+ const props = [
153
+ style ? `<w:pStyle w:val="${style}"/>` : '',
154
+ align ? `<w:jc w:val="${align}"/>` : '',
155
+ indent ? `<w:ind w:firstLine="${indent}"/>` : '',
156
+ `<w:spacing w:after="${spaceAfter}" w:line="320" w:lineRule="auto"/>`,
157
+ ].join('')
158
+ return `<w:p><w:pPr>${props}</w:pPr>${inlineRuns(text, { size, bold })}</w:p>`
159
+ }
160
+
161
+ function numberedParagraph(text, numId) {
162
+ return (
163
+ '<w:p><w:pPr>' +
164
+ `<w:numPr><w:ilvl w:val="0"/><w:numId w:val="${numId}"/></w:numPr>` +
165
+ '<w:spacing w:after="60" w:line="320" w:lineRule="auto"/>' +
166
+ `</w:pPr>${inlineRuns(text, { size: 24 })}</w:p>`
167
+ )
168
+ }
169
+
170
+ function codeBlock(text) {
171
+ const lines = String(text ?? '').replace(/\r\n?/g, '\n').replace(/\s+$/, '').split('\n')
172
+ const runs = []
173
+ lines.forEach((line, index) => {
174
+ if (index) runs.push('<w:r><w:br/></w:r>')
175
+ runs.push(
176
+ `<w:r><w:rPr>${CODE_FONTS}<w:sz w:val="18"/><w:szCs w:val="18"/></w:rPr>` +
177
+ `<w:t xml:space="preserve">${escapeXml(line) || ' '}</w:t></w:r>`,
178
+ )
179
+ })
180
+ return (
181
+ '<w:p><w:pPr><w:pStyle w:val="Code"/>' +
182
+ '<w:shd w:val="clear" w:color="auto" w:fill="F4F4F4"/>' +
183
+ '<w:spacing w:after="120" w:line="240" w:lineRule="auto"/></w:pPr>' +
184
+ `${runs.join('')}</w:p>`
185
+ )
186
+ }
187
+
188
+ function tableBlock({ header = [], rows = [], widths = [] }) {
189
+ const columns = Math.max(header.length, ...rows.map((r) => r.length), 1)
190
+ const total = 9000
191
+ const cells = widths.length === columns ? widths : new Array(columns).fill(Math.floor(total / columns))
192
+ const grid = cells.map((w) => `<w:gridCol w:w="${w}"/>`).join('')
193
+ const border =
194
+ '<w:tblBorders>' +
195
+ '<w:top w:val="single" w:sz="4" w:space="0" w:color="808080"/>' +
196
+ '<w:left w:val="single" w:sz="4" w:space="0" w:color="808080"/>' +
197
+ '<w:bottom w:val="single" w:sz="4" w:space="0" w:color="808080"/>' +
198
+ '<w:right w:val="single" w:sz="4" w:space="0" w:color="808080"/>' +
199
+ '<w:insideH w:val="single" w:sz="4" w:space="0" w:color="808080"/>' +
200
+ '<w:insideV w:val="single" w:sz="4" w:space="0" w:color="808080"/>' +
201
+ '</w:tblBorders>'
202
+ const tblPr =
203
+ '<w:tblPr><w:tblW w:w="5000" w:type="pct"/>' + border +
204
+ '<w:tblLayout w:type="fixed"/></w:tblPr>'
205
+
206
+ const buildRow = (values, isHeader) => {
207
+ const tcs = []
208
+ for (let i = 0; i < columns; i += 1) {
209
+ const value = values[i] ?? ''
210
+ const shading = isHeader ? '<w:shd w:val="clear" w:color="auto" w:fill="EDEDED"/>' : ''
211
+ tcs.push(
212
+ '<w:tc><w:tcPr>' +
213
+ `<w:tcW w:w="${cells[i]}" w:type="dxa"/>` +
214
+ `${shading}<w:vAlign w:val="center"/></w:tcPr>` +
215
+ `<w:p><w:pPr><w:spacing w:after="20" w:line="260" w:lineRule="auto"/>` +
216
+ `${isHeader ? '<w:jc w:val="center"/>' : ''}</w:pPr>` +
217
+ `${inlineRuns(value, { size: 21, bold: isHeader })}</w:p></w:tc>`,
218
+ )
219
+ }
220
+ return `<w:tr>${isHeader ? '<w:trPr><w:tblHeader/></w:trPr>' : ''}${tcs.join('')}</w:tr>`
221
+ }
222
+
223
+ const headerRow = header.length ? buildRow(header, true) : ''
224
+ const bodyRows = rows.map((row) => buildRow(row, false)).join('')
225
+ return `<w:tbl>${tblPr}<w:tblGrid>${grid}</w:tblGrid>${headerRow}${bodyRows}</w:tbl>` +
226
+ '<w:p><w:pPr><w:spacing w:after="0"/></w:pPr></w:p>'
227
+ }
228
+
229
+ /** Render one block. Unknown types are ignored rather than crashing the build. */
230
+ function renderBlock(block) {
231
+ switch (block.type) {
232
+ case 'title':
233
+ return paragraph(block.text, { style: 'Title', align: 'center', size: 44, bold: true, spaceAfter: 160 })
234
+ case 'subtitle':
235
+ return paragraph(block.text, { style: 'Subtitle', align: 'center', size: 28, spaceAfter: 240 })
236
+ case 'heading':
237
+ return paragraph(block.text, { style: `Heading${Math.min(4, Math.max(1, block.level ?? 1))}`, spaceAfter: 120 })
238
+ case 'para':
239
+ return paragraph(block.text, { indent: block.indent === false ? null : 480 })
240
+ case 'bullets':
241
+ return (block.items || []).map((item) => numberedParagraph(item, 1)).join('')
242
+ case 'steps':
243
+ return (block.items || []).map((item) => numberedParagraph(item, 2)).join('')
244
+ case 'code':
245
+ return codeBlock(block.text)
246
+ case 'table':
247
+ return tableBlock(block)
248
+ case 'caption':
249
+ return paragraph(block.text, { style: 'Caption', align: 'center', size: 20, spaceAfter: 60 })
250
+ case 'note':
251
+ return paragraph(block.text, { style: 'Note', indent: false })
252
+ case 'pageBreak':
253
+ return '<w:p><w:r><w:br w:type="page"/></w:r></w:p>'
254
+ default:
255
+ return ''
256
+ }
257
+ }
258
+
259
+ /** Render a list of blocks into the body of `word/document.xml`. */
260
+ export function renderBlocks(blocks) {
261
+ return (blocks || []).map(renderBlock).join('')
262
+ }
263
+
264
+ const CONTENT_TYPES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
265
+ <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
266
+ <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
267
+ <Default Extension="xml" ContentType="application/xml"/>
268
+ <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
269
+ <Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
270
+ <Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/>
271
+ </Types>`
272
+
273
+ const ROOT_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
274
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
275
+ <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
276
+ </Relationships>`
277
+
278
+ const DOCUMENT_RELS = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
279
+ <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
280
+ <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
281
+ <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/>
282
+ </Relationships>`
283
+
284
+ const NUMBERING = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
285
+ <w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
286
+ <w:abstractNum w:abstractNumId="0">
287
+ <w:multiLevelType w:val="singleLevel"/>
288
+ <w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="bullet"/><w:lvlText w:val="\u2022"/><w:lvlJc w:val="left"/><w:pPr><w:ind w:left="840" w:hanging="360"/></w:pPr><w:rPr><w:rFonts w:ascii="Courier New" w:hAnsi="Courier New"/></w:rPr></w:lvl>
289
+ </w:abstractNum>
290
+ <w:abstractNum w:abstractNumId="1">
291
+ <w:multiLevelType w:val="singleLevel"/>
292
+ <w:lvl w:ilvl="0"><w:start w:val="1"/><w:numFmt w:val="decimal"/><w:lvlText w:val="%1."/><w:lvlJc w:val="left"/><w:pPr><w:ind w:left="840" w:hanging="420"/></w:pPr></w:lvl>
293
+ </w:abstractNum>
294
+ <w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num>
295
+ <w:num w:numId="2"><w:abstractNumId w:val="1"/></w:num>
296
+ </w:numbering>`
297
+
298
+ function headingStyle(id, name, size, outline, before, after) {
299
+ return (
300
+ `<w:style w:type="paragraph" w:styleId="${id}"><w:name w:val="${name}"/>` +
301
+ '<w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:qFormat/>' +
302
+ `<w:pPr><w:keepNext/><w:outlineLvl w:val="${outline}"/>` +
303
+ `<w:spacing w:before="${before}" w:after="${after}"/></w:pPr>` +
304
+ `<w:rPr><w:rFonts w:ascii="Arial" w:hAnsi="Arial" w:eastAsia="\u9ed1\u4f53"/><w:b/>` +
305
+ `<w:sz w:val="${size}"/><w:szCs w:val="${size}"/></w:rPr></w:style>`
306
+ )
307
+ }
308
+
309
+ const STYLES = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
310
+ <w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
311
+ <w:docDefaults><w:rPrDefault><w:rPr>${RUN_FONTS}<w:sz w:val="24"/><w:szCs w:val="24"/></w:rPr></w:rPrDefault>
312
+ <w:pPrDefault><w:pPr><w:spacing w:after="80" w:line="320" w:lineRule="auto"/></w:pPr></w:pPrDefault></w:docDefaults>
313
+ <w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>
314
+ <w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:qFormat/>
315
+ <w:pPr><w:jc w:val="center"/><w:spacing w:before="240" w:after="160"/></w:pPr>
316
+ <w:rPr><w:rFonts w:ascii="Arial" w:hAnsi="Arial" w:eastAsia="\u9ed1\u4f53"/><w:b/><w:sz w:val="44"/></w:rPr></w:style>
317
+ <w:style w:type="paragraph" w:styleId="Subtitle"><w:name w:val="Subtitle"/><w:basedOn w:val="Normal"/><w:qFormat/>
318
+ <w:pPr><w:jc w:val="center"/><w:spacing w:after="240"/></w:pPr>
319
+ <w:rPr><w:rFonts w:ascii="Arial" w:hAnsi="Arial" w:eastAsia="\u9ed1\u4f53"/><w:sz w:val="28"/><w:color w:val="595959"/></w:rPr></w:style>
320
+ ${headingStyle('Heading1', 'heading 1', 32, 0, 320, 160)}
321
+ ${headingStyle('Heading2', 'heading 2', 28, 1, 240, 120)}
322
+ ${headingStyle('Heading3', 'heading 3', 24, 2, 200, 100)}
323
+ ${headingStyle('Heading4', 'heading 4', 24, 3, 160, 80)}
324
+ <w:style w:type="paragraph" w:styleId="Code"><w:name w:val="Code"/><w:basedOn w:val="Normal"/>
325
+ <w:pPr><w:ind w:left="240" w:right="120"/><w:spacing w:after="120" w:line="240" w:lineRule="auto"/></w:pPr>
326
+ <w:rPr>${CODE_FONTS}<w:sz w:val="18"/></w:rPr></w:style>
327
+ <w:style w:type="paragraph" w:styleId="Caption"><w:name w:val="caption"/><w:basedOn w:val="Normal"/>
328
+ <w:pPr><w:jc w:val="center"/><w:keepNext/><w:spacing w:before="120" w:after="60"/></w:pPr>
329
+ <w:rPr><w:sz w:val="20"/><w:color w:val="404040"/></w:rPr></w:style>
330
+ <w:style w:type="paragraph" w:styleId="Note"><w:name w:val="Note"/><w:basedOn w:val="Normal"/>
331
+ <w:pPr><w:shd w:val="clear" w:color="auto" w:fill="FBF6E9"/><w:ind w:left="240" w:right="240"/><w:spacing w:before="80" w:after="120"/></w:pPr>
332
+ <w:rPr><w:sz w:val="21"/><w:color w:val="7F5F00"/></w:rPr></w:style>
333
+ </w:styles>`
334
+
335
+ const SECTION =
336
+ '<w:sectPr><w:pgSz w:w="11906" w:h="16838"/>' +
337
+ '<w:pgMar w:top="1440" w:right="1418" w:bottom="1440" w:left="1418" w:header="851" w:footer="992" w:gutter="0"/>' +
338
+ '<w:docGrid w:linePitch="312"/></w:sectPr>'
339
+
340
+ /** Wrap rendered blocks into a complete `word/document.xml`. */
341
+ export function documentXml(blocks) {
342
+ return (
343
+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
344
+ '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">' +
345
+ `<w:body>${renderBlocks(blocks)}${SECTION}</w:body></w:document>`
346
+ )
347
+ }
348
+
349
+ /** Build the whole .docx as a Buffer. */
350
+ export function buildDocx(blocks) {
351
+ return zip([
352
+ { name: '[Content_Types].xml', data: CONTENT_TYPES },
353
+ { name: '_rels/.rels', data: ROOT_RELS },
354
+ { name: 'word/document.xml', data: documentXml(blocks) },
355
+ { name: 'word/_rels/document.xml.rels', data: DOCUMENT_RELS },
356
+ { name: 'word/styles.xml', data: STYLES },
357
+ { name: 'word/numbering.xml', data: NUMBERING },
358
+ ])
359
+ }
@@ -17,7 +17,6 @@ export {
17
17
  portCount,
18
18
  describePorts,
19
19
  portsFromDeclarations,
20
- declarationsFor,
21
20
  } from './models.mjs'
22
21
 
23
22
  // Kept for consumers written against the old shape; the table behind it was
@@ -52,6 +51,15 @@ export {
52
51
  labModels,
53
52
  } from './labs.mjs'
54
53
 
54
+ export {
55
+ labConfigs,
56
+ labConfigScript,
57
+ hasLabConfig,
58
+ countLabConfigs,
59
+ maskFromPrefix,
60
+ addressCommand,
61
+ } from './labconfig.mjs'
62
+
55
63
  export {
56
64
  probeDevice,
57
65
  checkTopologySupport,
@@ -59,12 +67,12 @@ export {
59
67
  imageNotesFor,
60
68
  IMAGE_NOTES,
61
69
  rootFromExe,
70
+ pluginMediaFiles,
62
71
  } from './compat.mjs'
63
72
 
64
73
  export {
65
74
  doctor,
66
75
  formatDoctor,
67
- checkEnvironment,
68
76
  probeInputInjection,
69
77
  freeMemoryMb,
70
78
  busyConsolePorts,
@@ -90,12 +98,15 @@ export {
90
98
  locateWindow,
91
99
  launchEnsp,
92
100
  focusEnsp,
101
+ showEnsp,
93
102
  minimiseEnsp,
94
103
  clickToolbar,
95
104
  checkOcclusion,
96
105
  waitForConsole,
97
106
  startAllDevices,
98
107
  stopAllDevices,
108
+ topologyRendered,
109
+ canvasDeviceCount,
99
110
  } from './launcher.mjs'
100
111
 
101
112
  export {
@@ -123,13 +134,6 @@ export {
123
134
  searchScenarios,
124
135
  } from './presets.mjs'
125
136
 
126
- export {
127
- knowledgeTopics,
128
- knowledgeFor,
129
- searchKnowledge,
130
- renderKnowledge,
131
- } from './knowledge.mjs'
132
-
133
137
  export {
134
138
  curriculumLevels,
135
139
  curriculumChapters,
@@ -146,3 +150,18 @@ export {
146
150
  enspCandidates,
147
151
  vboxCandidates,
148
152
  } from './paths.mjs'
153
+
154
+ export {
155
+ buildDocx,
156
+ documentXml,
157
+ renderBlocks,
158
+ inlineRuns,
159
+ escapeXml,
160
+ zip,
161
+ } from './docx.mjs'
162
+
163
+ export {
164
+ collectEvidence,
165
+ buildReportBlocks,
166
+ writeReport,
167
+ } from './report.mjs'
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Machine usable configuration derived from a lab blueprint.
3
+ *
4
+ * A blueprint already carries the addressing plan: which device, which port,
5
+ * which address. That table was written for a reader to copy from. This module
6
+ * turns the same table into a VRP script so `ensp` can apply it without a human
7
+ * retyping sixteen interface blocks.
8
+ *
9
+ * The scope is deliberately the addressing plan and nothing else. Routing
10
+ * protocols, ACLs, VRRP groups and the rest stay in the scenario steps: those
11
+ * are what the exercise is about, and inventing a canonical answer for them
12
+ * would turn the skeleton into a paste-ready dump, which is the one thing the
13
+ * presets are built to avoid.
14
+ *
15
+ * @module dsh-ensp/labconfig
16
+ */
17
+
18
+ import { LABS } from './labs.mjs'
19
+
20
+ /**
21
+ * Turn the blueprint addressing table into one VRP script per device.
22
+ *
23
+ * @param {string} id scenario id
24
+ * @returns {{device: string, script: string, interfaces: number}[]}
25
+ */
26
+ export function labConfigs(id) {
27
+ const spec = LABS[id]
28
+ if (!spec) return []
29
+
30
+ /** @type {Map<string, string[]>} */
31
+ const byDevice = new Map()
32
+ for (const row of spec.addressing ?? []) {
33
+ const commands = byDevice.get(row.device) ?? []
34
+ // Interface names go in verbatim: the blueprint stores them exactly as the
35
+ // CLI accepts them (`LoopBack0`, `Vlanif10`, `GigabitEthernet0/0/1`).
36
+ commands.push(`interface ${row.port}`, ` ${addressCommand(row.ip)}`, ' quit')
37
+ byDevice.set(row.device, commands)
38
+ }
39
+
40
+ return [...byDevice].map(([device, body]) => {
41
+ const script = ['system-view', ...body, 'return'].join('\n')
42
+ return { device, script, interfaces: body.length / 3 }
43
+ })
44
+ }
45
+
46
+ /**
47
+ * One script per scenario, devices separated by a comment line.
48
+ *
49
+ * @param {string} id
50
+ * @returns {string} empty when the blueprint has no addressing plan
51
+ */
52
+ export function labConfigScript(id) {
53
+ return labConfigs(id)
54
+ .map((entry) => [`# ${entry.device}`, entry.script].join('\n'))
55
+ .join('\n\n')
56
+ }
57
+
58
+ /** Whether this scenario has any addressing to apply. */
59
+ export function hasLabConfig(id) {
60
+ return (LABS[id]?.addressing?.length ?? 0) > 0
61
+ }
62
+
63
+ /** How many blueprints carry an addressing plan. */
64
+ export function countLabConfigs() {
65
+ return Object.keys(LABS).filter((id) => hasLabConfig(id)).length
66
+ }
67
+
68
+ /**
69
+ * Prefix length to a dotted mask.
70
+ *
71
+ * @param {number} bits
72
+ * @returns {string}
73
+ */
74
+ export function maskFromPrefix(bits) {
75
+ // `0xffffffff << 32` is `0xffffffff << 0` in JavaScript, because the shift
76
+ // count is taken modulo 32, so /0 has to be answered before the shift.
77
+ if (bits === 0) return '0.0.0.0'
78
+ const value = (0xffffffff << (32 - bits)) >>> 0
79
+ return [24, 16, 8, 0].map((shift) => (value >>> shift) & 0xff).join('.')
80
+ }
81
+
82
+ /**
83
+ * One `ip address` (or `ipv6 address`) command for a CIDR row.
84
+ *
85
+ * @param {string} cidr e.g. `10.0.12.1/24` or `2001:db8:12::1/64`
86
+ * @returns {string}
87
+ */
88
+ export function addressCommand(cidr) {
89
+ const at = String(cidr).lastIndexOf('/')
90
+ if (at < 0) throw new Error(`addressing row "${cidr}" has no prefix length`)
91
+ const address = cidr.slice(0, at)
92
+ const bits = Number(cidr.slice(at + 1))
93
+ if (!Number.isInteger(bits) || bits < 0 || bits > 128) {
94
+ throw new Error(`addressing row "${cidr}" has a prefix that is not a number`)
95
+ }
96
+ if (address.includes(':')) return `ipv6 address ${address}/${bits}`
97
+ return `ip address ${address} ${maskFromPrefix(bits)}`
98
+ }
package/src/core/labs.mjs CHANGED
@@ -809,19 +809,19 @@ export const LABS = {
809
809
  ],
810
810
  ),
811
811
 
812
- // eNSP's default AR2220 template declares only GE ports; a Serial card has to be
813
- // added to the device by hand, and a blueprint cannot express that. VRP runs PPP
814
- // and HDLC over GE just as well, so the two links below carry the encapsulations
815
- // without a serial card: `link-protocol ppp` / `link-protocol hdlc` per interface.
812
+ // PPP and HDLC need real serial ports: an AR2220 rejects `link-protocol ppp` on a
813
+ // GE port with an empty error, so that path cannot be configured at all. The
814
+ // `Router` model carries four Serial ports out of the box, which is what this
815
+ // scenario is about, so both links ride Serial0/0/0 and Serial0/0/1.
816
816
  'ppp-hdlc': lab(
817
- [['R1', 'AR2220'], ['R2', 'AR2220']],
818
- [['R1', 'GigabitEthernet0/0/0', 'R2', 'GigabitEthernet0/0/0'],
819
- ['R1', 'GigabitEthernet0/0/1', 'R2', 'GigabitEthernet0/0/1']],
820
- [
821
- { device: 'R1', port: 'GigabitEthernet0/0/0', ip: '10.0.12.1/30', note: 'link-protocol ppp + CHAP' },
822
- { device: 'R2', port: 'GigabitEthernet0/0/0', ip: '10.0.12.2/30' },
823
- { device: 'R1', port: 'GigabitEthernet0/0/1', ip: '10.0.13.1/30', note: 'link-protocol hdlc 对照' },
824
- { device: 'R2', port: 'GigabitEthernet0/0/1', ip: '10.0.13.2/30' },
817
+ [['R1', 'Router'], ['R2', 'Router']],
818
+ [['R1', 'Serial0/0/0', 'R2', 'Serial0/0/0'],
819
+ ['R1', 'Serial0/0/1', 'R2', 'Serial0/0/1']],
820
+ [
821
+ { device: 'R1', port: 'Serial0/0/0', ip: '10.0.12.1/30', note: 'link-protocol ppp + CHAP' },
822
+ { device: 'R2', port: 'Serial0/0/0', ip: '10.0.12.2/30' },
823
+ { device: 'R1', port: 'Serial0/0/1', ip: '10.0.13.1/30', note: 'link-protocol hdlc 对照' },
824
+ { device: 'R2', port: 'Serial0/0/1', ip: '10.0.13.2/30' },
825
825
  ],
826
826
  ),
827
827