Initial scaffold: spec, architecture playbook, and M0 skeleton

This commit is contained in:
2026-06-04 22:14:33 +03:00
commit 056644450c
51 changed files with 13754 additions and 0 deletions
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { detectFormat, detectFormatFromUrl } from './format-detection';
describe('detectFormat', () => {
it('detects a JSON array of objects with high confidence', () => {
expect(detectFormat('[{"a":1},{"a":2}]')).toEqual({ format: 'json', confidence: 'high' });
});
it('detects a single JSON object as json', () => {
expect(detectFormat('{"a":1}')).toEqual({ format: 'json', confidence: 'high' });
});
it('detects a TopoJSON topology object', () => {
const topo = JSON.stringify({ type: 'Topology', objects: {}, arcs: [] });
expect(detectFormat(topo)).toEqual({ format: 'topojson', confidence: 'high' });
});
it('detects CSV from a comma-separated header + row with medium confidence', () => {
expect(detectFormat('a,b,c\n1,2,3')).toEqual({ format: 'csv', confidence: 'medium' });
});
it('detects TSV from a tab-separated header + row with medium confidence', () => {
expect(detectFormat('a\tb\tc\n1\t2\t3')).toEqual({ format: 'tsv', confidence: 'medium' });
});
it('prefers TSV over CSV when both delimiters appear in the header', () => {
expect(detectFormat('a\tb,c\n1\t2,3').format).toBe('tsv');
});
it('returns low confidence / null for unrecognized input', () => {
expect(detectFormat('just a sentence')).toEqual({ format: null, confidence: 'low' });
});
it('returns low confidence / null for empty input', () => {
expect(detectFormat(' ')).toEqual({ format: null, confidence: 'low' });
});
});
describe('detectFormatFromUrl', () => {
it.each([
['https://example.com/data.csv', 'csv'],
['https://example.com/data.tsv', 'tsv'],
['https://example.com/data.json', 'json'],
['https://example.com/world.topojson', 'topojson'],
['https://example.com/data.csv?v=2', 'csv'],
])('infers format from %s', (url, expected) => {
expect(detectFormatFromUrl(url)).toBe(expected);
});
it('returns null when no known extension is present', () => {
expect(detectFormatFromUrl('https://example.com/data')).toBeNull();
});
});
+61
View File
@@ -0,0 +1,61 @@
/**
* Format auto-detection for pasted dataset data.
*
* Portable core: no browser APIs, no React — usable in Node and tests.
* Implements the detection rules from spec §05 (Datasets → Auto-detection):
*
* - Valid JSON parses to `json`, or `topojson` when it is a topology object — high confidence.
* - Otherwise multi-line text with a header row is `tsv` (tab-separated) or `csv` (comma) — medium.
* - Unrecognized input yields `null` format — low confidence (saving should be blocked upstream).
*/
export type DataFormat = 'json' | 'csv' | 'tsv' | 'topojson';
export type DetectionConfidence = 'high' | 'medium' | 'low';
export interface FormatDetection {
format: DataFormat | null;
confidence: DetectionConfidence;
}
function isTopology(value: unknown): boolean {
return (
typeof value === 'object' &&
value !== null &&
(value as { type?: unknown }).type === 'Topology'
);
}
/** Detect the format of pasted inline data. */
export function detectFormat(raw: string): FormatDetection {
const text = raw.trim();
if (text === '') return { format: null, confidence: 'low' };
// 1. Try JSON first — highest confidence signal.
try {
const parsed = JSON.parse(text);
return {
format: isTopology(parsed) ? 'topojson' : 'json',
confidence: 'high',
};
} catch {
// not JSON — fall through to delimited detection
}
// 2. Delimited text: needs at least a header row plus one data row.
const lines = text.split(/\r?\n/).filter((l) => l.length > 0);
if (lines.length >= 2) {
const header = lines[0];
if (header.includes('\t')) return { format: 'tsv', confidence: 'medium' };
if (header.includes(',')) return { format: 'csv', confidence: 'medium' };
}
// 3. Unrecognized.
return { format: null, confidence: 'low' };
}
/** Infer a dataset's format from a URL's file extension (spec §05). */
export function detectFormatFromUrl(url: string): DataFormat | null {
const match = url.toLowerCase().match(/\.(csv|tsv|json|topojson)(?:[?#]|$)/);
if (!match) return null;
return match[1] as DataFormat;
}
+10
View File
@@ -0,0 +1,10 @@
/**
* Theme identity — portable core. No browser APIs, no React.
*
* `UiTheme` is the app-wide theme token. It lives in `src/core/` (not in a
* store) because pure core logic needs it too: the Vega chart-config mapping
* (`chartConfigFor`, see docs/architecture/05) keys off the same union, and core
* must never import from `src/app/`. The store and the document `data-theme`
* mirror this value; this is its single definition.
*/
export type UiTheme = 'light' | 'experimental';