afterpresets 0.2.0 → 0.4.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.
Files changed (139) hide show
  1. package/README.md +9 -6
  2. package/native-runtime/CMakeLists.txt +82 -0
  3. package/native-runtime/README.md +40 -0
  4. package/native-runtime/THIRD_PARTY_NOTICES.md +14 -0
  5. package/native-runtime/include/ap_native.h +223 -0
  6. package/native-runtime/src/apply.cpp +199 -0
  7. package/native-runtime/src/crypto.cpp +225 -0
  8. package/native-runtime/src/crypto.hpp +51 -0
  9. package/native-runtime/src/dialog.cpp +62 -0
  10. package/native-runtime/src/dialog.hpp +35 -0
  11. package/native-runtime/src/engine.cpp +909 -0
  12. package/native-runtime/src/engine.hpp +232 -0
  13. package/native-runtime/src/fsutil.cpp +291 -0
  14. package/native-runtime/src/fsutil.hpp +57 -0
  15. package/native-runtime/src/json.cpp +326 -0
  16. package/native-runtime/src/json.hpp +70 -0
  17. package/native-runtime/src/platform.hpp +83 -0
  18. package/native-runtime/src/platform_common.cpp +60 -0
  19. package/native-runtime/src/platform_mac.mm +314 -0
  20. package/native-runtime/src/platform_win.cpp +277 -0
  21. package/native-runtime/src/runtime.cpp +473 -0
  22. package/native-runtime/src/util.cpp +169 -0
  23. package/native-runtime/src/util.hpp +32 -0
  24. package/native-runtime/src/zip.cpp +243 -0
  25. package/native-runtime/src/zip.hpp +58 -0
  26. package/native-runtime/third_party/monocypher/LICENCE.md +167 -0
  27. package/native-runtime/third_party/monocypher/monocypher-ed25519.c +500 -0
  28. package/native-runtime/third_party/monocypher/monocypher-ed25519.h +140 -0
  29. package/native-runtime/third_party/monocypher/monocypher.c +2956 -0
  30. package/native-runtime/third_party/monocypher/monocypher.h +321 -0
  31. package/native-runtime/third_party/tinf/LICENSE +22 -0
  32. package/native-runtime/third_party/tinf/crc32.c +57 -0
  33. package/native-runtime/third_party/tinf/tinf.h +140 -0
  34. package/native-runtime/third_party/tinf/tinflate.c +636 -0
  35. package/native-runtime/vendor-files.json +39 -0
  36. package/package.json +7 -4
  37. package/runtime/docs/cep/host-after-effects.md +3 -3
  38. package/runtime/docs/cep/host-premiere.md +4 -4
  39. package/runtime/docs/cep/host.md +10 -2
  40. package/runtime/docs/common/README.md +1 -1
  41. package/runtime/docs/native/README.md +44 -0
  42. package/runtime/docs/native/debugging.md +140 -0
  43. package/runtime/docs/native/rules.md +402 -0
  44. package/runtime/docs/native/testing.md +154 -0
  45. package/runtime/package.json +1 -1
  46. package/runtime/scripts/ae-sdk-docs/config.js +102 -0
  47. package/runtime/scripts/ae-sdk-docs/convert.js +430 -0
  48. package/runtime/scripts/ae-sdk-docs/index.js +314 -0
  49. package/runtime/scripts/ae-sdk-docs/source.js +205 -0
  50. package/runtime/scripts/ae-sdk-docs/split.js +170 -0
  51. package/runtime/scripts/ae-sdk-docs.js +72 -0
  52. package/runtime/src/host.js +1 -1
  53. package/src/agent/cli-group.js +9 -0
  54. package/src/agent/extendscript.js +10 -0
  55. package/src/agent/host-target.js +1 -0
  56. package/src/agent/panels.js +1 -0
  57. package/src/agent/png.js +1 -0
  58. package/src/agent/run-tool.js +3 -0
  59. package/src/{mcp → agent}/snapshot.js +2 -2
  60. package/src/agent/tool-error.js +1 -0
  61. package/src/agent/tools/capture.js +3 -0
  62. package/src/agent/tools/host.js +1 -0
  63. package/src/agent/tools/native.js +1 -0
  64. package/src/agent/tools/panel.js +7 -0
  65. package/src/agent/tools.js +2 -0
  66. package/src/api/error-messages.js +1 -1
  67. package/src/build/build.js +9 -8
  68. package/src/check/check.js +3 -3
  69. package/src/check/native.js +14 -0
  70. package/src/check/pricing.js +1 -1
  71. package/src/cli.js +3 -3
  72. package/src/commands/build.js +8 -2
  73. package/src/commands/check.js +10 -2
  74. package/src/commands/comp.js +6 -0
  75. package/src/commands/dev.js +29 -2
  76. package/src/commands/docs.js +5 -1
  77. package/src/commands/host.js +1 -0
  78. package/src/commands/init.js +12 -7
  79. package/src/commands/panel.js +1 -0
  80. package/src/commands/push.js +9 -2
  81. package/src/commands/releases.js +8 -3
  82. package/src/commands/sdk.js +14 -0
  83. package/src/commands/testers.js +1 -1
  84. package/src/commands/upgrade.js +2 -2
  85. package/src/commands/whoami.js +1 -1
  86. package/src/dev/bridge.js +8 -0
  87. package/src/dev/native.js +3 -0
  88. package/src/dev/session.js +5 -4
  89. package/src/native/build.js +27 -0
  90. package/src/native/cmake/CMakeLists.txt +88 -0
  91. package/src/native/cmake/zig-windows.cmake +19 -0
  92. package/src/native/effect.js +3 -0
  93. package/src/native/install.js +8 -0
  94. package/src/native/installers.js +4 -0
  95. package/src/native/pipl.js +20 -0
  96. package/src/native/restart.js +2 -0
  97. package/src/native/sdk.js +8 -0
  98. package/src/project/docs.js +3 -3
  99. package/src/project/project.js +2 -2
  100. package/src/project/template.js +5 -4
  101. package/src/prompt.js +1 -1
  102. package/src/release/confirm.js +5 -0
  103. package/src/release/native.js +3 -0
  104. package/src/release/publish.js +1 -1
  105. package/src/system/run-command.js +1 -1
  106. package/src/targets.js +1 -1
  107. package/template/cep/AGENTS.md +102 -164
  108. package/template/cep/README.md +7 -3
  109. package/template/cep/docs/code_guide.md +67 -0
  110. package/template/cep/docs/initial_setup.md +63 -42
  111. package/template/cep/docs/release_checklist.md +25 -0
  112. package/template/cep/gitignore +2 -1
  113. package/template/cep/src/features/update-screen/update-screen.js +1 -1
  114. package/template/hosts/AGENTS.two-apps.md +12 -0
  115. package/template/native/AGENTS.md +106 -0
  116. package/template/native/README.md +71 -0
  117. package/template/native/docs/code_guide.md +72 -0
  118. package/template/native/docs/initial_setup.md +154 -0
  119. package/template/native/docs/release_checklist.md +25 -0
  120. package/template/native/effect.json +16 -0
  121. package/template/native/gitignore +9 -0
  122. package/template/native/package.json +14 -0
  123. package/template/native/src/Effect.cpp +224 -0
  124. package/template/native/src/License.cpp +95 -0
  125. package/template/native/src/License.h +29 -0
  126. package/template/native/src/Render.h +68 -0
  127. package/src/commands/mcp.js +0 -7
  128. package/src/mcp/extendscript.js +0 -9
  129. package/src/mcp/panels.js +0 -1
  130. package/src/mcp/protocol.js +0 -4
  131. package/src/mcp/server.js +0 -2
  132. package/src/mcp/tools/account.js +0 -3
  133. package/src/mcp/tools/host.js +0 -1
  134. package/src/mcp/tools/panel.js +0 -7
  135. package/src/mcp/tools/project.js +0 -40
  136. package/src/mcp/tools/releases.js +0 -29
  137. package/template/cep/.mcp.json +0 -8
  138. /package/src/{mcp → agent}/capture.js +0 -0
  139. /package/src/{mcp → agent}/redact.js +0 -0
@@ -0,0 +1,314 @@
1
+ "use strict";
2
+
3
+ // Builds the agent-friendly After Effects SDK topic files from the pinned
4
+ // guide source. See ../ae-sdk-docs.js for the command line and config.js for
5
+ // the pin, page selection and task index.
6
+
7
+ const fs = require("fs");
8
+ const path = require("path");
9
+ const config = require("./config");
10
+ const { convertPage, stripInline } = require("./convert");
11
+ const { findHeadings, planPage, rewriteLinks } = require("./split");
12
+ const { loadSource } = require("./source");
13
+
14
+ const MANIFEST = ".ae-sdk-docs.json";
15
+ const GENERATOR_VERSION = 1;
16
+
17
+ function estimateTokens(text) {
18
+ return Math.ceil(text.length / 4);
19
+ }
20
+
21
+ /** Pages in navigation order: [{ section, title, path }] from mkdocs.yml's nav. */
22
+ function parseNav(yml) {
23
+ const lines = yml.split(/\r?\n/);
24
+ const start = lines.findIndex((l) => /^nav:\s*$/.test(l));
25
+ if (start === -1) throw new Error("mkdocs.yml has no nav.");
26
+ const pages = [];
27
+ let section = "";
28
+ for (let i = start + 1; i < lines.length; i++) {
29
+ const line = lines[i];
30
+ if (/^\S/.test(line)) break;
31
+ const page = line.match(/^(\s*)-\s+(.*):\s+([\w./-]+\.md)\s*$/);
32
+ if (page) {
33
+ pages.push({ section: page[1].length <= 4 ? "" : section, title: page[2].replace(/^["']|["']$/g, ""), path: page[3] });
34
+ continue;
35
+ }
36
+ const group = line.match(/^\s*-\s+(.*):\s*$/);
37
+ if (group) section = group[1].replace(/^["']|["']$/g, "");
38
+ }
39
+ return pages;
40
+ }
41
+
42
+ function siteUrl(pin, sourcePath, anchor) {
43
+ const page = sourcePath === "index.md" ? "" : sourcePath.replace(/(^|\/)index\.md$/, "$1").replace(/\.md$/, "/");
44
+ return pin.site + "/" + page + (anchor ? "#" + anchor : "");
45
+ }
46
+
47
+ function firstSentence(lines) {
48
+ const text = lines.find((l) => l.trim() && !/^\s*(#|>|```|- |\* |\||Source:|\*From )/.test(l));
49
+ if (!text) return "";
50
+ const plain = stripInline(text).replace(/\s+/g, " ");
51
+ const end = plain.search(/[.!?](\s|$)/);
52
+ const sentence = end === -1 ? plain : plain.slice(0, end + 1);
53
+ return sentence.length > 110 ? sentence.slice(0, 107).replace(/\s+\S*$/, "") + "..." : sentence;
54
+ }
55
+
56
+ function summarize(headings, lines) {
57
+ const h2 = headings.filter((h) => h.level === 2).map((h) => h.text);
58
+ const list = h2.length ? h2 : headings.filter((h) => h.level === 3).map((h) => h.text);
59
+ if (!list.length) return firstSentence(lines);
60
+ const shown = list.slice(0, 6).join(", ");
61
+ return list.length > 6 ? shown + ", ..." : shown;
62
+ }
63
+
64
+ function clip(text, max) {
65
+ return text.length > max ? text.slice(0, max - 3).replace(/\s+\S*$/, "") + "..." : text;
66
+ }
67
+
68
+ function headingsBetween(page, range) {
69
+ return page.headings.filter((h) => h.index >= range.start && h.index < range.end && h.index !== range.start);
70
+ }
71
+
72
+ function tableCell(text) {
73
+ return String(text).replace(/\|/g, "\\|");
74
+ }
75
+
76
+ /**
77
+ * Generates the topic files. Returns { files: [{ name, tokens, source }], totalTokens,
78
+ * sourceTokens, commit, digest, warnings }. Writes them when options.out is set.
79
+ */
80
+ async function generate(options) {
81
+ options = options || {};
82
+ // options.config overrides parts of config.js (tests use a small fixture guide).
83
+ const cfg = Object.assign({}, config, options.config || {});
84
+ const pin = cfg.PIN;
85
+ const limits = cfg.LIMITS;
86
+ const source = await loadSource(pin, options);
87
+ const warnings = [];
88
+
89
+ const pages = parseNav(source.files["mkdocs.yml"]).filter((p) => !cfg.EXCLUDE.some((re) => re.test(p.path)));
90
+ const byPath = {};
91
+ pages.forEach((page) => {
92
+ const text = source.files["docs/" + page.path];
93
+ if (text == null) throw new Error("mkdocs.yml lists " + page.path + " but the guide has no such file.");
94
+ page.name = cfg.outputName(page.path);
95
+ page.sourceTokens = estimateTokens(text);
96
+ const converted = convertPage(text);
97
+ converted.warnings.forEach((w) => warnings.push(page.path + ": " + w));
98
+ page.lines = converted.lines;
99
+ page.headings = findHeadings(page.lines);
100
+ const h1 = page.headings.find((h) => h.level === 1 && h.index === 0);
101
+ page.heading = h1 ? h1.text : page.title;
102
+ page.plan = planPage(page, page.lines, page.headings, limits);
103
+ page.anchors = {};
104
+ page.headings.forEach((h) => {
105
+ const entry = page.plan.find((p) => h.index >= p.start && h.index < p.end) || page.plan[0];
106
+ page.anchors[h.slug] = entry.name;
107
+ });
108
+ byPath[page.path] = page;
109
+ });
110
+
111
+ const seen = {};
112
+ pages.forEach((page) =>
113
+ page.plan.forEach((entry) => {
114
+ if (seen[entry.name]) throw new Error("Two topic files would be named " + entry.name + ".md (" + seen[entry.name] + ", " + page.path + "). Add a RENAME in config.js.");
115
+ seen[entry.name] = page.path;
116
+ })
117
+ );
118
+
119
+ // Resolves a source reference ("page.md" or "page.md#anchor", relative to `from`).
120
+ function resolveRef(ref, from) {
121
+ const hash = ref.indexOf("#");
122
+ const filePart = hash === -1 ? ref : ref.slice(0, hash);
123
+ const anchor = hash === -1 ? "" : ref.slice(hash + 1);
124
+ const target = filePart ? path.posix.normalize(path.posix.join(path.posix.dirname(from), filePart)) : from;
125
+ return { target, anchor, page: byPath[target] };
126
+ }
127
+
128
+ function linkTo(ref, from, currentName) {
129
+ if (/^[a-z][a-z0-9+.-]*:/i.test(ref)) return null;
130
+ const { target, anchor, page } = resolveRef(ref, from);
131
+ if (!/\.md$/.test(target)) return null;
132
+ if (!page) return siteUrl(pin, target, anchor);
133
+ let name = page.plan[0].name;
134
+ if (anchor) {
135
+ if (page.anchors[anchor]) name = page.anchors[anchor];
136
+ else warnings.push(from + ": link to a missing heading " + ref);
137
+ }
138
+ if (name === currentName && anchor) return "#" + anchor;
139
+ return name + ".md" + (anchor ? "#" + anchor : "");
140
+ }
141
+
142
+ // Assemble the files.
143
+ const out = [];
144
+ pages.forEach((page) => {
145
+ const firstAnchor = (entry) => (page.headings.find((h) => h.index === entry.start) || {}).slug;
146
+ page.plan.forEach((entry, i) => {
147
+ let body = page.lines.slice(entry.start, entry.end);
148
+ const header = [];
149
+ if (i === 0) {
150
+ if (/^# /.test(body[0] || "")) body = body.slice(1);
151
+ header.push("# " + page.heading, "", "*From Adobe's After Effects SDK Guide: " + siteUrl(pin, page.path) + "*", "");
152
+ } else {
153
+ header.push(
154
+ "# " + page.heading + ": " + entry.title,
155
+ "",
156
+ "*Part of [" + page.heading + "](" + page.plan[0].name + ".md). From Adobe's After Effects SDK Guide: " + siteUrl(pin, page.path, firstAnchor(entry)) + "*",
157
+ ""
158
+ );
159
+ }
160
+ body = rewriteLinks(body, (href) => linkTo(href, page.path, entry.name));
161
+ const footer = [];
162
+ if (entry.overview) {
163
+ footer.push("", "## Parts of this page", "");
164
+ page.plan.slice(1).forEach((part) => {
165
+ const inside = headingsBetween(page, part)
166
+ .filter((h) => h.level <= 3 && part.title.indexOf(h.text) === -1)
167
+ .map((h) => clip(h.text, 60));
168
+ const more = inside.length ? " (also: " + inside.slice(0, 4).join("; ") + (inside.length > 4 ? "; ..." : "") + ")" : "";
169
+ footer.push("- [" + part.title + "](" + part.name + ".md)" + more);
170
+ });
171
+ }
172
+ const text = header.concat(body, footer).join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n";
173
+ const headingsIn = headingsBetween(page, entry).filter((h) => h.level > 1 && (!entry.title || entry.title.indexOf(h.text) === -1));
174
+ out.push({
175
+ name: entry.name,
176
+ text,
177
+ tokens: estimateTokens(text),
178
+ source: page.path,
179
+ section: page.section,
180
+ about: entry.overview ? "Overview and list of parts" : summarize(headingsIn, i === 0 ? page.lines.slice(entry.start, entry.end) : []),
181
+ pageTitle: page.title,
182
+ part: i > 0 ? entry.title : null,
183
+ });
184
+ });
185
+ });
186
+
187
+ // README.md: the index.
188
+ const fileFor = (ref) => {
189
+ const { target, anchor, page } = resolveRef(ref, "");
190
+ if (!page) throw new Error("config.js refers to " + ref + ", which is not a generated page.");
191
+ if (anchor && !page.anchors[anchor]) throw new Error("config.js refers to " + ref + ": no such heading.");
192
+ return (anchor ? page.anchors[anchor] : page.plan[0].name) + ".md" + (anchor ? "#" + anchor : "");
193
+ };
194
+ // One link per file; the first reference's heading goes in the href.
195
+ const linkList = (refs) => {
196
+ const files = [];
197
+ refs.map(fileFor).forEach((f) => {
198
+ const file = f.split("#")[0];
199
+ if (!files.some((x) => x.split("#")[0] === file)) files.push(f);
200
+ });
201
+ return files.map((f) => "[" + f.split("#")[0] + "](" + f + ")").join(", ");
202
+ };
203
+ const total = out.reduce((n, f) => n + f.tokens, 0);
204
+ const short = pin.commit.slice(0, 7);
205
+ const rules = options.rulesLink === undefined ? "../rules.md" : options.rulesLink;
206
+ const readme = [
207
+ "---",
208
+ "title: After Effects SDK guide (topic files)",
209
+ "description: Adobe's After Effects C++ plug-in SDK guide split into small topic files, with an index from tasks to files.",
210
+ "---",
211
+ "",
212
+ "# After Effects SDK guide, by topic",
213
+ "",
214
+ "Adobe's After Effects C++ SDK guide (" + pin.sdk + "), cut into small files for",
215
+ "you, the agent: one topic per file, tables flattened, cross-links pointing at these files.",
216
+ "Generated on this computer from the community copy at",
217
+ "https://github.com/" + pin.repo + " (commit " + short + ", " + pin.date + ").",
218
+ "The content is copyright Adobe. It is not part of AfterPresets: do not copy it",
219
+ "into the product, its listing or its source files.",
220
+ "",
221
+ "**Read the task's files below before writing SDK code.** Do not guess struct",
222
+ "fields, flag names or call order. Where these files and your memory disagree,",
223
+ "these files win; where they disagree with the SDK headers in the project, the headers win.",
224
+ rules ? "AfterPresets' own rules for native effects are in [" + path.posix.basename(rules) + "](" + rules + "); they come first." : "",
225
+ "",
226
+ "Look up a name across every file:",
227
+ "",
228
+ "```sh",
229
+ 'grep -rn "PF_OutFlag_DEEP_COLOR_AWARE" ' + (options.grepPath || "docs/afterpresets/sdk/"),
230
+ "```",
231
+ "",
232
+ "## Start here (first effect)",
233
+ "",
234
+ cfg.START_HERE.map((ref, i) => i + 1 + ". " + linkList([ref])).join("\n"),
235
+ "",
236
+ "## Tasks",
237
+ "",
238
+ "| Task | Read |",
239
+ "|---|---|",
240
+ cfg.TASKS.map((t) => "| " + tableCell(t[0]) + " | " + linkList(t[1]) + " |").join("\n"),
241
+ "",
242
+ "Every file, with what it covers and its size: [contents.md](contents.md).",
243
+ ];
244
+ const readmeText = readme.join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n";
245
+
246
+ // contents.md: every file by section.
247
+ const contents = [
248
+ "# After Effects SDK guide: all files",
249
+ "",
250
+ "By section of Adobe's guide. Start from [README.md](README.md) for tasks. Tokens are estimates (characters / 4).",
251
+ ];
252
+ let section = null;
253
+ out.forEach((f) => {
254
+ if (f.section !== section) {
255
+ section = f.section;
256
+ contents.push("", "## " + (section || "General"), "", "| File | Covers | Tokens |", "|---|---|---|");
257
+ }
258
+ const title = stripInline(f.part ? f.pageTitle + ": " + f.part : f.pageTitle);
259
+ contents.push("| [" + f.name + ".md](" + f.name + ".md) | **" + tableCell(title) + "**" + (f.about ? ". " + tableCell(f.about) : "") + " | " + f.tokens + " |");
260
+ });
261
+ const contentsText = contents.join("\n").trim() + "\n";
262
+ out.unshift({ name: "contents", text: contentsText, tokens: estimateTokens(contentsText), source: null, section: "", about: "All files" });
263
+ out.unshift({ name: "README", text: readmeText, tokens: estimateTokens(readmeText), source: null, section: "", about: "Index" });
264
+
265
+ const sourceTokens = pages.reduce((n, p) => n + p.sourceTokens, 0);
266
+ const result = {
267
+ commit: source.commit,
268
+ digest: source.digest,
269
+ from: source.from,
270
+ files: out.map((f) => ({ name: f.name + ".md", tokens: f.tokens, source: f.source })),
271
+ totalTokens: out.reduce((n, f) => n + f.tokens, 0),
272
+ sourceTokens,
273
+ pages: pages.length,
274
+ warnings,
275
+ };
276
+ if (total <= 0) throw new Error("Nothing generated.");
277
+ if (options.out) writeOutput(options.out, out, result, pin);
278
+ return result;
279
+ }
280
+
281
+ /** Writes into `dir`, replacing a previous run's files. Refuses a folder it did not create. */
282
+ function writeOutput(dir, files, result, pin) {
283
+ const manifestPath = path.join(dir, MANIFEST);
284
+ if (fs.existsSync(dir)) {
285
+ const entries = fs.readdirSync(dir);
286
+ if (entries.length && !fs.existsSync(manifestPath)) {
287
+ throw new Error(dir + " is not empty and was not made by this generator. Choose an empty folder.");
288
+ }
289
+ if (fs.existsSync(manifestPath)) {
290
+ let old = { files: [] };
291
+ try {
292
+ old = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
293
+ } catch (error) {
294
+ // Unreadable manifest: only our own names below get replaced.
295
+ }
296
+ (old.files || []).forEach((f) => {
297
+ const name = path.basename(String(f.name || ""));
298
+ if (/^[a-z0-9-]+\.md$|^README\.md$/.test(name)) fs.rmSync(path.join(dir, name), { force: true });
299
+ });
300
+ }
301
+ }
302
+ fs.mkdirSync(dir, { recursive: true });
303
+ files.forEach((f) => fs.writeFileSync(path.join(dir, f.name + ".md"), f.text));
304
+ const manifest = {
305
+ generator: GENERATOR_VERSION,
306
+ repo: pin.repo,
307
+ commit: result.commit,
308
+ digest: result.digest,
309
+ files: result.files,
310
+ };
311
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
312
+ }
313
+
314
+ module.exports = { generate, parseNav, MANIFEST, GENERATOR_VERSION, estimateTokens };
@@ -0,0 +1,205 @@
1
+ "use strict";
2
+
3
+ // Gets the pinned After Effects SDK guide source onto this computer.
4
+ //
5
+ // The guide is copyright Adobe and has no open licence, so AfterPresets does
6
+ // not ship it: each user's machine downloads the public GitHub repository at
7
+ // the pinned commit (a tarball, no git needed), keeps only mkdocs.yml and
8
+ // docs/**/*.md, checks them against the pinned digest and caches them by
9
+ // commit in a folder shared by every project.
10
+ //
11
+ // The download is untrusted input: entry paths are filtered to a strict
12
+ // pattern (nothing else is written), the archive size is capped, the commit in
13
+ // the archive header must be the pinned one, and the content digest must match.
14
+
15
+ const crypto = require("crypto");
16
+ const fs = require("fs");
17
+ const os = require("os");
18
+ const path = require("path");
19
+ const zlib = require("zlib");
20
+
21
+ const MAX_ARCHIVE_BYTES = 64 * 1024 * 1024;
22
+ const KEEP = /^(mkdocs\.yml|docs\/(?!_)[A-Za-z0-9_-]+(\/[A-Za-z0-9_-]+)*\.md)$/;
23
+
24
+ function defaultCacheDir() {
25
+ return path.join(os.homedir(), ".afterpresets", "cache");
26
+ }
27
+
28
+ /** sha256 over the kept files, sorted by path: "path\0content\0" each. Independent of tar/gzip bytes. */
29
+ function digestFiles(files) {
30
+ const hash = crypto.createHash("sha256");
31
+ Object.keys(files)
32
+ .sort()
33
+ .forEach((name) => {
34
+ hash.update(name + "\0");
35
+ hash.update(files[name]);
36
+ hash.update("\0");
37
+ });
38
+ return "sha256:" + hash.digest("hex");
39
+ }
40
+
41
+ function readTree(root) {
42
+ const files = {};
43
+ const add = (rel) => {
44
+ files[rel] = fs.readFileSync(path.join(root, rel.split("/").join(path.sep)), "utf8");
45
+ };
46
+ if (fs.existsSync(path.join(root, "mkdocs.yml"))) add("mkdocs.yml");
47
+ const walk = (dirRel) => {
48
+ const abs = path.join(root, dirRel.split("/").join(path.sep));
49
+ if (!fs.existsSync(abs)) return;
50
+ fs.readdirSync(abs, { withFileTypes: true }).forEach((d) => {
51
+ const rel = dirRel + "/" + d.name;
52
+ if (d.isDirectory()) walk(rel);
53
+ else if (d.isFile() && KEEP.test(rel)) add(rel);
54
+ });
55
+ };
56
+ walk("docs");
57
+ return files;
58
+ }
59
+
60
+ function parsePax(buf) {
61
+ const out = {};
62
+ let i = 0;
63
+ while (i < buf.length) {
64
+ const space = buf.indexOf(0x20, i);
65
+ if (space === -1) break;
66
+ const len = parseInt(buf.toString("utf8", i, space), 10);
67
+ if (!len || len < 0) break;
68
+ const record = buf.toString("utf8", space + 1, i + len - 1);
69
+ const eq = record.indexOf("=");
70
+ if (eq !== -1) out[record.slice(0, eq)] = record.slice(eq + 1);
71
+ i += len;
72
+ }
73
+ return out;
74
+ }
75
+
76
+ function cString(buf, start, end) {
77
+ const slice = buf.subarray(start, end);
78
+ const nul = slice.indexOf(0);
79
+ return slice.toString("utf8", 0, nul === -1 ? slice.length : nul);
80
+ }
81
+
82
+ /**
83
+ * Reads a (decompressed) tar archive. Returns { comment, files } where files
84
+ * holds only entries whose path, minus the top folder, matches KEEP.
85
+ */
86
+ function readTar(tar) {
87
+ const files = {};
88
+ let comment = null;
89
+ let nextPath = null;
90
+ let offset = 0;
91
+ while (offset + 512 <= tar.length) {
92
+ const header = tar.subarray(offset, offset + 512);
93
+ if (header.every((b) => b === 0)) break;
94
+ const size = parseInt(cString(header, 124, 136).trim() || "0", 8);
95
+ if (!Number.isFinite(size) || size < 0) throw new Error("Bad tar header.");
96
+ const type = String.fromCharCode(header[156] || 48);
97
+ const dataStart = offset + 512;
98
+ const data = tar.subarray(dataStart, dataStart + size);
99
+ offset = dataStart + Math.ceil(size / 512) * 512;
100
+
101
+ if (type === "g") {
102
+ const pax = parsePax(data);
103
+ if (pax.comment) comment = pax.comment.trim();
104
+ continue;
105
+ }
106
+ if (type === "x") {
107
+ const pax = parsePax(data);
108
+ if (pax.path) nextPath = pax.path;
109
+ continue;
110
+ }
111
+ if (type === "L") {
112
+ nextPath = cString(data, 0, data.length);
113
+ continue;
114
+ }
115
+ let name = nextPath;
116
+ nextPath = null;
117
+ if (!name) {
118
+ const prefix = cString(header, 345, 500);
119
+ name = (prefix ? prefix + "/" : "") + cString(header, 0, 100);
120
+ }
121
+ if (type !== "0" && type !== "\0") continue;
122
+ const rel = name.split("/").slice(1).join("/");
123
+ if (!KEEP.test(rel)) continue;
124
+ files[rel] = data.toString("utf8");
125
+ }
126
+ return { comment, files };
127
+ }
128
+
129
+ async function download(url, fetchImpl) {
130
+ const response = await fetchImpl(url, { redirect: "follow" });
131
+ if (!response.ok) throw new Error("Download failed (" + response.status + "): " + url);
132
+ const length = Number(response.headers && response.headers.get && response.headers.get("content-length"));
133
+ if (length > MAX_ARCHIVE_BYTES) throw new Error("The download is too large: " + url);
134
+ const buf = Buffer.from(await response.arrayBuffer());
135
+ if (buf.length > MAX_ARCHIVE_BYTES) throw new Error("The download is too large: " + url);
136
+ return buf;
137
+ }
138
+
139
+ function writeTree(root, files) {
140
+ Object.keys(files).forEach((rel) => {
141
+ if (!KEEP.test(rel)) throw new Error("Refusing to write " + rel);
142
+ const dest = path.join(root, rel.split("/").join(path.sep));
143
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
144
+ fs.writeFileSync(dest, files[rel]);
145
+ });
146
+ }
147
+
148
+ /**
149
+ * Returns { files, commit, digest, from } with files = { "mkdocs.yml": text, "docs/...md": text }.
150
+ *
151
+ * options.source a local checkout to read instead of downloading
152
+ * options.cacheDir shared cache root (default ~/.afterpresets/cache)
153
+ * options.fetch fetch implementation (default global fetch)
154
+ * options.allowUnpinned accept a local checkout whose digest differs (for bumping the pin)
155
+ */
156
+ async function loadSource(pin, options) {
157
+ options = options || {};
158
+ if (options.source) {
159
+ const files = readTree(options.source);
160
+ if (!files["mkdocs.yml"]) throw new Error("No mkdocs.yml in " + options.source + " (expected a checkout of " + pin.repo + ").");
161
+ const digest = digestFiles(files);
162
+ if (digest !== pin.digest && !options.allowUnpinned) {
163
+ throw new Error("The guide in " + options.source + " is not the pinned version (digest " + digest + ", pinned " + pin.digest + "). Use --allow-unpinned to bump the pin.");
164
+ }
165
+ return { files, commit: digest === pin.digest ? pin.commit : "local", digest, from: options.source };
166
+ }
167
+
168
+ const cacheRoot = options.cacheDir || defaultCacheDir();
169
+ const dir = path.join(cacheRoot, "ae-plugin-guide-" + pin.commit);
170
+ if (fs.existsSync(path.join(dir, ".complete"))) {
171
+ const files = readTree(dir);
172
+ if (digestFiles(files) === pin.digest) return { files, commit: pin.commit, digest: pin.digest, from: dir };
173
+ fs.rmSync(dir, { recursive: true, force: true });
174
+ }
175
+
176
+ const url = "https://codeload.github.com/" + pin.repo + "/tar.gz/" + pin.commit;
177
+ const fetchImpl = options.fetch || ((u, init) => fetch(u, init));
178
+ const archive = await download(url, fetchImpl);
179
+ let tar;
180
+ try {
181
+ tar = zlib.gunzipSync(archive, { maxOutputLength: 4 * MAX_ARCHIVE_BYTES });
182
+ } catch (error) {
183
+ throw new Error("The downloaded guide is not a valid .tar.gz (" + error.message + ").");
184
+ }
185
+ const { comment, files } = readTar(tar);
186
+ if (comment !== pin.commit) throw new Error("The downloaded guide is commit " + comment + ", expected " + pin.commit + ".");
187
+ const digest = digestFiles(files);
188
+ if (digest !== pin.digest) throw new Error("The downloaded guide does not match the pinned digest (" + digest + ").");
189
+
190
+ fs.mkdirSync(cacheRoot, { recursive: true });
191
+ const tmp = fs.mkdtempSync(path.join(cacheRoot, ".ae-plugin-guide-"));
192
+ try {
193
+ writeTree(tmp, files);
194
+ fs.writeFileSync(path.join(tmp, ".complete"), pin.commit + "\n");
195
+ fs.rmSync(dir, { recursive: true, force: true });
196
+ fs.renameSync(tmp, dir);
197
+ } catch (error) {
198
+ fs.rmSync(tmp, { recursive: true, force: true });
199
+ // Another run may have filled the cache at the same time: that copy is as good.
200
+ if (!fs.existsSync(path.join(dir, ".complete"))) throw error;
201
+ }
202
+ return { files, commit: pin.commit, digest, from: url };
203
+ }
204
+
205
+ module.exports = { loadSource, digestFiles, readTar, readTree, defaultCacheDir, KEEP };
@@ -0,0 +1,170 @@
1
+ "use strict";
2
+
3
+ // Splits converted pages into topic files of bounded size, records which file
4
+ // holds every heading anchor, and rewrites links to point at those files.
5
+ //
6
+ // A page under `singleMax` characters stays one file. A longer page becomes
7
+ // <name>.md the page title, its introduction and a list of parts
8
+ // <name>--<part>.md one per H2 section (H3 if a section is still too long,
9
+ // then top-level blocks), small neighbours merged
10
+ // so an agent opens only the part it needs.
11
+
12
+ const { slugify, uniqueSlugger, stripInline, isFenceOpen, fenceEnd } = require("./convert");
13
+
14
+ const HEADING = /^(#{1,6})\s+(.*?)\s*#*\s*$/;
15
+
16
+ function size(lines, start, end) {
17
+ let n = 0;
18
+ for (let i = start; i < end; i++) n += lines[i].length + 1;
19
+ return n;
20
+ }
21
+
22
+ /** Headings outside code: [{ index, level, text, slug }]. Slugs unique per page, in order. */
23
+ function findHeadings(lines) {
24
+ const slug = uniqueSlugger();
25
+ const out = [];
26
+ for (let i = 0; i < lines.length; i++) {
27
+ if (isFenceOpen(lines[i])) {
28
+ i = fenceEnd(lines, i) - 1;
29
+ continue;
30
+ }
31
+ const m = lines[i].match(HEADING);
32
+ if (m) out.push({ index: i, level: m[1].length, text: stripInline(m[2]), slug: slug(m[2]) });
33
+ }
34
+ return out;
35
+ }
36
+
37
+ /** Cuts [start, end) before each heading of `level`. Returns ranges; the first may have no heading. */
38
+ function cutAtLevel(headings, start, end, level) {
39
+ const cuts = headings.filter((h) => h.level === level && h.index > start && h.index < end).map((h) => h.index);
40
+ const bounds = [start].concat(cuts, [end]);
41
+ const out = [];
42
+ for (let i = 0; i < bounds.length - 1; i++) if (bounds[i] < bounds[i + 1]) out.push({ start: bounds[i], end: bounds[i + 1] });
43
+ return out;
44
+ }
45
+
46
+ /** Cuts a range at top-level block starts (non-indented line after a blank, outside code) into pieces <= max. */
47
+ function cutAtBlocks(lines, start, end, max) {
48
+ const starts = [];
49
+ for (let i = start + 1; i < end; i++) {
50
+ if (isFenceOpen(lines[i])) {
51
+ if (!lines[i - 1].trim() && !/^\s/.test(lines[i])) starts.push(i);
52
+ i = fenceEnd(lines, i) - 1;
53
+ continue;
54
+ }
55
+ if (lines[i].trim() && !lines[i - 1].trim() && !/^\s/.test(lines[i])) starts.push(i);
56
+ }
57
+ const out = [];
58
+ let from = start;
59
+ let lastGood = -1;
60
+ starts.concat([end]).forEach((cut) => {
61
+ if (size(lines, from, cut) > max && lastGood > from) {
62
+ out.push({ start: from, end: lastGood });
63
+ from = lastGood;
64
+ }
65
+ lastGood = cut;
66
+ });
67
+ if (from < end) out.push({ start: from, end });
68
+ return out;
69
+ }
70
+
71
+ function headingAt(headings, range) {
72
+ return headings.find((h) => h.index === range.start) || null;
73
+ }
74
+
75
+ /**
76
+ * Plans the files for one converted page. Returns [{ name, start, end, title, continued }];
77
+ * the first entry is the page itself (single file or the overview of a split page).
78
+ */
79
+ function planPage(page, lines, headings, options) {
80
+ const { maxChars, singleMax, minChars, mergeMax } = options;
81
+ if (size(lines, 0, lines.length) <= singleMax) return [{ name: page.name, start: 0, end: lines.length, title: null, overview: false }];
82
+
83
+ const pieces = [];
84
+ const sections = cutAtLevel(headings, 0, lines.length, 2);
85
+ sections.forEach((section) => {
86
+ const subs = size(lines, section.start, section.end) > maxChars ? cutAtLevel(headings, section.start, section.end, 3) : [section];
87
+ subs.forEach((sub) => {
88
+ const blocks = size(lines, sub.start, sub.end) > maxChars ? cutAtBlocks(lines, sub.start, sub.end, maxChars) : [sub];
89
+ blocks.forEach((b) => pieces.push(b));
90
+ });
91
+ });
92
+
93
+ // The page's introduction (its first piece: everything before the first H2,
94
+ // or the first block run of a page without H2s) is the overview.
95
+ const overview = pieces.shift();
96
+
97
+ // Merge a small piece into its neighbour (each piece keeps its own file
98
+ // otherwise, so the file name says what is in it).
99
+ const merged = [];
100
+ pieces.forEach((p) => {
101
+ const last = merged[merged.length - 1];
102
+ const pSize = size(lines, p.start, p.end);
103
+ const small = pSize < minChars || (last && size(lines, last.start, last.end) < minChars);
104
+ if (last && small && size(lines, last.start, p.end) <= mergeMax) {
105
+ last.end = p.end;
106
+ last.members.push(p);
107
+ } else merged.push({ start: p.start, end: p.end, members: [p] });
108
+ });
109
+
110
+ // A piece's title: its heading, or for a piece cut between blocks, the
111
+ // nearest heading above it plus "(continued)".
112
+ const titleOf = (range) => {
113
+ const h = headingAt(headings, range);
114
+ if (h) return { text: h.text, continued: false };
115
+ const above = headings.filter((x) => x.index < range.start && x.level >= 2).pop();
116
+ return { text: above ? above.text : "", continued: true };
117
+ };
118
+
119
+ const used = new Set([page.name]);
120
+ const parts = merged.map((range) => {
121
+ // Named after its first piece of real size (else its largest), so a file
122
+ // of tiny sections is not named after a one-line heading; titled with
123
+ // every piece's heading.
124
+ const bySize = range.members.slice().sort((a, b) => size(lines, b.start, b.end) - size(lines, a.start, a.end));
125
+ const main = titleOf(range.members.find((m) => size(lines, m.start, m.end) >= minChars / 2) || bySize[0]);
126
+ let name = page.name + "--" + (slugify(main.text || "continued").replace(/_/g, "-") || "part");
127
+ if (name.length > 70) name = name.slice(0, 70).replace(/-+$/, "");
128
+ let unique = name;
129
+ let n = 2;
130
+ while (used.has(unique)) unique = name + "-" + n++;
131
+ used.add(unique);
132
+ const titles = range.members.map(titleOf).map((t) => (t.continued ? (t.text ? t.text + " (continued)" : "Continued") : t.text));
133
+ const title = titles.length > 4 ? titles.slice(0, 4).join(", ") + ", ..." : titles.join(", ");
134
+ return { name: unique, start: range.start, end: range.end, title, overview: false };
135
+ });
136
+ return [{ name: page.name, start: overview.start, end: overview.end, title: null, overview: true }].concat(parts);
137
+ }
138
+
139
+ /**
140
+ * Rewrites Markdown links in `lines` (outside code). `resolve(href)` returns
141
+ * the new href or null to keep it.
142
+ */
143
+ function rewriteLinks(lines, resolve) {
144
+ const out = [];
145
+ for (let i = 0; i < lines.length; i++) {
146
+ if (isFenceOpen(lines[i])) {
147
+ const end = fenceEnd(lines, i);
148
+ for (let k = i; k < end; k++) out.push(lines[k]);
149
+ i = end - 1;
150
+ continue;
151
+ }
152
+ // Leave inline code alone: split on backtick spans.
153
+ const parts = lines[i].split(/(`[^`]*`)/);
154
+ out.push(
155
+ parts
156
+ .map((part) =>
157
+ part[0] === "`"
158
+ ? part
159
+ : part.replace(/(\[(?:[^[\]]|\[[^[\]]*\])*\]\()([^)\s]+)((?:\s+"[^"]*")?\))/g, (m, open, href, close) => {
160
+ const next = resolve(href);
161
+ return next == null ? m : open + next + close;
162
+ })
163
+ )
164
+ .join("")
165
+ );
166
+ }
167
+ return out;
168
+ }
169
+
170
+ module.exports = { findHeadings, planPage, rewriteLinks, cutAtBlocks, cutAtLevel, size };