@entrinsik/vite-plugin-informer 2.3.0 → 2.5.0-beta.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.
package/bin/deploy.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import dotenv from 'dotenv';
4
3
  import { readFile, writeFile } from 'node:fs/promises';
5
4
  import { resolve } from 'node:path';
6
5
  import { deploy } from '../src/deploy.js';
6
+ import { loadEnv, parseModeArg } from '../src/env.js';
7
7
 
8
- dotenv.config();
8
+ const mode = parseModeArg(process.argv);
9
+ loadEnv({ mode });
9
10
 
10
11
  const baseUrl = process.env.INFORMER_URL;
11
12
  const apiKey = process.env.INFORMER_API_KEY;
package/bin/init.js CHANGED
@@ -13,40 +13,66 @@ const cwd = process.cwd();
13
13
  function generateInformerYaml() {
14
14
  return `# Informer App Configuration
15
15
  # =========================
16
- # This file controls which Informer APIs your app can access and defines
17
- # custom roles for role-based UIs.
16
+ # This file controls the data your app depends on and defines custom
17
+ # roles for role-based UIs.
18
18
  #
19
- # Without an access section, all API access is blocked (secure by default).
19
+ # Without dependencies: or access:, all API access is blocked (secure
20
+ # by default).
20
21
 
21
22
  # ============================================================================
22
- # DATA ACCESS
23
+ # DEPENDENCIES (preferred)
23
24
  # ============================================================================
24
- # The access section controls which APIs the app can call when shared.
25
+ # Typed slots that the installer binds to actual resources at deploy
26
+ # time. Each slot becomes a property on the handler context object —
27
+ # call methods on it instead of building raw API URLs.
25
28
  #
26
- # access:
27
- # datasets:
28
- # - admin:sales-data # Full dataset access
29
- # - id: admin:orders # With row-level security
30
- # filter:
31
- # region: $user.custom.region
29
+ # Slot methods by target:
30
+ # dataset → context.<slot>.search(esQuery) / context.<slot>.fields()
31
+ # query → context.<slot>.execute(params)
32
+ # datasource → context.<slot>.query(payload)
33
+ # integration → context.<slot>.request({ method, path, body })
32
34
  #
33
- # queries:
34
- # - admin:monthly-summary
35
+ # defaultBinding must be a UUID (not a configId). Look up UUIDs via:
36
+ # GET /api/datasets-list → for target: dataset
37
+ # GET /api/queries-list → for target: query
38
+ # GET /api/datasources-list → for target: datasource
39
+ # GET /api/integrations-list → for target: integration
35
40
  #
36
- # integrations:
37
- # - salesforce
41
+ # Example:
38
42
  #
39
- # datasources:
40
- # - postgres-main
43
+ # dependencies:
44
+ # sales:
45
+ # target: dataset
46
+ # defaultBinding: 7d5a9b1e-0c83-4bde-9e2a-3a4b5c6d7e8f
47
+ # orders:
48
+ # target: dataset
49
+ # defaultBinding: 1f2e3d4c-5b6a-7980-1234-56789abcdef0
50
+ # options:
51
+ # filter:
52
+ # region: $user.custom.region # Row-level security
53
+ # monthly_summary:
54
+ # target: query
55
+ # defaultBinding: 9a8b7c6d-5e4f-3a2b-1c0d-fedcba987654
56
+ # salesforce:
57
+ # target: integration # No defaultBinding — installer picks
58
+ # options:
59
+ # headers:
60
+ # Authorization: Bearer $user.custom.sfToken
61
+
62
+ dependencies: {}
63
+
64
+ # ============================================================================
65
+ # ACCESS (raw API allowlist — for paths that don't fit the typed-slot model)
66
+ # ============================================================================
67
+ # Use access.apis for endpoints not covered by dependencies: slots,
68
+ # such as AI model routes or custom server endpoints.
41
69
  #
42
- # apis: # Raw API paths (advanced)
70
+ # access:
71
+ # apis:
72
+ # - POST /api/models/go_everyday/_object
73
+ # - POST /api/models/go_everyday/_chat
43
74
  # - POST /api/custom/endpoint
44
75
 
45
- access:
46
- datasets: []
47
- queries: []
48
- integrations: []
49
-
50
76
  # ============================================================================
51
77
  # ROLES (optional)
52
78
  # ============================================================================
@@ -111,7 +137,8 @@ async function init() {
111
137
  const pkgPath = resolve(cwd, 'package.json');
112
138
  if (!await exists(pkgPath)) {
113
139
  console.error('No package.json found. Run this in a Vite project directory.');
114
- console.error('Create one first with: npm create vite@latest');
140
+ console.error('Create one first with: npm create vite@latest . -- --template react');
141
+ console.error('(Use --template react-ts for TypeScript, or vanilla/vue/svelte/etc.)');
115
142
  process.exit(1);
116
143
  }
117
144
 
@@ -121,7 +148,8 @@ async function init() {
121
148
  const hasVite = pkg.devDependencies?.vite || pkg.dependencies?.vite;
122
149
  if (!hasVite) {
123
150
  console.error('Vite not found in dependencies.');
124
- console.error('Create a Vite project first: npm create vite@latest');
151
+ console.error('Create a Vite project first: npm create vite@latest . -- --template react');
152
+ console.error('(Use --template react-ts for TypeScript, or vanilla/vue/svelte/etc.)');
125
153
  process.exit(1);
126
154
  }
127
155
 
@@ -198,7 +226,7 @@ INFORMER_API_KEY=your-api-key
198
226
  const informerYamlPath = resolve(cwd, 'informer.yaml');
199
227
  if (!await exists(informerYamlPath)) {
200
228
  await writeFile(informerYamlPath, generateInformerYaml());
201
- console.log('Created informer.yaml (configure API access and roles)');
229
+ console.log('Created informer.yaml (declare your data dependencies and roles)');
202
230
  }
203
231
 
204
232
  // 10. Add .env to .gitignore if not present
@@ -207,7 +235,7 @@ INFORMER_API_KEY=your-api-key
207
235
  console.log('\nSetup complete!\n');
208
236
  console.log('Next steps:');
209
237
  console.log(' 1. Update .env with your Informer credentials');
210
- console.log(' 2. Update informer.yaml with the datasets/APIs your app needs');
238
+ console.log(' 2. Declare your data dependencies in informer.yaml (see comments inside)');
211
239
  console.log(' 3. Run: npm install');
212
240
  console.log(' 4. Run: npm run dev');
213
241
  console.log('');
package/bin/publish.js ADDED
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFile, access } from 'node:fs/promises';
4
+ import { resolve, join } from 'node:path';
5
+ import { collectAppFiles } from '../src/assemble.js';
6
+ import { extractNotes } from '../src/changelog.js';
7
+ import { publish } from '../src/publish.js';
8
+ import { loadEnv, parseModeArg } from '../src/env.js';
9
+
10
+ const mode = parseModeArg(process.argv);
11
+ loadEnv({ mode });
12
+
13
+ // --- Target + auth. You already have API keys sorted — just point these at the
14
+ // License Manager that fronts the marketplace cloud-api. ---
15
+ const marketplaceUrl = process.env.INFORMER_MARKETPLACE_URL;
16
+ const token = process.env.INFORMER_PUBLISH_TOKEN || process.env.INFORMER_API_KEY;
17
+
18
+ if (!marketplaceUrl || !token) {
19
+ console.error('Missing required environment variables.');
20
+ console.error(' INFORMER_MARKETPLACE_URL=https://<license-manager-host>/cloud-api');
21
+ console.error(' INFORMER_PUBLISH_TOKEN=lmpub_<vendor publish key>');
22
+ process.exit(1);
23
+ }
24
+
25
+ // --- App + listing metadata from package.json "informer" block ---
26
+ const projectRoot = resolve('.');
27
+ let pkg;
28
+ try {
29
+ pkg = JSON.parse(await readFile(join(projectRoot, 'package.json'), 'utf8'));
30
+ } catch {
31
+ console.error('Could not read package.json in current directory.');
32
+ process.exit(1);
33
+ }
34
+
35
+ const inf = pkg.informer || {};
36
+ const name = inf.name || pkg.name;
37
+ const slug = inf.slug || defaultSlug(name);
38
+
39
+ // Version: prefer the git tag (CI: GITHUB_REF_NAME=vX.Y.Z), then --version, then package.json.
40
+ const version = argValue('--version') || tagVersion(process.env.GITHUB_REF_NAME) || pkg.version;
41
+
42
+ if (!name || !slug || !version) {
43
+ console.error('Need a name, slug, and version. Set package.json "informer.name"/"informer.slug" and tag a release (vX.Y.Z) or pass --version.');
44
+ process.exit(1);
45
+ }
46
+
47
+ // Release notes: the section for this version, else the cumulative Unreleased block.
48
+ const changeNotes = await extractNotes(join(projectRoot, 'CHANGELOG.md'), version);
49
+
50
+ // Provenance (GitHub Actions). All optional — best-effort audit trail.
51
+ const sourceRepo = process.env.GITHUB_REPOSITORY || undefined;
52
+ const sourceCommit = process.env.GITHUB_SHA || undefined;
53
+ const ciRun = process.env.GITHUB_RUN_ID
54
+ ? `${process.env.GITHUB_SERVER_URL || 'https://github.com'}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
55
+ : undefined;
56
+
57
+ // Assemble the same file set a deploy produces.
58
+ let files;
59
+ try {
60
+ files = await collectAppFiles({ distDir: join(projectRoot, 'dist'), projectRoot });
61
+ } catch (err) {
62
+ console.error(err.message);
63
+ process.exit(1);
64
+ }
65
+
66
+ const icon = await resolveIcon(projectRoot, inf.icon);
67
+ const channel = version.includes('-') ? 'beta' : 'stable';
68
+
69
+ console.log(`Publishing ${name} v${version} (${channel}) — ${files.length} files${changeNotes ? '' : ' — no release notes found'}`);
70
+
71
+ try {
72
+ const result = await publish({
73
+ marketplaceUrl,
74
+ token,
75
+ files,
76
+ icon,
77
+ fields: {
78
+ name,
79
+ slug,
80
+ version,
81
+ shortDescription: inf.shortDescription,
82
+ description: inf.description || pkg.description,
83
+ categories: inf.categories,
84
+ documentationUrl: inf.documentationUrl,
85
+ requires: inf.requires,
86
+ metadata: inf.metadata,
87
+ changeNotes,
88
+ sourceRepo,
89
+ sourceCommit,
90
+ ciRun
91
+ }
92
+ });
93
+ console.log(`Published ${slug} v${version} (${result.channel || channel}).`);
94
+ } catch (err) {
95
+ console.error(err.message);
96
+ process.exit(1);
97
+ }
98
+
99
+ // --- helpers ---
100
+
101
+ function argValue(flag) {
102
+ const i = process.argv.indexOf(flag);
103
+ return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : undefined;
104
+ }
105
+
106
+ function tagVersion(ref) {
107
+ return ref ? ref.replace(/^v/, '') : undefined;
108
+ }
109
+
110
+ function defaultSlug(value) {
111
+ if (!value) return undefined;
112
+ const base = value.startsWith('@') && value.includes('/') ? value.split('/')[1] : value;
113
+ return base.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
114
+ }
115
+
116
+ async function resolveIcon(root, configured) {
117
+ const candidates = [configured, 'favicon.svg', join('public', 'favicon.svg'), join('dist', 'favicon.svg')].filter(Boolean);
118
+ for (const rel of candidates) {
119
+ const abs = resolve(root, rel);
120
+ try {
121
+ await access(abs);
122
+ return { abs, filename: rel.split(/[/\\]/).pop() };
123
+ } catch {
124
+ // try next candidate
125
+ }
126
+ }
127
+ return undefined;
128
+ }
package/bin/workspace.js CHANGED
@@ -1,22 +1,28 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import dotenv from 'dotenv';
4
3
  import { readFile } from 'node:fs/promises';
5
4
  import { resolve } from 'node:path';
6
5
  import { createClient } from '../src/client.js';
6
+ import { loadEnv, envWritePath, parseModeArg } from '../src/env.js';
7
7
  import { init, migrate, reset } from '../src/workspace.js';
8
8
 
9
- dotenv.config();
9
+ const mode = parseModeArg(process.argv);
10
+ loadEnv({ mode });
10
11
 
11
- const command = process.argv[2];
12
+ // Strip --mode <value> from argv before parsing the command
13
+ const args = process.argv.slice(2).filter((arg, i, arr) => arg !== '--mode' && arr[i - 1] !== '--mode');
14
+ const command = args[0];
12
15
 
13
16
  if (!command || !['init', 'migrate', 'reset'].includes(command)) {
14
- console.error('Usage: informer-workspace <init|migrate|reset>');
17
+ console.error('Usage: informer-workspace <init|migrate|reset> [--mode <name>]');
15
18
  console.error('');
16
19
  console.error('Commands:');
17
20
  console.error(' init Create a dev workspace datasource and run migrations');
18
21
  console.error(' migrate Run pending migrations against the dev workspace');
19
22
  console.error(' reset Drop all tables and re-run all migrations');
23
+ console.error('');
24
+ console.error('Options:');
25
+ console.error(' --mode <name> Load .env.<name> (e.g. --mode test, --mode production)');
20
26
  process.exit(1);
21
27
  }
22
28
 
@@ -33,7 +39,7 @@ if (!baseUrl || (!apiKey && (!user || !pass))) {
33
39
 
34
40
  const api = createClient({ baseUrl, apiKey, user, pass });
35
41
  const migrationsDir = resolve('migrations');
36
- const envPath = resolve('.env');
42
+ const envPath = envWritePath({ mode });
37
43
 
38
44
  try {
39
45
  if (command === 'init') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@entrinsik/vite-plugin-informer",
3
- "version": "2.3.0",
3
+ "version": "2.5.0-beta.0",
4
4
  "description": "Vite plugin and deploy tool for Informer App development",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,10 +24,12 @@
24
24
  "default": "./src/index.js"
25
25
  },
26
26
  "./deploy": "./src/deploy.js",
27
+ "./publish": "./src/publish.js",
27
28
  "./workspace": "./src/workspace.js"
28
29
  },
29
30
  "bin": {
30
31
  "informer-deploy": "./bin/deploy.js",
32
+ "informer-publish": "./bin/publish.js",
31
33
  "informer-init": "./bin/init.js",
32
34
  "informer-workspace": "./bin/workspace.js",
33
35
  "create-magic-report": "./bin/init.js"
package/src/agent-dev.js CHANGED
@@ -2,6 +2,7 @@ import { readFile, readdir, stat, access } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { parse as parseUrl } from 'node:url';
4
4
  import yaml from 'yaml';
5
+ import { loadDependencies, buildDevContext, loadAppEnv, buildDevCrypto, buildDevMessaging, normalizeFetchPath } from './dev-dependencies.js';
5
6
  const parseYaml = yaml.parse;
6
7
 
7
8
  const MAX_STEPS = 20;
@@ -170,7 +171,12 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
170
171
  // fetch() — proxies API calls to the Informer server (same as server-routes.js)
171
172
  async function apiFetch(path, opts = {}) {
172
173
  const method = (opts.method || 'GET').toUpperCase();
173
- const url = `${serverOrigin}/api/${path.replace(/^\/?(?:api\/)?/, '')}`;
174
+ // Same strict canonicalization as prod (app-sandbox.js normalizeFetchPath).
175
+ const apiPath = normalizeFetchPath(path);
176
+ if (!apiPath) {
177
+ return { status: 400, body: { error: `Invalid fetch path: ${String(path).slice(0, 80)}` } };
178
+ }
179
+ const url = `${serverOrigin}${apiPath}`;
174
180
  const fetchOpts = {
175
181
  method,
176
182
  headers: { Authorization: authHeader, 'Content-Type': 'application/json' }
@@ -192,6 +198,33 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
192
198
  return { ok: true };
193
199
  }
194
200
 
201
+ // notify/email — delivery is a console-logged no-op in dev, but required-field
202
+ // validation mirrors prod so a tool that passes here won't 500 in production.
203
+ const { notify, email } = buildDevMessaging('[agent-dev]');
204
+
205
+ // crypto helper — mirrors the prod sandbox crypto surface
206
+ const cryptoHelper = buildDevCrypto();
207
+
208
+ // log helper — mirrors sandbox log(message, data) + log.info/warn/error/debug
209
+ const logCall = (level, message, data) => {
210
+ const msg = typeof message === 'string' ? message : JSON.stringify(message);
211
+ const args = [`[app-log] [${level}] ${msg}`];
212
+ if (data) args.push(data);
213
+ console.log(...args);
214
+ };
215
+ const log = Object.assign(
216
+ (message, data) => logCall('info', message, data),
217
+ {
218
+ debug: (message, data) => logCall('debug', message, data),
219
+ info: (message, data) => logCall('info', message, data),
220
+ warn: (message, data) => logCall('warn', message, data),
221
+ error: (message, data) => logCall('error', message, data)
222
+ }
223
+ );
224
+
225
+ // markdown helper — passthrough in dev (production uses `marked`)
226
+ const markdown = (text) => text;
227
+
195
228
  return async function agentDevMiddleware(req, res, next) {
196
229
  try {
197
230
  const parsed = parseUrl(req.url, true);
@@ -249,6 +282,14 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
249
282
  const instructions = agentDef.instructions || '';
250
283
  const agentToolNames = agentDef.tools || [];
251
284
 
285
+ // Build the typed dependency context + env the same way the
286
+ // server-routes dev middleware does, so tools using
287
+ // `context.<slot>.<method>(...)` and `env` work locally and match
288
+ // the prod sandbox bag.
289
+ const deps = await loadDependencies(projectRoot);
290
+ const context = buildDevContext({ deps, apiFetch });
291
+ const env = await loadAppEnv(projectRoot);
292
+
252
293
  // Load tool handlers via ssrLoadModule
253
294
  const localTools = await scanLocalTools(projectRoot);
254
295
  const toolMap = new Map(localTools.map(t => [t.name, t]));
@@ -366,7 +407,7 @@ export function createAgentMiddleware(viteServer, { serverOrigin, authHeader, de
366
407
 
367
408
  if (tool) {
368
409
  try {
369
- result = await tool.handler(tc.input, { query, fetch: apiFetch, emit, context: triggerEvent });
410
+ result = await tool.handler({ args: tc.input, run: { agentName, trigger: triggerEvent }, context, query, fetch: apiFetch, emit, notify, email, crypto: cryptoHelper, markdown, log, env });
370
411
  } catch (err) {
371
412
  console.error(`[agent-dev] Tool "${tc.name}" failed:`, err.message);
372
413
  result = { error: err.message };
@@ -0,0 +1,87 @@
1
+ import { readdir, stat, access } from 'node:fs/promises';
2
+ import { join, relative, posix } from 'node:path';
3
+
4
+ /**
5
+ * The canonical set of files that make up a deployable Informer app library.
6
+ *
7
+ * This is the SINGLE SOURCE OF TRUTH for what an app "is" on disk, shared by
8
+ * both `informer-deploy` (uploads each file to a live instance) and
9
+ * `informer-publish` (tars the same set for the marketplace). Keeping the
10
+ * enumeration in one place is what guarantees a published artifact is
11
+ * byte-identical to what a normal deploy produces.
12
+ *
13
+ * Layout rule: the Vite build output (`dist/`) lands at the library ROOT
14
+ * (index.html, assets/…), while the source trees keep their directory prefix
15
+ * (server/…, tools/…) — matching how an app's library is structured in Informer.
16
+ */
17
+ const ROOT_CONFIG_FILES = ['informer.yaml', 'data-access.yaml'];
18
+ const SOURCE_DIRS = ['migrations', 'tools', 'server', 'webhooks'];
19
+
20
+ async function exists(path) {
21
+ try {
22
+ await access(path);
23
+ return true;
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ /** Recursively walk a directory, returning absolute file paths (not dirs). */
30
+ async function walkDir(dir) {
31
+ const results = [];
32
+ const items = await readdir(dir);
33
+ for (const item of items) {
34
+ const full = join(dir, item);
35
+ const s = await stat(full);
36
+ if (s.isDirectory()) {
37
+ results.push(...await walkDir(full));
38
+ } else {
39
+ results.push(full);
40
+ }
41
+ }
42
+ return results;
43
+ }
44
+
45
+ /** Normalise a path to forward slashes for use as a library/tar entry name. */
46
+ function toLibraryPath(p) {
47
+ return posix.normalize(p.split('\\').join('/'));
48
+ }
49
+
50
+ /**
51
+ * Collect every file that belongs in the app library, each as { abs, rel },
52
+ * where `rel` is the path it should occupy in the library (and in the tar).
53
+ *
54
+ * @param {{ distDir: string, projectRoot: string }} opts
55
+ * @returns {Promise<Array<{ abs: string, rel: string }>>}
56
+ */
57
+ export async function collectAppFiles({ distDir, projectRoot }) {
58
+ if (!await exists(distDir)) {
59
+ throw new Error(`Build output not found at "${distDir}" — run the build first.`);
60
+ }
61
+
62
+ const files = [];
63
+
64
+ // dist/** -> library root
65
+ for (const abs of await walkDir(distDir)) {
66
+ files.push({ abs, rel: toLibraryPath(relative(distDir, abs)) });
67
+ }
68
+
69
+ // root config files -> library root
70
+ for (const name of ROOT_CONFIG_FILES) {
71
+ const abs = join(projectRoot, name);
72
+ if (await exists(abs)) files.push({ abs, rel: name });
73
+ }
74
+
75
+ // source trees -> keep their prefix (server/…, migrations/…, tools/…, webhooks/…)
76
+ for (const dir of SOURCE_DIRS) {
77
+ const root = join(projectRoot, dir);
78
+ if (!await exists(root)) continue;
79
+ for (const abs of await walkDir(root)) {
80
+ files.push({ abs, rel: toLibraryPath(relative(projectRoot, abs)) });
81
+ }
82
+ }
83
+
84
+ return files;
85
+ }
86
+
87
+ export { ROOT_CONFIG_FILES, SOURCE_DIRS };
@@ -0,0 +1,39 @@
1
+ import { readFile } from 'node:fs/promises';
2
+
3
+ /**
4
+ * Extract release notes for a version from a Keep-a-Changelog style CHANGELOG.md.
5
+ *
6
+ * Looks for the section header whose name matches `version` (e.g. `## [1.4.0]`,
7
+ * `## 1.4.0`, `## v1.4.0`); if absent, falls back to `## [Unreleased]`. Returns
8
+ * the section body trimmed, or '' if nothing matches / the file is missing.
9
+ *
10
+ * This is what makes the dual notes flow work: betas ship the cumulative
11
+ * `Unreleased` block, while a stable release promotes `Unreleased` to a
12
+ * versioned heading before tagging so its exact section is matched.
13
+ *
14
+ * @param {string} changelogPath
15
+ * @param {string} version
16
+ * @returns {Promise<string>}
17
+ */
18
+ export async function extractNotes(changelogPath, version) {
19
+ let text;
20
+ try {
21
+ text = await readFile(changelogPath, 'utf8');
22
+ } catch {
23
+ return '';
24
+ }
25
+ return sectionBody(text, version) ?? sectionBody(text, 'Unreleased') ?? '';
26
+ }
27
+
28
+ /** Body of the first `## [name]` / `## name` section, up to the next `## `. */
29
+ function sectionBody(text, name) {
30
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
31
+ const header = new RegExp(`^##\\s+\\[?v?${escaped}\\]?.*$`, 'im');
32
+ const match = header.exec(text);
33
+ if (!match) return null;
34
+
35
+ const after = text.slice(match.index + match[0].length);
36
+ const next = after.search(/^##\s/m);
37
+ const body = (next === -1 ? after : after.slice(0, next)).trim();
38
+ return body || null;
39
+ }