diff --git a/src/lib/diff.ts b/src/lib/diff.ts new file mode 100644 index 0000000..abc9043 --- /dev/null +++ b/src/lib/diff.ts @@ -0,0 +1,18 @@ +import DiffMatchPatch from "diff-match-patch"; + +const dmp = new DiffMatchPatch(); + +export function createDiff(oldText: string, newText: string): string { + const patches = dmp.patch_make(oldText, newText); + return dmp.patch_toText(patches); +} + +export function applyDiff(text: string, patchText: string): string { + const patches = dmp.patch_fromText(patchText); + const [result, applied] = dmp.patch_apply(patches, text); + const failed = applied.filter((ok) => !ok); + if (failed.length > 0) { + throw new Error(`Failed to apply ${failed.length} patch(es)`); + } + return result; +} diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts new file mode 100644 index 0000000..9ce505f --- /dev/null +++ b/src/lib/markdown.ts @@ -0,0 +1,89 @@ +import MarkdownIt from "markdown-it"; +import type StateInline from "markdown-it/lib/rules_inline/state_inline.mjs"; +import { type VizPluginEnv, vizPlugin } from "./viz/plugin.ts"; + +export interface RenderResult { + html: string; + hasViz: boolean; +} + +export interface MarkdownEnv extends VizPluginEnv { + wikiSlug?: string; +} + +const md = new MarkdownIt({ html: false, linkify: true }); + +// Wikilink plugin: transforms [[slug]] and [[slug|label]] into links +function wikilinkPlugin(mdi: MarkdownIt): void { + mdi.inline.ruler.push("wikilink", (state: StateInline, silent: boolean) => { + const src = state.src; + const pos = state.pos; + + if (src[pos] !== "[" || src[pos + 1] !== "[") return false; + + const closeIdx = src.indexOf("]]", pos + 2); + if (closeIdx === -1) return false; + + if (!silent) { + const content = src.slice(pos + 2, closeIdx); + let slug: string; + let label: string; + if (content.includes("|")) { + const parts = content.split("|"); + slug = parts[0] ?? content; + label = parts.slice(1).join("|"); + } else { + slug = content; + label = content; + } + + const env = state.env as MarkdownEnv; + const wikiSlug = env.wikiSlug; + const href = wikiSlug ? `/wiki/${wikiSlug}/${slug.trim()}` : slug.trim(); + + const token = state.push("html_inline", "", 0); + token.content = `${label.trim()}`; + } + + state.pos = closeIdx + 2; + return true; + }); +} + +md.use(wikilinkPlugin); +md.use(vizPlugin); + +export function renderMarkdown( + source: string, + wikiSlug?: string, +): RenderResult { + const env: MarkdownEnv = { wikiSlug }; + const html = md.render(source, env); + return { html, hasViz: env.hasViz === true }; +} + +/** + * Extract unique wikilink target slugs from markdown content. + * Matches [[slug]] and [[slug|label]], returns deduplicated trimmed slugs. + */ +export function extractWikilinks(content: string): string[] { + const seen = new Set(); + let pos = 0; + while (pos < content.length) { + if (content[pos] === "[" && content[pos + 1] === "[") { + const closeIdx = content.indexOf("]]", pos + 2); + if (closeIdx === -1) break; + const inner = content.slice(pos + 2, closeIdx); + const slug = (inner.includes("|") ? inner.split("|")[0] : inner)?.trim(); + if (slug) { + seen.add(slug); + } + pos = closeIdx + 2; + } else { + pos++; + } + } + return [...seen]; +} + +export { md }; diff --git a/src/lib/slug.ts b/src/lib/slug.ts new file mode 100644 index 0000000..220c83d --- /dev/null +++ b/src/lib/slug.ts @@ -0,0 +1,7 @@ +export { default as slugify } from "@sindresorhus/slugify"; + +const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export function isValidSlug(slug: string): boolean { + return SLUG_RE.test(slug) && slug.length >= 1 && slug.length <= 128; +} diff --git a/tests/lib/diff.test.ts b/tests/lib/diff.test.ts new file mode 100644 index 0000000..b9e1a69 --- /dev/null +++ b/tests/lib/diff.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; +import { applyDiff, createDiff } from "../../src/lib/diff.ts"; + +describe("createDiff and applyDiff", () => { + test("roundtrips a simple edit", () => { + const oldText = "Hello world"; + const newText = "Hello brave new world"; + const diff = createDiff(oldText, newText); + expect(applyDiff(oldText, diff)).toBe(newText); + }); + + test("handles first revision (diff from empty)", () => { + const content = "# My First Note\n\nSome content here."; + const diff = createDiff("", content); + expect(applyDiff("", diff)).toBe(content); + }); + + test("handles deletion", () => { + const oldText = "line one\nline two\nline three"; + const newText = "line one\nline three"; + const diff = createDiff(oldText, newText); + expect(applyDiff(oldText, diff)).toBe(newText); + }); + + test("handles no change", () => { + const text = "unchanged content"; + const diff = createDiff(text, text); + expect(applyDiff(text, diff)).toBe(text); + }); + + test("handles multiline markdown", () => { + const oldText = "# Title\n\nParagraph one.\n\n## Section\n\nParagraph two."; + const newText = + "# Title\n\nParagraph one, edited.\n\n## Section\n\nParagraph two.\n\n## New Section\n\nNew content."; + const diff = createDiff(oldText, newText); + expect(applyDiff(oldText, diff)).toBe(newText); + }); + + test("chains multiple diffs", () => { + const v1 = ""; + const v2 = "Hello"; + const v3 = "Hello world"; + const v4 = "Hello brave world"; + + const diff1 = createDiff(v1, v2); + const diff2 = createDiff(v2, v3); + const diff3 = createDiff(v3, v4); + + let current = v1; + current = applyDiff(current, diff1); + expect(current).toBe(v2); + current = applyDiff(current, diff2); + expect(current).toBe(v3); + current = applyDiff(current, diff3); + expect(current).toBe(v4); + }); + + test("diff output is a string", () => { + const diff = createDiff("a", "b"); + expect(typeof diff).toBe("string"); + expect(diff.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/lib/markdown.test.ts b/tests/lib/markdown.test.ts new file mode 100644 index 0000000..07802b9 --- /dev/null +++ b/tests/lib/markdown.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test } from "bun:test"; +import { extractWikilinks, renderMarkdown } from "../../src/lib/markdown.ts"; + +describe("renderMarkdown", () => { + test("renders basic markdown", () => { + const { html } = renderMarkdown("# Hello\n\nWorld"); + expect(html).toContain("

Hello

"); + expect(html).toContain("

World

"); + }); + + test("renders bold and italic", () => { + const { html } = renderMarkdown("**bold** and *italic*"); + expect(html).toContain("bold"); + expect(html).toContain("italic"); + }); + + test("renders code blocks", () => { + const { html } = renderMarkdown("```js\nconsole.log('hi');\n```"); + expect(html).toContain(" { + const { html } = renderMarkdown("use `const` here"); + expect(html).toContain("const"); + }); + + test("renders links", () => { + const { html } = renderMarkdown("[click](https://example.com)"); + expect(html).toContain('href="https://example.com"'); + expect(html).toContain("click"); + }); + + test("does not render raw HTML", () => { + const { html } = renderMarkdown(''); + expect(html).not.toContain("