amxx-builder 1.5.2 → 1.6.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,115 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const { fetchRepo, resolveRefIfLatest } = require('./repo-fetcher');
7
+ const { fetchReleaseDep } = require('./release-fetcher');
8
+ const { fetchDepRoot } = require('./deps-resolver');
9
+ const { parseDepString, parseDepObject } = require('./manifest');
10
+
11
+ // Fallback doc files a dependency repo may ship when it declares no explicit
12
+ // `docs:` paths. Checked in order at the repo root.
13
+ // Deliberately NOT AGENTS.md: that file is instructions for agents working
14
+ // INSIDE the repo (build commands, contribution rules), not consumer-facing
15
+ // API docs — feeding it to an outside agent leaks the wrong context.
16
+ const DOCS_CONVENTIONS = ['docs/API.md', 'API.md'];
17
+
18
+ /**
19
+ * Normalize a raw dep value (string or dep object) into a parsed dep object.
20
+ * @param {string|object} raw
21
+ * @returns {object} parsed dep object
22
+ */
23
+ function normalizeDep(raw) {
24
+ if (typeof raw === 'string') return parseDepString(raw);
25
+ if (raw && typeof raw === 'object') return parseDepObject(raw);
26
+ throw new Error('Dep must be a string or an object');
27
+ }
28
+
29
+ /**
30
+ * Resolve which agent-facing doc files exist for a dependency inside rootDir.
31
+ * Declared `dep.docs` paths come first (in array order), then convention
32
+ * candidates that exist on disk. Deduped by rel path — declared wins.
33
+ *
34
+ * @param {object} dep - parsed dep object ({ docs: string[]|null, ... })
35
+ * @param {string} rootDir - local root of the fetched repo/asset
36
+ * @returns {{ files: Array<{ rel: string, abs: string, origin: 'declared'|'convention' }>, missing: string[] }}
37
+ */
38
+ function resolveDepDocs(dep, rootDir) {
39
+ const files = [];
40
+ const missing = [];
41
+ const seen = new Set();
42
+
43
+ const declared = Array.isArray(dep.docs) ? dep.docs : [];
44
+
45
+ for (const rel of declared) {
46
+ const abs = path.resolve(rootDir, rel);
47
+ if (abs !== rootDir && !abs.startsWith(rootDir + path.sep)) {
48
+ throw new Error(`docs path escapes the repo root: "${rel}"`);
49
+ }
50
+ if (seen.has(rel)) continue;
51
+ seen.add(rel);
52
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
53
+ files.push({ rel, abs, origin: 'declared' });
54
+ } else {
55
+ missing.push(rel);
56
+ }
57
+ }
58
+
59
+ for (const rel of DOCS_CONVENTIONS) {
60
+ if (seen.has(rel)) continue;
61
+ const abs = path.join(rootDir, rel);
62
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
63
+ seen.add(rel);
64
+ files.push({ rel, abs, origin: 'convention' });
65
+ }
66
+ }
67
+
68
+ return { files, missing };
69
+ }
70
+
71
+ /**
72
+ * UTF-8 read that never throws: binary files and read errors are returned as
73
+ * descriptive placeholder strings instead.
74
+ * @param {string} abs - absolute file path
75
+ * @returns {string}
76
+ */
77
+ function safeRead(abs) {
78
+ try {
79
+ const buf = fs.readFileSync(abs);
80
+ try {
81
+ const text = buf.toString('utf8');
82
+ if (text.includes('\u0000')) return `[binary file, ${buf.length} bytes]`;
83
+ return text;
84
+ } catch (_) {
85
+ return `[binary file, ${buf.length} bytes]`;
86
+ }
87
+ } catch (err) {
88
+ return `[error reading file: ${err.message}]`;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Fetch a dependency and collect its agent-facing doc files with contents.
94
+ * `fetchRoot` is a test seam — defaults to the core fetchDepRoot.
95
+ *
96
+ * @param {object} dep - parsed dep object
97
+ * @param {object} [opts]
98
+ * @param {string} [opts.token]
99
+ * @param {boolean} [opts.noFetch]
100
+ * @param {boolean} [opts.ssh]
101
+ * @param {Function} [opts.fetchRoot] - ({ dep, ... }) => Promise<{ rootDir, label }>
102
+ * @returns {Promise<{ label: string, files: Array<{ rel: string, content: string, origin: string }>, missing: string[] }>}
103
+ */
104
+ async function collectDepDocs(dep, { token, noFetch, ssh, fetchRoot } = {}) {
105
+ const fetch = fetchRoot || fetchDepRoot;
106
+ const { rootDir, label } = await fetch(dep, { token, noFetch, ssh });
107
+ const { files, missing } = resolveDepDocs(dep, rootDir);
108
+ return {
109
+ label,
110
+ files: files.map((f) => ({ rel: f.rel, content: safeRead(f.abs), origin: f.origin })),
111
+ missing,
112
+ };
113
+ }
114
+
115
+ module.exports = { DOCS_CONVENTIONS, resolveDepDocs, collectDepDocs, normalizeDep };
package/src/dep-graph.js CHANGED
@@ -24,6 +24,8 @@ class DepGraph {
24
24
  this._includeDirs = includeDirs;
25
25
  // absPath → Set<absPath>: direct includes of each file
26
26
  this._deps = new Map();
27
+ // absPath → [{name, isAngle}]: includes that could not be resolved
28
+ this._missing = new Map();
27
29
  this._parsed = new Set();
28
30
  }
29
31
 
@@ -34,23 +36,44 @@ class DepGraph {
34
36
  if (!fs.existsSync(absPath)) return;
35
37
 
36
38
  const directs = new Set();
39
+ const missing = [];
37
40
  for (const { name, isAngle } of extractIncludes(absPath)) {
38
41
  const resolved = this._resolve(absPath, name, isAngle);
39
42
  if (resolved) {
40
43
  directs.add(resolved);
41
44
  this.parseFile(resolved);
45
+ } else {
46
+ missing.push({ name, isAngle });
42
47
  }
43
48
  }
44
49
  this._deps.set(absPath, directs);
50
+ if (missing.length) this._missing.set(absPath, missing);
51
+ else this._missing.delete(absPath);
45
52
  }
46
53
 
47
54
  // Re-parse a changed file (drops stale edges, keeps the rest of the graph).
48
55
  update(absPath) {
49
56
  this._parsed.delete(absPath);
50
57
  this._deps.delete(absPath);
58
+ this._missing.delete(absPath);
51
59
  this.parseFile(absPath);
52
60
  }
53
61
 
62
+ // Structured snapshot of the parsed graph: every parsed file with its direct
63
+ // (resolved) includes, plus includes that could not be resolved. Exposed to
64
+ // interface layers (serve/JSON-RPC) so the graph can be serialized.
65
+ snapshot() {
66
+ const files = [];
67
+ for (const [file, deps] of this._deps) {
68
+ files.push({ file, isSma: file.endsWith('.sma'), includes: [...deps].sort() });
69
+ }
70
+ const missing = [];
71
+ for (const [file, list] of this._missing) {
72
+ for (const { name, isAngle } of list) missing.push({ file, name, isAngle });
73
+ }
74
+ return { files, missing };
75
+ }
76
+
54
77
  // Returns Set<absPath> of .sma files that transitively depend on incPath.
55
78
  getSmasDependingOn(incPath) {
56
79
  const smas = new Set();
package/src/deployer.js CHANGED
@@ -92,9 +92,11 @@ function deployPlugin(manifest, buildDir, amxxName) {
92
92
  /**
93
93
  * Deploy a single changed local file (watch mode for amxmodx/ or assets/).
94
94
  * relPath is relative to the section root (amxmodx/ or assets/).
95
+ * Returns the destination path, or null when not deployed (no deploy path,
96
+ * missing source, or excluded).
95
97
  */
96
98
  function deployFile(manifest, buildDir, relPath, section) {
97
- if (!manifest.deploy.path) return;
99
+ if (!manifest.deploy.path) return null;
98
100
 
99
101
  const { amxmodxDest, assetsDest, deployRoot } = resolveDeployDirs(manifest);
100
102
 
@@ -104,24 +106,26 @@ function deployFile(manifest, buildDir, relPath, section) {
104
106
  const src = path.join(srcBase, relPath);
105
107
  const dest = path.join(destBase, relPath);
106
108
 
107
- if (!fs.existsSync(src)) return;
109
+ if (!fs.existsSync(src)) return null;
108
110
  if (isExcluded(dest, deployRoot, manifest.deploy.exclude || [])) {
109
111
  logger.verbose(` skip (excluded): ${relPath}`);
110
- return;
112
+ return null;
111
113
  }
112
114
 
113
115
  fs.mkdirSync(path.dirname(dest), { recursive: true });
114
116
  fs.copyFileSync(src, dest);
115
117
  logger.success(`Deployed: ${relPath}`);
116
118
  logger.verbose(` → ${dest}`);
119
+ return dest;
117
120
  }
118
121
 
119
122
  /**
120
123
  * Removes a deployed file that was deleted locally (watch mode).
121
124
  * relPath is relative to the section root. Honours deploy.exclude.
125
+ * Returns the removed destination path, or null when nothing was removed.
122
126
  */
123
127
  function removeDeployedFile(manifest, buildDir, relPath, section) {
124
- if (!manifest.deploy.path) return;
128
+ if (!manifest.deploy.path) return null;
125
129
 
126
130
  const { amxmodxDest, assetsDest, deployRoot } = resolveDeployDirs(manifest);
127
131
 
@@ -130,12 +134,13 @@ function removeDeployedFile(manifest, buildDir, relPath, section) {
130
134
 
131
135
  if (isExcluded(dest, deployRoot, manifest.deploy.exclude || [])) {
132
136
  logger.verbose(` skip delete (excluded): ${relPath}`);
133
- return;
137
+ return null;
134
138
  }
135
139
 
136
- if (!fs.existsSync(dest)) return;
140
+ if (!fs.existsSync(dest)) return null;
137
141
  fs.rmSync(dest, { force: true });
138
142
  logger.success(`Removed: ${relPath}`);
143
+ return dest;
139
144
  }
140
145
 
141
146
  // ─── helpers ─────────────────────────────────────────────────────────────────
@@ -5,6 +5,7 @@ const logger = require('./logger');
5
5
  const { parseDepsLines, resolveGithubToken } = require('./manifest');
6
6
  const { fetchRepo, resolveRefIfLatest } = require('./repo-fetcher');
7
7
  const { fetchReleaseDep } = require('./release-fetcher');
8
+ const { fetchFungunDep } = require('./fungun-fetcher');
8
9
 
9
10
  /**
10
11
  * Resolves all deps, clones them, copies .inc files to build/_includes/,
@@ -52,11 +53,14 @@ async function resolveDeps(manifest, repoLocalDirs, noFetch, buildDir) {
52
53
  const includeDirs = [];
53
54
 
54
55
  for (const [k, dep] of merged) {
55
- const token = resolveGithubToken(manifest, dep.repo);
56
56
  let srcDir;
57
57
  if (dep.source === 'release') {
58
+ const token = resolveGithubToken(manifest, dep.repo);
58
59
  srcDir = await fetchReleaseDep(dep, token, noFetch);
60
+ } else if (dep.source === 'fungun') {
61
+ srcDir = await fetchFungunDep(dep, noFetch);
59
62
  } else {
63
+ const token = resolveGithubToken(manifest, dep.repo);
60
64
  const resolvedDepRef = await resolveRefIfLatest(dep.ref, dep.repo, token);
61
65
  const depDir = await fetchRepo(dep.repo, resolvedDepRef, token, noFetch, manifest.github.ssh);
62
66
  srcDir = resolveIncludePath(depDir, dep.include_path, dep.repo);
@@ -72,7 +76,7 @@ async function resolveDeps(manifest, repoLocalDirs, noFetch, buildDir) {
72
76
  fs.copyFileSync(path.join(srcDir, f), dest);
73
77
  }
74
78
 
75
- logger.dim(` ${dep.repo}@${dep.ref}: ${files.length} .inc files`);
79
+ logger.dim(` ${depLabel(dep)}: ${files.length} .inc files`);
76
80
  includeDirs.push(destDir);
77
81
  }
78
82
 
@@ -106,10 +110,57 @@ function resolveIncludePath(repoDir, explicitPath, repoName) {
106
110
  return repoDir;
107
111
  }
108
112
 
113
+ /**
114
+ * Fetch a dependency's root directory and return a human-readable label for it.
115
+ * Single source of truth shared by the build pipeline, the MCP server and
116
+ * dep-docs resolution.
117
+ *
118
+ * `dep` is a parsed dep OBJECT ({ repo, ref, source, include_path, asset }).
119
+ * GitHub token resolution (per-owner fallbacks) is an interface-layer concern —
120
+ * callers pass the already-resolved token, this function never calls fallbackToken.
121
+ *
122
+ * @param {object} dep - parsed dep object
123
+ * @param {object} [opts]
124
+ * @param {string} [opts.token] - GitHub token or null (anonymous)
125
+ * @param {boolean} [opts.noFetch] - only use cache, skip network fetches
126
+ * @param {boolean} [opts.ssh] - clone via system git instead of tarball
127
+ * @returns {Promise<{ rootDir: string, label: string }>}
128
+ */
129
+ async function fetchDepRoot(dep, { token, noFetch, ssh = false } = {}) {
130
+ if (dep.source === 'release') {
131
+ const dir = await fetchReleaseDep(dep, token, noFetch);
132
+ return { rootDir: dir, label: `${dep.repo}@${dep.ref} (release)` };
133
+ }
134
+
135
+ if (dep.source === 'fungun') {
136
+ const dir = await fetchFungunDep(dep, noFetch);
137
+ return { rootDir: dir, label: depLabel(dep) };
138
+ }
139
+
140
+ const resolvedRef = await resolveRefIfLatest(dep.ref, dep.repo, token);
141
+ const repoDir = await fetchRepo(dep.repo, resolvedRef, token, noFetch, ssh);
142
+ if (dep.include_path) {
143
+ const sub = path.join(repoDir, dep.include_path);
144
+ if (!fs.existsSync(sub)) {
145
+ throw new Error(`include_path "${dep.include_path}" not found in ${dep.repo}`);
146
+ }
147
+ return { rootDir: sub, label: `${dep.repo}@${dep.ref || 'default branch'}` };
148
+ }
149
+ return { rootDir: repoDir, label: `${dep.repo}@${dep.ref || 'default branch'}` };
150
+ }
151
+
109
152
  // Single source of truth for repo-name normalization (used for cache keys
110
153
  // and dedup by core modules that previously inlined repo.toLowerCase()).
111
154
  function normalize(repo) { return repo.toLowerCase(); }
112
155
 
156
+ // Human-readable dep label (fungun deps: no repo@ref — addressed by shop page id).
157
+ function depLabel(dep) {
158
+ if (dep && dep.source === 'fungun') {
159
+ return `fungun.net plugin #${dep.id}`;
160
+ }
161
+ return `${dep.repo}@${dep.ref || 'default branch'}`;
162
+ }
163
+
113
164
  function repoKey(repoConfig) {
114
165
  return `${repoConfig.repo}@${repoConfig._resolvedRef || repoConfig.ref || 'HEAD'}`;
115
166
  }
@@ -124,4 +175,4 @@ function countIncFiles(dir) {
124
175
  return n;
125
176
  }
126
177
 
127
- module.exports = { resolveDeps, readDepsListFile, normalize, repoKey };
178
+ module.exports = { resolveDeps, readDepsListFile, normalize, repoKey, fetchDepRoot, depLabel };
package/src/deps-tree.js CHANGED
@@ -100,6 +100,24 @@ function assembleRootDeps(manifest) {
100
100
  // ─── Recursive walk ────────────────────────────────────────────────────────────
101
101
 
102
102
  async function walkDep(dep, ctx) {
103
+ // Fungun: closed-source page dep — no repo to resolve a ref on, no DEPS_LIST to recurse into.
104
+ if (dep.source === 'fungun') {
105
+ return {
106
+ repo: dep.repo || `fungun.net/#${dep.id}`,
107
+ ref: null,
108
+ resolvedRef: null,
109
+ id: dep.id,
110
+ source: 'fungun',
111
+ include_path: null,
112
+ asset: null,
113
+ from: ctx.from,
114
+ error: null,
115
+ cycle: false,
116
+ shared: false,
117
+ dependencies: [],
118
+ };
119
+ }
120
+
103
121
  const { resolveToken, noFetch, depth, visited, pathStack, getDepsOverride } = ctx;
104
122
 
105
123
  const repo = dep.repo;
@@ -0,0 +1,347 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Fungun dep source (fungun.net) — closed-source AMXX plugin shop.
5
+ *
6
+ * Plugins ship no archives and no direct file URLs. The only public artifact
7
+ * is the plugin "show" page, which always embeds one Bootstrap modal per
8
+ * shipped file (configs, lang, and — when the plugin exposes an API — exactly
9
+ * the .inc include). Modal DOM ids are page-specific (`ModalText<number>`),
10
+ * so a plugin is addressed by its page index and the .inc payload is located
11
+ * by scanning every modal for a `modal-title` filename ending in `.inc`:
12
+ *
13
+ * <div class="modal fade" id="ModalText267" ...>
14
+ * <div class="modal-body ...">
15
+ * <button ... close ...></button>
16
+ * <h3 class="modal-title">bonusmenu_rbs.inc</h3>
17
+ * <div class="border_text"></div>
18
+ * <pre class="language-cpp"><code>... include text ...</code></pre>
19
+ *
20
+ * Ownership (single source of truth for everything fungun):
21
+ * - id/url normalization and validation (parsePluginRef)
22
+ * - page fetch + modal extraction (fetchFungunPage, extractIncFiles)
23
+ * - on-disk cache: <CACHE_DIR>/fungun/<plugin-id>/ (sentinel `.fungun`)
24
+ *
25
+ * Manifest/dep parsing (`src/manifest.js`) and the fetch entry points in
26
+ * deps-resolver / include-tree delegate here — no other module re-parses
27
+ * fungun references or re-implements the extraction.
28
+ */
29
+
30
+ const fs = require('fs');
31
+ const path = require('path');
32
+ const axios = require('axios');
33
+
34
+ const logger = require('./logger');
35
+ const { getCacheDir } = require('./cache-dir');
36
+ const { withRetry } = require('./retry');
37
+
38
+ const FUNGUN_BASE_URL = 'https://fungun.net';
39
+ const FUNGUN_SHOW_PATH = '/shop/?p=show&id=';
40
+
41
+ // fungun serves plain HTML to browsers; a bare axios UA may get a different
42
+ // (login/captcha) response. Keep a realistic browser UA on every request.
43
+ const BROWSER_UA =
44
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
45
+ '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
46
+
47
+ const SENTINEL_FILE = '.fungun'; // content: plugin id — mirrors release-deps `.extracted`
48
+
49
+ // Fungun pages pin no version/ref — refetch the page cache daily (--no-fetch still uses the stale copy).
50
+ const FUNGUN_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
51
+
52
+ function pluginPageUrl(id) {
53
+ return `${FUNGUN_BASE_URL}${FUNGUN_SHOW_PATH}${id}`;
54
+ }
55
+
56
+ /**
57
+ * Normalize a fungun plugin reference.
58
+ *
59
+ * Accepts:
60
+ * - a positive integer id (number or numeric string) — `106`
61
+ * - a full plugin page URL — `https://fungun.net/shop/?p=show&id=106`
62
+ *
63
+ * Returns `{ id, url }` (id as a numeric string; url is the canonical page URL
64
+ * for bare ids and the caller-provided URL otherwise). Throws on anything
65
+ * else — this is the single validation point used by both manifest parsing
66
+ * and the fetcher, so error messages are user-facing.
67
+ *
68
+ * @param {number|string} value
69
+ * @returns {{ id: string, url: string }|null}
70
+ */
71
+ function parsePluginRef(value) {
72
+ if (value == null || value === '') return null;
73
+
74
+ let str;
75
+ if (typeof value === 'number') {
76
+ if (!Number.isInteger(value) || value <= 0) {
77
+ throw new Error(
78
+ `Invalid fungun plugin id: ${value} — expected a positive plugin index`
79
+ );
80
+ }
81
+ str = String(value);
82
+ return { id: str, url: pluginPageUrl(str) };
83
+ }
84
+
85
+ if (typeof value !== 'string') {
86
+ throw new Error(
87
+ `Invalid fungun plugin reference: ${JSON.stringify(value)} — expected a plugin id or fungun.net URL`
88
+ );
89
+ }
90
+ str = value.trim();
91
+ if (!str) return null;
92
+
93
+ // Bare plugin index
94
+ if (/^\d+$/.test(str)) return { id: str, url: pluginPageUrl(str) };
95
+
96
+ // Full page URL
97
+ if (/^https?:\/\//i.test(str)) {
98
+ let hostname;
99
+ try {
100
+ hostname = new URL(str).hostname;
101
+ } catch {
102
+ throw new Error(`Invalid fungun plugin URL: "${str}"`);
103
+ }
104
+ if (hostname !== 'fungun.net' && !hostname.endsWith('.fungun.net')) {
105
+ throw new Error(
106
+ `Invalid fungun dep URL "${str}" — expected a fungun.net page (host: ${hostname})`
107
+ );
108
+ }
109
+ const idMatch = str.match(/[?&]id=(\d+)/);
110
+ if (!idMatch) {
111
+ throw new Error(
112
+ `Invalid fungun plugin URL "${str}" — missing "id=<index>" parameter`
113
+ );
114
+ }
115
+ return { id: idMatch[1], url: str };
116
+ }
117
+
118
+ throw new Error(
119
+ `Invalid fungun plugin reference "${str}" — ` +
120
+ `expected a plugin index (e.g. 106) or a fungun.net page URL`
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Fetch a fungun plugin page. `ref` is the output of parsePluginRef.
126
+ *
127
+ * @param {{ id: string, url: string }} ref
128
+ * @returns {Promise<string>} page HTML (UTF-8)
129
+ */
130
+ async function fetchFungunPage(ref) {
131
+ const url = ref.url || pluginPageUrl(ref.id);
132
+ const response = await withRetry(
133
+ () => axios.get(url, {
134
+ headers: {
135
+ 'User-Agent': BROWSER_UA,
136
+ Accept: 'text/html,application/xhtml+xml',
137
+ },
138
+ responseType: 'text',
139
+ timeout: 30000,
140
+ maxRedirects: 5,
141
+ }),
142
+ { label: `fungun plugin #${ref.id}` }
143
+ );
144
+
145
+ if (response.status !== 200) {
146
+ throw new Error(`Failed to fetch fungun plugin page #${ref.id} — HTTP ${response.status}`);
147
+ }
148
+ return String(response.data);
149
+ }
150
+
151
+ // ─── HTML extraction ──────────────────────────────────────────────────────────
152
+ // Deliberately regex-based: the target markup is a fixed two-level shape
153
+ // (modal div → h3.modal-title + pre > code) inside page-generated ids.
154
+ // No HTML parser dependency is needed to stay correct on Node 18 + offline CI.
155
+
156
+ /**
157
+ * Parse every "file modal" out of a plugin page. Returns entries in page
158
+ * order: `{ modalId, filename, content }` (entities decoded, content trimmed).
159
+ * Modals without an h3 filename or a code block are skipped.
160
+ *
161
+ * @param {string} html
162
+ * @returns {Array<{ modalId: string, filename: string, content: string }>}
163
+ */
164
+ function extractIncModals(html) {
165
+ const opens = [];
166
+ const modalRe = /<div[^>]*\sid="(ModalText[^"]+)"[^>]*>/g;
167
+ let m;
168
+ while ((m = modalRe.exec(html))) opens.push({ id: m[1], start: m.index });
169
+
170
+ const modals = [];
171
+ for (let i = 0; i < opens.length; i++) {
172
+ const end = i + 1 < opens.length ? opens[i + 1].start : html.length;
173
+ const seg = html.slice(opens[i].start, end);
174
+
175
+ const filename = matchModalFilename(seg);
176
+ const content = matchModalCode(seg);
177
+ if (!filename || content === null) continue;
178
+
179
+ modals.push({ modalId: opens[i].id, filename, content: content.trim() });
180
+ }
181
+ return modals;
182
+ }
183
+
184
+ function matchModalFilename(seg) {
185
+ const m = seg.match(/<h3[^>]*>([\s\S]*?)<\/h3>/);
186
+ return m ? decodeEntities(m[1]).trim() : null;
187
+ }
188
+
189
+ function matchModalCode(seg) {
190
+ const m = seg.match(/<pre[^>]*>[\s\S]*?<code[^>]*>([\s\S]*?)<\/code>[\s\S]*?<\/pre>/);
191
+ return m ? decodeEntities(m[1]) : null;
192
+ }
193
+
194
+ /**
195
+ * Extract the include files (.inc modals) from a plugin page.
196
+ *
197
+ * @param {string} html
198
+ * @returns {Array<{ modalId: string, filename: string, content: string }>}
199
+ */
200
+ function extractIncFiles(html) {
201
+ return extractIncModals(html).filter((m) => m.filename.toLowerCase().endsWith('.inc'));
202
+ }
203
+
204
+ function decodeEntities(str) {
205
+ return String(str)
206
+ .replace(/&#x([0-9a-f]+);/gi, (_, hex) => codePointToString(parseInt(hex, 16)))
207
+ .replace(/&#(\d+);/g, (_, dec) => codePointToString(parseInt(dec, 10)))
208
+ .replace(/&quot;/g, '"')
209
+ .replace(/&apos;/g, "'")
210
+ .replace(/&#39;/g, "'")
211
+ .replace(/&lt;/g, '<')
212
+ .replace(/&gt;/g, '>')
213
+ .replace(/&nbsp;/g, ' ')
214
+ .replace(/&amp;/g, '&');
215
+ }
216
+
217
+ function codePointToString(cp) {
218
+ try {
219
+ return String.fromCodePoint(cp);
220
+ } catch {
221
+ return '\uFFFD';
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Fetch the .inc payload of a fungun plugin into the local cache.
227
+ *
228
+ * Cache: <CACHE_DIR>/fungun/<plugin-id>/ — one file per .inc modal, named
229
+ * after the modal title (path traversal is stripped via basename). The
230
+ * sentinel `.fungun` marks a complete fetch. Because fungun pages have no
231
+ * version to pin, the cache expires after FUNGUN_CACHE_TTL_MS and is
232
+ * refetched on the next build. A refetch that fails (network, or the page
233
+ * no longer exposing the include) falls back to the last known-good copy,
234
+ * so an expired cache never breaks a build by itself.
235
+ *
236
+ * @param {object|number|string} dep - parsed fungun dep ({ id } / { url }) or a raw reference
237
+ * @param {boolean} [noFetch=false] - only use the cache, skip network
238
+ * @returns {Promise<string>} cache directory containing the .inc file(s)
239
+ */
240
+ async function fetchFungunDep(dep, noFetch = false) {
241
+ if (dep && typeof dep === 'object' && dep.id == null && dep.url == null) {
242
+ throw new Error(
243
+ `Fungun dep entry missing a plugin reference (expected "id" or "url"): ${JSON.stringify(dep)}`
244
+ );
245
+ }
246
+ const raw = (dep && dep.id != null) ? dep.id
247
+ : (dep && dep.url != null) ? dep.url
248
+ : dep;
249
+ const ref = parsePluginRef(raw);
250
+ if (!ref) {
251
+ throw new Error(
252
+ `Fungun dep entry missing a plugin reference (expected "id" or "url"): ${JSON.stringify(dep)}`
253
+ );
254
+ }
255
+
256
+ const cacheDir = path.join(getCacheDir(), 'fungun', ref.id);
257
+ const sentinelFile = path.join(cacheDir, SENTINEL_FILE);
258
+
259
+ if (fs.existsSync(sentinelFile) && isFreshSentinel(sentinelFile)) {
260
+ logger.dim(` fungun.net plugin #${ref.id} (cached)`);
261
+ return cacheDir;
262
+ }
263
+
264
+ const staleCache = fs.existsSync(sentinelFile);
265
+
266
+ if (noFetch) {
267
+ if (staleCache) {
268
+ // CI reuse: a day-old include is still a valid include.
269
+ logger.dim(` fungun.net plugin #${ref.id} (cached, stale — --no-fetch)`);
270
+ return cacheDir;
271
+ }
272
+ throw new Error(
273
+ `Fungun dep cache missing for plugin #${ref.id} and --no-fetch is set.\n` +
274
+ `Run without --no-fetch to populate the cache.`
275
+ );
276
+ }
277
+
278
+ if (staleCache) {
279
+ logger.dim(` fungun.net plugin #${ref.id}: cache expired, refetching...`);
280
+ }
281
+ logger.step(`Fungun dep: plugin #${ref.id} @ ${ref.url || pluginPageUrl(ref.id)}`);
282
+
283
+ try {
284
+ const html = await fetchFungunPage(ref);
285
+ const incs = extractIncFiles(html);
286
+
287
+ if (incs.length === 0) {
288
+ const pageUrl = ref.url || pluginPageUrl(ref.id);
289
+ const titles = extractIncModals(html).map((m) => m.filename);
290
+ throw new Error(
291
+ `No .inc include found on fungun.net plugin page #${ref.id} (${pageUrl}).\n` +
292
+ `Shipped file modals: ${titles.length ? titles.join(', ') : 'none'} — this plugin exposes no include file.`
293
+ );
294
+ }
295
+
296
+ fs.mkdirSync(cacheDir, { recursive: true });
297
+ for (const inc of incs) {
298
+ fs.writeFileSync(path.join(cacheDir, safeFileName(inc.filename)), inc.content, 'utf8');
299
+ }
300
+ touchSentinel(sentinelFile, ref.id);
301
+
302
+ logger.info(`Fungun dep: plugin #${ref.id} ready (${incs.map((f) => f.filename).join(', ')})`);
303
+ return cacheDir;
304
+ } catch (err) {
305
+ if (staleCache) {
306
+ // Refetch failed but the previous copy is still on disk — keep the build
307
+ // green and reset freshness so we don't hammer the site on every build.
308
+ logger.warn(`Fungun dep: refetch of plugin #${ref.id} failed (${err.message}) — using cached copy`);
309
+ touchSentinel(sentinelFile, ref.id);
310
+ return cacheDir;
311
+ }
312
+ throw err;
313
+ }
314
+ }
315
+
316
+ function isFreshSentinel(sentinelFile) {
317
+ try {
318
+ return Date.now() - fs.statSync(sentinelFile).mtimeMs < FUNGUN_CACHE_TTL_MS;
319
+ } catch {
320
+ return false;
321
+ }
322
+ }
323
+
324
+ function touchSentinel(sentinelFile, id) {
325
+ fs.mkdirSync(path.dirname(sentinelFile), { recursive: true });
326
+ const sentinelTmp = sentinelFile + '.tmp';
327
+ fs.writeFileSync(sentinelTmp, id, 'utf8');
328
+ fs.renameSync(sentinelTmp, sentinelFile);
329
+ }
330
+
331
+ function safeFileName(filename) {
332
+ const base = path.basename(String(filename || '').trim());
333
+ if (!base || base === '.' || base === '..') {
334
+ throw new Error(`Unsafe filename in fungun modal: ${JSON.stringify(filename)}`);
335
+ }
336
+ return base;
337
+ }
338
+
339
+ module.exports = {
340
+ FUNGUN_BASE_URL,
341
+ pluginPageUrl,
342
+ parsePluginRef,
343
+ fetchFungunPage,
344
+ extractIncModals,
345
+ extractIncFiles,
346
+ fetchFungunDep,
347
+ };