@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.
- package/dist/Types-sh2VRxfo.d.ts +15 -0
- package/dist/chunk-7UDSRZKG.js +103 -0
- package/dist/chunk-7UDSRZKG.js.map +1 -0
- package/dist/chunk-O5HCIUAD.js +577 -0
- package/dist/chunk-O5HCIUAD.js.map +1 -0
- package/dist/chunk-PJ7AODIM.js +3040 -0
- package/dist/chunk-PJ7AODIM.js.map +1 -0
- package/dist/chunk-QWVRN6I4.js +81 -0
- package/dist/chunk-QWVRN6I4.js.map +1 -0
- package/dist/chunk-S4F2RY37.js +92 -0
- package/dist/chunk-S4F2RY37.js.map +1 -0
- package/dist/chunk-URU6QMLY.js +888 -0
- package/dist/chunk-URU6QMLY.js.map +1 -0
- package/dist/chunk-VJN7UB2Z.js +145 -0
- package/dist/chunk-VJN7UB2Z.js.map +1 -0
- package/dist/doc/index.d.ts +3 -0
- package/dist/doc/index.js +126 -0
- package/dist/doc/index.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +217 -0
- package/dist/index.js.map +1 -0
- package/dist/markdown/index.d.ts +237 -0
- package/dist/markdown/index.js +33 -0
- package/dist/markdown/index.js.map +1 -0
- package/dist/schemas/index.d.ts +70 -0
- package/dist/schemas/index.js +61 -0
- package/dist/schemas/index.js.map +1 -0
- package/dist/spatial/index.d.ts +111 -0
- package/dist/spatial/index.js +25 -0
- package/dist/spatial/index.js.map +1 -0
- package/dist/storage/index.d.ts +91 -0
- package/dist/storage/index.js +11 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/story/index.d.ts +691 -0
- package/dist/story/index.js +126 -0
- package/dist/story/index.js.map +1 -0
- package/dist/themeLibrary-DySHPcgj.d.ts +1352 -0
- package/dist/types-DHiv_Pnm.d.ts +496 -0
- package/package.json +91 -0
- package/src/__tests__/animationUtils.test.ts +77 -0
- package/src/__tests__/geohash.test.ts +109 -0
- package/src/__tests__/getLayers.test.ts +200 -0
- package/src/__tests__/haversine.test.ts +63 -0
- package/src/__tests__/localForageAdapter.test.ts +155 -0
- package/src/__tests__/markdown.test.ts +806 -0
- package/src/__tests__/markdownToDoc.test.ts +374 -0
- package/src/__tests__/storage.test.ts +60 -0
- package/src/__tests__/templates.test.ts +247 -0
- package/src/doc/docToMarkdown.ts +98 -0
- package/src/doc/getLayers.ts +152 -0
- package/src/doc/index.ts +8 -0
- package/src/doc/markdownToDoc.ts +277 -0
- package/src/doc/templates/accentImage.ts +338 -0
- package/src/doc/templates/captionUtils.ts +33 -0
- package/src/doc/templates/comparisonBar.ts +140 -0
- package/src/doc/templates/coverBlock.ts +156 -0
- package/src/doc/templates/dateEvent.ts +122 -0
- package/src/doc/templates/definitionCard.ts +129 -0
- package/src/doc/templates/factCard.ts +114 -0
- package/src/doc/templates/fullBleedQuote.ts +59 -0
- package/src/doc/templates/imageWithCaption.ts +167 -0
- package/src/doc/templates/index.ts +571 -0
- package/src/doc/templates/listBlock.ts +101 -0
- package/src/doc/templates/mapBlock.ts +132 -0
- package/src/doc/templates/persistentLayers.ts +522 -0
- package/src/doc/templates/photoGrid.ts +199 -0
- package/src/doc/templates/pullQuote.ts +119 -0
- package/src/doc/templates/quoteBlock.ts +112 -0
- package/src/doc/templates/sectionHeader.ts +105 -0
- package/src/doc/templates/statHighlight.ts +113 -0
- package/src/doc/templates/titleBlock.ts +100 -0
- package/src/doc/templates/twoColumn.ts +239 -0
- package/src/doc/templates/videoPullQuote.ts +128 -0
- package/src/doc/templates/videoWithCaption.ts +126 -0
- package/src/doc/utils/animationUtils.ts +135 -0
- package/src/doc/utils/themeUtils.ts +139 -0
- package/src/index.ts +5 -0
- package/src/markdown/convert.ts +897 -0
- package/src/markdown/htmlParse.ts +212 -0
- package/src/markdown/index.ts +110 -0
- package/src/markdown/parse.ts +103 -0
- package/src/markdown/stringify.ts +122 -0
- package/src/markdown/types.ts +605 -0
- package/src/markdown/utils.ts +165 -0
- package/src/ngeohash.d.ts +11 -0
- package/src/schemas/BlockTemplates.ts +706 -0
- package/src/schemas/Doc.ts +565 -0
- package/src/schemas/LayoutStrategy.ts +178 -0
- package/src/schemas/MediaProvider.ts +70 -0
- package/src/schemas/Theme.ts +235 -0
- package/src/schemas/Types.ts +15 -0
- package/src/schemas/Viewport.ts +91 -0
- package/src/schemas/index.ts +14 -0
- package/src/schemas/themeLibrary.ts +492 -0
- package/src/spatial/Geohash.ts +168 -0
- package/src/spatial/Haversine.ts +59 -0
- package/src/spatial/index.ts +2 -0
- package/src/storage/LocalForageAdapter.ts +103 -0
- package/src/storage/LocalStorageAdapter.ts +57 -0
- package/src/storage/MemoryStorageAdapter.ts +37 -0
- package/src/storage/Storage.ts +28 -0
- 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, '&')
|
|
176
|
+
.replace(/"/g, '"')
|
|
177
|
+
.replace(/</g, '<')
|
|
178
|
+
.replace(/>/g, '>');
|
|
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
|
+
}
|