euparliamentmonitor 0.9.21 → 0.9.23

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 (47) hide show
  1. package/package.json +6 -2
  2. package/scripts/aggregator/article-metadata.js +69 -14
  3. package/scripts/aggregator/editorial-brief-resolver.js +23 -0
  4. package/scripts/aggregator/html/headline.d.ts +41 -9
  5. package/scripts/aggregator/html/headline.js +69 -10
  6. package/scripts/aggregator/html/shell.js +73 -17
  7. package/scripts/aggregator/manifest/index.d.ts +1 -1
  8. package/scripts/aggregator/manifest/index.js +1 -1
  9. package/scripts/aggregator/manifest/resolver.d.ts +28 -1
  10. package/scripts/aggregator/manifest/resolver.js +61 -5
  11. package/scripts/aggregator/markdown-renderer.js +11 -0
  12. package/scripts/aggregator/metadata/artifact-category-heading.d.ts +81 -0
  13. package/scripts/aggregator/metadata/artifact-category-heading.js +353 -0
  14. package/scripts/aggregator/metadata/artifact-walker.js +29 -10
  15. package/scripts/aggregator/metadata/brief-body.d.ts +12 -0
  16. package/scripts/aggregator/metadata/brief-body.js +69 -0
  17. package/scripts/aggregator/metadata/briefing-highlight.d.ts +47 -0
  18. package/scripts/aggregator/metadata/briefing-highlight.js +469 -0
  19. package/scripts/aggregator/metadata/editorial-highlight.d.ts +18 -0
  20. package/scripts/aggregator/metadata/editorial-highlight.js +40 -1
  21. package/scripts/aggregator/metadata/heading-rules.d.ts +2 -81
  22. package/scripts/aggregator/metadata/heading-rules.js +78 -269
  23. package/scripts/aggregator/metadata/keyword-filters.d.ts +60 -0
  24. package/scripts/aggregator/metadata/keyword-filters.js +156 -0
  25. package/scripts/aggregator/metadata/lede-extractor.js +11 -2
  26. package/scripts/aggregator/metadata/priority-finding-cleaning.d.ts +22 -0
  27. package/scripts/aggregator/metadata/priority-finding-cleaning.js +181 -0
  28. package/scripts/aggregator/metadata/priority-finding-highlight.js +75 -159
  29. package/scripts/aggregator/metadata/resolve-helpers.d.ts +34 -0
  30. package/scripts/aggregator/metadata/resolve-helpers.js +202 -15
  31. package/scripts/aggregator/metadata/seo-budgets.d.ts +140 -0
  32. package/scripts/aggregator/metadata/seo-budgets.js +202 -0
  33. package/scripts/aggregator/metadata/text-truncate.d.ts +75 -0
  34. package/scripts/aggregator/metadata/text-truncate.js +277 -0
  35. package/scripts/aggregator/metadata/text-utils-constants.d.ts +96 -0
  36. package/scripts/aggregator/metadata/text-utils-constants.js +209 -0
  37. package/scripts/aggregator/metadata/text-utils.d.ts +32 -143
  38. package/scripts/aggregator/metadata/text-utils.js +119 -439
  39. package/scripts/aggregator/metadata/title-rejection.d.ts +37 -0
  40. package/scripts/aggregator/metadata/title-rejection.js +179 -0
  41. package/scripts/copy-vendor.js +84 -112
  42. package/scripts/dump-article-seo.js +640 -0
  43. package/scripts/fix-mermaid-diagrams.js +931 -0
  44. package/scripts/generators/news-indexes/backfill.d.ts +6 -1
  45. package/scripts/generators/news-indexes/backfill.js +71 -4
  46. package/scripts/validate-article-seo.js +534 -0
  47. package/scripts/validate-mermaid-diagrams.js +306 -0
@@ -0,0 +1,179 @@
1
+ // SPDX-FileCopyrightText: 2024-2026 Hack23 AB
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Title-rejection predicates shared by the metadata resolver and the
5
+ * SEO validation gate.
6
+ *
7
+ * Every English `<title>` and `<meta description>` on the site is
8
+ * resolved from the run's `executive-brief.md` via
9
+ * {@link ./resolve-helpers.ts}. Bold-prose labels inside the brief
10
+ * (`**Strategic significance:** …`, `**Threat Level:** …`,
11
+ * `**Key Assumptions Check:** …`) and trailing ellipsis fragments
12
+ * from over-budget strong-prose paragraphs have leaked into the
13
+ * `<title>` surface (216-article audit, 2026-05-24). This module
14
+ * provides the canonical denylist + structural rejection rules so
15
+ * resolver and validator stay in lock-step.
16
+ *
17
+ * NEVER inline these predicates — duplicating the denylist makes the
18
+ * validator and resolver drift, which is exactly how the bad titles
19
+ * shipped in the first place.
20
+ */
21
+ /**
22
+ * Bold-prose labels that appear inside `executive-brief.md` as
23
+ * `**Label:** …` lines. The priority-finding extractor was treating
24
+ * the bold label as a headline; the resolver now rejects these as
25
+ * usable titles.
26
+ *
27
+ * Keep entries lowercase and exact — matching is case-insensitive
28
+ * after trimming and stripping a trailing `:`, `…`, `.`.
29
+ */
30
+ const SECTION_HEADER_DENYLIST = Object.freeze([
31
+ 'strategic significance',
32
+ 'event description',
33
+ 'key intelligence',
34
+ 'threat level',
35
+ 'close to adoption',
36
+ 'convergence themes',
37
+ 'convergence theme',
38
+ 'key assumptions check',
39
+ 'risk assessment',
40
+ 'stakeholder map',
41
+ 'intelligence summary',
42
+ 'session overview',
43
+ 'situation summary',
44
+ 'priority analysis',
45
+ 'priority intelligence items',
46
+ 'priority intelligence item',
47
+ 'lead story',
48
+ 'bluf',
49
+ 'tl;dr',
50
+ '60-second read',
51
+ 'classification',
52
+ 'confidence summary',
53
+ 'methodological notes',
54
+ 'source reliability assessment',
55
+ 'corrections and caveats',
56
+ 'forward look',
57
+ 'top three action items',
58
+ 'political landscape summary',
59
+ 'external environment summary',
60
+ 'coalition & bloc summary',
61
+ 'coalition and bloc summary',
62
+ 'week ahead',
63
+ 'week in review',
64
+ 'month ahead',
65
+ 'month in review',
66
+ 'year ahead',
67
+ 'term outlook',
68
+ 'quarter ahead',
69
+ 'election cycle',
70
+ // single-noun outputs that occasionally surface from H2/H3 walks
71
+ 'overview',
72
+ 'background',
73
+ 'context',
74
+ 'analysis',
75
+ 'summary',
76
+ 'conclusion',
77
+ 'recommendations',
78
+ ]);
79
+ const SECTION_HEADER_SET = new Set(SECTION_HEADER_DENYLIST);
80
+ /** Ellipsis at end of string (Unicode `…` or ASCII `...`). */
81
+ const ELLIPSIS_TAIL_RE = /(?:\u2026|\.\.\.)\s*$/u;
82
+ /**
83
+ * Adopted-text doc-ID (`TA-10-2026-0160`) — these are procedure
84
+ * identifiers, never editorial titles.
85
+ */
86
+ const DOC_ID_RE = /^TA-\d+-\d{4}-\d{3,4}$/iu;
87
+ /**
88
+ * Detect a candidate that is really a complete sentence rather than a
89
+ * headline (e.g. `Routine inter-sessional day, no breaking signal.`,
90
+ * `EP10 enters the second half of its mandate with a structurally
91
+ * constrained but operational grand coalition.`).
92
+ *
93
+ * Gold-standard brief H1s never end with a period — they are
94
+ * noun-phrase headlines (`EU Parliament Year Ahead (May 2026 – May
95
+ * 2027)`, `EP Committee Reports · Week of 2026-05-14–21`). A trailing
96
+ * single `.` (NOT `…` and NOT `...`) on a ≥4-word candidate is the
97
+ * cleanest signal that we are looking at sentence prose leaked from a
98
+ * BLUF / lede paragraph rather than an editorial headline.
99
+ *
100
+ * @param value - Title candidate
101
+ * @returns `true` when the candidate looks like a complete sentence.
102
+ */
103
+ function looksLikeFullSentence(value) {
104
+ const trimmed = value.trim();
105
+ // Must end with exactly one period — `…` and `...` are caught by
106
+ // looksLikeEllipsisCut, and other terminal punctuation (`?`, `!`,
107
+ // `:`) is left to lower-priority filters.
108
+ if (!/[^.]\.\s*$/u.test(trimmed))
109
+ return false;
110
+ if (/\.\.\.\s*$|\u2026\s*$/u.test(trimmed))
111
+ return false;
112
+ const wordCount = trimmed.split(/\s+/u).length;
113
+ return wordCount >= 4;
114
+ }
115
+ /**
116
+ * `true` when the candidate is a bold-prose section header that
117
+ * leaked through the priority-finding extractor (e.g. `Strategic
118
+ * significance`, `Threat Level`).
119
+ *
120
+ * @param value - Title candidate
121
+ * @returns `true` when the candidate matches the section-header denylist.
122
+ */
123
+ export function looksLikeSectionHeader(value) {
124
+ if (!value)
125
+ return false;
126
+ const normalised = value
127
+ .toLowerCase()
128
+ .replace(/[\u2026.:!?]+\s*$/u, '')
129
+ .replace(/^[*_\s]+/u, '')
130
+ .replace(/[*_\s]+$/u, '')
131
+ .trim();
132
+ if (!normalised)
133
+ return false;
134
+ return SECTION_HEADER_SET.has(normalised);
135
+ }
136
+ /**
137
+ * `true` when the candidate ends with `…` or `...` (was truncated
138
+ * over the title budget).
139
+ *
140
+ * @param value - Title candidate
141
+ * @returns `true` when the candidate has a trailing ellipsis.
142
+ */
143
+ export function looksLikeEllipsisCut(value) {
144
+ return ELLIPSIS_TAIL_RE.test(value);
145
+ }
146
+ /**
147
+ * `true` when the candidate is a bare adopted-text doc-ID.
148
+ *
149
+ * @param value - Title candidate
150
+ * @returns `true` when the candidate matches the `TA-NN-YYYY-NNNN` shape.
151
+ */
152
+ export function looksLikeDocId(value) {
153
+ return DOC_ID_RE.test(value.trim());
154
+ }
155
+ /**
156
+ * Master rejection predicate. Returns the reason code (one of
157
+ * `section-header`, `ellipsis-cut`, `doc-id`, `sentence-fragment`)
158
+ * when the candidate should be rejected, or `null` when it is
159
+ * usable.
160
+ *
161
+ * @param value - Title candidate
162
+ * @returns Reason code, or `null` when the candidate is usable.
163
+ */
164
+ export function findTitleRejectionReason(value) {
165
+ if (!value)
166
+ return null;
167
+ if (looksLikeEllipsisCut(value))
168
+ return 'ellipsis-cut';
169
+ if (looksLikeDocId(value))
170
+ return 'doc-id';
171
+ if (looksLikeSectionHeader(value))
172
+ return 'section-header';
173
+ if (looksLikeFullSentence(value))
174
+ return 'sentence-fragment';
175
+ return null;
176
+ }
177
+ /** Exposed for unit tests + the SEO validator. */
178
+ export const TITLE_REJECTION_DENYLIST = SECTION_HEADER_DENYLIST;
179
+ //# sourceMappingURL=title-rejection.js.map
@@ -12,15 +12,25 @@
12
12
  * - chart.js → js/vendor/chart.umd.min.js
13
13
  * - chartjs-plugin-annotation → js/vendor/chartjs-plugin-annotation.min.js
14
14
  * - d3 → js/vendor/d3.min.js
15
- * - mermaid → js/vendor/mermaid/ (entry + chunks/)
15
+ * - mermaid → js/vendor/mermaid/mermaid.esm.min.mjs
16
16
  *
17
- * Mermaid is special: v11+ ships as code-split ESM. The entry
18
- * `mermaid.esm.min.mjs` does dynamic `import()` on diagram-specific chunks
19
- * under `dist/chunks/mermaid.esm.min/*.mjs`. To make every diagram type render
20
- * without external network calls, we copy the **entire mermaid `dist/`
21
- * directory** (filtered to the `.esm.min` flavour to keep payload small).
17
+ * Mermaid is special: v11+ ships as a **code-split ESM bundle**. The entry
18
+ * `mermaid.esm.min.mjs` (28 KB) statically imports 81 diagram-specific chunks
19
+ * from `dist/chunks/mermaid.esm.min/*.mjs`. Empirically (May 2026), serving
20
+ * those chunks through S3 + CloudFront has been unreliable — the entry returns
21
+ * 200 OK but every chunk URL returns 403 from CloudFront, breaking every
22
+ * article that references the loader.
22
23
  *
23
- * Idempotent: rerunning overwrites prior copies and leaves licenses in place.
24
+ * To eliminate that failure mode, we **bundle Mermaid into a single
25
+ * self-contained ESM file at copy-vendor time using esbuild** (devDependency).
26
+ * The output is written to the same path / filename that the loader and the
27
+ * existing article HTML already reference (`mermaid.esm.min.mjs`), so the
28
+ * loader (`js/mermaid-init.js`) and the generated articles continue to work
29
+ * unchanged — only the file's content changes (3.2 MB self-contained vs.
30
+ * 28 KB entry-plus-81-chunks).
31
+ *
32
+ * Idempotent: rerunning overwrites prior copies and leaves licenses in place;
33
+ * stale `chunks/` directories from prior layouts are pruned.
24
34
  *
25
35
  * Failure modes:
26
36
  * - Missing chart.js / d3 / chartjs-plugin-annotation → hard error (these
@@ -28,11 +38,12 @@
28
38
  * - Missing mermaid → soft error (logged, exit 0). Mermaid is also a pinned
29
39
  * `devDependency`, but optional installs (e.g. `npm ci --omit=dev`) may
30
40
  * skip it; we want the deploy to succeed without diagrams rather than fail.
41
+ * - Bundling failure → hard error: mermaid is present but unusable, which
42
+ * would silently ship a broken page; fail fast at build time instead.
31
43
  */
32
44
 
33
45
  import {
34
46
  copyFileSync,
35
- cpSync,
36
47
  existsSync,
37
48
  mkdirSync,
38
49
  readdirSync,
@@ -43,6 +54,7 @@ import {
43
54
  } from 'node:fs';
44
55
  import path from 'node:path';
45
56
  import process from 'node:process';
57
+ import * as esbuild from 'esbuild';
46
58
 
47
59
  const ROOT = process.cwd();
48
60
  const NODE_MODULES = path.join(ROOT, 'node_modules');
@@ -120,7 +132,8 @@ function copyOrFail(label, srcRel, dstRel, license) {
120
132
  function copyMermaid() {
121
133
  const mermaidDist = path.join(NODE_MODULES, 'mermaid', 'dist');
122
134
  const target = path.join(VENDOR_DIR, 'mermaid');
123
- if (!existsSync(mermaidDist)) {
135
+ const entryPoint = path.join(mermaidDist, 'mermaid.esm.min.mjs');
136
+ if (!existsSync(entryPoint)) {
124
137
  process.stdout.write(
125
138
  ' ⚠ mermaid not installed (devDependency); skipping diagram bundle.\n',
126
139
  );
@@ -128,84 +141,68 @@ function copyMermaid() {
128
141
  }
129
142
  ensureDir(target);
130
143
 
131
- // Per-file idempotency: walk the source tree and only copy files whose
132
- // bytes differ from what's already in `js/vendor/mermaid/`. Replaces the
133
- // earlier `rmSync` + `cpSync` approach which always touched every chunk's
134
- // mtime `aws s3 sync` (size+mtime by default) then re-uploaded the
135
- // entire mermaid bundle on every deploy even though the bundle is byte-
136
- // identical until the pinned mermaid version in package.json changes.
144
+ // Bundle mermaid's code-split ESM entry plus all of its dynamic-import
145
+ // chunks into a SINGLE self-contained ESM file. esbuild follows every
146
+ // static and dynamic `import` from the entry and inlines the transitive
147
+ // closure, so the resulting file has no external module references
148
+ // exactly what the static-site origin needs.
137
149
  //
138
- // Filename contract preserved exactly: entry stays at
139
- // `js/vendor/mermaid/mermaid.esm.min.mjs` and chunks stay at
140
- // `js/vendor/mermaid/chunks/mermaid.esm.min/*.mjs` so every existing
141
- // `<script type="module" src="../js/vendor/mermaid/mermaid.esm.min.mjs">`
142
- // and dynamic `import()` from the entry continues to resolve.
143
-
144
- // Build the set of source files we want to ship (filter mirrors the
145
- // previous cpSync filter exactly).
146
- const wantedTopLevel = new Set(['mermaid.esm.min.mjs']);
147
- const wantedFiles = []; // { src, rel } — `rel` is relative to mermaidDist
148
-
149
- function shouldShip(rel) {
150
- if (rel.endsWith('.map')) return false;
151
- const segments = rel.split(path.sep);
152
- const top = segments[0];
153
- if (top === 'chunks') {
154
- if (segments.length === 1) return false; // directory itself, not a file
155
- const flavour = segments[1];
156
- return flavour === 'mermaid.esm.min';
157
- }
158
- if (segments.length === 1) {
159
- return wantedTopLevel.has(top);
160
- }
161
- return false;
162
- }
163
-
164
- function walkSource(dir) {
165
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
166
- const full = path.join(dir, entry.name);
167
- const rel = path.relative(mermaidDist, full);
168
- if (entry.isDirectory()) {
169
- walkSource(full);
170
- } else if (entry.isFile() && shouldShip(rel)) {
171
- wantedFiles.push({ src: full, rel });
150
+ // We write the output under the same filename the loader and existing
151
+ // article HTML already reference (`mermaid.esm.min.mjs`), so this script
152
+ // is the only place that changes when we switch from "entry + 81 chunks"
153
+ // to "single bundle". The previous chunk-shipping layout (`chunks/`) is
154
+ // pruned below.
155
+ const outFile = path.join(target, 'mermaid.esm.min.mjs');
156
+ try {
157
+ esbuild.buildSync({
158
+ entryPoints: [entryPoint],
159
+ outfile: outFile,
160
+ bundle: true,
161
+ format: 'esm',
162
+ minify: true,
163
+ // `browser` keeps mermaid's runtime-detection paths (e.g. `document`
164
+ // checks) intact — same target as the upstream `.esm.min.mjs` build.
165
+ platform: 'browser',
166
+ target: 'es2022',
167
+ // Resolve `import.meta.url` at runtime (relative to the served bundle
168
+ // location) rather than baking in the build-time path.
169
+ supported: { 'import-meta': true },
170
+ // Drop sourcemaps; the upstream bundle ships them as `.map` siblings
171
+ // and we previously excluded those from vendor copy.
172
+ sourcemap: false,
173
+ legalComments: 'none',
174
+ // Use 'error' so esbuild prints its own detailed diagnostics (file,
175
+ // line, column) on failure — 'silent' previously swallowed all context.
176
+ logLevel: 'error',
177
+ });
178
+ } catch (err) {
179
+ // esbuild attaches structured diagnostics on `err.errors`; print them
180
+ // so CI logs are actionable without re-running locally.
181
+ if (err && Array.isArray(err.errors)) {
182
+ for (const e of err.errors) {
183
+ const loc = e.location
184
+ ? `${e.location.file}:${e.location.line}:${e.location.column}: `
185
+ : '';
186
+ process.stderr.write(` ${loc}${e.text}\n`);
172
187
  }
173
188
  }
174
- }
175
- walkSource(mermaidDist);
176
-
177
- // Copy only-if-changed.
178
- let copied = 0;
179
- let unchanged = 0;
180
- for (const { src, rel } of wantedFiles) {
181
- const dst = path.join(target, rel);
182
- ensureDir(path.dirname(dst));
183
- if (copyFileIfChanged(src, dst)) {
184
- copied++;
185
- } else {
186
- unchanged++;
187
- }
189
+ process.stderr.write(
190
+ `error: mermaid bundle failed: ${err && err.message ? err.message : err}\n` +
191
+ ' Check that node_modules/mermaid is installed (run `npm ci`) and that\n' +
192
+ ' esbuild can resolve the ESM entry point at node_modules/mermaid/dist/mermaid.esm.min.mjs.\n',
193
+ );
194
+ process.exit(1);
188
195
  }
189
196
 
190
- // Remove orphaned files in the destination tree that no longer have a
191
- // matching wanted source this preserves the "no stale chunks from a
192
- // previous mermaid version" guarantee that the old `rmSync` provided,
193
- // without touching any current chunk's mtime.
194
- const wantedDstSet = new Set(
195
- wantedFiles.map(({ rel }) => path.join(target, rel)),
196
- );
197
- // Allow our REUSE sidecar files alongside their primary file.
197
+ // Prune the obsolete chunks layout (and any other orphans) from previous
198
+ // copy-vendor runs. The bundled file is fully self-contained, so anything
199
+ // other than the bundle itself + its REUSE sidecar is stale.
200
+ const wantedDstSet = new Set([outFile]);
198
201
  function isAllowedSidecar(absPath) {
199
202
  if (!absPath.endsWith('.license')) return false;
200
203
  const primary = absPath.slice(0, -'.license'.length);
201
204
  return wantedDstSet.has(primary);
202
205
  }
203
- // Also allow the chunks-dir flavour-level license sidecar we drop below.
204
- const flavourLicensePath = path.join(
205
- target,
206
- 'chunks',
207
- 'mermaid.esm.min.license',
208
- );
209
206
 
210
207
  function pruneOrphans(dir) {
211
208
  if (!existsSync(dir)) return;
@@ -213,7 +210,6 @@ function copyMermaid() {
213
210
  const full = path.join(dir, entry.name);
214
211
  if (entry.isDirectory()) {
215
212
  pruneOrphans(full);
216
- // Remove now-empty directories so a flavour rename leaves no shell.
217
213
  try {
218
214
  if (readdirSync(full).length === 0) {
219
215
  rmSync(full, { recursive: true, force: true });
@@ -222,11 +218,7 @@ function copyMermaid() {
222
218
  // best-effort
223
219
  }
224
220
  } else if (entry.isFile()) {
225
- if (
226
- !wantedDstSet.has(full) &&
227
- !isAllowedSidecar(full) &&
228
- full !== flavourLicensePath
229
- ) {
221
+ if (!wantedDstSet.has(full) && !isAllowedSidecar(full)) {
230
222
  rmSync(full, { force: true });
231
223
  }
232
224
  }
@@ -234,39 +226,19 @@ function copyMermaid() {
234
226
  }
235
227
  pruneOrphans(target);
236
228
 
237
- // REUSE sidecar for the entry file + flavour directory.
238
- const entry = path.join(target, 'mermaid.esm.min.mjs');
239
- if (existsSync(entry)) {
240
- writeLicense(entry, '2014-2026 Mermaid contributors', 'MIT');
241
- }
242
- // Also drop a license file at the chunks dir so REUSE lint passes for the
243
- // generated tree without us having to enumerate every chunk by name.
244
- const chunksDir = path.join(target, 'chunks', 'mermaid.esm.min');
245
- if (existsSync(chunksDir)) {
246
- writeIfChanged(
247
- flavourLicensePath,
248
- 'SPDX-FileCopyrightText: 2014-2026 Mermaid contributors\nSPDX-License-Identifier: MIT\n',
249
- );
250
- }
229
+ // REUSE sidecar for the bundled file. The bundle contains code from
230
+ // mermaid + its transitive ESM deps; mermaid's own MIT license header
231
+ // remains intact in the dependency tree (REUSE.toml covers the vendored
232
+ // artifact via path-level annotation; this sidecar keeps the file
233
+ // self-documenting).
234
+ writeLicense(outFile, '2014-2026 Mermaid contributors', 'MIT');
235
+
236
+ const size = statSync(outFile).size;
251
237
  process.stdout.write(
252
- ` ✓ mermaid/ (${copied} copied, ${unchanged} unchanged; ${countMjs(target)} total mjs chunks)\n`,
238
+ ` ✓ mermaid/mermaid.esm.min.mjs (${(size / 1024).toFixed(0)} KB self-contained bundle)\n`,
253
239
  );
254
240
  }
255
241
 
256
- function countMjs(dir) {
257
- let n = 0;
258
- function walk(d) {
259
- if (!existsSync(d)) return;
260
- for (const entry of readdirSync(d, { withFileTypes: true })) {
261
- const p = path.join(d, entry.name);
262
- if (entry.isDirectory()) walk(p);
263
- else if (entry.isFile() && entry.name.endsWith('.mjs')) n += 1;
264
- }
265
- }
266
- walk(dir);
267
- return n;
268
- }
269
-
270
242
  function main() {
271
243
  ensureDir(VENDOR_DIR);
272
244
  process.stdout.write(`Copying vendor JS libraries to ${path.relative(ROOT, VENDOR_DIR)}/\n`);