@foldkit/markdown 0.0.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 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ export * from './view.js';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/view/index.ts"],"names":[],"mappings":"AAAA,cAAc,WAAW,CAAA"}
@@ -0,0 +1 @@
1
+ export * from './view.js';
@@ -0,0 +1,87 @@
1
+ import { Html } from 'foldkit/html';
2
+ import { Alignment, Blockquote, CodeBlock, Emphasis, HardBreak, Heading, Image, InlineCode, Link, List, ListItem, MarkdownDocument, Paragraph, Strikethrough, Strong, Table, TableCell, TableRow, Text, ThematicBreak } from '../ast/index.js';
3
+ import type { IslandDefinitions } from '../island/index.js';
4
+ /** Rendered inline content, ready to pass as element children. */
5
+ export type InlineContent = ReadonlyArray<Html | string>;
6
+ /**
7
+ * Renders one island directive. Receives the directive's attributes, the
8
+ * rendered nested blocks (empty for leaf directives), and the zero-based
9
+ * occurrence of this island name in the document, in document order. Use the
10
+ * occurrence index to derive identifiers that must be unique per instance,
11
+ * such as an `h.submodel` slotId.
12
+ */
13
+ export type IslandView = (attributes: Readonly<Record<string, string>>, content: ReadonlyArray<Html>, occurrenceIndex: number) => Html;
14
+ /** Island views by directive name. */
15
+ export type Islands = Readonly<Record<string, IslandView>>;
16
+ /**
17
+ * One typed island view per definition. Each view receives its attributes
18
+ * already decoded through the island's schema, so the record is exhaustive
19
+ * over the declared island names by construction.
20
+ */
21
+ export type IslandViewsFor<Definitions extends IslandDefinitions> = Readonly<{
22
+ [Name in keyof Definitions]: (attributes: Definitions[Name]['Type'], content: ReadonlyArray<Html>, occurrenceIndex: number) => Html;
23
+ }>;
24
+ /**
25
+ * One view function per markdown node. Container nodes receive their already
26
+ * rendered content alongside the node itself.
27
+ */
28
+ export type Views = Readonly<{
29
+ Text: (text: Text) => Html | string;
30
+ InlineCode: (inlineCode: InlineCode) => Html;
31
+ HardBreak: (hardBreak: HardBreak) => Html;
32
+ Emphasis: (emphasis: Emphasis, content: InlineContent) => Html;
33
+ Strong: (strong: Strong, content: InlineContent) => Html;
34
+ Strikethrough: (strikethrough: Strikethrough, content: InlineContent) => Html;
35
+ Link: (link: Link, content: InlineContent) => Html;
36
+ Image: (image: Image) => Html;
37
+ Heading: (heading: Heading, content: InlineContent) => Html;
38
+ Paragraph: (paragraph: Paragraph, content: InlineContent) => Html;
39
+ CodeBlock: (codeBlock: CodeBlock) => Html;
40
+ List: (list: List, items: ReadonlyArray<Html>) => Html;
41
+ ListItem: (listItem: ListItem, blocks: ReadonlyArray<Html>) => Html;
42
+ Blockquote: (blockquote: Blockquote, blocks: ReadonlyArray<Html>) => Html;
43
+ ThematicBreak: (thematicBreak: ThematicBreak) => Html;
44
+ Table: (table: Table, headerRow: Html, bodyRows: ReadonlyArray<Html>) => Html;
45
+ TableRow: (tableRow: TableRow, cells: ReadonlyArray<Html>) => Html;
46
+ TableCell: (tableCell: TableCell, content: InlineContent, alignment: Alignment, isHeader: boolean) => Html;
47
+ }>;
48
+ /** Configuration for {@link view} and {@link viewBlocks}. */
49
+ export type ViewConfig = Readonly<{
50
+ islands?: Islands | undefined;
51
+ views?: Partial<Views> | undefined;
52
+ }>;
53
+ /**
54
+ * Unstyled semantic defaults for every markdown node. Spread these into your
55
+ * own record and replace the nodes you want to restyle:
56
+ *
57
+ * @example
58
+ * ```typescript
59
+ * const blogViews: Markdown.Views = {
60
+ * ...Markdown.defaultViews,
61
+ * Paragraph: (paragraph, content) =>
62
+ * h.p([h.Class('leading-relaxed text-stone-700')], content),
63
+ * }
64
+ * ```
65
+ */
66
+ export declare const defaultViews: Views;
67
+ /**
68
+ * Pairs island attribute schemas with typed views, producing the plain
69
+ * {@link Islands} record the fold consumes. Attributes decode through each
70
+ * island's schema before dispatch, and the views record must cover every
71
+ * declared island name. Pass the same definitions to the markdown Vite
72
+ * plugin's `islands` option so invalid directives fail the build instead of
73
+ * reaching this decode.
74
+ */
75
+ export declare const islandsFor: <const Definitions extends IslandDefinitions>(definitions: Definitions, islandViews: IslandViewsFor<Definitions>) => Islands;
76
+ /**
77
+ * Folds a document into one Html node per top-level block. Use this when the
78
+ * blocks should land directly inside your own container element.
79
+ */
80
+ export declare const viewBlocks: (document: MarkdownDocument, config?: ViewConfig) => ReadonlyArray<Html>;
81
+ /**
82
+ * Folds a document into a single Html tree. Every node renders through
83
+ * {@link defaultViews} unless overridden in `config.views`, and every Island
84
+ * directive renders through the matching entry in `config.islands`.
85
+ */
86
+ export declare const view: (document: MarkdownDocument, config?: ViewConfig) => Html;
87
+ //# sourceMappingURL=view.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"view.d.ts","sourceRoot":"","sources":["../../src/view/view.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,164 @@
1
+ import { Array, Match as M, Option, Record as Record_, Schema as S, } from 'effect';
2
+ import { html } from 'foldkit/html';
3
+ const h = html();
4
+ const titleAttribute = (maybeTitle) => Option.match(maybeTitle, {
5
+ onNone: () => [],
6
+ onSome: title => [h.Title(title)],
7
+ });
8
+ const startAttribute = (maybeStartNumber) => Option.match(maybeStartNumber, {
9
+ onNone: () => [],
10
+ onSome: startNumber => [h.Start(startNumber)],
11
+ });
12
+ const alignmentAttribute = (alignment) => M.value(alignment).pipe(M.when('None', () => []), M.when('Left', () => [h.Style({ 'text-align': 'left' })]), M.when('Center', () => [h.Style({ 'text-align': 'center' })]), M.when('Right', () => [h.Style({ 'text-align': 'right' })]), M.exhaustive);
13
+ /**
14
+ * Unstyled semantic defaults for every markdown node. Spread these into your
15
+ * own record and replace the nodes you want to restyle:
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * const blogViews: Markdown.Views = {
20
+ * ...Markdown.defaultViews,
21
+ * Paragraph: (paragraph, content) =>
22
+ * h.p([h.Class('leading-relaxed text-stone-700')], content),
23
+ * }
24
+ * ```
25
+ */
26
+ // NOTE: These views deliberately render without vdom keys. A markdown document
27
+ // is immutable data, so a given position never switches branches within one
28
+ // document, and per-branch keys like 'Ordered' or 'Header' would repeat across
29
+ // sibling lists and cells, which corrupts snabbdom's keyed diff. Identity for a
30
+ // whole document belongs on the consumer's key around the rendered output.
31
+ export const defaultViews = {
32
+ Text: ({ value }) => value,
33
+ InlineCode: ({ value }) => h.code([], [value]),
34
+ HardBreak: () => h.br([]),
35
+ Emphasis: (_emphasis, content) => h.em([], content),
36
+ Strong: (_strong, content) => h.strong([], content),
37
+ Strikethrough: (_strikethrough, content) => h.del([], content),
38
+ Link: ({ url, maybeTitle }, content) => h.a([h.Href(url), ...titleAttribute(maybeTitle)], content),
39
+ Image: ({ url, alt, maybeTitle }) => h.img([h.Src(url), h.Alt(alt), ...titleAttribute(maybeTitle)]),
40
+ Heading: ({ level }, content) => M.value(level).pipe(M.withReturnType(), M.when(1, () => h.h1([], content)), M.when(2, () => h.h2([], content)), M.when(3, () => h.h3([], content)), M.when(4, () => h.h4([], content)), M.when(5, () => h.h5([], content)), M.when(6, () => h.h6([], content)), M.exhaustive),
41
+ Paragraph: (_paragraph, content) => h.p([], content),
42
+ CodeBlock: ({ value }) => h.pre([], [h.code([], [value])]),
43
+ List: ({ isOrdered, maybeStartNumber }, items) => {
44
+ if (isOrdered) {
45
+ return h.ol(startAttribute(maybeStartNumber), items);
46
+ }
47
+ else {
48
+ return h.ul([], items);
49
+ }
50
+ },
51
+ ListItem: (_listItem, blocks) => h.li([], blocks),
52
+ Blockquote: (_blockquote, blocks) => h.blockquote([], blocks),
53
+ ThematicBreak: () => h.hr([]),
54
+ Table: (_table, headerRow, bodyRows) => h.table([], [h.thead([], [headerRow]), h.tbody([], bodyRows)]),
55
+ TableRow: (_tableRow, cells) => h.tr([], cells),
56
+ TableCell: (_tableCell, content, alignment, isHeader) => {
57
+ if (isHeader) {
58
+ return h.th(alignmentAttribute(alignment), content);
59
+ }
60
+ else {
61
+ return h.td(alignmentAttribute(alignment), content);
62
+ }
63
+ },
64
+ };
65
+ const inlineView = (views, inline) => M.value(inline).pipe(M.withReturnType(), M.tagsExhaustive({
66
+ Text: views.Text,
67
+ InlineCode: views.InlineCode,
68
+ HardBreak: views.HardBreak,
69
+ Emphasis: emphasis => views.Emphasis(emphasis, inlineContent(views, emphasis.content)),
70
+ Strong: strong => views.Strong(strong, inlineContent(views, strong.content)),
71
+ Strikethrough: strikethrough => views.Strikethrough(strikethrough, inlineContent(views, strikethrough.content)),
72
+ Link: link => views.Link(link, inlineContent(views, link.content)),
73
+ Image: views.Image,
74
+ }));
75
+ const inlineContent = (views, content) => Array.map(content, inline => inlineView(views, inline));
76
+ const alignmentAt = (alignments, columnIndex) => Option.getOrElse(Array.get(alignments, columnIndex), () => 'None');
77
+ const tableRowView = (views, table, row, isHeader) => views.TableRow(row, Array.map(row.cells, (cell, columnIndex) => views.TableCell(cell, inlineContent(views, cell.content), alignmentAt(table.alignments, columnIndex), isHeader)));
78
+ // NOTE: Warns once per island name for the lifetime of the process, not per
79
+ // document, so a missing island in one document suppresses the warning for the
80
+ // same name in every later document. Per-render warnings would flood the
81
+ // console, since the fold runs on every Message dispatch.
82
+ const warnedInvalidAttributeIslandNames = new Set();
83
+ const warnInvalidAttributesOnce = (islandName, detail) => {
84
+ if (!warnedInvalidAttributeIslandNames.has(islandName)) {
85
+ warnedInvalidAttributeIslandNames.add(islandName);
86
+ console.warn(`[@foldkit/markdown] Invalid attributes for island "${islandName}", so it renders nothing. ` +
87
+ `Compile with the markdown plugin's islands option to catch this at build time. ${detail}`);
88
+ }
89
+ };
90
+ /**
91
+ * Pairs island attribute schemas with typed views, producing the plain
92
+ * {@link Islands} record the fold consumes. Attributes decode through each
93
+ * island's schema before dispatch, and the views record must cover every
94
+ * declared island name. Pass the same definitions to the markdown Vite
95
+ * plugin's `islands` option so invalid directives fail the build instead of
96
+ * reaching this decode.
97
+ */
98
+ export const islandsFor = (definitions, islandViews) => {
99
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
100
+ const islandViewsByName = islandViews;
101
+ return Record_.map(definitions, (attributesSchema, islandName) => {
102
+ const decodeAttributes = S.decodeUnknownSync(attributesSchema);
103
+ return (attributes, content, occurrenceIndex) => Option.match(Record_.get(islandViewsByName, islandName), {
104
+ onNone: () => {
105
+ warnMissingIslandOnce(islandName);
106
+ return null;
107
+ },
108
+ onSome: renderIsland => {
109
+ try {
110
+ return renderIsland(decodeAttributes(attributes), content, occurrenceIndex);
111
+ }
112
+ catch (error) {
113
+ warnInvalidAttributesOnce(islandName, error instanceof Error ? error.message : String(error));
114
+ return null;
115
+ }
116
+ },
117
+ });
118
+ });
119
+ };
120
+ const warnedIslandNames = new Set();
121
+ const warnMissingIslandOnce = (islandName) => {
122
+ if (!warnedIslandNames.has(islandName)) {
123
+ warnedIslandNames.add(islandName);
124
+ console.warn(`[@foldkit/markdown] No island view registered for "${islandName}", so it renders nothing. ` +
125
+ 'Add it to the islands record passed to Markdown.view.');
126
+ }
127
+ };
128
+ const islandView = (views, islands, islandOccurrenceCounts, island) => {
129
+ const occurrenceIndex = islandOccurrenceCounts.get(island.name) ?? 0;
130
+ islandOccurrenceCounts.set(island.name, occurrenceIndex + 1);
131
+ return Option.match(Record_.get(islands, island.name), {
132
+ onNone: () => {
133
+ warnMissingIslandOnce(island.name);
134
+ return null;
135
+ },
136
+ onSome: renderIsland => renderIsland(island.attributes, Array.map(island.blocks, block => blockView(views, islands, islandOccurrenceCounts, block)), occurrenceIndex),
137
+ });
138
+ };
139
+ const blockView = (views, islands, islandOccurrenceCounts, block) => M.value(block).pipe(M.withReturnType(), M.tagsExhaustive({
140
+ Heading: heading => views.Heading(heading, inlineContent(views, heading.content)),
141
+ Paragraph: paragraph => views.Paragraph(paragraph, inlineContent(views, paragraph.content)),
142
+ CodeBlock: views.CodeBlock,
143
+ List: list => views.List(list, Array.map(list.items, item => views.ListItem(item, Array.map(item.blocks, child => blockView(views, islands, islandOccurrenceCounts, child))))),
144
+ Blockquote: blockquote => views.Blockquote(blockquote, Array.map(blockquote.blocks, child => blockView(views, islands, islandOccurrenceCounts, child))),
145
+ ThematicBreak: views.ThematicBreak,
146
+ Table: table => views.Table(table, tableRowView(views, table, table.headerRow, true), Array.map(table.bodyRows, row => tableRowView(views, table, row, false))),
147
+ Island: island => islandView(views, islands, islandOccurrenceCounts, island),
148
+ }));
149
+ /**
150
+ * Folds a document into one Html node per top-level block. Use this when the
151
+ * blocks should land directly inside your own container element.
152
+ */
153
+ export const viewBlocks = (document, config = {}) => {
154
+ const views = { ...defaultViews, ...config.views };
155
+ const islands = config.islands ?? {};
156
+ const islandOccurrenceCounts = new Map();
157
+ return Array.map(document.blocks, block => blockView(views, islands, islandOccurrenceCounts, block));
158
+ };
159
+ /**
160
+ * Folds a document into a single Html tree. Every node renders through
161
+ * {@link defaultViews} unless overridden in `config.views`, and every Island
162
+ * directive renders through the matching entry in `config.islands`.
163
+ */
164
+ export const view = (document, config = {}) => h.div([], viewBlocks(document, config));
@@ -0,0 +1,18 @@
1
+ import type { Root } from 'mdast';
2
+ import { MarkdownDocument } from '../ast/index.js';
3
+ import type { IslandDefinitions } from '../island/index.js';
4
+ /**
5
+ * Options for {@link normalizeRoot}. `islands`, when provided, maps each
6
+ * allowed directive name to the schema for its attributes; unknown names,
7
+ * unknown attributes, and attribute values outside the schema all fail with
8
+ * an error naming the offender.
9
+ */
10
+ export type NormalizeOptions = Readonly<{
11
+ islands?: IslandDefinitions | undefined;
12
+ }>;
13
+ /**
14
+ * Converts a parsed mdast tree into a typed {@link MarkdownDocument}.
15
+ * Throws on any node outside the markdown vocabulary.
16
+ */
17
+ export declare const normalizeRoot: (root: Root, options?: NormalizeOptions) => MarkdownDocument;
18
+ //# sourceMappingURL=normalize.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"normalize.d.ts","sourceRoot":"","sources":["../../src/vite/normalize.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAKV,IAAI,EAEL,MAAM,OAAO,CAAA;AAId,OAAO,EAeL,gBAAgB,EASjB,MAAM,iBAAiB,CAAA;AACxB,OAAO,KAAK,EAAoB,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AAE7E;;;;;GAKG;AACH,MAAM,MAAM,gBAAgB,GAAG,QAAQ,CAAC;IACtC,OAAO,CAAC,EAAE,iBAAiB,GAAG,SAAS,CAAA;CACxC,CAAC,CAAA;AAoIF;;;GAGG;AACH,eAAO,MAAM,aAAa,GACxB,MAAM,IAAI,EACV,UAAS,gBAAqB,KAC7B,gBAwHF,CAAA"}
@@ -0,0 +1,157 @@
1
+ import { Array, Match as M, Option, Record as Record_, Schema as S, } from 'effect';
2
+ import { Blockquote, CodeBlock, Emphasis, HardBreak, Heading, Image, InlineCode, Island, Link, List, ListItem, Paragraph, Strikethrough, Strong, Table, TableCell, TableRow, Text, ThematicBreak, } from '../ast/index.js';
3
+ const UNSUPPORTED_GUIDANCE = {
4
+ html: 'Raw HTML is not part of the markdown vocabulary. Use an island directive to render custom views.',
5
+ textDirective: 'Inline directives are not supported. Use a leaf directive (`::Name`) on its own line, or a container directive (`:::Name`).',
6
+ linkReference: 'Reference-style links are not supported. Use an inline link (`[text](url)`).',
7
+ imageReference: 'Reference-style images are not supported. Use an inline image (`![alt](url)`).',
8
+ definition: 'Reference-style link definitions are not supported. Use inline links (`[text](url)`).',
9
+ footnoteReference: 'Footnotes are not supported.',
10
+ footnoteDefinition: 'Footnotes are not supported.',
11
+ yaml: 'Frontmatter is not supported. Keep document metadata in application code, for example a typed post registry.',
12
+ 'leaf directive label': 'Leaf directive labels (`::Name[label]`) are not supported. Pass information through attributes (`::Name{label="..."}`) instead.',
13
+ };
14
+ const sourceLocation = (node) => {
15
+ const line = node.position?.start.line;
16
+ if (line === undefined) {
17
+ return '';
18
+ }
19
+ else {
20
+ return ` (line ${line})`;
21
+ }
22
+ };
23
+ const unsupported = (node) => {
24
+ const guidance = UNSUPPORTED_GUIDANCE[node.type] ??
25
+ 'It is outside the @foldkit/markdown vocabulary.';
26
+ throw new Error(`Unsupported markdown node "${node.type}"${sourceLocation(node)}. ${guidance}`);
27
+ };
28
+ const normalizeAttributes = (attributes) => Object.fromEntries(Object.entries(attributes ?? {}).map(([name, value]) => [
29
+ name,
30
+ value ?? '',
31
+ ]));
32
+ const URL_SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
33
+ const SAFE_URL_SCHEME_PATTERN = /^(?:https?|mailto|tel):/i;
34
+ // NOTE: The scheme check runs against the URL with ASCII control characters
35
+ // and spaces stripped, because browsers strip them too, so `java\tscript:`
36
+ // would otherwise smuggle an executable scheme past a plain prefix test.
37
+ const toSafeUrl = (node, url) => {
38
+ const compactUrl = url.replace(/[\u0000-\u0020]/g, '');
39
+ if (URL_SCHEME_PATTERN.test(compactUrl) &&
40
+ !SAFE_URL_SCHEME_PATTERN.test(compactUrl)) {
41
+ throw new Error(`Unsupported URL scheme in "${url}"${sourceLocation(node)}. ` +
42
+ 'Link and image URLs may be relative, or use the http:, https:, mailto:, or tel: schemes.');
43
+ }
44
+ return url;
45
+ };
46
+ const toInline = (node) => M.value(node).pipe(M.withReturnType(), M.discriminators('type')({
47
+ text: ({ value }) => Text({ value }),
48
+ inlineCode: ({ value }) => InlineCode({ value }),
49
+ break: () => HardBreak(),
50
+ emphasis: ({ children }) => Emphasis({ content: children.map(toInline) }),
51
+ strong: ({ children }) => Strong({ content: children.map(toInline) }),
52
+ delete: ({ children }) => Strikethrough({ content: children.map(toInline) }),
53
+ link: linkNode => Link({
54
+ url: toSafeUrl(linkNode, linkNode.url),
55
+ maybeTitle: Option.fromNullishOr(linkNode.title),
56
+ content: linkNode.children.map(toInline),
57
+ }),
58
+ image: imageNode => Image({
59
+ url: toSafeUrl(imageNode, imageNode.url),
60
+ alt: imageNode.alt ?? '',
61
+ maybeTitle: Option.fromNullishOr(imageNode.title),
62
+ }),
63
+ }), M.orElse(unsupported));
64
+ const toAlignment = (align) => M.value(align).pipe(M.withReturnType(), M.when('left', () => 'Left'), M.when('center', () => 'Center'), M.when('right', () => 'Right'), M.orElse(() => 'None'));
65
+ const toTableRow = (row) => TableRow({
66
+ cells: row.children.map(cell => TableCell({ content: cell.children.map(toInline) })),
67
+ });
68
+ /**
69
+ * Converts a parsed mdast tree into a typed {@link MarkdownDocument}.
70
+ * Throws on any node outside the markdown vocabulary.
71
+ */
72
+ export const normalizeRoot = (root, options = {}) => {
73
+ const validateIslandAttributes = (directive, attributesSchema, attributes) => {
74
+ const allowedAttributeNames = Object.keys(attributesSchema.fields);
75
+ const unknownAttributeNames = Object.keys(attributes).filter(attributeName => !allowedAttributeNames.includes(attributeName));
76
+ if (Array.isArrayNonEmpty(unknownAttributeNames)) {
77
+ const allowedDescription = Array.match(allowedAttributeNames, {
78
+ onEmpty: () => 'It takes no attributes.',
79
+ onNonEmpty: names => `Allowed attributes: ${names.join(', ')}.`,
80
+ });
81
+ throw new Error(`Unknown attribute "${Array.headNonEmpty(unknownAttributeNames)}" for island "${directive.name}"${sourceLocation(directive)}. ` +
82
+ allowedDescription);
83
+ }
84
+ try {
85
+ S.decodeUnknownSync(attributesSchema)(attributes);
86
+ }
87
+ catch (error) {
88
+ throw new Error(`Invalid attributes for island "${directive.name}"${sourceLocation(directive)}. ` +
89
+ `${error instanceof Error ? error.message : String(error)}`);
90
+ }
91
+ };
92
+ const toIsland = (directive, blocks) => {
93
+ const attributes = normalizeAttributes(directive.attributes);
94
+ const { islands } = options;
95
+ if (islands !== undefined) {
96
+ Option.match(Record_.get(islands, directive.name), {
97
+ onNone: () => {
98
+ throw new Error(`Unknown island "${directive.name}"${sourceLocation(directive)}. ` +
99
+ `Allowed islands: ${Object.keys(islands).join(', ')}.`);
100
+ },
101
+ onSome: attributesSchema => validateIslandAttributes(directive, attributesSchema, attributes),
102
+ });
103
+ }
104
+ return Island({ name: directive.name, attributes, blocks });
105
+ };
106
+ const toListItem = (node) => {
107
+ if (node.checked === null || node.checked === undefined) {
108
+ return ListItem({ blocks: node.children.map(toBlock) });
109
+ }
110
+ else {
111
+ return unsupported({ type: 'task list item', position: node.position });
112
+ }
113
+ };
114
+ const toTable = (node) => Array.matchLeft(node.children.map(toTableRow), {
115
+ onEmpty: () => unsupported(node),
116
+ onNonEmpty: (headerRow, bodyRows) => Table({
117
+ alignments: (node.align ?? []).map(toAlignment),
118
+ headerRow,
119
+ bodyRows,
120
+ }),
121
+ });
122
+ const toBlock = (node) => M.value(node).pipe(M.withReturnType(), M.discriminators('type')({
123
+ heading: ({ depth, children }) => Heading({ level: depth, content: children.map(toInline) }),
124
+ paragraph: ({ children }) => Paragraph({ content: children.map(toInline) }),
125
+ code: ({ lang, meta, value }) => CodeBlock({
126
+ maybeLanguage: Option.fromNullishOr(lang),
127
+ maybeMeta: Option.fromNullishOr(meta),
128
+ value,
129
+ }),
130
+ list: listNode => {
131
+ const items = listNode.children.map(toListItem);
132
+ // Array.match?
133
+ if (Array.isArrayNonEmpty(items)) {
134
+ return List({
135
+ isOrdered: listNode.ordered === true,
136
+ maybeStartNumber: Option.fromNullishOr(listNode.start),
137
+ items,
138
+ });
139
+ }
140
+ else {
141
+ return unsupported(listNode);
142
+ }
143
+ },
144
+ blockquote: ({ children }) => Blockquote({ blocks: children.map(toBlock) }),
145
+ thematicBreak: () => ThematicBreak(),
146
+ table: toTable,
147
+ leafDirective: directive => Array.match(directive.children, {
148
+ onEmpty: () => toIsland(directive, []),
149
+ onNonEmpty: () => unsupported({
150
+ type: 'leaf directive label',
151
+ position: directive.position,
152
+ }),
153
+ }),
154
+ containerDirective: directive => toIsland(directive, directive.children.map(toBlock)),
155
+ }), M.orElse(unsupported));
156
+ return { blocks: root.children.map(toBlock) };
157
+ };
@@ -0,0 +1,3 @@
1
+ export { markdown, parseMarkdown } from './vite.js';
2
+ export type { MarkdownPluginOptions } from './vite.js';
3
+ //# sourceMappingURL=public.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"public.d.ts","sourceRoot":"","sources":["../../src/vite/public.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,WAAW,CAAA;AACnD,YAAY,EAAE,qBAAqB,EAAE,MAAM,WAAW,CAAA"}
@@ -0,0 +1 @@
1
+ export { markdown, parseMarkdown } from './vite.js';
@@ -0,0 +1,19 @@
1
+ import type { Plugin } from 'vite';
2
+ import { MarkdownDocument } from '../ast/index.js';
3
+ import type { NormalizeOptions } from './normalize.js';
4
+ /** Options for {@link markdown} and {@link parseMarkdown}. */
5
+ export type MarkdownPluginOptions = NormalizeOptions;
6
+ /**
7
+ * Parses markdown source into a typed {@link MarkdownDocument}. Throws on any
8
+ * construct outside the markdown vocabulary, and on malformed options. The
9
+ * {@link markdown} plugin runs this per `.md` module; call it directly for
10
+ * one-off compilation in scripts.
11
+ */
12
+ export declare const parseMarkdown: (source: string, options?: MarkdownPluginOptions) => MarkdownDocument;
13
+ /**
14
+ * Vite plugin that compiles imported `.md` files at build time into typed
15
+ * document modules. Decode the default export with `decodeDocument` and
16
+ * render it with `Markdown.view`.
17
+ */
18
+ export declare const markdown: (options?: MarkdownPluginOptions) => Plugin;
19
+ //# sourceMappingURL=vite.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vite.d.ts","sourceRoot":"","sources":["../../src/vite/vite.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAA;AAElC,OAAO,EAAE,gBAAgB,EAAkB,MAAM,iBAAiB,CAAA;AAElE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAYtD,8DAA8D;AAC9D,MAAM,MAAM,qBAAqB,GAAG,gBAAgB,CAAA;AAwBpD;;;;;GAKG;AACH,eAAO,MAAM,aAAa,GACxB,QAAQ,MAAM,EACd,UAAS,qBAA0B,KAClC,gBACwE,CAAA;AAE3E;;;;GAIG;AACH,eAAO,MAAM,QAAQ,GAAI,UAAS,qBAA0B,KAAG,MAgB9D,CAAA"}
@@ -0,0 +1,58 @@
1
+ import { Schema as S } from 'effect';
2
+ import remarkDirective from 'remark-directive';
3
+ import remarkFrontmatter from 'remark-frontmatter';
4
+ import remarkGfm from 'remark-gfm';
5
+ import remarkParse from 'remark-parse';
6
+ import { unified } from 'unified';
7
+ import { encodeDocument } from '../ast/index.js';
8
+ import { normalizeRoot } from './normalize.js';
9
+ // NOTE: remark-frontmatter is included so that YAML frontmatter parses as one
10
+ // `yaml` node and fails the build with guidance. Without it, remark reads
11
+ // `---` fences as a thematic break plus a setext heading and renders garbage.
12
+ const processor = unified()
13
+ .use(remarkParse)
14
+ .use(remarkFrontmatter)
15
+ .use(remarkGfm)
16
+ .use(remarkDirective)
17
+ .freeze();
18
+ const validateMarkdownPluginOptions = (options) => {
19
+ const islands = options.islands;
20
+ if (islands === undefined) {
21
+ return options;
22
+ }
23
+ for (const [islandName, attributesSchema] of Object.entries(islands)) {
24
+ if (!S.isSchema(attributesSchema) || !('fields' in attributesSchema)) {
25
+ throw new Error(`Island "${islandName}" in markdown plugin options must map to a Schema struct describing its attributes.`);
26
+ }
27
+ }
28
+ return options;
29
+ };
30
+ const parseWithValidatedOptions = (source, options) => normalizeRoot(processor.parse(source), options);
31
+ /**
32
+ * Parses markdown source into a typed {@link MarkdownDocument}. Throws on any
33
+ * construct outside the markdown vocabulary, and on malformed options. The
34
+ * {@link markdown} plugin runs this per `.md` module; call it directly for
35
+ * one-off compilation in scripts.
36
+ */
37
+ export const parseMarkdown = (source, options = {}) => parseWithValidatedOptions(source, validateMarkdownPluginOptions(options));
38
+ /**
39
+ * Vite plugin that compiles imported `.md` files at build time into typed
40
+ * document modules. Decode the default export with `decodeDocument` and
41
+ * render it with `Markdown.view`.
42
+ */
43
+ export const markdown = (options = {}) => {
44
+ const validatedOptions = validateMarkdownPluginOptions(options);
45
+ return {
46
+ name: 'foldkit-markdown',
47
+ transform(source, id) {
48
+ if (!id.endsWith('.md')) {
49
+ return undefined;
50
+ }
51
+ const document = parseWithValidatedOptions(source, validatedOptions);
52
+ return {
53
+ code: `export default ${JSON.stringify(encodeDocument(document))}`,
54
+ map: null,
55
+ };
56
+ },
57
+ };
58
+ };
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "@foldkit/markdown",
3
+ "version": "0.0.0",
4
+ "description": "Markdown compiled at build time into typed Foldkit views, with live component islands",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ },
14
+ "./vite": {
15
+ "types": "./dist/vite/public.d.ts",
16
+ "import": "./dist/vite/public.js"
17
+ },
18
+ "./content": {
19
+ "types": "./content.d.ts"
20
+ }
21
+ },
22
+ "sideEffects": false,
23
+ "files": [
24
+ "dist",
25
+ "content.d.ts"
26
+ ],
27
+ "peerDependencies": {
28
+ "effect": "4.0.0-beta.97",
29
+ "foldkit": "^0",
30
+ "vite": "^7.0.0 || ^8.0.0"
31
+ },
32
+ "dependencies": {
33
+ "remark-directive": "^4.0.0",
34
+ "remark-frontmatter": "^5.0.0",
35
+ "remark-gfm": "^4.0.1",
36
+ "remark-parse": "^11.0.0",
37
+ "unified": "^11.0.5"
38
+ },
39
+ "devDependencies": {
40
+ "@types/mdast": "^4.0.4",
41
+ "@types/unist": "^3.0.3",
42
+ "effect": "4.0.0-beta.97",
43
+ "happy-dom": "^20.10.4",
44
+ "mdast-util-directive": "^3.1.0",
45
+ "rimraf": "^6.1.3",
46
+ "typescript": "^6.0.3",
47
+ "vite": "^8.0.16",
48
+ "vitest": "^4.1.9",
49
+ "foldkit": "0.128.1"
50
+ },
51
+ "keywords": [
52
+ "foldkit",
53
+ "markdown",
54
+ "vite-plugin",
55
+ "islands",
56
+ "mdast"
57
+ ],
58
+ "author": "Devin Jameson",
59
+ "license": "MIT",
60
+ "repository": {
61
+ "type": "git",
62
+ "url": "https://github.com/foldkit/foldkit.git",
63
+ "directory": "packages/markdown"
64
+ },
65
+ "publishConfig": {
66
+ "access": "public"
67
+ },
68
+ "engines": {
69
+ "node": ">=18.0.0"
70
+ },
71
+ "scripts": {
72
+ "clean": "rimraf dist *.tsbuildinfo",
73
+ "build": "pnpm run clean && tsc -b tsconfig.build.json",
74
+ "watch": "tsc -b tsconfig.build.json --watch",
75
+ "lint": "pnpm --filter @foldkit/oxlint-plugin build && oxlint src",
76
+ "typecheck": "tsc -p tsconfig.json --noEmit",
77
+ "test": "vitest run"
78
+ }
79
+ }