@stackql/docusaurus-plugin-aeo 0.4.1 → 0.5.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,100 @@
1
+ // Feature 1b: advertise each page's .md companion from the page itself.
2
+ //
3
+ // After the companions are written, every route that got one has
4
+ //
5
+ // <link rel="alternate" type="text/markdown" href="https://site/foo.md">
6
+ //
7
+ // inserted into its built HTML before </head>, so an agent that lands on
8
+ // the page can find the markdown without guessing the URL. Optionally the
9
+ // same relation is written as an HTTP header for Netlify-style hosts via a
10
+ // `_headers` file (`Link: <url>; rel="alternate"; type="text/markdown"`),
11
+ // for agents that read headers before bodies.
12
+ //
13
+ // Same approach as @stackql/docusaurus-plugin-structured-data, which also
14
+ // post-processes the built HTML in postBuild: the file is a string, the
15
+ // insertion point is the first </head>, no DOM library.
16
+
17
+ const path = require('path');
18
+ const fs = require('fs').promises;
19
+ const { htmlPath, companionUrl } = require('../companionPath');
20
+
21
+ const MARK = 'rel="alternate" type="text/markdown"';
22
+
23
+ function linkTag(href) {
24
+ return `<link ${MARK} href="${href}">`;
25
+ }
26
+
27
+ function headerBlock(route, href) {
28
+ return `${route}\n Link: <${href}>; rel="alternate"; type="text/markdown"\n`;
29
+ }
30
+
31
+ async function readIfExists(file) {
32
+ try {
33
+ return await fs.readFile(file, 'utf8');
34
+ } catch (e) {
35
+ if (e.code === 'ENOENT') return null;
36
+ throw e;
37
+ }
38
+ }
39
+
40
+ module.exports = async function injectAlternateLinks({
41
+ props,
42
+ emittedCompanions,
43
+ linkHeader,
44
+ verbose,
45
+ }) {
46
+ const { outDir, siteConfig } = props;
47
+ const trailingSlash = siteConfig.trailingSlash;
48
+ const siteUrl = siteConfig.url;
49
+
50
+ let injected = 0;
51
+ let skipped = 0;
52
+ const headerBlocks = [];
53
+
54
+ for (const item of emittedCompanions) {
55
+ const href = companionUrl(siteUrl, item.permalink, trailingSlash);
56
+ const file = path.join(outDir, htmlPath(item.permalink, trailingSlash));
57
+ const html = await readIfExists(file);
58
+ if (html === null) {
59
+ skipped++;
60
+ if (verbose) {
61
+ console.log(`[plugin-aeo] alternate link: no HTML at ${path.relative(outDir, file)} for ${item.permalink}`);
62
+ }
63
+ continue;
64
+ }
65
+ if (!html.includes(MARK)) {
66
+ const at = html.indexOf('</head>');
67
+ if (at === -1) {
68
+ skipped++;
69
+ if (verbose) {
70
+ console.log(`[plugin-aeo] alternate link: no </head> in ${path.relative(outDir, file)}`);
71
+ }
72
+ continue;
73
+ }
74
+ await fs.writeFile(file, `${html.slice(0, at)}${linkTag(href)}${html.slice(at)}`, 'utf8');
75
+ injected++;
76
+ }
77
+ if (linkHeader) {
78
+ // Netlify matches the route as requested; with trailingSlash false
79
+ // that is the bare route, otherwise the route with its slash.
80
+ const route = htmlPath(item.permalink, trailingSlash).replace(/index\.html$/, '').replace(/\.html$/, '');
81
+ headerBlocks.push(headerBlock(route === '' ? '/' : route, href));
82
+ }
83
+ }
84
+
85
+ if (linkHeader && headerBlocks.length > 0) {
86
+ // Append to a `_headers` the site may already ship from static/.
87
+ const file = path.join(outDir, '_headers');
88
+ const existing = (await readIfExists(file)) || '';
89
+ const banner = '# @stackql/docusaurus-plugin-aeo: .md companions as Link headers\n';
90
+ const sep = existing && !existing.endsWith('\n') ? '\n\n' : existing ? '\n' : '';
91
+ await fs.writeFile(file, `${existing}${sep}${banner}${headerBlocks.join('')}`, 'utf8');
92
+ }
93
+
94
+ if (verbose) {
95
+ console.log(
96
+ `[plugin-aeo] alternate link: injected into ${injected} page(s)${skipped ? `, skipped ${skipped}` : ''}${linkHeader ? `, ${headerBlocks.length} _headers rule(s)` : ''}`,
97
+ );
98
+ }
99
+ return { injected, skipped, headerRules: headerBlocks.length };
100
+ };
@@ -1,6 +1,8 @@
1
1
  const path = require('path');
2
2
  const fs = require('fs').promises;
3
3
  const matter = require('gray-matter');
4
+ const { companionPath, pageUrl } = require('../companionPath');
5
+ const { toPlainMarkdown, resolveFormat } = require('./plainMarkdown');
4
6
 
5
7
  // Minimal glob matcher for route paths. Patterns like "/blog/tags/**",
6
8
  // "/search". Avoids pulling micromatch directly.
@@ -38,43 +40,6 @@ function toRegExp(glob) {
38
40
  return new RegExp('^' + re + '$');
39
41
  }
40
42
 
41
- function stripMdx(source) {
42
- let out = source;
43
- // Strip import / export lines (top-of-file MDX).
44
- out = out.replace(/^\s*import\s+[^\n]+\n/gm, '');
45
- out = out.replace(/^\s*export\s+[^\n]+\n/gm, '');
46
- // Strip self-closing JSX tags like <Component prop="x" />.
47
- out = out.replace(/<([A-Z][A-Za-z0-9]*)\b[^>]*\/>\s*/g, '');
48
- // Strip paired JSX blocks like <Foo>...</Foo> (non-greedy, single component).
49
- out = out.replace(
50
- /<([A-Z][A-Za-z0-9]*)\b[^>]*>[\s\S]*?<\/\1>\s*/g,
51
- '',
52
- );
53
- // Strip stray opening or closing component tags left behind.
54
- out = out.replace(/<\/?[A-Z][A-Za-z0-9]*\b[^>]*>/g, '');
55
- // Collapse 3+ blank lines.
56
- out = out.replace(/\n{3,}/g, '\n\n');
57
- return out;
58
- }
59
-
60
- function routeToCompanionPath(routePath, trailingSlash, outDir) {
61
- // Docusaurus emits either /foo/index.html (trailingSlash: true / undefined)
62
- // or /foo.html (trailingSlash: false). The companion mirrors the HTML.
63
- let rel;
64
- if (routePath === '/' || routePath === '') {
65
- rel = 'index.md';
66
- } else {
67
- const cleaned = routePath.replace(/^\/+|\/+$/g, '');
68
- if (trailingSlash === false) {
69
- rel = `${cleaned}.md`;
70
- } else {
71
- // true or undefined (Docusaurus default behavior: index.html in folder)
72
- rel = `${cleaned}/index.md`;
73
- }
74
- }
75
- return path.join(outDir, rel);
76
- }
77
-
78
43
  function collectFromDocs(content, pluginName, pluginId) {
79
44
  // Docs plugin loaded content shape:
80
45
  // { loadedVersions: [{ docs: [{ id, permalink, source, title, description, frontMatter }] }] }
@@ -122,6 +87,26 @@ function collectFromBlog(content, pluginName, pluginId) {
122
87
  return results;
123
88
  }
124
89
 
90
+ // Every page that gets a companion: docs and blog pages with a source file,
91
+ // minus `companions.exclude`. Generated category indexes, React pages and
92
+ // blog list/tag/author pages have no source and are not in this list. Used
93
+ // by postBuild to emit the files and by allContentLoaded to tell the Ask AI
94
+ // button which routes have one.
95
+ function collectCompanionItems(loadedContentByPlugin, options) {
96
+ const items = [];
97
+ for (const [, entry] of loadedContentByPlugin) {
98
+ const { pluginName, pluginId, content } = entry;
99
+ if (pluginName === 'docusaurus-plugin-content-docs') {
100
+ items.push(...collectFromDocs(content, pluginName, pluginId));
101
+ } else if (pluginName === 'docusaurus-plugin-content-blog') {
102
+ items.push(...collectFromBlog(content, pluginName, pluginId));
103
+ }
104
+ // Custom pages plugin emits React components, not markdown. Skip silently.
105
+ }
106
+ const exclude = (options && options.exclude) || [];
107
+ return items.filter((item) => !matchAny(item.permalink, exclude));
108
+ }
109
+
125
110
  function resolveSourcePath(sourceRef, siteDir) {
126
111
  if (!sourceRef) return null;
127
112
  // Docusaurus content references look like "@site/docs/intro.md".
@@ -136,6 +121,34 @@ async function ensureDir(p) {
136
121
  await fs.mkdir(path.dirname(p), { recursive: true });
137
122
  }
138
123
 
124
+ // Put the page's title, description and canonical URL at the top of a
125
+ // plain companion so an LLM sees them without parsing YAML, and can cite
126
+ // the page:
127
+ //
128
+ // # Title
129
+ //
130
+ // > description
131
+ //
132
+ // Source: https://site/route
133
+ //
134
+ // A body that already opens with an H1 keeps it; the rest goes under it.
135
+ function withTitleBlock(markdown, title, description, sourceUrl) {
136
+ const lines = markdown.split('\n');
137
+ const first = lines.findIndex((l) => l.trim() !== '');
138
+ const hasH1 = first >= 0 && /^#\s+\S/.test(lines[first]);
139
+ const extras = [];
140
+ if (description) extras.push(`> ${String(description).replace(/\s+/g, ' ').trim()}`);
141
+ if (sourceUrl) extras.push(`Source: ${sourceUrl}`);
142
+ if (hasH1) {
143
+ if (extras.length === 0) return markdown;
144
+ return [...lines.slice(0, first + 1), '', extras.join('\n\n'), ...lines.slice(first + 1)].join('\n');
145
+ }
146
+ const head = [];
147
+ if (title) head.push(`# ${title}`);
148
+ head.push(...extras);
149
+ return head.length ? `${head.join('\n\n')}\n\n${markdown}` : markdown;
150
+ }
151
+
139
152
  module.exports = async function emitCompanions({
140
153
  props,
141
154
  options,
@@ -144,30 +157,16 @@ module.exports = async function emitCompanions({
144
157
  }) {
145
158
  const { outDir, siteConfig, siteDir } = props;
146
159
  const trailingSlash = siteConfig.trailingSlash;
147
- const excludePatterns = options.exclude || [];
148
- const format = options.format;
160
+ // 'clean' is accepted as a synonym for 'plain' (the name the external
161
+ // audit of 0.4.2 used for this behaviour).
162
+ const format = options.format === 'clean' ? 'plain' : options.format;
163
+ const markdownConfigFormat = siteConfig.markdown && siteConfig.markdown.format;
149
164
 
150
- // Build the work list from captured loadedContent.
151
- const items = [];
152
- for (const [, entry] of loadedContentByPlugin) {
153
- const { pluginName, pluginId, content } = entry;
154
- if (pluginName === 'docusaurus-plugin-content-docs') {
155
- items.push(...collectFromDocs(content, pluginName, pluginId));
156
- } else if (pluginName === 'docusaurus-plugin-content-blog') {
157
- items.push(...collectFromBlog(content, pluginName, pluginId));
158
- }
159
- // Custom pages plugin emits React components, not markdown. Skip silently.
160
- }
165
+ const items = collectCompanionItems(loadedContentByPlugin, options);
161
166
 
162
167
  const emitted = [];
168
+ let fallbacks = 0;
163
169
  for (const item of items) {
164
- if (matchAny(item.permalink, excludePatterns)) {
165
- if (verbose) {
166
- console.log(`[plugin-aeo] companions: excluded ${item.permalink}`);
167
- }
168
- continue;
169
- }
170
-
171
170
  const sourcePath = resolveSourcePath(item.sourceRef, siteDir);
172
171
  if (!sourcePath) {
173
172
  if (verbose) {
@@ -192,20 +191,31 @@ module.exports = async function emitCompanions({
192
191
 
193
192
  let body = raw;
194
193
  if (format === 'plain') {
195
- // Strip frontmatter, then strip MDX-specific bits.
196
194
  const parsed = matter(raw);
197
- body = stripMdx(parsed.content);
198
- // Re-prepend a minimal "title + description" block so an LLM can see them
199
- // without parsing YAML.
200
- const prefixParts = [];
201
- if (item.title) prefixParts.push(`# ${item.title}`);
202
- if (item.description) prefixParts.push(`> ${item.description}`);
203
- if (prefixParts.length > 0) {
204
- body = `${prefixParts.join('\n\n')}\n\n${body.trim()}\n`;
195
+ const sourceFormat = resolveFormat({
196
+ filePath: sourcePath,
197
+ frontMatterFormat: parsed.data && parsed.data.format,
198
+ markdownConfigFormat,
199
+ });
200
+ const result = await toPlainMarkdown(parsed.content, {
201
+ format: sourceFormat,
202
+ filePath: sourcePath,
203
+ });
204
+ if (result.fallback) {
205
+ fallbacks++;
206
+ console.warn(
207
+ `[plugin-aeo] companions: ${path.relative(siteDir, sourcePath)} did not parse as ${sourceFormat} (${result.error && result.error.message}); used the regex stripper for its companion`,
208
+ );
205
209
  }
210
+ body = withTitleBlock(
211
+ result.markdown,
212
+ item.title,
213
+ item.description,
214
+ pageUrl(siteConfig.url, item.permalink),
215
+ );
206
216
  }
207
217
 
208
- const target = routeToCompanionPath(item.permalink, trailingSlash, outDir);
218
+ const target = path.join(outDir, companionPath(item.permalink, trailingSlash));
209
219
  await ensureDir(target);
210
220
  await fs.writeFile(target, body, 'utf8');
211
221
 
@@ -228,7 +238,13 @@ module.exports = async function emitCompanions({
228
238
  }
229
239
 
230
240
  if (verbose) {
231
- console.log(`[plugin-aeo] companions: emitted ${emitted.length} file(s)`);
241
+ console.log(
242
+ `[plugin-aeo] companions: emitted ${emitted.length} file(s)${fallbacks ? `, ${fallbacks} via the regex fallback` : ''}`,
243
+ );
232
244
  }
233
245
  return emitted;
234
246
  };
247
+
248
+ module.exports.collectCompanionItems = collectCompanionItems;
249
+ module.exports.withTitleBlock = withTitleBlock;
250
+ module.exports.matchAny = matchAny;
@@ -1,5 +1,6 @@
1
1
  const path = require('path');
2
2
  const fs = require('fs').promises;
3
+ const { companionUrl, pageUrl } = require('../companionPath');
3
4
 
4
5
  function matchAny(value, patterns) {
5
6
  if (!patterns || patterns.length === 0) return false;
@@ -32,17 +33,6 @@ function toRegExp(glob) {
32
33
  return new RegExp('^' + re + '$');
33
34
  }
34
35
 
35
- function companionUrl(permalink, trailingSlash) {
36
- if (permalink === '/' || permalink === '') {
37
- return '/index.md';
38
- }
39
- const cleaned = permalink.replace(/\/+$/, '');
40
- if (trailingSlash === false) {
41
- return `${cleaned}.md`;
42
- }
43
- return `${cleaned}/index.md`;
44
- }
45
-
46
36
  function groupKind(kind, pluginId) {
47
37
  if (kind === 'docs') return 'docs';
48
38
  if (kind === 'blog') return 'blog';
@@ -149,30 +139,20 @@ function buildSections(filtered, options, verbose) {
149
139
  return sections;
150
140
  }
151
141
 
152
- module.exports = async function emitLlmsTxt({
153
- props,
154
- options,
155
- emittedCompanions,
156
- companionsEnabled,
157
- verbose,
158
- }) {
159
- const { outDir, siteConfig } = props;
160
- const trailingSlash = siteConfig.trailingSlash;
161
-
162
- // Filter the companions through include/exclude rules.
163
- const filtered = emittedCompanions.filter((item) => {
164
- if (options.include && !options.include.some((p) => toRegExp(p).test(item.permalink))) {
165
- return false;
166
- }
167
- if (matchAny(item.permalink, options.exclude)) {
168
- return false;
169
- }
170
- return true;
171
- });
172
-
173
- const sections = buildSections(filtered, options, verbose);
142
+ // Normalised view of `llmsTxt.fullTxt`: the boolean form (`true` = every
143
+ // section, no cap) or the object form `{ include, maxBytes }`.
144
+ function fullTxtOptions(fullTxt) {
145
+ if (!fullTxt) return null;
146
+ if (fullTxt === true) return { include: null, maxBytes: null };
147
+ return {
148
+ include: Array.isArray(fullTxt.include) && fullTxt.include.length > 0 ? fullTxt.include : null,
149
+ maxBytes: Number.isFinite(fullTxt.maxBytes) && fullTxt.maxBytes > 0 ? fullTxt.maxBytes : null,
150
+ };
151
+ }
174
152
 
175
- // Build llms.txt.
153
+ // Render llms.txt from the sections. Pure, so it can be tested without a
154
+ // build: returns the file text.
155
+ function renderLlmsTxt({ siteConfig, siteUrl, trailingSlash, sections, options, companionsEnabled }) {
176
156
  const lines = [];
177
157
  lines.push(`# ${siteConfig.title}`);
178
158
  lines.push('');
@@ -189,9 +169,11 @@ module.exports = async function emitLlmsTxt({
189
169
  lines.push(`## ${section.title}`);
190
170
  lines.push('');
191
171
  for (const item of section.items) {
172
+ // Absolute, as the llms.txt spec's examples are: the file is read
173
+ // away from the site, where a relative link has no base.
192
174
  const url = companionsEnabled
193
- ? companionUrl(item.permalink, trailingSlash)
194
- : item.permalink;
175
+ ? companionUrl(siteUrl, item.permalink, trailingSlash)
176
+ : pageUrl(siteUrl, item.permalink);
195
177
  const desc = item.description || siteConfig.tagline || '';
196
178
  const titleText = item.title || item.permalink;
197
179
  if (desc) {
@@ -203,8 +185,68 @@ module.exports = async function emitLlmsTxt({
203
185
  lines.push('');
204
186
  }
205
187
 
188
+ // The llmstxt.org convention for secondary resources: an "Optional"
189
+ // section a reader can skip. llms-full.txt is the whole corpus in one
190
+ // file, so it belongs here rather than inline with the page list.
191
+ if (options.fullTxt && options.linkFullTxt !== false && companionsEnabled) {
192
+ lines.push('## Optional');
193
+ lines.push('');
194
+ lines.push(
195
+ `- [Full text of every page](${siteUrl}/llms-full.txt): one file with the content of every page listed above`,
196
+ );
197
+ lines.push('');
198
+ }
199
+
200
+ return lines.join('\n');
201
+ }
202
+
203
+ // One block of llms-full.txt: the companion body with a `Source:` line (and
204
+ // the title, when the body does not already open with it). A plain
205
+ // companion already carries both, so the header is skipped and the body
206
+ // is emitted as is.
207
+ function fullTxtBlock({ body, item, siteUrl }) {
208
+ const url = pageUrl(siteUrl, item.permalink);
209
+ const head = body.trimStart().split('\n', 8);
210
+ const hasH1 = /^#\s+\S/.test(head[0] || '');
211
+ const hasSource = head.some((l) => /^Source: \S/.test(l));
212
+ const header = [
213
+ ...(hasH1 ? [] : [`# ${item.title || item.permalink}`, '']),
214
+ ...(hasSource ? [] : [`Source: ${url}`, '']),
215
+ ];
216
+ const prefix = header.length ? `${header.join('\n')}\n` : '';
217
+ return `${prefix}${body.trim()}\n`;
218
+ }
219
+
220
+ module.exports = async function emitLlmsTxt({
221
+ props,
222
+ options,
223
+ emittedCompanions,
224
+ companionsEnabled,
225
+ verbose,
226
+ }) {
227
+ const { outDir, siteConfig } = props;
228
+ const trailingSlash = siteConfig.trailingSlash;
229
+ const siteUrl = (siteConfig.url || '').replace(/\/+$/, '');
230
+
231
+ // Filter the companions through include/exclude rules.
232
+ const filtered = emittedCompanions.filter((item) => {
233
+ if (options.include && !options.include.some((p) => toRegExp(p).test(item.permalink))) {
234
+ return false;
235
+ }
236
+ if (matchAny(item.permalink, options.exclude)) {
237
+ return false;
238
+ }
239
+ return true;
240
+ });
241
+
242
+ const sections = buildSections(filtered, options, verbose);
243
+
206
244
  const llmsTxtPath = path.join(outDir, 'llms.txt');
207
- await fs.writeFile(llmsTxtPath, lines.join('\n'), 'utf8');
245
+ await fs.writeFile(
246
+ llmsTxtPath,
247
+ renderLlmsTxt({ siteConfig, siteUrl, trailingSlash, sections, options, companionsEnabled }),
248
+ 'utf8',
249
+ );
208
250
  if (verbose) {
209
251
  console.log(`[plugin-aeo] llmsTxt: wrote ${llmsTxtPath}`);
210
252
  }
@@ -212,7 +254,8 @@ module.exports = async function emitLlmsTxt({
212
254
  // Build llms-full.txt by concatenating the companion files that we just
213
255
  // emitted in feature 1. Requires feature 1 to have been enabled. Section
214
256
  // headings mirror llms.txt so an LLM can navigate the corpus by H2.
215
- if (options.fullTxt) {
257
+ const full = fullTxtOptions(options.fullTxt);
258
+ if (full) {
216
259
  if (!companionsEnabled) {
217
260
  if (verbose) {
218
261
  console.log(
@@ -222,9 +265,26 @@ module.exports = async function emitLlmsTxt({
222
265
  return;
223
266
  }
224
267
 
225
- const sectionChunks = [];
226
- for (const section of sections) {
227
- const blocks = [];
268
+ // `include` names content instances ("<plugin>@<id>") or, with per-type
269
+ // grouping, the group keys (docs, blog, pages). Sections keep the order
270
+ // of llms.txt; `maxBytes` stops appending once the next block would
271
+ // cross the cap, so the file always ends on a whole page.
272
+ const wanted = full.include
273
+ ? sections.filter((s) => full.include.includes(s.key))
274
+ : sections;
275
+ if (full.include && wanted.length === 0 && verbose) {
276
+ console.log(
277
+ `[plugin-aeo] llmsTxt: fullTxt.include matched no section (have: ${sections.map((s) => s.key).join(', ')})`,
278
+ );
279
+ }
280
+
281
+ const SEP = '\n---\n\n';
282
+ let out = '';
283
+ let size = 0;
284
+ let truncated = false;
285
+ let pages = 0;
286
+ outer: for (const section of wanted) {
287
+ let sectionOpen = false;
228
288
  for (const item of section.items) {
229
289
  let body;
230
290
  try {
@@ -237,23 +297,31 @@ module.exports = async function emitLlmsTxt({
237
297
  }
238
298
  continue;
239
299
  }
240
- const url = `${(siteConfig.url || '').replace(/\/$/, '')}${item.permalink}`;
241
- const header = [
242
- `# ${item.title || item.permalink}`,
243
- '',
244
- `Source: ${url}`,
245
- '',
246
- ].join('\n');
247
- blocks.push(`${header}${body.trim()}\n`);
300
+ const block = fullTxtBlock({ body, item, siteUrl });
301
+ const chunk = `${sectionOpen ? SEP : `${out ? SEP : ''}## ${section.title}\n\n`}${block}`;
302
+ const bytes = Buffer.byteLength(chunk, 'utf8');
303
+ if (full.maxBytes && size + bytes > full.maxBytes) {
304
+ truncated = true;
305
+ break outer;
306
+ }
307
+ out += chunk;
308
+ size += bytes;
309
+ sectionOpen = true;
310
+ pages++;
248
311
  }
249
- if (blocks.length === 0) continue;
250
- sectionChunks.push(`## ${section.title}\n\n${blocks.join('\n---\n\n')}`);
251
312
  }
252
313
 
253
314
  const fullPath = path.join(outDir, 'llms-full.txt');
254
- await fs.writeFile(fullPath, sectionChunks.join('\n---\n\n'), 'utf8');
315
+ await fs.writeFile(fullPath, out, 'utf8');
255
316
  if (verbose) {
256
- console.log(`[plugin-aeo] llmsTxt: wrote ${fullPath}`);
317
+ console.log(
318
+ `[plugin-aeo] llmsTxt: wrote ${fullPath} (${pages} page(s), ${size} bytes${truncated ? `, stopped at the ${full.maxBytes}-byte cap` : ''})`,
319
+ );
257
320
  }
258
321
  }
259
322
  };
323
+
324
+ module.exports.renderLlmsTxt = renderLlmsTxt;
325
+ module.exports.fullTxtBlock = fullTxtBlock;
326
+ module.exports.fullTxtOptions = fullTxtOptions;
327
+ module.exports.buildSections = buildSections;