enarratio-astro 0.1.0 → 0.4.2

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/src/markdown.ts CHANGED
@@ -1,164 +1,171 @@
1
- import { type ChartSpec, renderChart } from "./chart.js";
2
- import { chartMarkup } from "./figure.js";
3
- import { chartStyles, type StyleOptions } from "./styles.js";
4
-
5
- /** Options for the Markdown plugins: how charts are styled. */
6
- export type MarkdownChartOptions = StyleOptions;
7
-
8
- const BOOLEAN_KEYS = new Set(["regression", "interactive"]);
9
- const KEYS = new Set([
10
- "type",
11
- "alt",
12
- "title",
13
- "caption",
14
- "x",
15
- "y",
16
- "series",
17
- "value",
18
- "xLabel",
19
- "yLabel",
20
- "layout",
21
- "orientation",
22
- "scale",
23
- "regression",
24
- "dataTable",
25
- "id",
26
- "interactive",
27
- ]);
28
-
29
- /**
30
- * Reads a `chart` code block: `key: value` lines, a line of three dashes, then the data.
31
- *
32
- * ```chart
33
- * type: line
34
- * x: month
35
- * y: visitors
36
- * alt: Monthly visitors rise from 820 to 1,190.
37
- * ---
38
- * month,visitors
39
- * 2026-01-01,820
40
- * ```
41
- */
42
- export function parseChartBlock(source: string): ChartSpec & { readonly interactive: boolean } {
43
- const lines = source.split(/\r?\n/);
44
- const rule = lines.findIndex((line) => line.trim() === "---");
45
- if (rule === -1)
46
- throw new Error('Put a line of three dashes ("---") between the options and the data.');
47
- const spec: Record<string, string | boolean> = {};
48
- for (const line of lines.slice(0, rule)) {
49
- if (line.trim() === "") continue;
50
- const colon = line.indexOf(":");
51
- const key = colon === -1 ? "" : line.slice(0, colon).trim();
52
- if (!KEYS.has(key)) {
53
- throw new Error(`"${line.trim()}" is not an option. Options are: ${[...KEYS].join(", ")}.`);
54
- }
55
- const raw = line.slice(colon + 1).trim();
56
- spec[key] = BOOLEAN_KEYS.has(key) ? raw === "true" || raw === "yes" : raw;
57
- }
58
- return {
59
- ...(spec as Omit<ChartSpec, "data">),
60
- data: lines.slice(rule + 1).join("\n"),
61
- interactive: spec["interactive"] === true,
62
- };
63
- }
64
-
65
- /** The HTML for one chart block, with the chart CSS when it is the document's first chart. */
66
- async function chartHtml(
67
- source: string,
68
- first: boolean,
69
- options: MarkdownChartOptions,
70
- ): Promise<string> {
71
- let markup: string;
72
- try {
73
- const { interactive, ...spec } = parseChartBlock(source);
74
- const result = await renderChart(spec);
75
- if (!result.ok) console.warn(`[enarratio-astro] Markdown chart not drawn: ${result.error}`);
76
- markup = chartMarkup(result, interactive);
77
- } catch (error) {
78
- markup = chartMarkup(
79
- { ok: false, error: error instanceof Error ? error.message : String(error) },
80
- false,
81
- );
82
- }
83
- const css = first ? chartStyles(options) : "";
84
- return css ? `<style>${css}</style>\n${markup}` : markup;
85
- }
86
-
87
- /** The parts of a Markdown code node the plugins read. */
88
- interface CodeNode {
89
- readonly type: "code";
90
- readonly lang?: string | null | undefined;
91
- readonly value: string;
92
- }
93
-
94
- /** The parts of the Sätteri visitor context the plugin uses. */
95
- interface SatteriContext {
96
- replaceNode(node: Readonly<CodeNode>, content: { type: "html"; value: string }): void;
97
- }
98
-
99
- /**
100
- * Charts in Markdown for Astro's default Markdown processor, Sätteri (Astro 7 and later): every
101
- * code block whose language is `chart` becomes a chart, drawn when the site builds or renders.
102
- *
103
- * @example
104
- * // astro.config.mjs
105
- * import { satteri } from "@astrojs/markdown-satteri";
106
- * import { satteriCharts } from "enarratio-astro/markdown";
107
- * export default defineConfig({ markdown: { processor: satteri({ mdastPlugins: [satteriCharts()] }) } });
108
- */
109
- export function satteriCharts(options: MarkdownChartOptions = {}) {
110
- let first = true;
111
- return {
112
- name: "enarratio-charts",
113
- before() {
114
- first = true;
115
- },
116
- async code(node: Readonly<CodeNode>, ctx: SatteriContext) {
117
- if (node.lang !== "chart") return;
118
- // Visitors run concurrently, so the first block claims the stylesheet before it awaits.
119
- const isFirst = first;
120
- first = false;
121
- const value = await chartHtml(node.value, isFirst, options);
122
- ctx.replaceNode(node, { type: "html", value });
123
- },
124
- };
125
- }
126
-
127
- interface MdastParent {
128
- children: Array<{
129
- type: string;
130
- lang?: string | null | undefined;
131
- value?: string | undefined;
132
- children?: unknown[] | undefined;
133
- }>;
134
- }
135
-
136
- /**
137
- * Charts in Markdown for the unified (remark) processor, Astro 6's default and an option in
138
- * Astro 7: every code block whose language is `chart` becomes a chart.
139
- *
140
- * @example
141
- * // astro.config.mjs, Astro 6
142
- * import { remarkCharts } from "enarratio-astro/markdown";
143
- * export default defineConfig({ markdown: { remarkPlugins: [remarkCharts] } });
144
- */
145
- export function remarkCharts(options: MarkdownChartOptions = {}) {
146
- return async (tree: MdastParent): Promise<void> => {
147
- const blocks: MdastParent["children"] = [];
148
- const walk = (parent: MdastParent) => {
149
- for (const child of parent.children) {
150
- if (child.type === "code" && child.lang === "chart") blocks.push(child);
151
- else if (Array.isArray(child.children)) walk(child as MdastParent);
152
- }
153
- };
154
- walk(tree);
155
- let first = true;
156
- for (const node of blocks) {
157
- const value = await chartHtml(node.value ?? "", first, options);
158
- first = false;
159
- // Turned into raw HTML in place, as remark plugins do.
160
- Object.assign(node, { type: "html", value });
161
- delete node.lang;
162
- }
163
- };
164
- }
1
+ import { type ChartSpec, renderChart } from "./chart.js";
2
+ import { chartMarkup } from "./figure.js";
3
+ import { chartStyles, type StyleOptions } from "./styles.js";
4
+
5
+ /** Options for the Markdown plugins: how charts are styled. */
6
+ export type MarkdownChartOptions = StyleOptions;
7
+
8
+ const BOOLEAN_KEYS = new Set(["regression", "interactive"]);
9
+ const KEYS = new Set([
10
+ "type",
11
+ "alt",
12
+ "title",
13
+ "subtitle",
14
+ "caption",
15
+ "notes",
16
+ "source",
17
+ "sourceHref",
18
+ "byline",
19
+ "x",
20
+ "y",
21
+ "series",
22
+ "value",
23
+ "xLabel",
24
+ "yLabel",
25
+ "xUnit",
26
+ "yUnit",
27
+ "layout",
28
+ "orientation",
29
+ "scale",
30
+ "regression",
31
+ "dataTable",
32
+ "id",
33
+ "interactive",
34
+ ]);
35
+
36
+ /**
37
+ * Reads a `chart` code block: `key: value` lines, a line of three dashes, then the data.
38
+ *
39
+ * ```chart
40
+ * type: line
41
+ * x: month
42
+ * y: visitors
43
+ * alt: Monthly visitors rise from 820 to 1,190.
44
+ * ---
45
+ * month,visitors
46
+ * 2026-01-01,820
47
+ * ```
48
+ */
49
+ export function parseChartBlock(source: string): ChartSpec & { readonly interactive: boolean } {
50
+ const lines = source.split(/\r?\n/);
51
+ const rule = lines.findIndex((line) => line.trim() === "---");
52
+ if (rule === -1)
53
+ throw new Error('Put a line of three dashes ("---") between the options and the data.');
54
+ const spec: Record<string, string | boolean> = {};
55
+ for (const line of lines.slice(0, rule)) {
56
+ if (line.trim() === "") continue;
57
+ const colon = line.indexOf(":");
58
+ const key = colon === -1 ? "" : line.slice(0, colon).trim();
59
+ if (!KEYS.has(key)) {
60
+ throw new Error(`"${line.trim()}" is not an option. Options are: ${[...KEYS].join(", ")}.`);
61
+ }
62
+ const raw = line.slice(colon + 1).trim();
63
+ spec[key] = BOOLEAN_KEYS.has(key) ? raw === "true" || raw === "yes" : raw;
64
+ }
65
+ return {
66
+ ...(spec as Omit<ChartSpec, "data">),
67
+ data: lines.slice(rule + 1).join("\n"),
68
+ interactive: spec["interactive"] === true,
69
+ };
70
+ }
71
+
72
+ /** The HTML for one chart block, with the chart CSS when it is the document's first chart. */
73
+ async function chartHtml(
74
+ source: string,
75
+ first: boolean,
76
+ options: MarkdownChartOptions,
77
+ ): Promise<string> {
78
+ let markup: string;
79
+ try {
80
+ const { interactive, ...spec } = parseChartBlock(source);
81
+ const result = await renderChart(spec, { interactive });
82
+ if (!result.ok) console.warn(`[enarratio-astro] Markdown chart not drawn: ${result.error}`);
83
+ markup = chartMarkup(result, interactive);
84
+ } catch (error) {
85
+ markup = chartMarkup(
86
+ { ok: false, error: error instanceof Error ? error.message : String(error) },
87
+ false,
88
+ );
89
+ }
90
+ const css = first ? chartStyles(options) : "";
91
+ return css ? `<style>${css}</style>\n${markup}` : markup;
92
+ }
93
+
94
+ /** The parts of a Markdown code node the plugins read. */
95
+ interface CodeNode {
96
+ readonly type: "code";
97
+ readonly lang?: string | null | undefined;
98
+ readonly value: string;
99
+ }
100
+
101
+ /** The parts of the Sätteri visitor context the plugin uses. */
102
+ interface SatteriContext {
103
+ replaceNode(node: Readonly<CodeNode>, content: { type: "html"; value: string }): void;
104
+ }
105
+
106
+ /**
107
+ * Charts in Markdown for Astro's default Markdown processor, Sätteri (Astro 7 and later): every
108
+ * code block whose language is `chart` becomes a chart, drawn when the site builds or renders.
109
+ *
110
+ * @example
111
+ * // astro.config.mjs
112
+ * import { satteri } from "@astrojs/markdown-satteri";
113
+ * import { satteriCharts } from "enarratio-astro/markdown";
114
+ * export default defineConfig({ markdown: { processor: satteri({ mdastPlugins: [satteriCharts()] }) } });
115
+ */
116
+ export function satteriCharts(options: MarkdownChartOptions = {}) {
117
+ let first = true;
118
+ return {
119
+ name: "enarratio-charts",
120
+ before() {
121
+ first = true;
122
+ },
123
+ async code(node: Readonly<CodeNode>, ctx: SatteriContext) {
124
+ if (node.lang !== "chart") return;
125
+ // Visitors run concurrently, so the first block claims the stylesheet before it awaits.
126
+ const isFirst = first;
127
+ first = false;
128
+ const value = await chartHtml(node.value, isFirst, options);
129
+ ctx.replaceNode(node, { type: "html", value });
130
+ },
131
+ };
132
+ }
133
+
134
+ interface MdastParent {
135
+ children: Array<{
136
+ type: string;
137
+ lang?: string | null | undefined;
138
+ value?: string | undefined;
139
+ children?: unknown[] | undefined;
140
+ }>;
141
+ }
142
+
143
+ /**
144
+ * Charts in Markdown for the unified (remark) processor, Astro 6's default and an option in
145
+ * Astro 7: every code block whose language is `chart` becomes a chart.
146
+ *
147
+ * @example
148
+ * // astro.config.mjs, Astro 6
149
+ * import { remarkCharts } from "enarratio-astro/markdown";
150
+ * export default defineConfig({ markdown: { remarkPlugins: [remarkCharts] } });
151
+ */
152
+ export function remarkCharts(options: MarkdownChartOptions = {}) {
153
+ return async (tree: MdastParent): Promise<void> => {
154
+ const blocks: MdastParent["children"] = [];
155
+ const walk = (parent: MdastParent) => {
156
+ for (const child of parent.children) {
157
+ if (child.type === "code" && child.lang === "chart") blocks.push(child);
158
+ else if (Array.isArray(child.children)) walk(child as MdastParent);
159
+ }
160
+ };
161
+ walk(tree);
162
+ let first = true;
163
+ for (const node of blocks) {
164
+ const value = await chartHtml(node.value ?? "", first, options);
165
+ first = false;
166
+ // Turned into raw HTML in place, as remark plugins do.
167
+ Object.assign(node, { type: "html", value });
168
+ delete node.lang;
169
+ }
170
+ };
171
+ }
package/src/once.ts CHANGED
@@ -1,11 +1,11 @@
1
- const styled = new WeakSet<object>();
2
-
3
- /**
4
- * True the first time it is asked about a page render. Astro gives each render its own Request,
5
- * so the theme's stylesheet is written once per page however many charts the page has.
6
- */
7
- export function firstOnPage(request: object): boolean {
8
- if (styled.has(request)) return false;
9
- styled.add(request);
10
- return true;
11
- }
1
+ const styled = new WeakSet<object>();
2
+
3
+ /**
4
+ * True the first time it is asked about a page render. Astro gives each render its own Request,
5
+ * so the theme's stylesheet is written once per page however many charts the page has.
6
+ */
7
+ export function firstOnPage(request: object): boolean {
8
+ if (styled.has(request)) return false;
9
+ styled.add(request);
10
+ return true;
11
+ }
package/src/styles.ts CHANGED
@@ -1,36 +1,36 @@
1
- import { baseStylesheet, type StylesheetOptions, stylesheet, type Theme } from "enarratio";
2
- import { defaultTheme } from "enarratio/themes";
3
-
4
- /**
5
- * What a page's charts are styled with:
6
- * - `"theme"` (default): an Enarratio theme's colors and rules, light and dark.
7
- * - `"base"`: the rules without colors or fonts, for a site whose own tokens define the
8
- * `--enarratio-*` properties (Capsomer's tokens do).
9
- * - `"none"`: nothing; the site includes Enarratio's stylesheet itself.
10
- */
11
- export type Styles = "theme" | "base" | "none";
12
-
13
- /** How charts are styled: the {@link Styles} choice, and for `"theme"` which theme and how. */
14
- export interface StyleOptions {
15
- readonly styles?: Styles | undefined;
16
- /** The Enarratio theme. Default: Enarratio's default theme. */
17
- readonly theme?: Theme | undefined;
18
- /** Passed to Enarratio's `stylesheet()`, e.g. `{ colorScheme: { dark: '[data-theme="dark"]' } }`. */
19
- readonly stylesheetOptions?: StylesheetOptions | undefined;
20
- }
21
-
22
- /** The note that replaces a chart that cannot be drawn, set apart from the text around it. */
23
- const NOTE =
24
- ".enarratio-chart-error{border-inline-start:3px solid currentColor;padding-inline-start:.75em;font-size:.875em}";
25
-
26
- /** The CSS written once per page (or Markdown document) with a chart; empty for `"none"`. */
27
- export function chartStyles(options: StyleOptions = {}): string {
28
- switch (options.styles ?? "theme") {
29
- case "none":
30
- return "";
31
- case "base":
32
- return `${baseStylesheet()}\n${NOTE}`;
33
- default:
34
- return `${stylesheet(options.theme ?? defaultTheme, options.stylesheetOptions)}\n${NOTE}`;
35
- }
36
- }
1
+ import { baseStylesheet, type StylesheetOptions, stylesheet, type Theme } from "enarratio";
2
+ import { defaultTheme } from "enarratio/themes";
3
+
4
+ /**
5
+ * What a page's charts are styled with:
6
+ * - `"theme"` (default): an Enarratio theme's colors and rules, light and dark.
7
+ * - `"base"`: the rules without colors or fonts, for a site whose own tokens define the
8
+ * `--enarratio-*` properties (Capsomer's tokens do).
9
+ * - `"none"`: nothing; the site includes Enarratio's stylesheet itself.
10
+ */
11
+ export type Styles = "theme" | "base" | "none";
12
+
13
+ /** How charts are styled: the {@link Styles} choice, and for `"theme"` which theme and how. */
14
+ export interface StyleOptions {
15
+ readonly styles?: Styles | undefined;
16
+ /** The Enarratio theme. Default: Enarratio's default theme. */
17
+ readonly theme?: Theme | undefined;
18
+ /** Passed to Enarratio's `stylesheet()`, e.g. `{ colorScheme: { dark: '[data-theme="dark"]' } }`. */
19
+ readonly stylesheetOptions?: StylesheetOptions | undefined;
20
+ }
21
+
22
+ /** The note that replaces a chart that cannot be drawn, set apart from the text around it. */
23
+ const NOTE =
24
+ ".enarratio-chart-error{border-inline-start:3px solid currentColor;padding-inline-start:.75em;font-size:.875em}";
25
+
26
+ /** The CSS written once per page (or Markdown document) with a chart; empty for `"none"`. */
27
+ export function chartStyles(options: StyleOptions = {}): string {
28
+ switch (options.styles ?? "theme") {
29
+ case "none":
30
+ return "";
31
+ case "base":
32
+ return `${baseStylesheet()}\n${NOTE}`;
33
+ default:
34
+ return `${stylesheet(options.theme ?? defaultTheme, options.stylesheetOptions)}\n${NOTE}`;
35
+ }
36
+ }