@entropicwarrior/sdoc 0.2.17 → 0.2.19

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.
@@ -8,6 +8,7 @@
8
8
  // const html = renderSlides(nodes, { meta, themeCss, themeJs });
9
9
 
10
10
  const { parseInline, renderKatex, escapeHtml, escapeAttr, sanitizeSvg, colorSwatchHtml } = require("./sdoc");
11
+ const { extractConfig, buildBody, accentClass, slug } = require("./slide-layouts");
11
12
 
12
13
  // ---------------------------------------------------------------------------
13
14
  // Inline rendering — produces clean HTML without sdoc-* classes
@@ -164,35 +165,18 @@ function renderChildren(nodes) {
164
165
  return nodes.map((node) => renderNode(node)).join("\n");
165
166
  }
166
167
 
168
+ // Callbacks handed to the layout builders so slide-layouts.js stays free of
169
+ // parser and KaTeX dependencies.
170
+ const LAYOUT_CONTEXT = { renderChildren, renderInline, escapeHtml };
171
+
172
+ // Layouts whose bare name is also emitted as a slide class, for themes written
173
+ // before `layout-*` existed. New layouts are not added here.
174
+ const LEGACY_LAYOUT_CLASSES = new Set(["center", "two-column"]);
175
+
167
176
  // ---------------------------------------------------------------------------
168
177
  // Slide-level extraction
169
178
  // ---------------------------------------------------------------------------
170
179
 
171
- // Extracts config: lines from the beginning of a scope's children.
172
- // Returns { config: { key: value, ... }, contentNodes: [...] }
173
- function extractSlideConfig(children) {
174
- const config = {};
175
- const contentNodes = [];
176
- let pastConfig = false;
177
-
178
- for (const child of children) {
179
- if (!pastConfig && child.type === "paragraph") {
180
- const match = child.text.match(/^config\s*:\s*(.+)$/i);
181
- if (match) {
182
- const value = match[1].trim();
183
- // Support multiple config lines; last one wins for the same key
184
- // For now, config values are simple strings; primary use is layout
185
- config.layout = value;
186
- continue;
187
- }
188
- }
189
- pastConfig = true;
190
- contentNodes.push(child);
191
- }
192
-
193
- return { config, contentNodes };
194
- }
195
-
196
180
  // Separates @notes child scope from other children
197
181
  function extractNotes(children) {
198
182
  const notes = [];
@@ -237,13 +221,25 @@ function renderSlide(scope, slideIndex, overlayHtml, position) {
237
221
  // Pull :detail children out first so they don't appear inline in the spine
238
222
  // slide's content; they're rendered as sibling vertical slides instead.
239
223
  const { contentNodes: afterDetails } = extractDetails(scope.children);
240
- const { config, contentNodes: afterConfig } = extractSlideConfig(afterDetails);
224
+ const { config, contentNodes: afterConfig } = extractConfig(afterDetails);
241
225
  const { notes, contentNodes } = extractNotes(afterConfig);
242
226
 
227
+ const layout = config.layout || "";
243
228
  const classes = ["slide"];
244
- if (config.layout) {
245
- classes.push(config.layout);
229
+ if (layout) {
230
+ // `layout-*` is the class themes target. The bare layout name is emitted
231
+ // only for the two layouts that predate it, because themes in the wild
232
+ // are written against `.center` and `.two-column`. It is not emitted for
233
+ // the structured layouts: those put a container class of the same name
234
+ // inside the slide (`.columns`, `.stats`, `.rows`…), and a slide also
235
+ // carrying that class would match the container's own rules.
236
+ if (LEGACY_LAYOUT_CLASSES.has(layout)) classes.push(layout);
237
+ // Slugged, so no configuration value can close the class attribute.
238
+ const layoutSlug = slug(layout);
239
+ if (layoutSlug) classes.push(`layout-${layoutSlug}`);
246
240
  }
241
+ const accent = accentClass(config.accent).trim();
242
+ if (accent) classes.push(accent);
247
243
  if (position && position.detail === 0 && position.hasDetails) {
248
244
  classes.push("slide-has-details");
249
245
  }
@@ -251,29 +247,33 @@ function renderSlide(scope, slideIndex, overlayHtml, position) {
251
247
  classes.push("slide-detail");
252
248
  }
253
249
 
254
- const title = scope.hasHeading !== false && scope.title
255
- ? `<h2>${renderInline(scope.title)}</h2>`
250
+ // On a title slide the kicker sits beneath the statement rather than above
251
+ // it, so the eye lands on the name first.
252
+ const isTitleLayout = layout === "title";
253
+ const kickerHtml = config.kicker
254
+ ? `<div class="kicker">${renderInline(config.kicker)}</div>`
256
255
  : "";
257
256
 
258
- let bodyHtml;
259
- if (config.layout === "two-column") {
260
- // In two-column layout, child scopes become columns
261
- const columns = contentNodes.filter((n) => n.type === "scope" && n.scopeType !== "comment");
262
- const nonColumns = contentNodes.filter((n) => n.type !== "scope" || n.scopeType === "comment");
263
- const preamble = nonColumns.length ? renderChildren(nonColumns) : "";
264
- const columnsHtml = columns
265
- .map((col) => {
266
- const colTitle = col.hasHeading !== false && col.title
267
- ? `<h3>${renderInline(col.title)}</h3>`
268
- : "";
269
- const colContent = renderChildren(col.children);
270
- return `<div class="column">${colTitle}\n${colContent}</div>`;
271
- })
272
- .join("\n");
273
- bodyHtml = preamble + `\n<div class="columns">\n${columnsHtml}\n</div>`;
274
- } else {
275
- bodyHtml = renderChildren(contentNodes);
257
+ const headParts = [];
258
+ if (kickerHtml && !isTitleLayout) headParts.push(kickerHtml);
259
+ if (scope.hasHeading !== false && scope.title) {
260
+ const tag = isTitleLayout ? "h1" : "h2";
261
+ headParts.push(`<${tag}>${renderInline(scope.title)}</${tag}>`);
276
262
  }
263
+ if (config.lede) headParts.push(`<p class="lede">${renderInline(config.lede)}</p>`);
264
+ const title = headParts.length
265
+ ? `<header class="slide-head">\n${headParts.join("\n")}\n</header>`
266
+ : "";
267
+
268
+ const bodyInner = buildBody(layout, { config, contentNodes }, LAYOUT_CONTEXT);
269
+
270
+ const tailParts = [];
271
+ if (kickerHtml && isTitleLayout) tailParts.push(kickerHtml);
272
+ if (config.status) tailParts.push(`<div class="status">${renderInline(config.status)}</div>`);
273
+ if (config.footnote) tailParts.push(`<div class="footnote">${renderInline(config.footnote)}</div>`);
274
+ const tailHtml = tailParts.length ? `\n${tailParts.join("\n")}` : "";
275
+
276
+ const bodyHtml = `<div class="slide-body">\n${bodyInner}\n</div>${tailHtml}`;
277
277
 
278
278
  const notesHtml = notes.length
279
279
  ? `\n<aside class="notes">${notes.map((n) => renderChildren(n.children)).join("\n")}</aside>`
@@ -310,9 +310,27 @@ function renderSlides(nodes, options = {}) {
310
310
  meta = {},
311
311
  themeCss = "",
312
312
  themeJs = "",
313
- darkMode = false
313
+ darkMode = false,
314
+ themeConfig = {},
315
+ fit = null
314
316
  } = options;
315
317
 
318
+ // The design box and print page come from the theme (themes/<name>/theme.json).
319
+ // They must agree: the box in CSS pixels is the page in inches at 96 dpi, which
320
+ // is what makes screen and PDF the same geometry.
321
+ const slideW = (themeConfig.slide && themeConfig.slide.width) || 1280;
322
+ const slideH = (themeConfig.slide && themeConfig.slide.height) || 720;
323
+ const pageW = (themeConfig.page && themeConfig.page.width) || 13.333;
324
+ const pageH = (themeConfig.page && themeConfig.page.height) || 7.5;
325
+
326
+ // How the design box meets a window of a different shape.
327
+ // contain — scale to fit, letterbox the remainder (the default)
328
+ // cover — scale to fill, crop the overflow
329
+ // stretch — scale each axis independently, distorting the slide
330
+ const FIT_MODES = new Set(["contain", "cover", "stretch"]);
331
+ const requested = (fit || themeConfig.fit || "contain").toLowerCase();
332
+ const fitMode = FIT_MODES.has(requested) ? requested : "contain";
333
+
316
334
  // The nodes from extractMeta have @meta already stripped.
317
335
  // If there's a document scope wrapper, unwrap it to get the slides.
318
336
  let slideScopes;
@@ -322,8 +340,15 @@ function renderSlides(nodes, options = {}) {
322
340
  slideScopes = nodes;
323
341
  }
324
342
 
325
- // Filter to scope nodes only (skip stray paragraphs and :comment scopes)
326
- const slides = slideScopes.filter((n) => n.type === "scope" && n.scopeType !== "comment");
343
+ // Filter to scope nodes only (skip stray paragraphs, :comment scopes, and the
344
+ // reserved @meta / @about metadata scopes — these are document metadata, not slides).
345
+ const RESERVED_SCOPE_IDS = new Set(["meta", "about"]);
346
+ const slides = slideScopes.filter(
347
+ (n) =>
348
+ n.type === "scope" &&
349
+ n.scopeType !== "comment" &&
350
+ !(n.id && RESERVED_SCOPE_IDS.has(n.id.toLowerCase()))
351
+ );
327
352
 
328
353
  // Build per-slide footer:
329
354
  // < CONFIDENTIAL ---gap--- Company N/Total >
@@ -387,7 +412,48 @@ function renderSlides(nodes, options = {}) {
387
412
 
388
413
  // Structural styles — always injected regardless of theme.
389
414
  const structuralCss = `
390
- .slide { position: relative; }
415
+ /* --- Fixed design box + fit-to-window scaling ---------------------------
416
+ Slides are laid out at a fixed design size (1280x720 CSS px, which is
417
+ exactly the 13.333in x 7.5in print page at 96dpi) and then scaled as a
418
+ whole to fill the window. This is the standard deck technique: the
419
+ author's layout is preserved verbatim at every window size, and screen
420
+ and PDF are the same geometry by construction.
421
+
422
+ --sdoc-slide-scale and --sdoc-slide-scale-y are written by the theme
423
+ runtime (fitSlidesToWindow in themes/default/theme.js) on load and on
424
+ resize, according to the fit mode on <html data-sdoc-fit>. If JS never runs the
425
+ scale stays 1 and the deck renders at its natural design size, which is
426
+ the pre-scaling behaviour rather than a broken one.
427
+
428
+ Themes must NOT set width/height/max-width/margin on .slide — those come
429
+ from the design-box variables below. A theme that wants a different
430
+ aspect ratio should override --sdoc-slide-w / --sdoc-slide-h on :root
431
+ (and the @page size in the print block, if PDF output matters). */
432
+ :root {
433
+ --sdoc-slide-w: ${slideW}px;
434
+ --sdoc-slide-h: ${slideH}px;
435
+ --sdoc-slide-scale: 1;
436
+ /* Defaults to the horizontal scale, so a theme shipping its own runtime
437
+ that writes only --sdoc-slide-scale still scales uniformly. Only the
438
+ stretch fit mode ever sets these two to different values. */
439
+ --sdoc-slide-scale-y: var(--sdoc-slide-scale);
440
+ /* The ground behind the slide, seen wherever the window is not the slide's
441
+ shape. A theme that leaves this equal to its slide background gets an
442
+ invisible letterbox: the slide has no edge and anything pinned to its
443
+ bottom looks stranded. */
444
+ --sdoc-letterbox: #000;
445
+ }
446
+ .slide {
447
+ position: absolute;
448
+ top: 50%;
449
+ left: 50%;
450
+ width: var(--sdoc-slide-w);
451
+ height: var(--sdoc-slide-h);
452
+ transform: translate(-50%, -50%)
453
+ scale(var(--sdoc-slide-scale), var(--sdoc-slide-scale-y));
454
+ transform-origin: center center;
455
+ overflow: hidden;
456
+ }
391
457
  .slide-footer {
392
458
  position: absolute; bottom: 20px; left: 32px; right: 32px;
393
459
  display: flex; align-items: baseline;
@@ -424,8 +490,15 @@ function renderSlides(nodes, options = {}) {
424
490
  beforeprint handler to shrink overflowing content to fit the page. */
425
491
  .slide-content-scale { display: contents; }
426
492
 
493
+ /* Head and body wrappers are transparent to layout by default, so a theme
494
+ written before they existed sees exactly the box tree it saw then: the
495
+ heading and the content as direct children of the slide. A theme that
496
+ wants a real header band or a body that fills the remaining height
497
+ overrides these two rules. */
498
+ .slide-head, .slide-body { display: contents; }
499
+
427
500
  @media print {
428
- @page { size: 13.333in 7.5in; margin: 0; }
501
+ @page { size: ${pageW}in ${pageH}in; margin: 0; }
429
502
  body { overflow: visible; height: auto; }
430
503
  .slide {
431
504
  display: block !important;
@@ -433,7 +506,14 @@ function renderSlides(nodes, options = {}) {
433
506
  opacity: 1 !important;
434
507
  pointer-events: auto !important;
435
508
  page-break-after: always; break-after: page;
436
- width: 100vw; height: 100vh; max-width: none;
509
+ /* The design box IS the page (the theme's px box at 96dpi),
510
+ so no fit-to-window scaling applies here — each slide flows as one
511
+ page at its natural size. Overflowing content is still shrunk by
512
+ the inner .slide-content-scale wrapper (see fitSlidesForPrint). */
513
+ top: auto !important; left: auto !important;
514
+ transform: none !important;
515
+ margin: 0 !important;
516
+ width: var(--sdoc-slide-w); height: var(--sdoc-slide-h); max-width: none;
437
517
  overflow: hidden;
438
518
  page-break-inside: avoid; break-inside: avoid;
439
519
  }
@@ -478,7 +558,7 @@ blockquote p { color: #9d9d9d; }
478
558
  : "";
479
559
 
480
560
  return `<!DOCTYPE html>
481
- <html lang="en">
561
+ <html lang="en" data-sdoc-fit="${fitMode}">
482
562
  <head>
483
563
  <meta charset="UTF-8">
484
564
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
package/src/theme.js ADDED
@@ -0,0 +1,141 @@
1
+ // SDOC Slides — theme loading.
2
+ //
3
+ // A theme is a directory holding `theme.css`, optionally `theme.js`, and
4
+ // optionally assets the CSS refers to: web fonts, background images, textures.
5
+ //
6
+ // Loading a theme inlines those assets. Every `url(...)` in the stylesheet
7
+ // that names a path relative to the theme directory is replaced by a `data:`
8
+ // URI, so the built deck stays a single self-contained file that renders the
9
+ // same offline, on another machine, and inside headless Chrome during PDF
10
+ // export. Absolute URLs, protocol-relative URLs and existing `data:` URIs are
11
+ // left alone.
12
+
13
+ const fs = require("fs");
14
+ const path = require("path");
15
+
16
+ const MIME_TYPES = {
17
+ ".woff2": "font/woff2",
18
+ ".woff": "font/woff",
19
+ ".ttf": "font/ttf",
20
+ ".otf": "font/otf",
21
+ ".eot": "application/vnd.ms-fontobject",
22
+ ".svg": "image/svg+xml",
23
+ ".png": "image/png",
24
+ ".jpg": "image/jpeg",
25
+ ".jpeg": "image/jpeg",
26
+ ".gif": "image/gif",
27
+ ".webp": "image/webp",
28
+ ".avif": "image/avif",
29
+ };
30
+
31
+ function isExternal(url) {
32
+ return /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(url);
33
+ }
34
+
35
+ // Replaces relative url(...) references in `css` with data: URIs read from
36
+ // `baseDir`. Returns { css, inlined, missing }.
37
+ function inlineCssAssets(css, baseDir) {
38
+ const inlined = [];
39
+ const missing = [];
40
+
41
+ const out = css.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (match, _quote, url) => {
42
+ const target = url.trim();
43
+ if (!target || isExternal(target)) return match;
44
+
45
+ // Strip any ?query or #fragment before resolving against the file system.
46
+ const clean = target.replace(/[?#].*$/, "");
47
+ const filePath = path.resolve(baseDir, clean);
48
+
49
+ // Refuse to read outside the theme directory.
50
+ const relative = path.relative(baseDir, filePath);
51
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
52
+ missing.push(target);
53
+ return match;
54
+ }
55
+
56
+ if (!fs.existsSync(filePath)) {
57
+ missing.push(target);
58
+ return match;
59
+ }
60
+
61
+ const mime = MIME_TYPES[path.extname(filePath).toLowerCase()] || "application/octet-stream";
62
+ const data = fs.readFileSync(filePath).toString("base64");
63
+ inlined.push(relative);
64
+ return `url("data:${mime};base64,${data}")`;
65
+ });
66
+
67
+ return { css: out, inlined, missing };
68
+ }
69
+
70
+ // The design box and print page a theme uses when it declares none.
71
+ // 1280 x 720 CSS px is exactly 13.333 x 7.5 in at 96 dpi, so screen and PDF
72
+ // are the same geometry by construction.
73
+ const DEFAULT_THEME_CONFIG = {
74
+ slide: { width: 1280, height: 720 },
75
+ page: { width: 13.333, height: 7.5 },
76
+ // How the slide meets a window of a different shape: contain, cover or
77
+ // stretch. The CLI's --fit overrides whatever a theme declares.
78
+ fit: "contain",
79
+ };
80
+
81
+ function readThemeConfig(themeDir) {
82
+ const jsonPath = path.join(themeDir, "theme.json");
83
+ if (!fs.existsSync(jsonPath)) return { config: { ...DEFAULT_THEME_CONFIG }, warning: null };
84
+ try {
85
+ const parsed = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
86
+ return {
87
+ config: {
88
+ fit: DEFAULT_THEME_CONFIG.fit,
89
+ ...parsed,
90
+ slide: { ...DEFAULT_THEME_CONFIG.slide, ...(parsed.slide || {}) },
91
+ page: { ...DEFAULT_THEME_CONFIG.page, ...(parsed.page || {}) },
92
+ },
93
+ warning: null,
94
+ };
95
+ } catch (err) {
96
+ return {
97
+ config: { ...DEFAULT_THEME_CONFIG },
98
+ warning: `theme.json could not be parsed (${err.message}); using the default design box`,
99
+ };
100
+ }
101
+ }
102
+
103
+ // Reads a theme directory. `fallbackDir` supplies theme.js when the theme
104
+ // ships none, which is how a theme opts into the default runtime (keyboard
105
+ // navigation, touch, fit-to-window scaling) without copying it.
106
+ function loadTheme(themeDir, fallbackDir) {
107
+ const resolved = path.resolve(themeDir);
108
+ const cssPath = path.join(resolved, "theme.css");
109
+ const jsPath = path.join(resolved, "theme.js");
110
+
111
+ const warnings = [];
112
+ let themeCss = "";
113
+ let themeJs = "";
114
+
115
+ const { config: themeConfig, warning: configWarning } = readThemeConfig(resolved);
116
+ if (configWarning) warnings.push(configWarning);
117
+
118
+ if (fs.existsSync(cssPath)) {
119
+ const raw = fs.readFileSync(cssPath, "utf-8");
120
+ const { css, missing } = inlineCssAssets(raw, resolved);
121
+ themeCss = css;
122
+ for (const ref of missing) {
123
+ warnings.push(`theme asset not found, left as a plain reference: ${ref}`);
124
+ }
125
+ } else {
126
+ warnings.push(`theme.css not found at ${cssPath}`);
127
+ }
128
+
129
+ if (fs.existsSync(jsPath)) {
130
+ themeJs = fs.readFileSync(jsPath, "utf-8");
131
+ } else if (fallbackDir) {
132
+ const fallbackJs = path.join(path.resolve(fallbackDir), "theme.js");
133
+ if (fs.existsSync(fallbackJs)) {
134
+ themeJs = fs.readFileSync(fallbackJs, "utf-8");
135
+ }
136
+ }
137
+
138
+ return { themeCss, themeJs, themeConfig, warnings, dir: resolved };
139
+ }
140
+
141
+ module.exports = { loadTheme, inlineCssAssets, readThemeConfig, DEFAULT_THEME_CONFIG };
package/src/zip.js ADDED
@@ -0,0 +1,102 @@
1
+ // SDOC — minimal ZIP writer.
2
+ //
3
+ // Enough of the format to build an OOXML package: stored or deflated entries,
4
+ // local headers, a central directory and an end-of-central-directory record.
5
+ // Node's zlib supplies the compression, so this stays dependency-free.
6
+
7
+ const zlib = require("zlib");
8
+
9
+ const CRC_TABLE = (() => {
10
+ const table = new Int32Array(256);
11
+ for (let i = 0; i < 256; i++) {
12
+ let c = i;
13
+ for (let k = 0; k < 8; k++) {
14
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
15
+ }
16
+ table[i] = c;
17
+ }
18
+ return table;
19
+ })();
20
+
21
+ function crc32(buffer) {
22
+ let crc = -1;
23
+ for (let i = 0; i < buffer.length; i++) {
24
+ crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ buffer[i]) & 0xff];
25
+ }
26
+ return (crc ^ -1) >>> 0;
27
+ }
28
+
29
+ // Fixed timestamp so the same input produces the same archive byte for byte.
30
+ const DOS_TIME = 0; // 00:00:00
31
+ const DOS_DATE = (1980 - 1980) << 9 | (1 << 5) | 1; // 1980-01-01
32
+
33
+ // files: [{ name, data: Buffer|string, store?: boolean }]
34
+ function zip(files) {
35
+ const locals = [];
36
+ const central = [];
37
+ let offset = 0;
38
+
39
+ for (const file of files) {
40
+ const nameBuf = Buffer.from(file.name, "utf-8");
41
+ const raw = Buffer.isBuffer(file.data) ? file.data : Buffer.from(file.data, "utf-8");
42
+ const crc = crc32(raw);
43
+
44
+ // Already-compressed payloads (PNG, JPEG, woff2) gain nothing from
45
+ // deflate and cost time, so they go in stored.
46
+ const store = file.store === true;
47
+ const body = store ? raw : zlib.deflateRawSync(raw, { level: 9 });
48
+ const method = store ? 0 : 8;
49
+
50
+ const localHeader = Buffer.alloc(30);
51
+ localHeader.writeUInt32LE(0x04034b50, 0);
52
+ localHeader.writeUInt16LE(20, 4); // version needed
53
+ localHeader.writeUInt16LE(0, 6); // flags
54
+ localHeader.writeUInt16LE(method, 8);
55
+ localHeader.writeUInt16LE(DOS_TIME, 10);
56
+ localHeader.writeUInt16LE(DOS_DATE, 12);
57
+ localHeader.writeUInt32LE(crc, 14);
58
+ localHeader.writeUInt32LE(body.length, 18);
59
+ localHeader.writeUInt32LE(raw.length, 22);
60
+ localHeader.writeUInt16LE(nameBuf.length, 26);
61
+ localHeader.writeUInt16LE(0, 28); // extra length
62
+
63
+ locals.push(localHeader, nameBuf, body);
64
+
65
+ const centralHeader = Buffer.alloc(46);
66
+ centralHeader.writeUInt32LE(0x02014b50, 0);
67
+ centralHeader.writeUInt16LE(20, 4); // version made by
68
+ centralHeader.writeUInt16LE(20, 6); // version needed
69
+ centralHeader.writeUInt16LE(0, 8);
70
+ centralHeader.writeUInt16LE(method, 10);
71
+ centralHeader.writeUInt16LE(DOS_TIME, 12);
72
+ centralHeader.writeUInt16LE(DOS_DATE, 14);
73
+ centralHeader.writeUInt32LE(crc, 16);
74
+ centralHeader.writeUInt32LE(body.length, 20);
75
+ centralHeader.writeUInt32LE(raw.length, 24);
76
+ centralHeader.writeUInt16LE(nameBuf.length, 28);
77
+ centralHeader.writeUInt16LE(0, 30); // extra
78
+ centralHeader.writeUInt16LE(0, 32); // comment
79
+ centralHeader.writeUInt16LE(0, 34); // disk
80
+ centralHeader.writeUInt16LE(0, 36); // internal attrs
81
+ centralHeader.writeUInt32LE(0, 38); // external attrs
82
+ centralHeader.writeUInt32LE(offset, 42);
83
+
84
+ central.push(centralHeader, nameBuf);
85
+ offset += localHeader.length + nameBuf.length + body.length;
86
+ }
87
+
88
+ const centralBuf = Buffer.concat(central);
89
+ const end = Buffer.alloc(22);
90
+ end.writeUInt32LE(0x06054b50, 0);
91
+ end.writeUInt16LE(0, 4);
92
+ end.writeUInt16LE(0, 6);
93
+ end.writeUInt16LE(files.length, 8);
94
+ end.writeUInt16LE(files.length, 10);
95
+ end.writeUInt32LE(centralBuf.length, 12);
96
+ end.writeUInt32LE(offset, 16);
97
+ end.writeUInt16LE(0, 20);
98
+
99
+ return Buffer.concat([...locals, centralBuf, end]);
100
+ }
101
+
102
+ module.exports = { zip, crc32 };