@ibalzam/codejitsu-core 0.1.0 → 0.2.1

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.
@@ -3,121 +3,122 @@ import path from 'path';
3
3
  import matter from 'gray-matter';
4
4
 
5
5
  /**
6
- * Generates /llms.txt (concise) and /llms-full.txt (detailed) into `outDir`.
7
- *
8
- * @param {object} config
9
- * @param {string} config.siteUrl e.g. 'https://acme.com'
10
- * @param {string} config.siteName
11
- * @param {string} config.tagline Short one-line description.
12
- * @param {string} config.about Longer "About" paragraph (used in concise file).
13
- * @param {string} [config.aboutFull] Full "About" content (used in full file; falls back to `about`).
14
- * @param {Section[]} [config.sections]
15
- * @param {string} [config.aiGuidance] "For AI Assistants" block content.
16
- * @param {string} [config.blogDir] If set, auto-includes recent blog posts.
17
- * @param {number} [config.blogLimit=10] How many recent posts to include in concise file.
18
- * @param {number} [config.blogFullLimit=20] How many in full file.
19
- * @param {string} config.outDir Where to write the files (typically the site's `public/`).
20
- *
21
- * @typedef {object} Section
22
- * @property {string} title
23
- * @property {string} [description] Short intro for the full file.
24
- * @property {SectionItem[]} items
25
- *
26
- * @typedef {object} SectionItem
27
- * @property {string} title
28
- * @property {string} description
29
- * @property {string} url Relative or absolute.
30
- * @property {string} [fullDescription] Longer text for llms-full.txt.
6
+ * Top-level entry picks 'config' or 'content-scan' mode based on `llms.mode`.
31
7
  */
32
- export async function generateLlms(config) {
33
- const {
34
- siteUrl,
35
- siteName,
36
- tagline,
37
- about,
38
- aboutFull,
39
- sections = [],
40
- aiGuidance,
41
- blogDir,
42
- blogLimit = 10,
43
- blogFullLimit = 20,
44
- outDir,
45
- } = config;
46
-
47
- if (!outDir) throw new Error('generateLlms: outDir is required.');
48
- if (!siteUrl) throw new Error('generateLlms: siteUrl is required.');
8
+ export async function generateLlms({ config, cwd, outDir }) {
9
+ const llms = config.llms ?? {};
10
+ const mode = llms.mode ?? 'config';
11
+
49
12
  if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
50
13
 
51
- const today = new Date().toISOString().split('T')[0];
52
- const blogPosts = blogDir ? readBlog(blogDir) : [];
14
+ if (mode === 'content-scan') {
15
+ const out = await generateContentScan({ config, cwd });
16
+ fs.writeFileSync(path.join(outDir, 'llms.txt'), out.concise);
17
+ fs.writeFileSync(path.join(outDir, 'llms-full.txt'), out.full);
18
+ } else {
19
+ const out = generateFromConfig({ config });
20
+ fs.writeFileSync(path.join(outDir, 'llms.txt'), out.concise);
21
+ fs.writeFileSync(path.join(outDir, 'llms-full.txt'), out.full);
22
+ }
23
+ console.log(`✓ ${path.relative(cwd, path.join(outDir, 'llms.txt'))}`);
24
+ console.log(`✓ ${path.relative(cwd, path.join(outDir, 'llms-full.txt'))}`);
25
+ }
53
26
 
54
- const concise = renderConcise({
55
- siteUrl,
56
- siteName,
57
- tagline,
58
- about,
59
- sections,
60
- aiGuidance,
61
- today,
62
- blogPosts: blogPosts.slice(0, blogLimit),
63
- });
27
+ // ─── Common helpers ─────────────────────────────────────────────────────────
64
28
 
65
- const full = renderFull({
66
- siteUrl,
67
- siteName,
68
- tagline,
69
- about: aboutFull ?? about,
70
- sections,
71
- aiGuidance,
72
- today,
73
- blogPosts: blogPosts.slice(0, blogFullLimit),
74
- });
29
+ function getTodayUTC() {
30
+ const now = new Date();
31
+ return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
32
+ }
75
33
 
76
- fs.writeFileSync(path.join(outDir, 'llms.txt'), concise);
77
- fs.writeFileSync(path.join(outDir, 'llms-full.txt'), full);
78
- console.log(`✓ wrote ${path.relative(process.cwd(), path.join(outDir, 'llms.txt'))}`);
79
- console.log(`✓ wrote ${path.relative(process.cwd(), path.join(outDir, 'llms-full.txt'))}`);
34
+ function isoDate() {
35
+ return new Date().toISOString().split('T')[0];
80
36
  }
81
37
 
82
- function readBlog(blogDir) {
83
- const abs = path.resolve(process.cwd(), blogDir);
84
- if (!fs.existsSync(abs)) return [];
85
- const now = new Date();
86
- const today = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
38
+ function absoluteUrl(siteUrl, url) {
39
+ if (/^https?:\/\//.test(url)) return url;
40
+ const base = siteUrl.replace(/\/$/, '');
41
+ return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
42
+ }
87
43
 
44
+ function readBlogPosts(blogDir, dateField = 'date', draftField = null) {
45
+ if (!blogDir || !fs.existsSync(blogDir)) return [];
46
+ const today = getTodayUTC();
88
47
  return fs
89
- .readdirSync(abs)
48
+ .readdirSync(blogDir)
90
49
  .filter((n) => n.endsWith('.md'))
91
50
  .map((fileName) => {
92
- const raw = fs.readFileSync(path.join(abs, fileName), 'utf8');
93
- const parsed = matter(raw);
94
- const data = parsed.data;
95
- return {
96
- slug: data.slug || fileName.replace(/\.md$/, ''),
97
- title: data.title,
98
- description: data.description,
99
- date: data.date,
100
- author: data.author,
101
- tags: data.tags,
102
- };
51
+ const raw = fs.readFileSync(path.join(blogDir, fileName), 'utf8');
52
+ const { data } = matter(raw);
53
+ const slug = data.slug || fileName.replace(/\.md$/, '');
54
+ const dateVal = data[dateField];
55
+ const date = dateVal instanceof Date
56
+ ? dateVal.toISOString().split('T')[0]
57
+ : (typeof dateVal === 'string' ? dateVal : '');
58
+ return { ...data, slug, date };
59
+ })
60
+ .filter((p) => {
61
+ if (draftField && p[draftField]) return false;
62
+ if (!p.date) return true;
63
+ return new Date(p.date) <= today;
103
64
  })
104
- .filter((p) => p.date && new Date(p.date) <= today)
105
65
  .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
106
66
  }
107
67
 
108
- function absoluteUrl(siteUrl, url) {
109
- if (/^https?:\/\//.test(url)) return url;
110
- return `${siteUrl.replace(/\/$/, '')}${url.startsWith('/') ? '' : '/'}${url}`;
68
+ // ─── 'config' mode ──────────────────────────────────────────────────────────
69
+
70
+ function generateFromConfig({ config }) {
71
+ const site = config.site;
72
+ const llms = config.llms ?? {};
73
+ const blogPosts = llms.blogDir
74
+ ? readBlogPosts(path.resolve(process.cwd(), llms.blogDir))
75
+ : [];
76
+
77
+ const concise = renderConcise({
78
+ siteUrl: site.url,
79
+ siteName: site.name,
80
+ tagline: llms.tagline,
81
+ about: llms.about,
82
+ business: site.business,
83
+ sections: llms.sections ?? [],
84
+ aiGuidance: llms.aiGuidance,
85
+ blogPosts: blogPosts.slice(0, llms.blogLimit ?? 10),
86
+ today: isoDate(),
87
+ });
88
+
89
+ const full = renderFull({
90
+ siteUrl: site.url,
91
+ siteName: site.name,
92
+ tagline: llms.tagline,
93
+ about: llms.aboutFull ?? llms.about,
94
+ business: site.business,
95
+ sections: llms.sections ?? [],
96
+ aiGuidance: llms.aiGuidance,
97
+ blogPosts: blogPosts.slice(0, llms.blogFullLimit ?? 20),
98
+ today: isoDate(),
99
+ });
100
+
101
+ return { concise, full };
111
102
  }
112
103
 
113
- function renderConcise({ siteUrl, siteName, tagline, about, sections, aiGuidance, today, blogPosts }) {
104
+ function renderConcise({ siteUrl, siteName, tagline, about, business, sections, aiGuidance, blogPosts, today }) {
114
105
  let out = `# ${siteName}${tagline ? ` — ${tagline}` : ''}\n`;
115
106
  out += `Last Updated: ${today}\n\n`;
116
- out += `> ${about}\n\n`;
107
+ if (about) out += `> ${about}\n\n`;
108
+
109
+ if (business) {
110
+ out += `## Contact\n\n`;
111
+ if (business.telephone) out += `- Phone: ${business.telephone}\n`;
112
+ if (business.email) out += `- Email: ${business.email}\n`;
113
+ if (business.address) out += `- Address: ${formatAddress(business.address)}\n`;
114
+ out += `- Website: ${siteUrl}\n`;
115
+ if (business.license) out += `- License: ${business.license}\n`;
116
+ out += '\n';
117
+ }
117
118
 
118
119
  for (const section of sections) {
119
120
  if (!section.items?.length) continue;
120
- out += `## ${section.title}\n`;
121
+ out += `## ${section.title}\n\n`;
121
122
  for (const item of section.items) {
122
123
  out += `- [${item.title}](${absoluteUrl(siteUrl, item.url)}): ${item.description}\n`;
123
124
  }
@@ -125,26 +126,33 @@ function renderConcise({ siteUrl, siteName, tagline, about, sections, aiGuidance
125
126
  }
126
127
 
127
128
  if (blogPosts.length) {
128
- out += `## Recent Blog Posts\n`;
129
+ out += `## Recent Blog Posts\n\n`;
129
130
  for (const post of blogPosts) {
130
131
  out += `- [${post.title}](${siteUrl}/blog/${post.slug}/): ${post.description}\n`;
131
132
  }
132
133
  out += '\n';
133
134
  }
134
135
 
135
- if (aiGuidance) {
136
- out += `## For AI Assistants\n\n${aiGuidance}\n\n`;
137
- }
138
-
136
+ if (aiGuidance) out += `## For AI Assistants\n\n${aiGuidance}\n\n`;
139
137
  out += `---\nGenerated automatically during build\n`;
140
138
  return out;
141
139
  }
142
140
 
143
- function renderFull({ siteUrl, siteName, tagline, about, sections, aiGuidance, today, blogPosts }) {
141
+ function renderFull({ siteUrl, siteName, tagline, about, business, sections, aiGuidance, blogPosts, today }) {
144
142
  let out = `# ${siteName} — Complete Documentation\n`;
145
143
  out += `Last Updated: ${today}\n\n`;
146
- out += `> This file contains the complete content of ${siteName}'s website for AI/LLM ingestion. For a concise navigation overview, see /llms.txt\n\n`;
147
- out += `---\n\n# About\n\n${about}\n\n---\n\n`;
144
+ out += `> For a concise navigation overview, see /llms.txt\n\n---\n\n`;
145
+ if (about) out += `# About\n\n${about}\n\n---\n\n`;
146
+
147
+ if (business) {
148
+ out += `# Contact Information\n\n`;
149
+ if (business.telephone) out += `- **Phone:** ${business.telephone}\n`;
150
+ if (business.email) out += `- **Email:** ${business.email}\n`;
151
+ if (business.address) out += `- **Address:** ${formatAddress(business.address)}\n`;
152
+ out += `- **Website:** ${siteUrl}\n`;
153
+ if (business.license) out += `- **License:** ${business.license}\n`;
154
+ out += '\n---\n\n';
155
+ }
148
156
 
149
157
  for (const section of sections) {
150
158
  if (!section.items?.length) continue;
@@ -153,8 +161,7 @@ function renderFull({ siteUrl, siteName, tagline, about, sections, aiGuidance, t
153
161
  for (const item of section.items) {
154
162
  out += `## ${item.title}\n\n`;
155
163
  out += `**URL**: ${absoluteUrl(siteUrl, item.url)}\n\n`;
156
- out += `${item.fullDescription ?? item.description}\n\n`;
157
- out += `---\n\n`;
164
+ out += `${item.fullDescription ?? item.description}\n\n---\n\n`;
158
165
  }
159
166
  }
160
167
 
@@ -162,18 +169,277 @@ function renderFull({ siteUrl, siteName, tagline, about, sections, aiGuidance, t
162
169
  out += `# Blog Posts\n\n`;
163
170
  for (const post of blogPosts) {
164
171
  out += `## ${post.title}\n\n`;
165
- out += `**Published**: ${post.date}\n`;
172
+ if (post.date) out += `**Published**: ${post.date}\n`;
166
173
  if (post.author) out += `**Author**: ${post.author}\n`;
167
174
  if (post.tags?.length) out += `**Tags**: ${post.tags.join(', ')}\n`;
168
- out += `**URL**: ${siteUrl}/blog/${post.slug}/\n\n`;
169
- out += `${post.description}\n\n---\n\n`;
175
+ out += `**URL**: ${siteUrl}/blog/${post.slug}/\n\n${post.description}\n\n---\n\n`;
170
176
  }
171
177
  }
172
178
 
173
- if (aiGuidance) {
174
- out += `# For AI Assistants\n\n${aiGuidance}\n\n`;
179
+ if (aiGuidance) out += `# For AI Assistants\n\n${aiGuidance}\n\n`;
180
+ out += `---\nGenerated automatically during build\n`;
181
+ return out;
182
+ }
183
+
184
+ function formatAddress(addr) {
185
+ const parts = [
186
+ addr.streetAddress,
187
+ [addr.addressLocality, addr.addressRegion].filter(Boolean).join(', '),
188
+ addr.postalCode,
189
+ addr.addressCountry,
190
+ ].filter(Boolean);
191
+ return parts.join(', ');
192
+ }
193
+
194
+ // ─── 'content-scan' mode ────────────────────────────────────────────────────
195
+
196
+ async function generateContentScan({ config, cwd }) {
197
+ const site = config.site;
198
+ const llms = config.llms;
199
+ const scan = llms.contentScan ?? {};
200
+
201
+ const servicesDir = scan.servicesDir ? path.resolve(cwd, scan.servicesDir) : null;
202
+ const locationsDir = scan.locationsDir ? path.resolve(cwd, scan.locationsDir) : null;
203
+ const pagesDir = scan.pagesDir ? path.resolve(cwd, scan.pagesDir) : null;
204
+ const blogDir = llms.blogDir ? path.resolve(cwd, llms.blogDir) : null;
205
+
206
+ const services = readContentDir(servicesDir);
207
+ const locations = readContentDir(locationsDir);
208
+ const blogPosts = readBlogPosts(blogDir, 'pubDate', 'draft').concat(
209
+ // Also try 'date' field for fallback
210
+ blogDir && readBlogPosts(blogDir, 'date', 'draft').filter((p) => !p.pubDate) || []
211
+ );
212
+ const pages = pagesDir ? collectStaticPages(pagesDir) : [];
213
+
214
+ const dynamicRoutes = scan.dynamicRoutes ?? [];
215
+ const expandedUrls = expandRoutes(dynamicRoutes, {
216
+ services: services.map((s) => s.slug),
217
+ locations: locations.map((l) => l.slug),
218
+ });
219
+
220
+ const concise = renderContentScanConcise({
221
+ siteUrl: site.url,
222
+ siteName: site.name,
223
+ tagline: llms.tagline,
224
+ about: llms.about,
225
+ business: site.business,
226
+ services,
227
+ locations,
228
+ pages: pages.concat(expandedUrls),
229
+ blogPosts: blogPosts.slice(0, llms.blogLimit ?? 10),
230
+ aiGuidance: llms.aiGuidance,
231
+ today: isoDate(),
232
+ });
233
+
234
+ const full = renderContentScanFull({
235
+ siteUrl: site.url,
236
+ siteName: site.name,
237
+ tagline: llms.tagline,
238
+ about: llms.aboutFull ?? llms.about,
239
+ business: site.business,
240
+ services,
241
+ locations,
242
+ aiGuidance: llms.aiGuidance,
243
+ today: isoDate(),
244
+ });
245
+
246
+ return { concise, full };
247
+ }
248
+
249
+ function readContentDir(dir) {
250
+ if (!dir || !fs.existsSync(dir)) return [];
251
+ const entries = walkMd(dir);
252
+ return entries.map((file) => {
253
+ const raw = fs.readFileSync(file, 'utf8');
254
+ const { data, content } = matter(raw);
255
+ const slug = path.basename(file, '.md');
256
+ return { file, slug, data, body: content };
257
+ });
258
+ }
259
+
260
+ function walkMd(dir) {
261
+ const out = [];
262
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
263
+ const full = path.join(dir, entry.name);
264
+ if (entry.isDirectory()) out.push(...walkMd(full));
265
+ else if (entry.isFile() && entry.name.endsWith('.md')) out.push(full);
266
+ }
267
+ return out;
268
+ }
269
+
270
+ function collectStaticPages(dir) {
271
+ const out = [];
272
+ function walk(d, prefix = '') {
273
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
274
+ const full = path.join(d, entry.name);
275
+ if (entry.isDirectory()) {
276
+ walk(full, `${prefix}/${entry.name}`);
277
+ continue;
278
+ }
279
+ if (!entry.isFile() || !entry.name.endsWith('.astro')) continue;
280
+ if (entry.name.includes('[')) continue;
281
+ const stem = entry.name.replace(/\.astro$/, '');
282
+ if (stem === '404') continue;
283
+ const route = stem === 'index' ? prefix || '/' : `${prefix}/${stem}`;
284
+ out.push(route.endsWith('/') ? route : `${route}/`);
285
+ }
175
286
  }
287
+ walk(dir);
288
+ return out;
289
+ }
176
290
 
177
- out += `---\nGenerated automatically during build\n`;
291
+ function expandRoutes(routes, slugSets) {
292
+ const out = [];
293
+ for (const route of routes) {
294
+ const template = typeof route === 'string' ? route : route.template;
295
+ const placeholders = (template.match(/\{(\w+)\}/g) ?? []).map((p) => p.slice(1, -1));
296
+ if (placeholders.length === 0) {
297
+ out.push(template);
298
+ continue;
299
+ }
300
+ const arrays = placeholders.map((name) => slugSets[name] ?? []);
301
+ if (arrays.some((a) => a.length === 0)) continue;
302
+ for (const combo of cartesian(arrays)) {
303
+ let url = template;
304
+ placeholders.forEach((name, i) => {
305
+ url = url.replace(`{${name}}`, combo[i]);
306
+ });
307
+ out.push(url);
308
+ }
309
+ }
178
310
  return out;
179
311
  }
312
+
313
+ function cartesian(arrays) {
314
+ return arrays.reduce(
315
+ (acc, curr) => acc.flatMap((a) => curr.map((c) => [...a, c])),
316
+ [[]]
317
+ );
318
+ }
319
+
320
+ function renderContentScanConcise({ siteUrl, siteName, tagline, about, business, services, locations, pages, blogPosts, aiGuidance, today }) {
321
+ const lines = [];
322
+ lines.push(`# ${siteName}${tagline ? ` — ${tagline}` : ''}`);
323
+ lines.push(`Last Updated: ${today}`, '');
324
+ if (about) lines.push(`> ${about}`, '');
325
+
326
+ if (business) {
327
+ lines.push('## Contact', '');
328
+ if (business.telephone) lines.push(`- Phone: ${business.telephone}`);
329
+ if (business.email) lines.push(`- Email: ${business.email}`);
330
+ if (business.address) lines.push(`- Address: ${formatAddress(business.address)}`);
331
+ lines.push(`- Website: ${siteUrl}`);
332
+ if (business.license) lines.push(`- License: ${business.license}`);
333
+ lines.push('');
334
+ }
335
+
336
+ if (services.length) {
337
+ lines.push('## Services', '');
338
+ for (const s of services) {
339
+ lines.push(`- ${s.data.title ?? s.slug}`);
340
+ }
341
+ lines.push('');
342
+ }
343
+
344
+ if (locations.length) {
345
+ lines.push('## Service Areas', '');
346
+ for (const l of locations) {
347
+ lines.push(`- ${l.data.name ?? l.data.title ?? l.slug}`);
348
+ }
349
+ lines.push('');
350
+ } else if (business?.areaServed?.length) {
351
+ lines.push('## Service Areas', '');
352
+ for (const area of business.areaServed) lines.push(`- ${area}`);
353
+ lines.push('');
354
+ }
355
+
356
+ if (blogPosts.length) {
357
+ lines.push('## Recent Blog Posts', '');
358
+ for (const post of blogPosts) {
359
+ lines.push(`- [${post.title}](${siteUrl}/blog/${post.slug}/): ${post.description ?? ''}`);
360
+ }
361
+ lines.push('');
362
+ }
363
+
364
+ if (pages.length) {
365
+ lines.push('## Site Pages', '');
366
+ for (const p of pages.sort()) lines.push(`- ${siteUrl}${p}`);
367
+ lines.push('');
368
+ }
369
+
370
+ if (aiGuidance) lines.push('## For AI Assistants', '', aiGuidance, '');
371
+ lines.push('---', 'Generated automatically during build');
372
+ return lines.join('\n') + '\n';
373
+ }
374
+
375
+ function renderContentScanFull({ siteUrl, siteName, tagline, about, business, services, locations, aiGuidance, today }) {
376
+ const lines = [];
377
+ lines.push(`# ${siteName} — Full Reference`);
378
+ lines.push(`Last Updated: ${today}`, '');
379
+ if (tagline) lines.push(`> ${tagline}`, '');
380
+ if (about) lines.push(about, '');
381
+ lines.push('---', '');
382
+
383
+ if (business) {
384
+ lines.push('## Contact Information', '');
385
+ if (business.telephone) lines.push(`- **Phone:** ${business.telephone}`);
386
+ if (business.email) lines.push(`- **Email:** ${business.email}`);
387
+ if (business.address) lines.push(`- **Address:** ${formatAddress(business.address)}`);
388
+ lines.push(`- **Website:** ${siteUrl}`);
389
+ if (business.license) lines.push(`- **License:** ${business.license}`);
390
+ lines.push('', '---', '');
391
+ }
392
+
393
+ if (services.length) {
394
+ lines.push('## Services', '');
395
+ for (const svc of services) {
396
+ const fm = svc.data;
397
+ lines.push(`### ${fm.title ?? svc.slug}`, '');
398
+ if (fm.shortDescription) lines.push(fm.shortDescription, '');
399
+ else if (fm.description) lines.push(fm.description, '');
400
+ if (Array.isArray(fm.benefits) && fm.benefits.length) {
401
+ lines.push('**Key Benefits:**', '');
402
+ for (const b of fm.benefits) lines.push(`- ${b}`);
403
+ lines.push('');
404
+ }
405
+ const faqs = extractFaqsFromBody(svc.body);
406
+ if (faqs.length) {
407
+ lines.push('**Frequently Asked Questions:**', '');
408
+ for (const faq of faqs) {
409
+ lines.push(`**Q: ${faq.q}**`, `A: ${faq.a}`, '');
410
+ }
411
+ }
412
+ lines.push('---', '');
413
+ }
414
+ }
415
+
416
+ if (locations.length) {
417
+ lines.push('## Service Areas', '');
418
+ for (const loc of locations) {
419
+ const fm = loc.data;
420
+ const name = fm.name ?? fm.title ?? loc.slug;
421
+ lines.push(`### ${name}`, '');
422
+ if (fm.description) {
423
+ const desc = typeof fm.description === 'string' ? fm.description : fm.description.join(' ');
424
+ lines.push(desc, '');
425
+ }
426
+ }
427
+ lines.push('---', '');
428
+ } else if (business?.areaServed?.length) {
429
+ lines.push('## Service Areas', '');
430
+ for (const area of business.areaServed) lines.push(`- ${area}`);
431
+ lines.push('', '---', '');
432
+ }
433
+
434
+ lines.push(`## Optional`, '', `- Sitemap: ${siteUrl}/sitemap-index.xml`, '');
435
+ if (aiGuidance) lines.push('## For AI Assistants', '', aiGuidance, '');
436
+ return lines.join('\n') + '\n';
437
+ }
438
+
439
+ function extractFaqsFromBody(body) {
440
+ const faqs = [];
441
+ const re = /- question: ["'](.+?)["']\s*\n\s*answer: ["'](.+?)["']/gs;
442
+ let m;
443
+ while ((m = re.exec(body)) !== null) faqs.push({ q: m[1], a: m[2] });
444
+ return faqs;
445
+ }