A single line break becomes a <br>. The preview is set to turn every newline inside a paragraph into a line break. GitHub does the same in issues, pull requests, comments and discussions, but not in a .md file such as a README, where a single newline does not break the line. So text that looks right in the preview can run together in a README. To force a break in a .md file, end the line with two spaces or a backslash, or use <br>.
The HTML is sanitized. Scripts and inline event handlers such as onerror are removed before the preview is shown or the HTML is copied.
-
There is a toolbar. Bold, Italic, Code, Link, H2, Image, Table and Task add the syntax at the cursor or around the selected text.
+
There is a toolbar. Bold, Italic, Code, Link, Image and Table add the syntax at the cursor or around the selected text, and H2 and Task turn the current line into a heading or a task.
You can take the result with you. Copy HTML copies the sanitized HTML, Copy Markdown copies the source, and Download .md saves it. A switch changes the preview between light and dark, and the bar below shows the word, character and line counts and the reading time.
Everything runs in your browser. Nothing you type is uploaded.
diff --git a/lib/__tests__/csv-utils.test.ts b/lib/__tests__/csv-utils.test.ts
index 997e63b..8b10dc2 100644
--- a/lib/__tests__/csv-utils.test.ts
+++ b/lib/__tests__/csv-utils.test.ts
@@ -1,3 +1,4 @@
+import ts from "typescript";
import { describe, expect, it } from "vitest";
import {
csvToJson,
@@ -148,6 +149,12 @@ describe("uniqueHeaders", () => {
it("suffixes duplicates and fills empty names", () => {
expect(uniqueHeaders(["a", "a", "", "b", "a"])).toEqual(["a", "a_2", "column_3", "b", "a_3"]);
});
+
+ it("skips a suffix that another column already uses", () => {
+ expect(uniqueHeaders(["id", "id", "id_2"])).toEqual(["id", "id_3", "id_2"]);
+ expect(uniqueHeaders(["a", "", "column_2"])).toEqual(["a", "column_2_2", "column_2"]);
+ expect(csvToJson(["id", "id", "id_2"], [["1", "2", "3"]])).toEqual([{ id: "1", id_3: "2", id_2: "3" }]);
+ });
});
describe("formatCsv", () => {
@@ -206,6 +213,77 @@ describe("csvToTypeScript", () => {
it("handles no columns", () => {
expect(csvToTypeScript([])).toBe("export interface Row {}");
});
+
+ describe("the type name", () => {
+ // Words TypeScript will not take as the name of an interface: reserved words, the ones reserved in strict mode
+ // (which a module always is), `await`, and the names of the built-in types.
+ const reserved = [
+ "break", "case", "catch", "class", "const", "continue", "debugger", "default", "delete", "do", "else", "enum",
+ "export", "extends", "false", "finally", "for", "function", "if", "import", "in", "instanceof", "new", "null",
+ "return", "super", "switch", "this", "throw", "true", "try", "typeof", "var", "void", "while", "with",
+ "implements", "interface", "let", "package", "private", "protected", "public", "static", "yield", "await",
+ "any", "unknown", "never", "number", "bigint", "boolean", "string", "symbol", "object", "undefined",
+ ];
+ // Words that look like they might be, and are fine.
+ const allowed = [
+ "Class", "Default", "String", "Number", "Object", "Array", "Date", "Row", "Person", "type", "of", "as", "async",
+ "from", "get", "set", "constructor", "declare", "module", "namespace", "readonly", "keyof", "abstract", "is",
+ "global", "require", "infer", "asserts", "arguments", "eval", "_", "$", "class_", "$class", "classy",
+ ];
+
+ /** The diagnostics the TypeScript compiler gives each source text, all checked in one program. */
+ function diagnose(texts: string[]): string[][] {
+ const files = new Map(texts.map((text, index) => [`/probe/f${index}.ts`, text]));
+ const options: ts.CompilerOptions = { noEmit: true, strict: true, skipLibCheck: true, target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.ESNext };
+ const host = ts.createCompilerHost(options);
+ const getSourceFile = host.getSourceFile.bind(host);
+ host.getSourceFile = (name, languageVersion, ...rest) =>
+ files.has(name) ? ts.createSourceFile(name, files.get(name) as string, languageVersion) : getSourceFile(name, languageVersion, ...rest);
+ host.fileExists = (name) => files.has(name) || ts.sys.fileExists(name);
+ host.readFile = (name) => files.get(name) ?? ts.sys.readFile(name);
+ const program = ts.createProgram([...files.keys()], options, host);
+ return [...files.keys()].map((name) => {
+ const file = program.getSourceFile(name) as ts.SourceFile;
+ return [...program.getSyntacticDiagnostics(file), ...program.getSemanticDiagnostics(file)].map((d) => ts.flattenDiagnosticMessageText(d.messageText, " "));
+ });
+ }
+
+ it.each(reserved)("falls back to Row for the reserved name %s", (name) => {
+ expect(csvToTypeScript(["a"], name)).toBe("export interface Row {\n a: string;\n}");
+ });
+
+ it("keeps a name that only looks reserved, or differs in case", () => {
+ for (const name of allowed) expect(csvToTypeScript(["a"], name), name).toBe(`export interface ${name} {\n a: string;\n}`);
+ });
+
+ it("still falls back for a name that is not an identifier", () => {
+ for (const name of ["", " ", "my type", "1st", "a-b", "a.b", "é", "class Row"]) {
+ expect(csvToTypeScript(["a"], name), JSON.stringify(name)).toBe("export interface Row {\n a: string;\n}");
+ }
+ });
+
+ it("uses the default name for no name at all", () => {
+ expect(csvToTypeScript(["a"])).toBe("export interface Row {\n a: string;\n}");
+ });
+
+ it("writes TypeScript that compiles, for every name here, and the list is what the compiler rejects", () => {
+ const names = [...reserved, ...allowed];
+ const outputs = names.map((name) => csvToTypeScript(["a", "class", "first name"], name));
+ const written = diagnose(outputs);
+ expect(written.map((found, index) => (found.length ? `${names[index]}: ${found[0]}` : null)).filter(Boolean)).toEqual([]);
+
+ // The same names used as they were typed: the compiler rejects exactly the reserved ones.
+ const raw = diagnose(names.map((name) => `export interface ${name} {\n a: string;\n}\n`));
+ const rejected = names.filter((_, index) => raw[index].length > 0);
+ expect(rejected.sort()).toEqual([...reserved].sort());
+ });
+
+ it("leaves property names alone, since a reserved word is fine as a property", () => {
+ expect(csvToTypeScript(["class", "default", "string"], "Row")).toBe(
+ "export interface Row {\n class: string;\n default: string;\n string: string;\n}",
+ );
+ });
+ });
});
describe("csvToYaml", () => {
@@ -217,6 +295,55 @@ describe("csvToYaml", () => {
expect(csvToYaml(["note"], [['a "b" \\ c\nd']])).toBe('- note: "a \\"b\\" \\\\ c\\nd"');
});
+ // A raw control character makes the document invalid YAML, and js-yaml refuses to load it. Escaped the way the
+ // JSON formatter's YAML conversion does (#234).
+ describe("control characters", () => {
+ const yamlOf = (value: string) => csvToYaml(["note"], [[value]]);
+
+ it.each([
+ ["NUL", "\u0000", "\\x00"],
+ ["BEL", "\u0007", "\\x07"],
+ ["backspace", "\u0008", "\\x08"],
+ ["vertical tab", "\u000b", "\\x0B"],
+ ["form feed", "\u000c", "\\x0C"],
+ ["shift out", "\u000e", "\\x0E"],
+ ["ESC", "\u001b", "\\x1B"],
+ ["unit separator", "\u001f", "\\x1F"],
+ ["DEL", "\u007f", "\\x7F"],
+ ["next line", "\u0085", "\\x85"],
+ ["C1 control", "\u009f", "\\x9F"],
+ ["line separator", "\u2028", "\\u2028"],
+ ["paragraph separator", "\u2029", "\\u2029"],
+ ["noncharacter FFFE", "\ufffe", "\\uFFFE"],
+ ["noncharacter FFFF", "\uffff", "\\uFFFF"],
+ ])("writes %s as an escape", (_name, character, escape) => {
+ expect(yamlOf(`a${character}b`)).toBe(`- note: "a${escape}b"`);
+ });
+
+ it("writes a tab as \\t", () => {
+ expect(yamlOf("a\tb")).toBe('- note: "a\\tb"');
+ });
+
+ it("keeps the escapes it already had", () => {
+ expect(yamlOf('q"\\\n\r')).toBe('- note: "q\\"\\\\\\n\\r"');
+ });
+
+ it("leaves no raw control character anywhere in the output", () => {
+ const every = Array.from({ length: 0xa0 }, (_, code) => String.fromCharCode(code)).join("");
+ const output = csvToYaml([`k${every}`], [[every, `x${every}y`]]);
+ // Only the line breaks between the lines of the document are left.
+ expect(output.replace(/\n/g, "")).not.toMatch(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/);
+ });
+
+ it("escapes them in a column name too", () => {
+ expect(csvToYaml(["a\u0007b"], [["x"]])).toBe('- "a\\x07b": "x"');
+ });
+
+ it("leaves other text, accents and emoji as they were", () => {
+ expect(yamlOf("café \u00e9 \u{1f600} \u00a0")).toBe('- note: "café \u00e9 \u{1f600} \u00a0"');
+ });
+ });
+
it("quotes non-identifier keys", () => {
expect(csvToYaml(["first name"], [["Ada"]])).toBe('- "first name": "Ada"');
});
diff --git a/lib/__tests__/invoice-types.test.ts b/lib/__tests__/invoice-types.test.ts
index 89f75ff..f046794 100644
--- a/lib/__tests__/invoice-types.test.ts
+++ b/lib/__tests__/invoice-types.test.ts
@@ -152,6 +152,32 @@ describe("nextInvoiceNumber", () => {
expect(nextInvoiceNumber([base("1001 "), base("draft"), base("")])).toBe("1002");
});
+ // A JavaScript number holds 15 digits exactly, so these used to come back lower than the number they follow.
+ it.each([
+ ["20261031143012345", "20261031143012346"],
+ ["INV-9007199254740993", "INV-9007199254740994"],
+ ["INV-9007199254740992", "INV-9007199254740993"],
+ ["INV-99999999999999999999", "INV-100000000000000000000"],
+ ["INV-9999999999999999999999", "INV-10000000000000000000000"],
+ ["INV-0000000000000000000007", "INV-0000000000000000000008"],
+ ["INV-12345678901234567890123456789", "INV-12345678901234567890123456790"],
+ ["2026-00000000000000000099", "2026-00000000000000000100"],
+ ])("counts on from %s without losing digits, giving %s", (highest, expected) => {
+ expect(nextInvoiceNumber([base(highest)])).toBe(expected);
+ });
+
+ it("picks the highest of long numbers that a JavaScript number cannot tell apart", () => {
+ expect(nextInvoiceNumber([base("INV-9007199254740993"), base("INV-9007199254740992")])).toBe("INV-9007199254740994");
+ expect(nextInvoiceNumber([base("INV-9007199254740992"), base("INV-9007199254740993")])).toBe("INV-9007199254740994");
+ expect(nextInvoiceNumber([base("INV-100000000000000000001"), base("INV-100000000000000000002"), base("INV-099999999999999999999")])).toBe(
+ "INV-100000000000000000003",
+ );
+ });
+
+ it("never gives a number that is not made of digits", () => {
+ expect(nextInvoiceNumber([base(`INV-${"9".repeat(40)}`)])).toBe(`INV-1${"0".repeat(40)}`);
+ });
+
it("starts at the fallback prefix when no invoice has a number", () => {
expect(nextInvoiceNumber([base("draft"), base("")])).toBe("INV0001");
expect(nextInvoiceNumber([], "BILL", 6)).toBe("BILL000001");
diff --git a/lib/__tests__/json-utils.test.ts b/lib/__tests__/json-utils.test.ts
index c16604f..1f889c4 100644
--- a/lib/__tests__/json-utils.test.ts
+++ b/lib/__tests__/json-utils.test.ts
@@ -12,6 +12,7 @@ import {
parseJson5Compat,
diffLines,
validate,
+ singularize,
SAMPLE_JSON,
} from "../json-utils";
@@ -282,6 +283,100 @@ describe("toXML", () => {
expect(result.value).toContain(" x\n y");
});
+ it("names the items of common plurals the way JSON to TypeScript does", () => {
+ const result = toXML('{"categories":["books"],"addresses":["Paris"],"address":["x"]}');
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+
+ expect(result.value).toContain(" books");
+ expect(result.value).toContain(" Paris");
+ expect(result.value).toContain(" x");
+ });
+
+ it("singularizes sibilant plurals (-ches, -shes, -xes, -zes)", () => {
+ const result = toXML(
+ '{"matches":["a"],"branches":["b"],"hashes":["c"],"boxes":["d"],"fixes":["e"],"quizzes":["f"]}',
+ );
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+
+ expect(result.value).toContain("a");
+ expect(result.value).toContain("b");
+ expect(result.value).toContain("c");
+ expect(result.value).toContain("d");
+ expect(result.value).toContain("e");
+ expect(result.value).toContain("f");
+ });
+
+ it("singularizes ambiguous -ses cases correctly", () => {
+ const result = toXML('{"cases":["x"],"addresses":["y"],"statuses":["z"]}');
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+
+ expect(result.value).toContain("x");
+ expect(result.value).toContain("y");
+ expect(result.value).toContain("z");
+ });
+
+ it("singularizes ambiguous -ses words based on stem endings", () => {
+ const stems = toXML(
+ '{"addresses":["a"],"classes":["b"],"statuses":["c"],"viruses":["d"],"bonuses":["e"],"campuses":["f"],"buses":["g"]}',
+ );
+ expect(stems.ok).toBe(true);
+ if (stems.ok) {
+ expect(stems.value).toContain("a");
+ expect(stems.value).toContain("b");
+ expect(stems.value).toContain("c");
+ expect(stems.value).toContain("d");
+ expect(stems.value).toContain("e");
+ expect(stems.value).toContain("f");
+ expect(stems.value).toContain("g");
+ }
+
+ const nonStems = toXML(
+ '{"cases":["a"],"causes":["b"],"houses":["c"],"responses":["d"],"courses":["e"],"releases":["f"],"licenses":["g"],"databases":["h"]}',
+ );
+ expect(nonStems.ok).toBe(true);
+ if (nonStems.ok) {
+ expect(nonStems.value).toContain("a");
+ expect(nonStems.value).toContain("b");
+ expect(nonStems.value).toContain("c");
+ expect(nonStems.value).toContain("d");
+ expect(nonStems.value).toContain("e");
+ expect(nonStems.value).toContain("f");
+ expect(nonStems.value).toContain("g");
+ expect(nonStems.value).toContain("h");
+ }
+ });
+
+ it("handles -zes plurals and caches appropriately in XML", () => {
+ const result = toXML(
+ '{"sizes":["s"],"prizes":["p"],"mazes":["m"],"quizzes":["q"],"caches":["c"]}',
+ );
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+
+ expect(result.value).toContain("s");
+ expect(result.value).toContain("p");
+ expect(result.value).toContain("m");
+ expect(result.value).toContain("q");
+ expect(result.value).toContain("c");
+ });
+
+ it("preserves existing singularization for -ies, simple -s, and non-plural keys", () => {
+ const result = toXML(
+ '{"categories":["a"],"users":["b"],"boss":["c"],"root":["d"],"tags":["e"]}',
+ );
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+
+ expect(result.value).toContain("a");
+ expect(result.value).toContain("b");
+ expect(result.value).toContain("c");
+ expect(result.value).toContain("d");
+ expect(result.value).toContain("e");
+ });
+
it("wraps a top-level array of arrays, each sub-array in its own element", () => {
const result = toXML("[[1,2],[3]]");
expect(result.ok).toBe(true);
@@ -755,6 +850,145 @@ describe("toTypeScript array items", () => {
expect(transpile.diagnostics ?? []).toHaveLength(0);
}
});
+
+ it("singularizes sibilant plural array names in interfaces", () => {
+ const result = toTypeScript('{"matches":[{"x":1}],"branches":[{"y":2}],"boxes":[{"z":3}]}');
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+
+ expect(result.value).toContain("export interface Match {");
+ expect(result.value).toContain("matches: Match[];");
+ expect(result.value).toContain("export interface Branch {");
+ expect(result.value).toContain("branches: Branch[];");
+ expect(result.value).toContain("export interface Box {");
+ expect(result.value).toContain("boxes: Box[];");
+ });
+
+ it("singularizes ambiguous -ses array names in interfaces", () => {
+ const result = toTypeScript('{"cases":[{"a":1}],"addresses":[{"b":2}],"statuses":[{"c":3}]}');
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+
+ expect(result.value).toContain("export interface Case {");
+ expect(result.value).toContain("cases: Case[];");
+ expect(result.value).toContain("export interface Address {");
+ expect(result.value).toContain("addresses: Address[];");
+ expect(result.value).toContain("export interface Status {");
+ expect(result.value).toContain("statuses: Status[];");
+ });
+
+ it("singularizes -ses and -zes array names in interfaces", () => {
+ const result = toTypeScript(
+ JSON.stringify({
+ classes: [{ id: 1 }],
+ viruses: [{ id: 2 }],
+ bonuses: [{ id: 3 }],
+ campuses: [{ id: 4 }],
+ buses: [{ id: 5 }],
+ causes: [{ id: 6 }],
+ houses: [{ id: 7 }],
+ responses: [{ id: 8 }],
+ courses: [{ id: 9 }],
+ releases: [{ id: 10 }],
+ licenses: [{ id: 11 }],
+ databases: [{ id: 12 }],
+ sizes: [{ id: 13 }],
+ prizes: [{ id: 14 }],
+ mazes: [{ id: 15 }],
+ quizzes: [{ id: 16 }],
+ }),
+ );
+ expect(result.ok).toBe(true);
+ if (!result.ok) return;
+
+ expect(result.value).toContain("export interface Class {");
+ expect(result.value).toContain("classes: Class[];");
+ expect(result.value).toContain("export interface Virus {");
+ expect(result.value).toContain("viruses: Virus[];");
+ expect(result.value).toContain("export interface Bonus {");
+ expect(result.value).toContain("bonuses: Bonus[];");
+ expect(result.value).toContain("export interface Campus {");
+ expect(result.value).toContain("campuses: Campus[];");
+ expect(result.value).toContain("export interface Bus {");
+ expect(result.value).toContain("buses: Bus[];");
+ expect(result.value).toContain("export interface Cause {");
+ expect(result.value).toContain("causes: Cause[];");
+ expect(result.value).toContain("export interface House {");
+ expect(result.value).toContain("houses: House[];");
+ expect(result.value).toContain("export interface Response {");
+ expect(result.value).toContain("responses: Response[];");
+ expect(result.value).toContain("export interface Course {");
+ expect(result.value).toContain("courses: Course[];");
+ expect(result.value).toContain("export interface Release {");
+ expect(result.value).toContain("releases: Release[];");
+ expect(result.value).toContain("export interface License {");
+ expect(result.value).toContain("licenses: License[];");
+ expect(result.value).toContain("export interface Database {");
+ expect(result.value).toContain("databases: Database[];");
+ expect(result.value).toContain("export interface Size {");
+ expect(result.value).toContain("sizes: Size[];");
+ expect(result.value).toContain("export interface Prize {");
+ expect(result.value).toContain("prizes: Prize[];");
+ expect(result.value).toContain("export interface Maze {");
+ expect(result.value).toContain("mazes: Maze[];");
+ expect(result.value).toContain("export interface Quiz {");
+ expect(result.value).toContain("quizzes: Quiz[];");
+ });
+});
+
+describe("singularize", () => {
+ it("singularizes all required maintainer corrections and preserves existing rules", () => {
+ // Sibilant plurals: -ches, -shes, -xes
+ expect(singularize("matches")).toBe("match");
+ expect(singularize("branches")).toBe("branch");
+ expect(singularize("hashes")).toBe("hash");
+ expect(singularize("boxes")).toBe("box");
+ expect(singularize("fixes")).toBe("fix");
+ expect(singularize("caches")).toBe("cach");
+
+ // -zes handling: -zzes vs non-doubled
+ expect(singularize("quizzes")).toBe("quiz");
+ expect(singularize("sizes")).toBe("size");
+ expect(singularize("prizes")).toBe("prize");
+ expect(singularize("mazes")).toBe("maze");
+
+ // Ambiguous -ses: stem ends in ss, tus, rus, nus, pus, bus -> remove es
+ expect(singularize("addresses")).toBe("address");
+ expect(singularize("classes")).toBe("class");
+ expect(singularize("statuses")).toBe("status");
+ expect(singularize("viruses")).toBe("virus");
+ expect(singularize("bonuses")).toBe("bonus");
+ expect(singularize("campuses")).toBe("campus");
+ expect(singularize("buses")).toBe("bus");
+
+ // Ambiguous -ses: stem does not end in those endings -> remove trailing s
+ expect(singularize("cases")).toBe("case");
+ expect(singularize("causes")).toBe("cause");
+ expect(singularize("houses")).toBe("house");
+ expect(singularize("responses")).toBe("response");
+ expect(singularize("courses")).toBe("course");
+ expect(singularize("releases")).toBe("release");
+ expect(singularize("licenses")).toBe("license");
+ expect(singularize("databases")).toBe("database");
+
+ // Preserved rules
+ expect(singularize("categories")).toBe("category");
+ expect(singularize("users")).toBe("user");
+ expect(singularize("boss")).toBe("bossItem");
+ expect(singularize("root")).toBe("rootItem");
+
+ // PascalCase support for TypeScript interface naming
+ expect(singularize("Addresses")).toBe("Address");
+ expect(singularize("Classes")).toBe("Class");
+ expect(singularize("Statuses")).toBe("Status");
+ expect(singularize("Viruses")).toBe("Virus");
+ expect(singularize("Bonuses")).toBe("Bonus");
+ expect(singularize("Campuses")).toBe("Campus");
+ expect(singularize("Buses")).toBe("Bus");
+ expect(singularize("Cases")).toBe("Case");
+ expect(singularize("Sizes")).toBe("Size");
+ expect(singularize("Quizzes")).toBe("Quiz");
+ });
});
/* -----------------------------------------------------------------------
diff --git a/lib/__tests__/markdown-preview.test.ts b/lib/__tests__/markdown-preview.test.ts
index 95f3454..76cee5c 100644
--- a/lib/__tests__/markdown-preview.test.ts
+++ b/lib/__tests__/markdown-preview.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { createStandaloneHtml, getMarkdownStats, insertMarkdown, insertTableMarkdown, renderMarkdown } from "@/lib/markdown-preview";
+import { createStandaloneHtml, getMarkdownStats, insertLinePrefix, insertMarkdown, insertTableMarkdown, renderMarkdown } from "@/lib/markdown-preview";
describe("markdown preview", () => {
it("wraps provided content in a complete light HTML document", () => {
@@ -65,6 +65,24 @@ describe("markdown preview", () => {
it("inserts formatting around the selection", () => expect(insertMarkdown("hello", 0, 5, "**")).toEqual({ value: "**hello**", selectionStart: 2, selectionEnd: 7 }));
});
+describe("insertLinePrefix", () => {
+ it("turns a line with text into a heading or task wherever the cursor is", () => {
+ expect(insertLinePrefix("My title", 8, 8, "## ")).toEqual({ value: "## My title", selectionStart: 11, selectionEnd: 11 });
+ expect(insertLinePrefix("My title", 3, 8, "## ")).toEqual({ value: "## My title", selectionStart: 6, selectionEnd: 11 });
+ expect(insertLinePrefix("Intro\nShopping list\nEnd", 10, 10, "- [ ] ", "task").value).toBe("Intro\n- [ ] Shopping list\nEnd");
+ expect(renderMarkdown(insertLinePrefix("My title", 8, 8, "## ").value)).toContain("