@bendyline/squisq 0.1.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.
Files changed (102) hide show
  1. package/dist/Types-sh2VRxfo.d.ts +15 -0
  2. package/dist/chunk-7UDSRZKG.js +103 -0
  3. package/dist/chunk-7UDSRZKG.js.map +1 -0
  4. package/dist/chunk-O5HCIUAD.js +577 -0
  5. package/dist/chunk-O5HCIUAD.js.map +1 -0
  6. package/dist/chunk-PJ7AODIM.js +3040 -0
  7. package/dist/chunk-PJ7AODIM.js.map +1 -0
  8. package/dist/chunk-QWVRN6I4.js +81 -0
  9. package/dist/chunk-QWVRN6I4.js.map +1 -0
  10. package/dist/chunk-S4F2RY37.js +92 -0
  11. package/dist/chunk-S4F2RY37.js.map +1 -0
  12. package/dist/chunk-URU6QMLY.js +888 -0
  13. package/dist/chunk-URU6QMLY.js.map +1 -0
  14. package/dist/chunk-VJN7UB2Z.js +145 -0
  15. package/dist/chunk-VJN7UB2Z.js.map +1 -0
  16. package/dist/doc/index.d.ts +3 -0
  17. package/dist/doc/index.js +126 -0
  18. package/dist/doc/index.js.map +1 -0
  19. package/dist/index.d.ts +8 -0
  20. package/dist/index.js +217 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/markdown/index.d.ts +237 -0
  23. package/dist/markdown/index.js +33 -0
  24. package/dist/markdown/index.js.map +1 -0
  25. package/dist/schemas/index.d.ts +70 -0
  26. package/dist/schemas/index.js +61 -0
  27. package/dist/schemas/index.js.map +1 -0
  28. package/dist/spatial/index.d.ts +111 -0
  29. package/dist/spatial/index.js +25 -0
  30. package/dist/spatial/index.js.map +1 -0
  31. package/dist/storage/index.d.ts +91 -0
  32. package/dist/storage/index.js +11 -0
  33. package/dist/storage/index.js.map +1 -0
  34. package/dist/story/index.d.ts +691 -0
  35. package/dist/story/index.js +126 -0
  36. package/dist/story/index.js.map +1 -0
  37. package/dist/themeLibrary-DySHPcgj.d.ts +1352 -0
  38. package/dist/types-DHiv_Pnm.d.ts +496 -0
  39. package/package.json +91 -0
  40. package/src/__tests__/animationUtils.test.ts +77 -0
  41. package/src/__tests__/geohash.test.ts +109 -0
  42. package/src/__tests__/getLayers.test.ts +200 -0
  43. package/src/__tests__/haversine.test.ts +63 -0
  44. package/src/__tests__/localForageAdapter.test.ts +155 -0
  45. package/src/__tests__/markdown.test.ts +806 -0
  46. package/src/__tests__/markdownToDoc.test.ts +374 -0
  47. package/src/__tests__/storage.test.ts +60 -0
  48. package/src/__tests__/templates.test.ts +247 -0
  49. package/src/doc/docToMarkdown.ts +98 -0
  50. package/src/doc/getLayers.ts +152 -0
  51. package/src/doc/index.ts +8 -0
  52. package/src/doc/markdownToDoc.ts +277 -0
  53. package/src/doc/templates/accentImage.ts +338 -0
  54. package/src/doc/templates/captionUtils.ts +33 -0
  55. package/src/doc/templates/comparisonBar.ts +140 -0
  56. package/src/doc/templates/coverBlock.ts +156 -0
  57. package/src/doc/templates/dateEvent.ts +122 -0
  58. package/src/doc/templates/definitionCard.ts +129 -0
  59. package/src/doc/templates/factCard.ts +114 -0
  60. package/src/doc/templates/fullBleedQuote.ts +59 -0
  61. package/src/doc/templates/imageWithCaption.ts +167 -0
  62. package/src/doc/templates/index.ts +571 -0
  63. package/src/doc/templates/listBlock.ts +101 -0
  64. package/src/doc/templates/mapBlock.ts +132 -0
  65. package/src/doc/templates/persistentLayers.ts +522 -0
  66. package/src/doc/templates/photoGrid.ts +199 -0
  67. package/src/doc/templates/pullQuote.ts +119 -0
  68. package/src/doc/templates/quoteBlock.ts +112 -0
  69. package/src/doc/templates/sectionHeader.ts +105 -0
  70. package/src/doc/templates/statHighlight.ts +113 -0
  71. package/src/doc/templates/titleBlock.ts +100 -0
  72. package/src/doc/templates/twoColumn.ts +239 -0
  73. package/src/doc/templates/videoPullQuote.ts +128 -0
  74. package/src/doc/templates/videoWithCaption.ts +126 -0
  75. package/src/doc/utils/animationUtils.ts +135 -0
  76. package/src/doc/utils/themeUtils.ts +139 -0
  77. package/src/index.ts +5 -0
  78. package/src/markdown/convert.ts +897 -0
  79. package/src/markdown/htmlParse.ts +212 -0
  80. package/src/markdown/index.ts +110 -0
  81. package/src/markdown/parse.ts +103 -0
  82. package/src/markdown/stringify.ts +122 -0
  83. package/src/markdown/types.ts +605 -0
  84. package/src/markdown/utils.ts +165 -0
  85. package/src/ngeohash.d.ts +11 -0
  86. package/src/schemas/BlockTemplates.ts +706 -0
  87. package/src/schemas/Doc.ts +565 -0
  88. package/src/schemas/LayoutStrategy.ts +178 -0
  89. package/src/schemas/MediaProvider.ts +70 -0
  90. package/src/schemas/Theme.ts +235 -0
  91. package/src/schemas/Types.ts +15 -0
  92. package/src/schemas/Viewport.ts +91 -0
  93. package/src/schemas/index.ts +14 -0
  94. package/src/schemas/themeLibrary.ts +492 -0
  95. package/src/spatial/Geohash.ts +168 -0
  96. package/src/spatial/Haversine.ts +59 -0
  97. package/src/spatial/index.ts +2 -0
  98. package/src/storage/LocalForageAdapter.ts +103 -0
  99. package/src/storage/LocalStorageAdapter.ts +57 -0
  100. package/src/storage/MemoryStorageAdapter.ts +37 -0
  101. package/src/storage/Storage.ts +28 -0
  102. package/src/storage/index.ts +5 -0
@@ -0,0 +1,212 @@
1
+ /**
2
+ * HTML ↔ HtmlNode Conversion
3
+ *
4
+ * Parses raw HTML strings into the squisq HtmlNode tree representation
5
+ * using hast-util-from-html (parse5-backed), and serializes HtmlNode trees
6
+ * back to HTML strings with a lightweight custom serializer.
7
+ */
8
+
9
+ import { fromHtml } from 'hast-util-from-html';
10
+ import type { HtmlNode } from './types.js';
11
+
12
+ // ============================================
13
+ // Hast → HtmlNode conversion
14
+ // ============================================
15
+
16
+ /**
17
+ * Mapping from hast DOM-style property names to HTML attribute names.
18
+ * Only includes names where the property and attribute differ.
19
+ */
20
+ const HAST_PROP_TO_ATTR: Record<string, string> = {
21
+ className: 'class',
22
+ htmlFor: 'for',
23
+ httpEquiv: 'http-equiv',
24
+ acceptCharset: 'accept-charset',
25
+ };
26
+
27
+ /** HTML void elements that are self-closing. */
28
+ const VOID_ELEMENTS = new Set([
29
+ 'area',
30
+ 'base',
31
+ 'br',
32
+ 'col',
33
+ 'embed',
34
+ 'hr',
35
+ 'img',
36
+ 'input',
37
+ 'link',
38
+ 'meta',
39
+ 'param',
40
+ 'source',
41
+ 'track',
42
+ 'wbr',
43
+ ]);
44
+
45
+ /**
46
+ * Convert a hast property name to an HTML attribute name.
47
+ */
48
+ function propertyToAttribute(prop: string): string {
49
+ if (HAST_PROP_TO_ATTR[prop]) return HAST_PROP_TO_ATTR[prop];
50
+
51
+ // data-* attributes: dataFooBar → data-foo-bar
52
+ if (prop.length > 4 && prop.startsWith('data') && prop[4] >= 'A' && prop[4] <= 'Z') {
53
+ return (
54
+ 'data-' +
55
+ prop
56
+ .slice(4)
57
+ .replace(/[A-Z]/g, (m) => '-' + m.toLowerCase())
58
+ .replace(/^-/, '')
59
+ );
60
+ }
61
+
62
+ // aria-* attributes: ariaLabel → aria-label
63
+ if (prop.length > 4 && prop.startsWith('aria') && prop[4] >= 'A' && prop[4] <= 'Z') {
64
+ return (
65
+ 'aria-' +
66
+ prop
67
+ .slice(4)
68
+ .replace(/[A-Z]/g, (m) => '-' + m.toLowerCase())
69
+ .replace(/^-/, '')
70
+ );
71
+ }
72
+
73
+ // Most HTML attributes are lowercase (tabIndex → tabindex, readOnly → readonly, etc.)
74
+ return prop.toLowerCase();
75
+ }
76
+
77
+ /**
78
+ * Convert hast element properties to a flat HTML attribute map.
79
+ */
80
+ function convertHastProperties(properties: Record<string, unknown>): Record<string, string> {
81
+ const attrs: Record<string, string> = {};
82
+ for (const [key, value] of Object.entries(properties)) {
83
+ if (value === false || value === undefined || value === null) continue;
84
+
85
+ const attrName = propertyToAttribute(key);
86
+
87
+ if (value === true) {
88
+ attrs[attrName] = '';
89
+ } else if (Array.isArray(value)) {
90
+ attrs[attrName] = value.join(' ');
91
+ } else {
92
+ attrs[attrName] = String(value);
93
+ }
94
+ }
95
+ return attrs;
96
+ }
97
+
98
+ /**
99
+ * Recursively convert hast child nodes to HtmlNode[].
100
+ */
101
+ function hastChildrenToNodes(children: unknown[]): HtmlNode[] {
102
+ const result: HtmlNode[] = [];
103
+ for (const child of children) {
104
+ const node = hastNodeToHtmlNode(child as Record<string, unknown>);
105
+ if (node) result.push(node);
106
+ }
107
+ return result;
108
+ }
109
+
110
+ /**
111
+ * Convert a single hast node to an HtmlNode.
112
+ */
113
+ function hastNodeToHtmlNode(node: Record<string, unknown>): HtmlNode | null {
114
+ switch (node.type) {
115
+ case 'element': {
116
+ const tagName = node.tagName as string;
117
+ const properties = (node.properties ?? {}) as Record<string, unknown>;
118
+ const children = (node.children ?? []) as unknown[];
119
+ return {
120
+ type: 'htmlElement',
121
+ tagName,
122
+ attributes: convertHastProperties(properties),
123
+ children: hastChildrenToNodes(children),
124
+ selfClosing: VOID_ELEMENTS.has(tagName) && children.length === 0,
125
+ };
126
+ }
127
+ case 'text':
128
+ return {
129
+ type: 'htmlText',
130
+ value: node.value as string,
131
+ };
132
+ case 'comment':
133
+ return {
134
+ type: 'htmlComment',
135
+ value: node.value as string,
136
+ };
137
+ case 'doctype':
138
+ // Doctype nodes are rare in markdown HTML; skip them
139
+ return null;
140
+ default:
141
+ return null;
142
+ }
143
+ }
144
+
145
+ // ============================================
146
+ // Public API
147
+ // ============================================
148
+
149
+ /**
150
+ * Parse a raw HTML string into an HtmlNode[] tree.
151
+ *
152
+ * Uses the HTML5 parsing algorithm (via parse5) for correct handling of
153
+ * self-closing tags, optional end tags, entity decoding, etc.
154
+ *
155
+ * @param html - Raw HTML string to parse
156
+ * @returns Array of top-level HtmlNode elements
157
+ */
158
+ export function parseHtmlToNodes(html: string): HtmlNode[] {
159
+ if (!html || !html.trim()) return [];
160
+
161
+ try {
162
+ const tree = fromHtml(html, { fragment: true });
163
+ return hastChildrenToNodes(tree.children as unknown[]);
164
+ } catch {
165
+ // If parsing fails, return the raw text as a single text node
166
+ return [{ type: 'htmlText', value: html }];
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Escape special characters in HTML attribute values.
172
+ */
173
+ function escapeHtmlAttr(value: string): string {
174
+ return value
175
+ .replace(/&/g, '&amp;')
176
+ .replace(/"/g, '&quot;')
177
+ .replace(/</g, '&lt;')
178
+ .replace(/>/g, '&gt;');
179
+ }
180
+
181
+ /**
182
+ * Serialize an HtmlNode back to an HTML string.
183
+ */
184
+ function stringifyNode(node: HtmlNode): string {
185
+ switch (node.type) {
186
+ case 'htmlElement': {
187
+ const attrs = Object.entries(node.attributes)
188
+ .map(([k, v]) => (v === '' ? k : `${k}="${escapeHtmlAttr(v)}"`))
189
+ .join(' ');
190
+ const open = attrs ? `<${node.tagName} ${attrs}>` : `<${node.tagName}>`;
191
+
192
+ if (node.selfClosing) return open;
193
+
194
+ const inner = node.children.map(stringifyNode).join('');
195
+ return `${open}${inner}</${node.tagName}>`;
196
+ }
197
+ case 'htmlText':
198
+ return node.value;
199
+ case 'htmlComment':
200
+ return `<!--${node.value}-->`;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Serialize an HtmlNode[] tree back to an HTML string.
206
+ *
207
+ * @param nodes - Array of HtmlNode elements to serialize
208
+ * @returns HTML string
209
+ */
210
+ export function stringifyHtmlNodes(nodes: HtmlNode[]): string {
211
+ return nodes.map(stringifyNode).join('');
212
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * @bendyline/squisq Markdown Module
3
+ *
4
+ * Provides a complete JSON representation of markdown documents with
5
+ * perfect-fidelity round-trip conversion (markdown ↔ JSON).
6
+ *
7
+ * Supports CommonMark + GFM (tables, strikethrough, task lists, footnotes) +
8
+ * math ($...$, $$...$$) + directives (:::container, ::leaf, :text).
9
+ * Raw HTML is parsed into a structured HtmlNode sub-DOM.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * import { parseMarkdown, stringifyMarkdown } from '@bendyline/squisq/markdown';
14
+ *
15
+ * // Parse markdown to JSON
16
+ * const doc = parseMarkdown('# Hello\n\nWorld **bold** and *italic*');
17
+ *
18
+ * // Inspect the tree
19
+ * console.log(doc.children[0].type); // 'heading'
20
+ *
21
+ * // Serialize back to markdown
22
+ * const md = stringifyMarkdown(doc);
23
+ * ```
24
+ */
25
+
26
+ // Types (all interfaces + union types)
27
+ export type {
28
+ // Position tracking
29
+ MarkdownPoint,
30
+ MarkdownSourcePosition,
31
+
32
+ // Heading template annotation
33
+ HeadingTemplateAnnotation,
34
+
35
+ // HTML sub-DOM
36
+ HtmlElement,
37
+ HtmlText,
38
+ HtmlComment,
39
+ HtmlNode,
40
+
41
+ // Document root
42
+ MarkdownDocument,
43
+
44
+ // Block content
45
+ MarkdownHeading,
46
+ MarkdownParagraph,
47
+ MarkdownBlockquote,
48
+ MarkdownList,
49
+ MarkdownListItem,
50
+ MarkdownCodeBlock,
51
+ MarkdownThematicBreak,
52
+ MarkdownTable,
53
+ MarkdownTableRow,
54
+ MarkdownTableCell,
55
+ MarkdownHtmlBlock,
56
+ MarkdownMathBlock,
57
+ MarkdownLinkDefinition,
58
+ MarkdownFootnoteDefinition,
59
+ MarkdownContainerDirective,
60
+ MarkdownLeafDirective,
61
+ MarkdownDefinitionList,
62
+ MarkdownDefinitionTerm,
63
+ MarkdownDefinitionDescription,
64
+
65
+ // Inline content
66
+ MarkdownText,
67
+ MarkdownEmphasis,
68
+ MarkdownStrong,
69
+ MarkdownStrikethrough,
70
+ MarkdownInlineCode,
71
+ MarkdownLink,
72
+ MarkdownImage,
73
+ MarkdownBreak,
74
+ MarkdownInlineHtml,
75
+ MarkdownInlineMath,
76
+ MarkdownFootnoteReference,
77
+ MarkdownLinkReference,
78
+ MarkdownImageReference,
79
+ MarkdownTextDirective,
80
+
81
+ // Union types
82
+ MarkdownBlockNode,
83
+ MarkdownInlineNode,
84
+ MarkdownNode,
85
+
86
+ // Options
87
+ ParseOptions,
88
+ StringifyOptions,
89
+ } from './types.js';
90
+
91
+ // Parser + serializer
92
+ export { parseMarkdown } from './parse.js';
93
+ export { stringifyMarkdown } from './stringify.js';
94
+
95
+ // Conversion layer (for advanced use: working with remark plugins directly)
96
+ export { fromMdast, toMdast } from './convert.js';
97
+
98
+ // HTML sub-DOM utilities
99
+ export { parseHtmlToNodes, stringifyHtmlNodes } from './htmlParse.js';
100
+
101
+ // Tree utilities
102
+ export {
103
+ getChildren,
104
+ walkMarkdownTree,
105
+ findNodesByType,
106
+ extractPlainText,
107
+ countNodes,
108
+ createDocument,
109
+ parseFrontmatter,
110
+ } from './utils.js';
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Markdown → JSON Parser
3
+ *
4
+ * Parses a markdown string into a MarkdownDocument JSON structure
5
+ * using the unified/remark ecosystem with GFM, math, and directive extensions.
6
+ */
7
+
8
+ import { unified } from 'unified';
9
+ import remarkParse from 'remark-parse';
10
+ import remarkGfm from 'remark-gfm';
11
+ import remarkMath from 'remark-math';
12
+ import remarkDirective from 'remark-directive';
13
+ import remarkFrontmatter from 'remark-frontmatter';
14
+ import type { MarkdownDocument, ParseOptions } from './types.js';
15
+ import { fromMdast } from './convert.js';
16
+ import { parseFrontmatter } from './utils.js';
17
+
18
+ // Cache the default processor (all extensions enabled) to avoid rebuilding on every call.
19
+ let defaultProcessor: any;
20
+
21
+ /**
22
+ * Parse a markdown string into a MarkdownDocument.
23
+ *
24
+ * All extensions (GFM, math, directives) are enabled by default.
25
+ * Use the `options` parameter to disable specific extensions.
26
+ *
27
+ * @param markdown - The markdown source text
28
+ * @param options - Parser options (extensions, HTML parsing)
29
+ * @returns A MarkdownDocument representing the parsed markdown
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * const doc = parseMarkdown('# Hello\n\nWorld **bold** and *italic*');
34
+ * // doc.type === 'document'
35
+ * // doc.children[0].type === 'heading'
36
+ * // doc.children[1].type === 'paragraph'
37
+ * ```
38
+ */
39
+ export function parseMarkdown(markdown: string, options?: ParseOptions): MarkdownDocument {
40
+ // Use cached default processor when all extensions are enabled (the common case).
41
+ const useDefaults =
42
+ !options ||
43
+ (options.gfm !== false &&
44
+ options.math !== false &&
45
+ options.directive !== false &&
46
+ options.frontmatter !== false);
47
+
48
+ let processor: any;
49
+
50
+ if (useDefaults) {
51
+ if (!defaultProcessor) {
52
+ defaultProcessor = unified()
53
+ .use(remarkParse)
54
+ .use(remarkGfm)
55
+ .use(remarkMath)
56
+ .use(remarkDirective)
57
+ .use(remarkFrontmatter, ['yaml']);
58
+ }
59
+ processor = defaultProcessor;
60
+ } else {
61
+ // Build a custom processor with requested extensions.
62
+ // unified's .use() chaining changes the generic signature each time,
63
+ // making strict typing impractical — use a widened Processor type.
64
+ processor = unified().use(remarkParse);
65
+
66
+ if (options?.gfm !== false) {
67
+ processor = processor.use(remarkGfm);
68
+ }
69
+ if (options?.math !== false) {
70
+ processor = processor.use(remarkMath);
71
+ }
72
+ if (options?.directive !== false) {
73
+ processor = processor.use(remarkDirective);
74
+ }
75
+ if (options?.frontmatter !== false) {
76
+ processor = processor.use(remarkFrontmatter, ['yaml']);
77
+ }
78
+ }
79
+
80
+ // Parse markdown → mdast tree (result is an mdast Root node)
81
+ const mdastTree = processor.parse(markdown) as {
82
+ type: string;
83
+ children?: Array<{ type: string; value?: string }>;
84
+ };
85
+
86
+ // Convert mdast → MarkdownDocument
87
+ const doc = fromMdast(mdastTree as Parameters<typeof fromMdast>[0], {
88
+ parseHtml: options?.parseHtml,
89
+ });
90
+
91
+ // Extract YAML frontmatter if present
92
+ if (options?.frontmatter !== false) {
93
+ const yamlNode = mdastTree.children?.find((n) => n.type === 'yaml');
94
+ if (yamlNode?.value) {
95
+ const fm = parseFrontmatter(yamlNode.value);
96
+ if (fm && Object.keys(fm).length > 0) {
97
+ doc.frontmatter = fm;
98
+ }
99
+ }
100
+ }
101
+
102
+ return doc;
103
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * JSON → Markdown Serializer
3
+ *
4
+ * Converts a MarkdownDocument JSON structure back to a markdown string
5
+ * using the unified/remark ecosystem with GFM, math, and directive extensions.
6
+ */
7
+
8
+ import { unified } from 'unified';
9
+ import remarkStringify from 'remark-stringify';
10
+ import remarkGfm from 'remark-gfm';
11
+ import remarkMath from 'remark-math';
12
+ import remarkDirective from 'remark-directive';
13
+ import type { MarkdownDocument, StringifyOptions } from './types.js';
14
+ import { toMdast } from './convert.js';
15
+
16
+ // Cache the default processor (all extensions, default formatting) to avoid rebuilding on every call.
17
+ let defaultProcessor: any;
18
+
19
+ /**
20
+ * Serialize a MarkdownDocument back to a markdown string.
21
+ *
22
+ * All extensions (GFM, math, directives) are enabled by default.
23
+ * Use the `options` parameter to control formatting and disable extensions.
24
+ *
25
+ * @param doc - The MarkdownDocument to serialize
26
+ * @param options - Serialization options (formatting, extensions)
27
+ * @returns A markdown string
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * const doc: MarkdownDocument = {
32
+ * type: 'document',
33
+ * children: [
34
+ * { type: 'heading', depth: 1, children: [{ type: 'text', value: 'Hello' }] },
35
+ * { type: 'paragraph', children: [{ type: 'text', value: 'World' }] },
36
+ * ],
37
+ * };
38
+ * const md = stringifyMarkdown(doc);
39
+ * // '# Hello\n\nWorld\n'
40
+ * ```
41
+ */
42
+ export function stringifyMarkdown(doc: MarkdownDocument, options?: StringifyOptions): string {
43
+ // Convert MarkdownDocument → mdast tree
44
+ const mdastTree = toMdast(doc);
45
+
46
+ // Use cached default processor when all extensions and default formatting are used.
47
+ const useDefaults =
48
+ !options ||
49
+ (options.gfm !== false &&
50
+ options.math !== false &&
51
+ options.directive !== false &&
52
+ !options.bullet &&
53
+ !options.bulletOrdered &&
54
+ !options.emphasis &&
55
+ !options.strong &&
56
+ !options.rule &&
57
+ !options.fence &&
58
+ options.setext == null);
59
+
60
+ let processor: any;
61
+
62
+ if (useDefaults) {
63
+ if (!defaultProcessor) {
64
+ defaultProcessor = unified()
65
+ .use(remarkGfm)
66
+ .use(remarkMath)
67
+ .use(remarkDirective)
68
+ .use(remarkStringify, {
69
+ bullet: '-',
70
+ bulletOrdered: '.',
71
+ emphasis: '*',
72
+ strong: '*',
73
+ rule: '-',
74
+ fence: '`',
75
+ setext: false,
76
+ });
77
+ }
78
+ processor = defaultProcessor;
79
+ } else {
80
+ // Build a custom processor with requested options.
81
+ // unified's .use() chaining changes the generic signature each time,
82
+ // making strict typing impractical — use a widened Processor type.
83
+ processor = unified();
84
+
85
+ if (options?.gfm !== false) {
86
+ processor = processor.use(remarkGfm);
87
+ }
88
+ if (options?.math !== false) {
89
+ processor = processor.use(remarkMath);
90
+ }
91
+ if (options?.directive !== false) {
92
+ processor = processor.use(remarkDirective);
93
+ }
94
+
95
+ processor = processor.use(remarkStringify, {
96
+ bullet: options?.bullet ?? '-',
97
+ bulletOrdered: options?.bulletOrdered ?? '.',
98
+ emphasis: options?.emphasis ?? '*',
99
+ strong: options?.strong ?? '*',
100
+ rule: options?.rule ?? '-',
101
+ fence: options?.fence ?? '`',
102
+ setext: options?.setext ?? false,
103
+ });
104
+ }
105
+
106
+ // Stringify mdast → markdown string
107
+ const result = processor.stringify(mdastTree) as string;
108
+
109
+ // remark-stringify escapes `[` in text nodes (to prevent link syntax),
110
+ // which turns `{[template]}` into `{\[template]}`. Unescape our annotation syntax.
111
+ const cleaned = result.replace(/\{\\\[([^\]]+)\]\}/g, '{[$1]}');
112
+
113
+ // Prepend YAML frontmatter if present
114
+ if (doc.frontmatter && Object.keys(doc.frontmatter).length > 0) {
115
+ const yamlLines = Object.entries(doc.frontmatter).map(
116
+ ([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`,
117
+ );
118
+ return `---\n${yamlLines.join('\n')}\n---\n\n${cleaned}`;
119
+ }
120
+
121
+ return cleaned;
122
+ }