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/CHANGELOG.md +86 -22
- package/LICENSE +21 -21
- package/README.md +155 -151
- package/dist/chart.d.ts +24 -2
- package/dist/chart.js +16 -12
- package/dist/index.d.ts +1 -1
- package/dist/markdown.js +8 -1
- package/package.json +66 -66
- package/src/Chart.astro +31 -31
- package/src/Enhance.astro +21 -21
- package/src/chart.ts +209 -177
- package/src/data.ts +156 -156
- package/src/env.d.ts +7 -7
- package/src/figure.ts +14 -14
- package/src/index.ts +18 -17
- package/src/markdown.ts +171 -164
- package/src/once.ts +11 -11
- package/src/styles.ts +36 -36
package/src/data.ts
CHANGED
|
@@ -1,156 +1,156 @@
|
|
|
1
|
-
/** A cell after parsing: a number, text, or `null` for an empty cell. */
|
|
2
|
-
export type Cell = number | string | null;
|
|
3
|
-
|
|
4
|
-
/** A table parsed from the block's data field. */
|
|
5
|
-
export interface Table {
|
|
6
|
-
readonly columns: readonly string[];
|
|
7
|
-
readonly rows: ReadonlyArray<Readonly<Record<string, Cell>>>;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/** Limits that keep one chart's render bounded, whatever an editor pastes. */
|
|
11
|
-
export const MAX_ROWS = 5000;
|
|
12
|
-
export const MAX_COLUMNS = 50;
|
|
13
|
-
|
|
14
|
-
const NUMBER = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i;
|
|
15
|
-
const GROUPED_NUMBER = /^[+-]?\d{1,3}(?:,\d{3})+(?:\.\d+)?$/;
|
|
16
|
-
|
|
17
|
-
/** Reads a cell: numbers (including "1,234") become numbers, an empty cell `null`, the rest text. */
|
|
18
|
-
function cell(raw: string): Cell {
|
|
19
|
-
const text = raw.trim();
|
|
20
|
-
if (text === "") return null;
|
|
21
|
-
if (NUMBER.test(text)) return Number(text);
|
|
22
|
-
if (GROUPED_NUMBER.test(text)) return Number(text.replaceAll(",", ""));
|
|
23
|
-
return text;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* The delimiter of pasted data: a tab when the header row has one (a spreadsheet paste),
|
|
28
|
-
* otherwise whichever of comma and semicolon appears more often outside quotes in that row.
|
|
29
|
-
*/
|
|
30
|
-
function delimiter(text: string): string {
|
|
31
|
-
const header = text.slice(0, text.search(/\r?\n|$/));
|
|
32
|
-
if (header.includes("\t")) return "\t";
|
|
33
|
-
const unquoted = header.replace(/"[^"]*"/g, "");
|
|
34
|
-
const count = (c: string) => unquoted.split(c).length - 1;
|
|
35
|
-
return count(";") > count(",") ? ";" : ",";
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Splits delimited text into records, following RFC 4180 quoting ("" is a literal quote). */
|
|
39
|
-
function records(text: string, sep: string): string[][] {
|
|
40
|
-
const out: string[][] = [];
|
|
41
|
-
let record: string[] = [];
|
|
42
|
-
let field = "";
|
|
43
|
-
let quoted = false;
|
|
44
|
-
for (let i = 0; i < text.length; i++) {
|
|
45
|
-
const c = text[i];
|
|
46
|
-
if (quoted) {
|
|
47
|
-
if (c === '"' && text[i + 1] === '"') {
|
|
48
|
-
field += '"';
|
|
49
|
-
i++;
|
|
50
|
-
} else if (c === '"') {
|
|
51
|
-
quoted = false;
|
|
52
|
-
} else {
|
|
53
|
-
field += c;
|
|
54
|
-
}
|
|
55
|
-
} else if (c === '"' && field.trim() === "") {
|
|
56
|
-
quoted = true;
|
|
57
|
-
field = "";
|
|
58
|
-
} else if (c === sep) {
|
|
59
|
-
record.push(field);
|
|
60
|
-
field = "";
|
|
61
|
-
} else if (c === "\n" || c === "\r") {
|
|
62
|
-
if (c === "\r" && text[i + 1] === "\n") i++;
|
|
63
|
-
record.push(field);
|
|
64
|
-
out.push(record);
|
|
65
|
-
record = [];
|
|
66
|
-
field = "";
|
|
67
|
-
} else {
|
|
68
|
-
field += c;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
if (quoted) throw new Error("The data has a quote that is never closed.");
|
|
72
|
-
record.push(field);
|
|
73
|
-
out.push(record);
|
|
74
|
-
return out.filter((r) => r.some((f) => f.trim() !== ""));
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Parses the data an editor pasted or typed: CSV, semicolon-separated or tab-separated text
|
|
79
|
-
* whose first row names the columns. Throws an Error whose message an editor can act on.
|
|
80
|
-
*/
|
|
81
|
-
export function parseTable(text: string): Table {
|
|
82
|
-
const all = records(text, delimiter(text));
|
|
83
|
-
const [header, ...body] = all;
|
|
84
|
-
if (!header || body.length === 0) {
|
|
85
|
-
throw new Error("Add data: a first row naming the columns, then at least one row of values.");
|
|
86
|
-
}
|
|
87
|
-
const columns = header.map((name) => name.trim());
|
|
88
|
-
if (columns.length > MAX_COLUMNS)
|
|
89
|
-
throw new Error(`The data has more than ${MAX_COLUMNS} columns.`);
|
|
90
|
-
if (body.length > MAX_ROWS) throw new Error(`The data has more than ${MAX_ROWS} rows.`);
|
|
91
|
-
const seen = new Set<string>();
|
|
92
|
-
for (const name of columns) {
|
|
93
|
-
if (name === "") throw new Error("Every column in the first row needs a name.");
|
|
94
|
-
const key = name.toLowerCase();
|
|
95
|
-
if (seen.has(key)) throw new Error(`Two columns are named "${name}".`);
|
|
96
|
-
seen.add(key);
|
|
97
|
-
}
|
|
98
|
-
const rows = body.map((record, n) => {
|
|
99
|
-
if (record.slice(columns.length).some((f) => f.trim() !== "")) {
|
|
100
|
-
throw new Error(`Row ${n + 2} has more values than there are columns.`);
|
|
101
|
-
}
|
|
102
|
-
// No prototype, so a column named "__proto__" is an ordinary field.
|
|
103
|
-
const row: Record<string, Cell> = Object.create(null);
|
|
104
|
-
columns.forEach((name, i) => {
|
|
105
|
-
row[name] = cell(record[i] ?? "");
|
|
106
|
-
});
|
|
107
|
-
return row;
|
|
108
|
-
});
|
|
109
|
-
return { columns, rows };
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* The column an editor named, matched without regard to case or surrounding space. Throws when
|
|
114
|
-
* there is none, listing the columns there are.
|
|
115
|
-
*/
|
|
116
|
-
export function column(table: Table, name: string | undefined, role: string): string {
|
|
117
|
-
const wanted = name?.trim().toLowerCase() ?? "";
|
|
118
|
-
if (wanted === "") throw new Error(`Name the ${role} column.`);
|
|
119
|
-
const found = table.columns.find((c) => c.toLowerCase() === wanted);
|
|
120
|
-
if (!found) {
|
|
121
|
-
throw new Error(
|
|
122
|
-
`There is no column "${name?.trim()}" for the ${role}. The columns are: ${table.columns.join(", ")}.`,
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
return found;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Reads one value from a row given as an object: dates as ISO text, true and false as text. */
|
|
129
|
-
function value(raw: unknown): Cell {
|
|
130
|
-
if (raw === null || raw === undefined || raw === "") return null;
|
|
131
|
-
if (typeof raw === "number") return Number.isFinite(raw) ? raw : null;
|
|
132
|
-
if (typeof raw === "string") return raw;
|
|
133
|
-
if (raw instanceof Date) return raw.toISOString();
|
|
134
|
-
if (typeof raw === "boolean") return String(raw);
|
|
135
|
-
throw new Error(
|
|
136
|
-
`A value in the data is ${Array.isArray(raw) ? "a list" : "an object"}; use numbers or text.`,
|
|
137
|
-
);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** Reads rows given as objects (from JSON or a content collection) into a table. */
|
|
141
|
-
export function tableFromRows(input: readonly Readonly<Record<string, unknown>>[]): Table {
|
|
142
|
-
if (input.length === 0) throw new Error("Add data: at least one row.");
|
|
143
|
-
if (input.length > MAX_ROWS) throw new Error(`The data has more than ${MAX_ROWS} rows.`);
|
|
144
|
-
const columns: string[] = [];
|
|
145
|
-
for (const row of input) {
|
|
146
|
-
for (const key of Object.keys(row)) if (!columns.includes(key)) columns.push(key);
|
|
147
|
-
}
|
|
148
|
-
if (columns.length > MAX_COLUMNS)
|
|
149
|
-
throw new Error(`The data has more than ${MAX_COLUMNS} columns.`);
|
|
150
|
-
const rows = input.map((source) => {
|
|
151
|
-
const row: Record<string, Cell> = Object.create(null);
|
|
152
|
-
for (const name of columns) row[name] = value(source[name]);
|
|
153
|
-
return row;
|
|
154
|
-
});
|
|
155
|
-
return { columns, rows };
|
|
156
|
-
}
|
|
1
|
+
/** A cell after parsing: a number, text, or `null` for an empty cell. */
|
|
2
|
+
export type Cell = number | string | null;
|
|
3
|
+
|
|
4
|
+
/** A table parsed from the block's data field. */
|
|
5
|
+
export interface Table {
|
|
6
|
+
readonly columns: readonly string[];
|
|
7
|
+
readonly rows: ReadonlyArray<Readonly<Record<string, Cell>>>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Limits that keep one chart's render bounded, whatever an editor pastes. */
|
|
11
|
+
export const MAX_ROWS = 5000;
|
|
12
|
+
export const MAX_COLUMNS = 50;
|
|
13
|
+
|
|
14
|
+
const NUMBER = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i;
|
|
15
|
+
const GROUPED_NUMBER = /^[+-]?\d{1,3}(?:,\d{3})+(?:\.\d+)?$/;
|
|
16
|
+
|
|
17
|
+
/** Reads a cell: numbers (including "1,234") become numbers, an empty cell `null`, the rest text. */
|
|
18
|
+
function cell(raw: string): Cell {
|
|
19
|
+
const text = raw.trim();
|
|
20
|
+
if (text === "") return null;
|
|
21
|
+
if (NUMBER.test(text)) return Number(text);
|
|
22
|
+
if (GROUPED_NUMBER.test(text)) return Number(text.replaceAll(",", ""));
|
|
23
|
+
return text;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The delimiter of pasted data: a tab when the header row has one (a spreadsheet paste),
|
|
28
|
+
* otherwise whichever of comma and semicolon appears more often outside quotes in that row.
|
|
29
|
+
*/
|
|
30
|
+
function delimiter(text: string): string {
|
|
31
|
+
const header = text.slice(0, text.search(/\r?\n|$/));
|
|
32
|
+
if (header.includes("\t")) return "\t";
|
|
33
|
+
const unquoted = header.replace(/"[^"]*"/g, "");
|
|
34
|
+
const count = (c: string) => unquoted.split(c).length - 1;
|
|
35
|
+
return count(";") > count(",") ? ";" : ",";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Splits delimited text into records, following RFC 4180 quoting ("" is a literal quote). */
|
|
39
|
+
function records(text: string, sep: string): string[][] {
|
|
40
|
+
const out: string[][] = [];
|
|
41
|
+
let record: string[] = [];
|
|
42
|
+
let field = "";
|
|
43
|
+
let quoted = false;
|
|
44
|
+
for (let i = 0; i < text.length; i++) {
|
|
45
|
+
const c = text[i];
|
|
46
|
+
if (quoted) {
|
|
47
|
+
if (c === '"' && text[i + 1] === '"') {
|
|
48
|
+
field += '"';
|
|
49
|
+
i++;
|
|
50
|
+
} else if (c === '"') {
|
|
51
|
+
quoted = false;
|
|
52
|
+
} else {
|
|
53
|
+
field += c;
|
|
54
|
+
}
|
|
55
|
+
} else if (c === '"' && field.trim() === "") {
|
|
56
|
+
quoted = true;
|
|
57
|
+
field = "";
|
|
58
|
+
} else if (c === sep) {
|
|
59
|
+
record.push(field);
|
|
60
|
+
field = "";
|
|
61
|
+
} else if (c === "\n" || c === "\r") {
|
|
62
|
+
if (c === "\r" && text[i + 1] === "\n") i++;
|
|
63
|
+
record.push(field);
|
|
64
|
+
out.push(record);
|
|
65
|
+
record = [];
|
|
66
|
+
field = "";
|
|
67
|
+
} else {
|
|
68
|
+
field += c;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (quoted) throw new Error("The data has a quote that is never closed.");
|
|
72
|
+
record.push(field);
|
|
73
|
+
out.push(record);
|
|
74
|
+
return out.filter((r) => r.some((f) => f.trim() !== ""));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Parses the data an editor pasted or typed: CSV, semicolon-separated or tab-separated text
|
|
79
|
+
* whose first row names the columns. Throws an Error whose message an editor can act on.
|
|
80
|
+
*/
|
|
81
|
+
export function parseTable(text: string): Table {
|
|
82
|
+
const all = records(text, delimiter(text));
|
|
83
|
+
const [header, ...body] = all;
|
|
84
|
+
if (!header || body.length === 0) {
|
|
85
|
+
throw new Error("Add data: a first row naming the columns, then at least one row of values.");
|
|
86
|
+
}
|
|
87
|
+
const columns = header.map((name) => name.trim());
|
|
88
|
+
if (columns.length > MAX_COLUMNS)
|
|
89
|
+
throw new Error(`The data has more than ${MAX_COLUMNS} columns.`);
|
|
90
|
+
if (body.length > MAX_ROWS) throw new Error(`The data has more than ${MAX_ROWS} rows.`);
|
|
91
|
+
const seen = new Set<string>();
|
|
92
|
+
for (const name of columns) {
|
|
93
|
+
if (name === "") throw new Error("Every column in the first row needs a name.");
|
|
94
|
+
const key = name.toLowerCase();
|
|
95
|
+
if (seen.has(key)) throw new Error(`Two columns are named "${name}".`);
|
|
96
|
+
seen.add(key);
|
|
97
|
+
}
|
|
98
|
+
const rows = body.map((record, n) => {
|
|
99
|
+
if (record.slice(columns.length).some((f) => f.trim() !== "")) {
|
|
100
|
+
throw new Error(`Row ${n + 2} has more values than there are columns.`);
|
|
101
|
+
}
|
|
102
|
+
// No prototype, so a column named "__proto__" is an ordinary field.
|
|
103
|
+
const row: Record<string, Cell> = Object.create(null);
|
|
104
|
+
columns.forEach((name, i) => {
|
|
105
|
+
row[name] = cell(record[i] ?? "");
|
|
106
|
+
});
|
|
107
|
+
return row;
|
|
108
|
+
});
|
|
109
|
+
return { columns, rows };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The column an editor named, matched without regard to case or surrounding space. Throws when
|
|
114
|
+
* there is none, listing the columns there are.
|
|
115
|
+
*/
|
|
116
|
+
export function column(table: Table, name: string | undefined, role: string): string {
|
|
117
|
+
const wanted = name?.trim().toLowerCase() ?? "";
|
|
118
|
+
if (wanted === "") throw new Error(`Name the ${role} column.`);
|
|
119
|
+
const found = table.columns.find((c) => c.toLowerCase() === wanted);
|
|
120
|
+
if (!found) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`There is no column "${name?.trim()}" for the ${role}. The columns are: ${table.columns.join(", ")}.`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return found;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Reads one value from a row given as an object: dates as ISO text, true and false as text. */
|
|
129
|
+
function value(raw: unknown): Cell {
|
|
130
|
+
if (raw === null || raw === undefined || raw === "") return null;
|
|
131
|
+
if (typeof raw === "number") return Number.isFinite(raw) ? raw : null;
|
|
132
|
+
if (typeof raw === "string") return raw;
|
|
133
|
+
if (raw instanceof Date) return raw.toISOString();
|
|
134
|
+
if (typeof raw === "boolean") return String(raw);
|
|
135
|
+
throw new Error(
|
|
136
|
+
`A value in the data is ${Array.isArray(raw) ? "a list" : "an object"}; use numbers or text.`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Reads rows given as objects (from JSON or a content collection) into a table. */
|
|
141
|
+
export function tableFromRows(input: readonly Readonly<Record<string, unknown>>[]): Table {
|
|
142
|
+
if (input.length === 0) throw new Error("Add data: at least one row.");
|
|
143
|
+
if (input.length > MAX_ROWS) throw new Error(`The data has more than ${MAX_ROWS} rows.`);
|
|
144
|
+
const columns: string[] = [];
|
|
145
|
+
for (const row of input) {
|
|
146
|
+
for (const key of Object.keys(row)) if (!columns.includes(key)) columns.push(key);
|
|
147
|
+
}
|
|
148
|
+
if (columns.length > MAX_COLUMNS)
|
|
149
|
+
throw new Error(`The data has more than ${MAX_COLUMNS} columns.`);
|
|
150
|
+
const rows = input.map((source) => {
|
|
151
|
+
const row: Record<string, Cell> = Object.create(null);
|
|
152
|
+
for (const name of columns) row[name] = value(source[name]);
|
|
153
|
+
return row;
|
|
154
|
+
});
|
|
155
|
+
return { columns, rows };
|
|
156
|
+
}
|
package/src/env.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// The type checker does not read .astro files; Astro's own compiler checks them when a site builds.
|
|
2
|
-
declare module "*.astro" {
|
|
3
|
-
import type { AstroComponentFactory } from "astro/runtime/server/index.js";
|
|
4
|
-
|
|
5
|
-
const component: AstroComponentFactory;
|
|
6
|
-
export default component;
|
|
7
|
-
}
|
|
1
|
+
// The type checker does not read .astro files; Astro's own compiler checks them when a site builds.
|
|
2
|
+
declare module "*.astro" {
|
|
3
|
+
import type { AstroComponentFactory } from "astro/runtime/server/index.js";
|
|
4
|
+
|
|
5
|
+
const component: AstroComponentFactory;
|
|
6
|
+
export default component;
|
|
7
|
+
}
|
package/src/figure.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import { escapeHtml } from "enarratio";
|
|
2
|
-
import type { ChartResult } from "./chart.js";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* The markup that goes on the page for a drawn chart, or for one that could not be drawn: the
|
|
6
|
-
* chart in a wrapper (marked when it is interactive), or a note saying why.
|
|
7
|
-
*/
|
|
8
|
-
export function chartMarkup(result: ChartResult, interactive: boolean): string {
|
|
9
|
-
if (!result.ok) {
|
|
10
|
-
return `<p class="enarratio-chart-error" role="note">This chart could not be drawn. ${escapeHtml(result.error)}</p>`;
|
|
11
|
-
}
|
|
12
|
-
const mark = interactive ? " data-enarratio-interactive-chart" : "";
|
|
13
|
-
return `<div class="enarratio-chart"${mark}>${result.html}</div>`;
|
|
14
|
-
}
|
|
1
|
+
import { escapeHtml } from "enarratio";
|
|
2
|
+
import type { ChartResult } from "./chart.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The markup that goes on the page for a drawn chart, or for one that could not be drawn: the
|
|
6
|
+
* chart in a wrapper (marked when it is interactive), or a note saying why.
|
|
7
|
+
*/
|
|
8
|
+
export function chartMarkup(result: ChartResult, interactive: boolean): string {
|
|
9
|
+
if (!result.ok) {
|
|
10
|
+
return `<p class="enarratio-chart-error" role="note">This chart could not be drawn. ${escapeHtml(result.error)}</p>`;
|
|
11
|
+
}
|
|
12
|
+
const mark = interactive ? " data-enarratio-interactive-chart" : "";
|
|
13
|
+
return `<div class="enarratio-chart"${mark}>${result.html}</div>`;
|
|
14
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Enarratio charts for Astro: a `<Chart>` component (`enarratio-astro/Chart.astro`), charts in
|
|
3
|
-
* Markdown (`enarratio-astro/markdown`), and the functions under both, drawn on the server by
|
|
4
|
-
* Enarratio as accessible HTML and SVG.
|
|
5
|
-
*
|
|
6
|
-
* @packageDocumentation
|
|
7
|
-
*/
|
|
8
|
-
export {
|
|
9
|
-
CHART_TYPES,
|
|
10
|
-
type ChartResult,
|
|
11
|
-
type ChartSpec,
|
|
12
|
-
type ChartType,
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
export {
|
|
17
|
-
export {
|
|
1
|
+
/**
|
|
2
|
+
* Enarratio charts for Astro: a `<Chart>` component (`enarratio-astro/Chart.astro`), charts in
|
|
3
|
+
* Markdown (`enarratio-astro/markdown`), and the functions under both, drawn on the server by
|
|
4
|
+
* Enarratio as accessible HTML and SVG.
|
|
5
|
+
*
|
|
6
|
+
* @packageDocumentation
|
|
7
|
+
*/
|
|
8
|
+
export {
|
|
9
|
+
CHART_TYPES,
|
|
10
|
+
type ChartResult,
|
|
11
|
+
type ChartSpec,
|
|
12
|
+
type ChartType,
|
|
13
|
+
type RenderOptions,
|
|
14
|
+
renderChart,
|
|
15
|
+
} from "./chart.js";
|
|
16
|
+
export { type Cell, parseTable, type Table, tableFromRows } from "./data.js";
|
|
17
|
+
export { chartMarkup } from "./figure.js";
|
|
18
|
+
export { chartStyles, type StyleOptions, type Styles } from "./styles.js";
|