@alkemdotdev/alkemist-components 1.0.0-beta.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/src/charts.ts ADDED
@@ -0,0 +1,401 @@
1
+ import type { TopLevelSpec } from 'vega-lite';
2
+
3
+ export type AlkChartType =
4
+ 'line' | 'bar' | 'scatter' | 'pie' | 'donut' | 'heatmap';
5
+ export type AlkChartFieldType =
6
+ 'quantitative' | 'temporal' | 'nominal' | 'ordinal';
7
+ export type AlkChartInk =
8
+ | 'cobalt'
9
+ | 'cyan'
10
+ | 'teal'
11
+ | 'fern'
12
+ | 'ochre'
13
+ | 'vermilion'
14
+ | 'rose'
15
+ | 'violet';
16
+
17
+ export interface AlkChartProps {
18
+ src: string;
19
+ type: AlkChartType;
20
+ x: string;
21
+ y: string;
22
+ title: string;
23
+ description: string;
24
+ xLabel?: string;
25
+ yLabel?: string;
26
+ xType?: AlkChartFieldType;
27
+ color?: string;
28
+ colorLabel?: string;
29
+ value?: string;
30
+ valueLabel?: string;
31
+ ink?: AlkChartInk;
32
+ height?: number;
33
+ grid?: boolean;
34
+ zoom?: boolean;
35
+ horizontal?: boolean;
36
+ stacked?: boolean;
37
+ caption?: string;
38
+ sample?: boolean;
39
+ }
40
+
41
+ export interface AlkChartTheme {
42
+ text: string;
43
+ rule: string;
44
+ inks: Record<AlkChartInk, string>;
45
+ }
46
+
47
+ export type AlkChartRow = Record<string, string | number | null>;
48
+
49
+ export const alkChartInks: AlkChartInk[] = [
50
+ 'cobalt',
51
+ 'cyan',
52
+ 'teal',
53
+ 'fern',
54
+ 'ochre',
55
+ 'vermilion',
56
+ 'rose',
57
+ 'violet',
58
+ ];
59
+
60
+ function literalField(field: string): string {
61
+ return field
62
+ .replaceAll('\\', '\\\\')
63
+ .replaceAll('.', '\\.')
64
+ .replaceAll('[', '\\[')
65
+ .replaceAll(']', '\\]');
66
+ }
67
+
68
+ export function alkChartXType(config: AlkChartProps): AlkChartFieldType {
69
+ return (
70
+ config.xType ??
71
+ (['bar', 'pie', 'donut'].includes(config.type)
72
+ ? 'nominal'
73
+ : config.type === 'heatmap'
74
+ ? 'ordinal'
75
+ : 'quantitative')
76
+ );
77
+ }
78
+
79
+ export function alkChartCanZoom(config: AlkChartProps): boolean {
80
+ return (
81
+ config.zoom !== false &&
82
+ ['line', 'scatter'].includes(config.type) &&
83
+ ['quantitative', 'temporal'].includes(alkChartXType(config))
84
+ );
85
+ }
86
+
87
+ /** Parse only explicitly numeric columns; identifiers and category labels stay intact. */
88
+ export function prepareAlkChartRows(
89
+ raw: AlkChartRow[],
90
+ config: AlkChartProps,
91
+ ): AlkChartRow[] {
92
+ if (!raw.length) throw new Error('The CSV contains no data rows.');
93
+ const required = [
94
+ config.x,
95
+ config.y,
96
+ config.color,
97
+ config.type === 'heatmap' ? config.value : undefined,
98
+ ].filter((field): field is string => Boolean(field));
99
+ if (config.type === 'heatmap' && !config.value)
100
+ throw new Error('A heatmap requires a value column.');
101
+ for (const field of required) {
102
+ if (!Object.hasOwn(raw[0], field))
103
+ throw new Error(`The CSV is missing the “${field}” column.`);
104
+ }
105
+ const numeric = new Set<string>();
106
+ if (alkChartXType(config) === 'quantitative') numeric.add(config.x);
107
+ if (config.type !== 'heatmap') numeric.add(config.y);
108
+ if (config.type === 'heatmap' && config.value) numeric.add(config.value);
109
+ if (config.type === 'heatmap') {
110
+ for (const field of [config.x, config.y]) {
111
+ if (
112
+ raw.every(
113
+ (row) =>
114
+ row[field] !== null &&
115
+ String(row[field]).trim() !== '' &&
116
+ Number.isFinite(Number(row[field])),
117
+ )
118
+ )
119
+ numeric.add(field);
120
+ }
121
+ }
122
+ const rows = raw.map((source, index) => {
123
+ const row = { ...source };
124
+ for (const field of numeric) {
125
+ const value = source[field];
126
+ if (value === null || String(value).trim() === '') {
127
+ row[field] = null;
128
+ } else {
129
+ const number = Number(value);
130
+ if (!Number.isFinite(number))
131
+ throw new Error(
132
+ `Row ${index + 2}: “${field}” must contain a finite number.`,
133
+ );
134
+ if (
135
+ ['pie', 'donut'].includes(config.type) &&
136
+ field === config.y &&
137
+ number < 0
138
+ )
139
+ throw new Error(
140
+ `Row ${index + 2}: pie and donut values cannot be negative.`,
141
+ );
142
+ row[field] = number;
143
+ }
144
+ }
145
+ if (alkChartXType(config) === 'temporal') {
146
+ const value = row[config.x];
147
+ if (value === null || String(value).trim() === '') {
148
+ row[config.x] = null;
149
+ } else if (!Number.isFinite(Date.parse(String(value)))) {
150
+ throw new Error(
151
+ `Row ${index + 2}: “${config.x}” must contain a valid date.`,
152
+ );
153
+ }
154
+ }
155
+ return row;
156
+ });
157
+ for (const field of numeric) {
158
+ if (rows.every((row) => row[field] === null))
159
+ throw new Error(`The “${field}” column contains no numeric values.`);
160
+ }
161
+ if (
162
+ alkChartXType(config) === 'temporal' &&
163
+ rows.every((row) => row[config.x] === null)
164
+ )
165
+ throw new Error(`The “${config.x}” column contains no dates.`);
166
+ if (
167
+ ['pie', 'donut'].includes(config.type) &&
168
+ !rows.some((row) => Number(row[config.y]) > 0)
169
+ )
170
+ throw new Error(
171
+ 'Pie and donut charts require at least one positive value.',
172
+ );
173
+ return rows;
174
+ }
175
+
176
+ /** Presets remain small; the Vega-Lite engine owns scales, marks, and interactions. */
177
+ export function createAlkChartSpec(
178
+ config: AlkChartProps,
179
+ rows: AlkChartRow[],
180
+ theme: AlkChartTheme,
181
+ width: number,
182
+ ): TopLevelSpec {
183
+ const palette = alkChartInks.map((ink) => theme.inks[ink]);
184
+ const categorical =
185
+ config.color ??
186
+ (['bar', 'pie', 'donut'].includes(config.type) ? config.x : undefined);
187
+ const color = categorical
188
+ ? {
189
+ field: literalField(categorical),
190
+ type: 'nominal' as const,
191
+ title: config.colorLabel ?? categorical,
192
+ scale: {
193
+ domain: [...new Set(rows.map((row) => row[categorical]))],
194
+ range: palette,
195
+ },
196
+ legend:
197
+ config.type === 'bar' && !config.color
198
+ ? null
199
+ : {
200
+ orient: 'bottom' as const,
201
+ columns: width < 420 ? 2 : 4,
202
+ labelLimit: Math.max(65, width / (width < 420 ? 2 : 4) - 42),
203
+ },
204
+ }
205
+ : { value: theme.inks[config.ink ?? 'cobalt'] };
206
+ const base = {
207
+ $schema: 'https://vega.github.io/schema/vega-lite/v6.json',
208
+ description: config.description,
209
+ width,
210
+ height: config.height ?? 300,
211
+ padding: 6,
212
+ autosize: { type: 'fit' as const, contains: 'padding' as const },
213
+ background: 'transparent',
214
+ data: { values: rows },
215
+ config: {
216
+ font: 'Ubuntu',
217
+ view: { stroke: null },
218
+ axis: {
219
+ grid: config.grid !== false,
220
+ gridColor: theme.rule,
221
+ domainColor: theme.rule,
222
+ tickColor: theme.rule,
223
+ labelColor: theme.text,
224
+ titleColor: theme.text,
225
+ labelFont: 'Ubuntu Mono',
226
+ titleFont: 'Ubuntu',
227
+ labelFontSize: 12,
228
+ titleFontSize: 12,
229
+ titleFontWeight: 400 as const,
230
+ titlePadding: 14,
231
+ labelPadding: 7,
232
+ labelLimit: 120,
233
+ tickCount: Math.max(3, Math.round(width / 100)),
234
+ },
235
+ legend: {
236
+ labelColor: theme.text,
237
+ titleColor: theme.text,
238
+ labelFont: 'Ubuntu',
239
+ titleFont: 'Ubuntu',
240
+ titleFontWeight: 400 as const,
241
+ labelFontSize: 12,
242
+ symbolSize: 80,
243
+ padding: 10,
244
+ rowPadding: 7,
245
+ },
246
+ },
247
+ };
248
+ const x = {
249
+ field: literalField(config.x),
250
+ type: alkChartXType(config),
251
+ title: config.xLabel ?? config.x,
252
+ axis:
253
+ config.type === 'bar' &&
254
+ !config.horizontal &&
255
+ width < 420 &&
256
+ ['nominal', 'ordinal'].includes(alkChartXType(config))
257
+ ? { labelAngle: -55, labelLimit: 70, labelOverlap: 'greedy' as const }
258
+ : { labelAngle: 0 },
259
+ ...(alkChartXType(config) === 'quantitative'
260
+ ? { scale: { zero: false } }
261
+ : {}),
262
+ };
263
+ const y = {
264
+ field: literalField(config.y),
265
+ type: 'quantitative' as const,
266
+ title: config.yLabel ?? config.y,
267
+ };
268
+ const tooltip = [
269
+ {
270
+ field: literalField(config.x),
271
+ type: alkChartXType(config),
272
+ title: config.xLabel ?? config.x,
273
+ },
274
+ {
275
+ field: literalField(config.y),
276
+ type:
277
+ config.type === 'heatmap'
278
+ ? ('ordinal' as const)
279
+ : ('quantitative' as const),
280
+ title: config.yLabel ?? config.y,
281
+ },
282
+ ...(config.color
283
+ ? [
284
+ {
285
+ field: literalField(config.color),
286
+ type: 'nominal' as const,
287
+ title: config.colorLabel ?? config.color,
288
+ },
289
+ ]
290
+ : []),
291
+ ];
292
+ if (config.type === 'pie' || config.type === 'donut') {
293
+ return {
294
+ ...base,
295
+ mark: {
296
+ type: 'arc',
297
+ innerRadius:
298
+ config.type === 'donut'
299
+ ? Math.min(width, config.height ?? 300) * 0.22
300
+ : 0,
301
+ padAngle: 0.015,
302
+ cornerRadius: 2,
303
+ },
304
+ encoding: { theta: { ...y, stack: true }, color, tooltip },
305
+ };
306
+ }
307
+ if (config.type === 'heatmap') {
308
+ return {
309
+ ...base,
310
+ mark: { type: 'rect', tooltip: true },
311
+ encoding: {
312
+ x: {
313
+ ...x,
314
+ scale: { paddingInner: 0.04 },
315
+ axis: { labelAngle: 0, labelOverlap: true },
316
+ },
317
+ y: {
318
+ field: literalField(config.y),
319
+ type: 'ordinal',
320
+ title: config.yLabel ?? config.y,
321
+ scale: { paddingInner: 0.04 },
322
+ sort: 'descending',
323
+ axis: { labelOverlap: true },
324
+ },
325
+ color: {
326
+ field: literalField(config.value!),
327
+ type: 'quantitative',
328
+ title: config.valueLabel ?? config.value,
329
+ scale: {
330
+ range: ['#253f67', theme.inks.cobalt, '#b6d6f6'],
331
+ interpolate: 'lab',
332
+ },
333
+ legend: {
334
+ orient: 'bottom',
335
+ gradientLength: Math.max(100, Math.min(240, width - 55)),
336
+ },
337
+ },
338
+ tooltip: [
339
+ ...tooltip,
340
+ {
341
+ field: literalField(config.value!),
342
+ type: 'quantitative',
343
+ title: config.valueLabel ?? config.value,
344
+ },
345
+ ],
346
+ },
347
+ };
348
+ }
349
+ if (config.type === 'bar') {
350
+ const magnitude = {
351
+ ...y,
352
+ stack: config.stacked ? ('zero' as const) : null,
353
+ };
354
+ return {
355
+ ...base,
356
+ mark: { type: 'bar', cornerRadiusEnd: 2 },
357
+ encoding: {
358
+ x: config.horizontal ? magnitude : x,
359
+ y: config.horizontal ? { ...x, axis: { labelAngle: 0 } } : magnitude,
360
+ ...(!config.stacked && config.color && config.color !== config.x
361
+ ? config.horizontal
362
+ ? { yOffset: { field: literalField(config.color) } }
363
+ : { xOffset: { field: literalField(config.color) } }
364
+ : {}),
365
+ color,
366
+ tooltip,
367
+ },
368
+ };
369
+ }
370
+ return {
371
+ ...base,
372
+ ...(alkChartCanZoom(config)
373
+ ? {
374
+ params: [
375
+ {
376
+ name: 'alk_window',
377
+ select: {
378
+ type: 'interval' as const,
379
+ encodings:
380
+ config.type === 'scatter'
381
+ ? ['x' as const, 'y' as const]
382
+ : ['x' as const],
383
+ zoom: 'wheel![event.shiftKey]',
384
+ },
385
+ bind: 'scales' as const,
386
+ },
387
+ ],
388
+ }
389
+ : {}),
390
+ mark:
391
+ config.type === 'line'
392
+ ? {
393
+ type: 'line',
394
+ strokeWidth: 2.5,
395
+ clip: true,
396
+ point: { filled: true, size: 8 },
397
+ }
398
+ : { type: 'point', filled: true, size: 36, opacity: 0.85, clip: true },
399
+ encoding: { x, y: { ...y, scale: { zero: false } }, color, tooltip },
400
+ };
401
+ }
@@ -0,0 +1,39 @@
1
+ function enhanceCode() {
2
+ document
3
+ .querySelectorAll<HTMLButtonElement>('[data-alk-copy]')
4
+ .forEach((button) => {
5
+ button.hidden = false;
6
+ if (button.dataset.alkReady) return;
7
+ button.dataset.alkReady = 'true';
8
+ button.addEventListener('click', async () => {
9
+ const figure = button.closest('.alk-code');
10
+ const code = figure?.querySelector('pre code');
11
+ const status = figure?.querySelector<HTMLElement>(
12
+ '[data-alk-copy-status]',
13
+ );
14
+ if (!code || !status) return;
15
+ try {
16
+ await navigator.clipboard.writeText(code.textContent ?? '');
17
+ status.textContent = 'Code copied to clipboard.';
18
+ button.textContent = 'Copied';
19
+ } catch {
20
+ // Selection remains useful when a browser or its permissions block the clipboard.
21
+ const selection = window.getSelection();
22
+ const range = document.createRange();
23
+ range.selectNodeContents(code);
24
+ selection?.removeAllRanges();
25
+ selection?.addRange(range);
26
+ (figure?.querySelector('pre') as HTMLElement | null)?.focus();
27
+ status.textContent =
28
+ 'Clipboard unavailable. Code selected; use your browser’s Copy command.';
29
+ button.textContent = 'Selected';
30
+ }
31
+ window.setTimeout(() => {
32
+ button.textContent = 'Copy';
33
+ }, 2400);
34
+ });
35
+ });
36
+ }
37
+
38
+ enhanceCode();
39
+ document.addEventListener('astro:page-load', enhanceCode);
@@ -0,0 +1,199 @@
1
+ import type { ShikiTransformer, ThemeRegistration } from 'shiki';
2
+ import { ALK_INKS } from '@alkemdotdev/alkemist-theme/palette';
3
+
4
+ const inks = Object.fromEntries(ALK_INKS.map(({ id, hex }) => [id, hex]));
5
+
6
+ /** One fixed ink set on a black code surface, independent of the page theme. */
7
+ export const AlkCodeTheme: ThemeRegistration = {
8
+ name: 'alkemist-board',
9
+ type: 'dark',
10
+ colors: { 'editor.background': '#111111', 'editor.foreground': '#eeeeee' },
11
+ tokenColors: [
12
+ {
13
+ scope: ['comment', 'punctuation.definition.comment'],
14
+ settings: { foreground: '#999999', fontStyle: 'italic' },
15
+ },
16
+ {
17
+ scope: [
18
+ 'keyword',
19
+ 'storage',
20
+ 'punctuation.definition.template-expression',
21
+ ],
22
+ settings: { foreground: inks.violet },
23
+ },
24
+ {
25
+ scope: ['string', 'markup.inserted'],
26
+ settings: { foreground: inks.fern },
27
+ },
28
+ {
29
+ scope: ['constant.numeric', 'constant.language', 'support.constant'],
30
+ settings: { foreground: inks.ochre },
31
+ },
32
+ {
33
+ scope: ['entity.name.function', 'support.function'],
34
+ settings: { foreground: inks.cyan },
35
+ },
36
+ {
37
+ scope: [
38
+ 'entity.name.type',
39
+ 'support.type',
40
+ 'support.class',
41
+ 'entity.name.class',
42
+ ],
43
+ settings: { foreground: inks.cobalt },
44
+ },
45
+ {
46
+ scope: ['variable.parameter', 'entity.other.attribute-name'],
47
+ settings: { foreground: inks.rose },
48
+ },
49
+ {
50
+ scope: ['entity.name.tag', 'markup.deleted'],
51
+ settings: { foreground: inks.vermilion },
52
+ },
53
+ {
54
+ scope: ['markup.heading', 'markup.bold'],
55
+ settings: { foreground: '#eeeeee', fontStyle: 'bold' },
56
+ },
57
+ { scope: ['markup.italic'], settings: { fontStyle: 'italic' } },
58
+ {
59
+ scope: ['invalid'],
60
+ settings: { foreground: '#eeeeee', fontStyle: 'underline' },
61
+ },
62
+ ],
63
+ };
64
+
65
+ export interface AlkCodeOptions {
66
+ title?: string;
67
+ highlightLines?: number[];
68
+ lineNumbers?: boolean;
69
+ }
70
+
71
+ function metadata(raw: string, options: AlkCodeOptions) {
72
+ let metaTitle: string | undefined;
73
+ // Quoted captions are content; their braces and flag words must stay inert.
74
+ const flags = raw.replace(
75
+ /(?:^|\s)title="([^"]*)"/g,
76
+ (_match, value: string) => {
77
+ metaTitle ??= value;
78
+ return ' ';
79
+ },
80
+ );
81
+ const title = options.title ?? metaTitle;
82
+ const highlighted = flags.match(/(?:^|\s)\{([^}]+)\}/)?.[1];
83
+ const ranges =
84
+ highlighted?.split(',').map((range) => {
85
+ const match = range.trim().match(/^(\d+)(?:-(\d+))?$/);
86
+ if (!match)
87
+ throw new Error(`Alkemist: invalid code highlight range "${range}".`);
88
+ const start = Number(match[1]);
89
+ const end = Number(match[2] ?? match[1]);
90
+ if (start < 1 || end < start || !Number.isSafeInteger(end)) {
91
+ throw new Error(
92
+ `Alkemist: code highlight lines must be positive, increasing integers: "${range}".`,
93
+ );
94
+ }
95
+ return [start, end] as const;
96
+ }) ?? [];
97
+ if (
98
+ options.highlightLines?.some(
99
+ (line) => !Number.isSafeInteger(line) || line < 1,
100
+ )
101
+ ) {
102
+ throw new Error(
103
+ 'Alkemist: highlightLines must contain positive integer line numbers.',
104
+ );
105
+ }
106
+ return {
107
+ title,
108
+ lineNumbers:
109
+ options.lineNumbers ?? !/(?:^|\s)no-line-numbers(?:\s|$)/.test(flags),
110
+ isHighlighted: (line: number) =>
111
+ options.highlightLines
112
+ ? options.highlightLines.includes(line)
113
+ : ranges.some(([start, end]) => line >= start && line <= end),
114
+ };
115
+ }
116
+
117
+ /** Shared by Markdown fences and AlkCode; all source and metadata become HAST text. */
118
+ export function createAlkCodeTransformer(
119
+ options: AlkCodeOptions = {},
120
+ ): ShikiTransformer {
121
+ return {
122
+ name: 'alkemist-code',
123
+ pre(node) {
124
+ const config = metadata(this.options.meta?.__raw ?? '', options);
125
+ node.properties.tabindex = 0;
126
+ node.properties.ariaLabel = `${config.title ?? this.options.lang} source code`;
127
+ this.addClassToHast(node, 'alk-code-pre');
128
+ if (config.lineNumbers) this.addClassToHast(node, 'alk-code-numbered');
129
+ },
130
+ line(node, line) {
131
+ const config = metadata(this.options.meta?.__raw ?? '', options);
132
+ node.properties.dataLine = String(line);
133
+ if (config.isHighlighted(line))
134
+ this.addClassToHast(node, 'alk-code-highlight');
135
+ },
136
+ root(root) {
137
+ const config = metadata(this.options.meta?.__raw ?? '', options);
138
+ const children = root.children.map((child) => {
139
+ if (child.type === 'doctype') {
140
+ throw new Error(
141
+ 'Alkemist: code highlighting must produce an HTML fragment.',
142
+ );
143
+ }
144
+ return child;
145
+ });
146
+ root.children = [
147
+ {
148
+ type: 'element',
149
+ tagName: 'figure',
150
+ properties: { className: ['alk-code'] },
151
+ children: [
152
+ {
153
+ type: 'element',
154
+ tagName: 'figcaption',
155
+ properties: { className: ['alk-code-caption'] },
156
+ children: [
157
+ {
158
+ type: 'element',
159
+ tagName: 'span',
160
+ properties: { className: ['alk-code-title'] },
161
+ children: [{ type: 'text', value: config.title ?? 'Source' }],
162
+ },
163
+ {
164
+ type: 'element',
165
+ tagName: 'span',
166
+ properties: { className: ['alk-code-language'] },
167
+ children: [{ type: 'text', value: this.options.lang }],
168
+ },
169
+ {
170
+ type: 'element',
171
+ tagName: 'button',
172
+ properties: {
173
+ type: 'button',
174
+ className: ['alk-code-copy'],
175
+ dataAlkCopy: '',
176
+ hidden: true,
177
+ ariaLabel: `Copy ${config.title ?? this.options.lang} source code`,
178
+ },
179
+ children: [{ type: 'text', value: 'Copy' }],
180
+ },
181
+ {
182
+ type: 'element',
183
+ tagName: 'span',
184
+ properties: {
185
+ className: ['alk-sr-only'],
186
+ role: 'status',
187
+ dataAlkCopyStatus: '',
188
+ },
189
+ children: [],
190
+ },
191
+ ],
192
+ },
193
+ ...children,
194
+ ],
195
+ },
196
+ ];
197
+ },
198
+ };
199
+ }