Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .well-known/funding-manifest-urls
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
https://oss.codercops.com/funding.json
https://raw.githubusercontent.com/codercops/.github/main/funding.json
14 changes: 14 additions & 0 deletions app/(tools)/markdown-preview/MarkdownPreviewClient.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,18 @@ describe("MarkdownPreviewClient", () => {
expect(preview?.querySelector("[onerror]")).toBeNull();
expect(preview?.querySelector('a[href^="javascript:"]')).toBeNull();
});

it.each([
["H2", "My title", "## My title"],
["Task", "Shopping list", "- [ ] Shopping list"],
])("%s turns the line under the cursor into its block", (label, text, expected) => {
render(<MarkdownPreviewClient />);
const editor = screen.getByLabelText("Markdown input") as HTMLTextAreaElement;
fireEvent.change(editor, { target: { value: text } });
editor.setSelectionRange(text.length, text.length);

fireEvent.click(screen.getByRole("button", { name: label }));

expect(editor).toHaveValue(expected);
});
});
19 changes: 13 additions & 6 deletions app/(tools)/markdown-preview/MarkdownPreviewClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useMemo, useRef, useState, useSyncExternalStore } from "react";
import DOMPurify from "dompurify";
import { CopyButton } from "@/components/shared/CopyButton";
import { createStandaloneHtml, getMarkdownStats, insertMarkdown, insertTableMarkdown, renderMarkdown } from "@/lib/markdown-preview";
import { createStandaloneHtml, getMarkdownStats, insertLinePrefix, insertMarkdown, insertTableMarkdown, renderMarkdown } from "@/lib/markdown-preview";

const starter = `# Hello, Markdown!\n\nWrite on the left and see **GitHub-flavored Markdown** rendered here.\n\n- [x] Live preview\n- [ ] Copy sanitized HTML\n\n| Feature | Status |\n| --- | --- |\n| Tables | Ready |\n| Code | \`const answer = 42\` |\n\n~~~ts\nconst greeting = "Hello, world!";\n~~~`;
const MAX_IMPORT_BYTES = 1024 * 1024;
Expand Down Expand Up @@ -42,11 +42,17 @@ export function MarkdownPreviewClient() {
const { words, chars, lines, readingMinutes } = getMarkdownStats(markdown);

function insert(before: string, after = before, placeholder = "text") {
apply((start, end) => insertMarkdown(markdown, start, end, before, after, placeholder));
}

function insertLine(prefix: string, placeholder = "text") {
apply((start, end) => insertLinePrefix(markdown, start, end, prefix, placeholder));
}

function apply(edit: (start: number, end: number) => ReturnType<typeof insertMarkdown>) {
const input = textareaRef.current;
if (!input) return;
const start = input.selectionStart;
const end = input.selectionEnd;
const result = insertMarkdown(markdown, start, end, before, after, placeholder);
const result = edit(input.selectionStart, input.selectionEnd);
setMarkdown(result.value);
requestAnimationFrame(() => { input.focus(); input.setSelectionRange(result.selectionStart, result.selectionEnd); });
}
Expand Down Expand Up @@ -101,10 +107,11 @@ export function MarkdownPreviewClient() {

return <section className="mx-auto max-w-7xl w-full px-4 sm:px-6 pb-10 space-y-3">
<div className="flex flex-wrap items-center gap-2">
{[['Bold','**','**'],['Italic','*','*'],['Code','`','`'],['Link','[','](https://example.com)'],['H2','## ','']].map(([label, before, after]) => <button key={label} type="button" className="btn" onClick={() => insert(before, after)}>{label}</button>)}
{[['Bold','**','**'],['Italic','*','*'],['Code','`','`'],['Link','[','](https://example.com)']].map(([label, before, after]) => <button key={label} type="button" className="btn" onClick={() => insert(before, after)}>{label}</button>)}
<button type="button" className="btn" onClick={() => insertLine("## ")}>H2</button>
<button type="button" className="btn" onClick={insertImage}>Image</button>
<button type="button" className="btn" onClick={insertTable}>Table</button>
<button type="button" className="btn" onClick={() => insert("- [ ] ", "", "task")}>Task</button>
<button type="button" className="btn" onClick={() => insertLine("- [ ] ", "task")}>Task</button>
<button type="button" className="btn" onClick={() => fileInputRef.current?.click()}>Open .md file</button>
<input ref={fileInputRef} type="file" accept={IMPORT_ACCEPT} onChange={handleFileChange} className="hidden" aria-label="Open Markdown file" />
<button type="button" className="btn ml-auto" onClick={() => setDarkPreview((v) => !v)}>{darkPreview ? "Light preview" : "Dark preview"}</button>
Expand Down
2 changes: 1 addition & 1 deletion app/guides/make-a-table-in-markdown/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ export default function MakeATableInMarkdownGuidePage() {
<ul className="list-disc pl-6 space-y-3">
<li><strong className="text-[var(--fg)]">A single line break becomes a <code className={codeClass}>&lt;br&gt;</code>.</strong> 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 <code className={codeClass}>.md</code> 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 <code className={codeClass}>.md</code> file, end the line with two spaces or a backslash, or use <code className={codeClass}>&lt;br&gt;</code>.</li>
<li><strong className="text-[var(--fg)]">The HTML is sanitized.</strong> Scripts and inline event handlers such as <code className={codeClass}>onerror</code> are removed before the preview is shown or the HTML is copied.</li>
<li><strong className="text-[var(--fg)]">There is a toolbar.</strong> Bold, Italic, Code, Link, H2, Image, Table and Task add the syntax at the cursor or around the selected text.</li>
<li><strong className="text-[var(--fg)]">There is a toolbar.</strong> 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.</li>
<li><strong className="text-[var(--fg)]">You can take the result with you.</strong> 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.</li>
<li><strong className="text-[var(--fg)]">Everything runs in your browser.</strong> Nothing you type is uploaded.</li>
</ul>
Expand Down
127 changes: 127 additions & 0 deletions lib/__tests__/csv-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import ts from "typescript";
import { describe, expect, it } from "vitest";
import {
csvToJson,
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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", () => {
Expand All @@ -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"');
});
Expand Down
26 changes: 26 additions & 0 deletions lib/__tests__/invoice-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading
Loading