@dogsbay/format-dogsbay-md 0.2.0-beta.135 → 0.2.0-beta.136

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/browser.d.ts CHANGED
@@ -21,5 +21,7 @@ export { transformCodeRegions, transformProseLines, transformProseSegments, fenc
21
21
  export { maskMinjaInCode, unmaskMinjaInCode, maskAllMinjaOpeners, isMinjaProcessedFence, stripInlineMinjaMarkers, stripFenceMinjaMarkers } from "./minja-mask.js";
22
22
  export { extractHeadings, slugifyHeadingText, type HeadingInfo } from "./headings.js";
23
23
  export { splitFrontmatter, type Frontmatter } from "./frontmatter.js";
24
+ export { DataFiles, DataCache, DataFileError, parseDataFile, parseDelimited, DEFAULT_DATA_LIMITS, UNTRUSTED_DATA_LIMITS, DATA_EXTENSIONS, type DataLimits, type DataFilesOptions, type DataExtension, } from "./data-files.js";
25
+ export { resolveDataDirectives, hasDataDirectives, DATA_DIRECTIVES, NO_PROJECT_MESSAGE, type DataProblem, type DataDirectivesResult, } from "./data-directives.js";
24
26
  export { TemplatePages, TemplatePageError, declaresParams, ATTRIBUTE_BLOCK_OPEN, type TemplatePagesOptions } from "./template-pages.js";
25
27
  export { dogsbayMdPageFiles, dogsbayMdPagePath, mountCopies, carriedFrontmatter, MEDIA_EXTS, type AssetMount } from "./project.js";
package/dist/browser.js CHANGED
@@ -21,5 +21,7 @@ export { transformCodeRegions, transformProseLines, transformProseSegments, fenc
21
21
  export { maskMinjaInCode, unmaskMinjaInCode, maskAllMinjaOpeners, isMinjaProcessedFence, stripInlineMinjaMarkers, stripFenceMinjaMarkers } from "./minja-mask.js";
22
22
  export { extractHeadings, slugifyHeadingText } from "./headings.js";
23
23
  export { splitFrontmatter } from "./frontmatter.js";
24
+ export { DataFiles, DataCache, DataFileError, parseDataFile, parseDelimited, DEFAULT_DATA_LIMITS, UNTRUSTED_DATA_LIMITS, DATA_EXTENSIONS, } from "./data-files.js";
25
+ export { resolveDataDirectives, hasDataDirectives, DATA_DIRECTIVES, NO_PROJECT_MESSAGE, } from "./data-directives.js";
24
26
  export { TemplatePages, TemplatePageError, declaresParams, ATTRIBUTE_BLOCK_OPEN } from "./template-pages.js";
25
27
  export { dogsbayMdPageFiles, dogsbayMdPagePath, mountCopies, carriedFrontmatter, MEDIA_EXTS } from "./project.js";
@@ -0,0 +1,43 @@
1
+ import { type DataFiles } from "./data-files.js";
2
+ /** The data directives' names, for editors and the directive lists. */
3
+ export declare const DATA_DIRECTIVES: readonly ["data-table", "data-cards"];
4
+ /** One directive that could not be resolved. `line` is 1-based, in the page as given. */
5
+ export interface DataProblem {
6
+ line: number;
7
+ message: string;
8
+ /** The data file, when the problem is in it. */
9
+ file?: string;
10
+ /** The line in the data file, when known. */
11
+ fileLine?: number;
12
+ }
13
+ export interface DataDirectivesResult {
14
+ /** The page with every resolvable directive replaced. */
15
+ text: string;
16
+ /** Data files the page read, in the data folder, in order of first read. */
17
+ reads: string[];
18
+ /** Directives left in place, and why. */
19
+ problems: DataProblem[];
20
+ }
21
+ /** The message a host without a data folder reports, once per directive. */
22
+ export declare const NO_PROJECT_MESSAGE = "data files need a project: this page is rendered on its own, with no data folder to read";
23
+ /** Cheap pre-check: could this page hold a data directive? */
24
+ export declare function hasDataDirectives(text: string): boolean;
25
+ /**
26
+ * Replace every `::data-table` / `::data-cards` line outside code with the
27
+ * table or cards it names. Without `data`, every directive stays and is
28
+ * reported with NO_PROJECT_MESSAGE.
29
+ */
30
+ export declare function resolveDataDirectives(text: string, data: DataFiles | undefined): Promise<DataDirectivesResult>;
31
+ /**
32
+ * A value as Markdown that reads back as exactly that text, on one line: the encoding a table
33
+ * cell gets, for a template page's `| text` filter. `*`, `<x>`, `[a](b)`, `{{ }}`, `|`, `$`,
34
+ * `~~` and a leading `- ` all show as written; a list of values is joined with ", " as in a
35
+ * cell, and an object is an error. Its output is entity-encoded, so `text` goes last in a chain.
36
+ */
37
+ export declare function literalMarkdown(value: unknown): string;
38
+ /**
39
+ * `{{ value | code }}`: the value as inline code, on one line, in a fence its own backticks
40
+ * cannot close. Template pages never evaluate inside a code span written in the page, so this is
41
+ * how a value is shown as code. In a table cell, follow it with `| replace("|", "\\|")`.
42
+ */
43
+ export declare function literalCode(value: unknown): string;
@@ -0,0 +1,238 @@
1
+ import { isSafeHref } from "@dogsbay/serialize-core";
2
+ import { createFenceTracker } from "./code-regions.js";
3
+ import { DataFileError } from "./data-files.js";
4
+ import { parseAttrs } from "./parse-attrs.js";
5
+ import { treeToDogsbayMd } from "./serialize.js";
6
+ /** The data directives' names, for editors and the directive lists. */
7
+ export const DATA_DIRECTIVES = ["data-table", "data-cards"];
8
+ /** The message a host without a data folder reports, once per directive. */
9
+ export const NO_PROJECT_MESSAGE = "data files need a project: this page is rendered on its own, with no data folder to read";
10
+ /**
11
+ * A directive on a line of its own: indented, in a blockquote (`> `), or as a
12
+ * definition-list tab body (`: `). Group 1 is that prefix.
13
+ */
14
+ const DIRECTIVE_LINE = /^((?:[ \t]*>)*[ \t]*(?::[ \t]+)?)::(data-table|data-cards)(\{.*\})?[ \t]*$/;
15
+ /** A line with nothing but its block prefix (`>`, whitespace). */
16
+ const isBlank = (line) => line === undefined || /^[\s>]*$/.test(line);
17
+ /** Cheap pre-check: could this page hold a data directive? */
18
+ export function hasDataDirectives(text) {
19
+ return text.includes("::data-");
20
+ }
21
+ /**
22
+ * Replace every `::data-table` / `::data-cards` line outside code with the
23
+ * table or cards it names. Without `data`, every directive stays and is
24
+ * reported with NO_PROJECT_MESSAGE.
25
+ */
26
+ export async function resolveDataDirectives(text, data) {
27
+ const reads = [];
28
+ const problems = [];
29
+ if (!hasDataDirectives(text))
30
+ return { text, reads, problems };
31
+ const fences = createFenceTracker();
32
+ const lines = text.split("\n");
33
+ const out = [];
34
+ let inComment = false;
35
+ for (let i = 0; i < lines.length; i++) {
36
+ const line = lines[i];
37
+ const kind = fences.step(line);
38
+ // An HTML comment is not prose: a directive commented out stays out.
39
+ const commented = kind === "prose" && inComment;
40
+ if (kind === "prose")
41
+ inComment = commentOpenAfter(line, inComment);
42
+ const m = kind === "prose" && !commented ? DIRECTIVE_LINE.exec(line.replace(/\r$/, "")) : null;
43
+ if (!m) {
44
+ out.push(line);
45
+ continue;
46
+ }
47
+ const [, prefix, name, block] = m;
48
+ if (!data) {
49
+ problems.push({ line: i + 1, message: `::${name}: ${NO_PROJECT_MESSAGE}` });
50
+ out.push(line);
51
+ continue;
52
+ }
53
+ try {
54
+ const props = parseAttrs(block ? block.slice(1, -1) : "").props;
55
+ const { path, value } = await data.read(stringProp(props, "src"));
56
+ if (!reads.includes(path))
57
+ reads.push(path);
58
+ const rows = rowsOf(value, path, props.path, name);
59
+ const node = name === "data-table" ? tableNode(rows, props, path) : cardsNode(rows, props, path);
60
+ const md = treeToDogsbayMd([node]).replace(/\n+$/, "");
61
+ // Later lines continue the block: a tab body's `:` becomes indentation, `>` stays.
62
+ const cont = prefix.replace(":", " ");
63
+ const blank = cont.trimEnd();
64
+ // Blank lines around it, so it neither interrupts a paragraph above nor takes the next line as a row.
65
+ if (!prefix.includes(":") && !isBlank(out[out.length - 1]) && out.length > 0)
66
+ out.push(blank);
67
+ md.split("\n").forEach((l, n) => out.push(l ? (n === 0 ? prefix : cont) + l : blank));
68
+ if (i + 1 < lines.length && !isBlank(lines[i + 1]))
69
+ out.push(blank);
70
+ }
71
+ catch (err) {
72
+ if (err instanceof DataFileError) {
73
+ problems.push({ line: i + 1, message: `::${name}: ${err.message}`, file: err.file, fileLine: err.line });
74
+ }
75
+ else if (err instanceof DirectiveError) {
76
+ problems.push({ line: i + 1, message: `::${name}: ${err.message}` });
77
+ }
78
+ else
79
+ throw err;
80
+ out.push(line);
81
+ }
82
+ }
83
+ return { text: out.join("\n"), reads, problems };
84
+ }
85
+ class DirectiveError extends Error {
86
+ }
87
+ /** Whether an HTML comment is open after this line. */
88
+ function commentOpenAfter(line, open) {
89
+ let at = 0;
90
+ for (;;) {
91
+ if (open) {
92
+ const end = line.indexOf("-->", at);
93
+ if (end < 0)
94
+ return true;
95
+ open = false;
96
+ at = end + 3;
97
+ }
98
+ else {
99
+ const start = line.indexOf("<!--", at);
100
+ if (start < 0)
101
+ return false;
102
+ open = true;
103
+ at = start + 4;
104
+ }
105
+ }
106
+ }
107
+ function stringProp(props, key) {
108
+ const v = props[key];
109
+ if (typeof v !== "string" || v.trim() === "")
110
+ throw new DirectiveError(`needs ${key}="…"`);
111
+ return v;
112
+ }
113
+ const own = (o, key) => o !== null && typeof o === "object" && Object.prototype.hasOwnProperty.call(o, key) ? o[key] : undefined;
114
+ /** The list of rows the directive reads: the file, or `path="a.b"` inside it. */
115
+ function rowsOf(value, file, path, name) {
116
+ let at = value;
117
+ if (typeof path === "string" && path.trim()) {
118
+ for (const seg of path.trim().split(".")) {
119
+ at = Array.isArray(at) && /^\d+$/.test(seg) ? at[Number(seg)] : own(at, seg);
120
+ if (at === undefined)
121
+ throw new DirectiveError(`${file} has nothing at path "${path}"`);
122
+ }
123
+ }
124
+ if (!Array.isArray(at)) {
125
+ const what = at !== null && typeof at === "object" ? "an object" : `a ${typeof at}`;
126
+ throw new DirectiveError(`${file}${typeof path === "string" ? ` at "${path}"` : ""} is ${what}, not a list${what === "an object" ? `; name the list in it with path="…"` : ""}`);
127
+ }
128
+ at.forEach((row, n) => {
129
+ if (row === null || typeof row !== "object" || Array.isArray(row)) {
130
+ throw new DirectiveError(`${file}: item ${n + 1} is not an object with named fields, so ::${name} cannot read columns from it`);
131
+ }
132
+ });
133
+ return at;
134
+ }
135
+ /** A value as one line of text: scalars as written, a list of scalars joined, nothing else. */
136
+ function cellText(v, file, key) {
137
+ if (v === undefined || v === null)
138
+ return "";
139
+ if (typeof v === "string")
140
+ return v.replace(/\s+/g, " ").trim();
141
+ if (typeof v === "number" || typeof v === "boolean")
142
+ return String(v);
143
+ if (Array.isArray(v) && v.every((x) => x === null || ["string", "number", "boolean"].includes(typeof x))) {
144
+ return v.map((x) => (x === null ? "" : String(x).replace(/\s+/g, " ").trim())).join(", ");
145
+ }
146
+ throw new DirectiveError(`${file}: "${key}" holds ${Array.isArray(v) ? "a list of objects" : "an object"}, which a cell cannot show`);
147
+ }
148
+ /**
149
+ * A value as a text node. Characters the serializer's text escaping leaves live are written as
150
+ * character references: `{` and `}` (it keeps `{{ }}` / `{% %}` verbatim as authored template
151
+ * syntax, and a trailing `{…}` is read as attributes), `$` (math), `~` (`~~x~~` struck
152
+ * through) and `|` (a cell boundary wherever `| text` lands in a table row). `&` too, so the
153
+ * value's own `&amp;` stays those characters.
154
+ */
155
+ const ENTITY = { "&": "&amp;", "{": "&#123;", "}": "&#125;", $: "&#36;", "~": "&#126;", "|": "&#124;" };
156
+ const text = (t) => ({ type: "text", text: t.replace(/[&{}$~|]/g, (c) => ENTITY[c]) });
157
+ /**
158
+ * A value as Markdown that reads back as exactly that text, on one line: the encoding a table
159
+ * cell gets, for a template page's `| text` filter. `*`, `<x>`, `[a](b)`, `{{ }}`, `|`, `$`,
160
+ * `~~` and a leading `- ` all show as written; a list of values is joined with ", " as in a
161
+ * cell, and an object is an error. Its output is entity-encoded, so `text` goes last in a chain.
162
+ */
163
+ export function literalMarkdown(value) {
164
+ let s;
165
+ try {
166
+ s = cellText(value, "| text", "value");
167
+ }
168
+ catch {
169
+ throw new Error("`| text` shows a value, or a list of values, not an object; pick a field (`x.name | text`)");
170
+ }
171
+ if (!s)
172
+ return "";
173
+ return treeToDogsbayMd([{ type: "paragraph", inline: [text(s)] }]).replace(/\n+$/, "");
174
+ }
175
+ /**
176
+ * `{{ value | code }}`: the value as inline code, on one line, in a fence its own backticks
177
+ * cannot close. Template pages never evaluate inside a code span written in the page, so this is
178
+ * how a value is shown as code. In a table cell, follow it with `| replace("|", "\\|")`.
179
+ */
180
+ export function literalCode(value) {
181
+ let s;
182
+ try {
183
+ s = cellText(value, "| code", "value");
184
+ }
185
+ catch {
186
+ throw new Error("`| code` shows a value, or a list of values, not an object; pick a field (`x.name | code`)");
187
+ }
188
+ s = s.replace(/\s*[\r\n]+\s*/g, " ");
189
+ if (!s)
190
+ return "";
191
+ return treeToDogsbayMd([{ type: "paragraph", inline: [{ type: "code", text: s }] }]).replace(/\n+$/, "");
192
+ }
193
+ const list = (v) => typeof v === "string" && v.trim() ? v.split(",").map((s) => s.trim()) : undefined;
194
+ function tableNode(rows, props, file) {
195
+ const columns = list(props.columns) ?? [...new Set(rows.flatMap((r) => Object.keys(r)))];
196
+ if (columns.length === 0)
197
+ throw new DirectiveError(`${file} has no columns to show`);
198
+ const headers = list(props.headers) ?? columns;
199
+ if (headers.length !== columns.length) {
200
+ throw new DirectiveError(`headers names ${headers.length} column(s), columns ${columns.length}`);
201
+ }
202
+ const tr = (cells, tag) => ({
203
+ type: "tr",
204
+ children: cells.map((c) => ({ type: tag, inline: c ? [text(c)] : [] })),
205
+ });
206
+ return {
207
+ type: "table",
208
+ children: [tr(headers, "th"), ...rows.map((r) => tr(columns.map((k) => cellText(own(r, k), file, k)), "td"))],
209
+ };
210
+ }
211
+ function cardsNode(rows, props, file) {
212
+ const titleKey = typeof props.title === "string" ? props.title : "title";
213
+ const descKey = typeof props.description === "string" ? props.description : "description";
214
+ const hrefKey = typeof props.href === "string" ? props.href : "href";
215
+ const iconKey = typeof props.icon === "string" ? props.icon : "icon";
216
+ const cards = rows.map((r, n) => {
217
+ const title = cellText(own(r, titleKey), file, titleKey);
218
+ if (!title)
219
+ throw new DirectiveError(`${file}: item ${n + 1} has no "${titleKey}" for the card title`);
220
+ const cardProps = { title };
221
+ const href = cellText(own(r, hrefKey), file, hrefKey);
222
+ if (href) {
223
+ if (!isSafeHref(href))
224
+ throw new DirectiveError(`${file}: item ${n + 1}'s "${hrefKey}" is not a link a page may carry`);
225
+ cardProps.href = href;
226
+ }
227
+ const icon = cellText(own(r, iconKey), file, iconKey);
228
+ if (icon)
229
+ cardProps.icon = icon;
230
+ const desc = cellText(own(r, descKey), file, descKey);
231
+ return {
232
+ type: "card",
233
+ props: cardProps,
234
+ children: desc ? [{ type: "paragraph", inline: [text(desc)] }] : [],
235
+ };
236
+ });
237
+ return { type: "cards", children: cards };
238
+ }
@@ -0,0 +1,99 @@
1
+ /** A data file could not be read. `line` is 1-based, in the data file. */
2
+ export declare class DataFileError extends Error {
3
+ readonly file: string;
4
+ readonly line?: number | undefined;
5
+ constructor(message: string, file: string, line?: number | undefined);
6
+ }
7
+ export interface DataLimits {
8
+ /** Bytes of one data file's text. */
9
+ maxFileBytes: number;
10
+ /** Bytes of all data files one `DataFiles` reads (one build). */
11
+ maxTotalBytes: number;
12
+ /** Values one file may expand to (every scalar, list and object counts one; aliases count each use). */
13
+ maxValues: number;
14
+ /** Nesting depth of one file. */
15
+ maxDepth: number;
16
+ /** Rows of a CSV/TSV file, header excluded. */
17
+ maxRows: number;
18
+ /** Columns of a CSV/TSV file. */
19
+ maxColumns: number;
20
+ /** Characters of one CSV/TSV cell. */
21
+ maxCellChars: number;
22
+ }
23
+ export declare const DEFAULT_DATA_LIMITS: DataLimits;
24
+ /**
25
+ * Limits for a build of content nobody vetted (a playground link, a public
26
+ * repo, the Worker). A host forces these whatever the site's config says.
27
+ */
28
+ export declare const UNTRUSTED_DATA_LIMITS: DataLimits;
29
+ /** The extensions a data file may have, in the order a bare name tries them. */
30
+ export declare const DATA_EXTENSIONS: readonly ["yml", "yaml", "json", "csv", "tsv"];
31
+ export type DataExtension = (typeof DATA_EXTENSIONS)[number];
32
+ /**
33
+ * CSV (RFC 4180) or TSV, header row first: a list of objects keyed by the
34
+ * header. Values stay strings (`007` is not 7). Quoted fields may hold the
35
+ * delimiter, `""` and newlines; CRLF and a leading BOM are read. A ragged row
36
+ * or an unclosed quote is an error naming its line, never a shifted column.
37
+ */
38
+ export declare function parseDelimited(text: string, delimiter: "," | "\t", file: string, limits?: DataLimits): Record<string, string>[];
39
+ /** Parse one data file by its extension. Throws DataFileError. */
40
+ export declare function parseDataFile(path: string, text: string, limits?: DataLimits): {
41
+ value: unknown;
42
+ weight: number;
43
+ };
44
+ /** Parsed data files kept by weight, least recently used dropped first. */
45
+ export declare class DataCache {
46
+ readonly maxBytes: number;
47
+ private readonly map;
48
+ private bytes;
49
+ private hits;
50
+ private misses;
51
+ constructor(maxBytes?: number);
52
+ get(key: string): unknown | undefined;
53
+ set(key: string, value: unknown, weight: number): void;
54
+ clear(): void;
55
+ stats(): {
56
+ entries: number;
57
+ bytes: number;
58
+ maxBytes: number;
59
+ hits: number;
60
+ misses: number;
61
+ };
62
+ }
63
+ export interface DataFilesOptions {
64
+ /**
65
+ * Read a file by path relative to the data folder (`felix/params.yml`);
66
+ * undefined when it does not exist. The host contains it.
67
+ */
68
+ load: (path: string) => string | undefined | Promise<string | undefined>;
69
+ limits?: DataLimits;
70
+ /** Shared between builds (a dev session, the Worker). Parses are keyed by path and text. */
71
+ cache?: DataCache;
72
+ }
73
+ /**
74
+ * The data files of one build. `get(name)` reads `name` (extension
75
+ * optional: `releases` finds `releases.yml`, `.yaml`, `.json`, `.csv` or
76
+ * `.tsv`, and two of them is an error). `read` also says which file it was,
77
+ * so a page can report the files it read and a host rebuild only those pages
78
+ * when one changes; the record is per call, as pages render concurrently.
79
+ */
80
+ export declare class DataFiles {
81
+ private readonly options;
82
+ private readonly limits;
83
+ private readonly cache;
84
+ private total;
85
+ private readonly counted;
86
+ constructor(options: DataFilesOptions);
87
+ /** Resolve a name to the file it reads, without parsing it. Throws DataFileError. */
88
+ resolve(name: string): Promise<{
89
+ path: string;
90
+ text: string;
91
+ }>;
92
+ /** The parsed contents of a data file. Throws DataFileError. */
93
+ get(name: string): Promise<unknown>;
94
+ /** The parsed contents of a data file, and its path in the data folder. Throws DataFileError. */
95
+ read(name: string): Promise<{
96
+ path: string;
97
+ value: unknown;
98
+ }>;
99
+ }