Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions packages/cli/src/commands/clone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,14 @@ Examples:
if (!row || row.folder == null || row.slug == null) return undefined;
return row.folder ? `${row.folder}/${row.slug}` : row.slug;
};
const entityTitleLookup = (externalId: string): string | undefined => {
const [row] = colDb
.select({ title: entities.title })
.from(entities)
.where(eq(entities.externalId, externalId))
.all();
return row?.title ?? undefined;
};

const byExtId = new Map<string, string>();
const byStem = new Map<string, string>();
Expand Down Expand Up @@ -205,6 +213,7 @@ Examples:
collectionName: localName,
crawlerType,
lookupEntityPath: entityPathLookup,
lookupEntityTitle: entityTitleLookup,
resolveWikilink,
};
const derivedTitle = themeEngine.getTitle(ctx);
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/commands/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,14 @@ export async function generateCollection(
if (!r || r.folder == null || r.slug == null) return undefined;
return r.folder ? `${r.folder}/${r.slug}` : r.slug;
};
const entityTitleLookup = (externalId: string): string | undefined => {
const rows = colDb
.select({ title: entities.title })
.from(entities)
.where(eq(entities.externalId, externalId))
.all();
return rows[0]?.title ?? undefined;
};

// Build stem-matching wikilink resolver (for Obsidian [[bare name]] links)
const allEntityRows = colDb
Expand Down Expand Up @@ -164,6 +172,7 @@ export async function generateCollection(
collectionName: col.name,
crawlerType: col.crawler,
lookupEntityPath: entityPathLookup,
lookupEntityTitle: entityTitleLookup,
resolveWikilink,
};
const derivedTitle = themeEngine.getTitle(baseCtx);
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/commands/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@ function makeEntityPathLookup(colDb: ColDb): (id: string) => string | undefined
};
}

/** Build entity title lookup for theme cross-reference labelling. */
function makeEntityTitleLookup(colDb: ColDb): (id: string) => string | undefined {
return (externalId: string) => {
const rows = colDb
.select({ title: entities.title })
.from(entities)
.where(eq(entities.externalId, externalId))
.all();
return rows[0]?.title ?? undefined;
};
}

/** Build stem-matching wikilink resolver (for Obsidian-style [[bare name]] links). */
function makeResolveWikilink(colDb: ColDb): (target: string) => string | undefined {
const allRows = colDb
Expand Down Expand Up @@ -240,6 +252,7 @@ async function checkSamples(
.all();

const lookupEntityPath = makeEntityPathLookup(colDb);
const lookupEntityTitle = makeEntityTitleLookup(colDb);
const resolveWikilink = makeResolveWikilink(colDb);

let sampled = 0;
Expand Down Expand Up @@ -273,6 +286,7 @@ async function checkSamples(
collectionName,
crawlerType,
lookupEntityPath,
lookupEntityTitle,
resolveWikilink,
};

Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,14 @@ export function createApiServer(
if (!row || row.folder == null || row.slug == null) return undefined;
return row.folder ? `${row.folder}/${row.slug}` : row.slug;
};
const lookupEntityTitle = (externalId: string): string | undefined => {
const [row] = colDb
.select({ title: entities.title })
.from(entities)
.where(eq(entities.externalId, externalId))
.all();
return row?.title ?? undefined;
};

const markdown = themeEngine.render({
entity: {
Expand All @@ -519,6 +527,7 @@ export function createApiServer(
collectionName: name,
crawlerType,
lookupEntityPath,
lookupEntityTitle,
});

return new Response(markdown, {
Expand Down Expand Up @@ -568,6 +577,14 @@ export function createApiServer(
if (!row || row.folder == null || row.slug == null) return undefined;
return row.folder ? `${row.folder}/${row.slug}` : row.slug;
};
const lookupEntityTitle = (externalId: string): string | undefined => {
const [row] = colDb
.select({ title: entities.title })
.from(entities)
.where(eq(entities.externalId, externalId))
.all();
return row?.title ?? undefined;
};

const html = themeEngine.renderHtml({
entity: {
Expand All @@ -581,6 +598,7 @@ export function createApiServer(
collectionName: name,
crawlerType: htmlCrawlerType,
lookupEntityPath,
lookupEntityTitle,
});

if (!html) return errorResponse("HTML rendering not available", 404);
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/crawler/sync-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,17 @@ export class SyncEngine {
};
}

private makeLookupEntityTitle(): (externalId: string) => string | undefined {
return (externalId: string) => {
const rows = this.db
.select({ title: entities.title })
.from(entities)
.where(eq(entities.externalId, externalId))
.all();
return rows[0]?.title ?? undefined;
};
}

/**
* Build a wikilink resolver that supports Obsidian-style stem matching.
* "Topic" matches an entity whose externalId ends with "/Topic.md" or is "Topic.md".
Expand Down Expand Up @@ -877,6 +888,7 @@ export class SyncEngine {
collectionName: this.collectionName,
crawlerType,
lookupEntityPath: this.makeLookupEntityPath(),
lookupEntityTitle: this.makeLookupEntityTitle(),
resolveWikilink: this.makeResolveWikilink(),
});
}
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/theme/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ export interface ThemeRenderContext {
* Used by themes to generate correct cross-reference wikilinks.
*/
lookupEntityPath?: (externalId: string) => string | undefined;
/**
* Look up the human-readable display title for any entity by its externalId.
* Themes can use this to label cross-references with the target's title rather
* than its slug/page-name. Returns undefined if the entity is unknown.
*/
lookupEntityTitle?: (externalId: string) => string | undefined;
/**
* Resolve an Obsidian-style wikilink target (bare name or path) to a markdown
* file path (relative to markdown root, without .md extension).
Expand Down
195 changes: 195 additions & 0 deletions packages/crawlers/src/mantishub/__tests__/theme.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ const lookup = (externalId: string): string | undefined => {
"issue:100": "issues/00100-linked-issue",
"issue:200": "issues/00200-another-issue",
"user:alice": "users/alice",
"page:1:onboarding-guide": "testproject/pages/onboarding-guide",
};
return map[externalId];
};

const titleLookup = (externalId: string): string | undefined => {
const map: Record<string, string> = {
"page:1:onboarding-guide": "Onboarding Guide for New Engineers",
};
return map[externalId];
};
Expand Down Expand Up @@ -131,6 +139,7 @@ describe("MantisHubTheme HTML page rendering", () => {
collectionName: "test",
crawlerType: "mantishub",
lookupEntityPath: lookup,
lookupEntityTitle: titleLookup,
};
}

Expand All @@ -150,6 +159,192 @@ describe("MantisHubTheme HTML page rendering", () => {
expect(html).toContain('class="mt-issue-ref"');
expect(html).toContain("#wikilink/issues%2F00100-linked-issue");
});

it("does not duplicate the page heading in markdown when content starts with H1", () => {
const md = theme.render({
entity: {
externalId: "page:1:getting-started",
entityType: "page",
title: "Getting Started",
data: {
id: 1, name: "getting-started", title: "Getting Started",
project: { id: 1, name: "TestProject" },
content: "# Getting Started\n\nWelcome.",
},
},
collectionName: "test",
crawlerType: "mantishub",
lookupEntityPath: lookup,
});
// Only one occurrence of the heading text in body (excluding frontmatter).
const body = md.replace(/^---[\s\S]*?---\n/, "");
const matches = body.match(/Getting Started/g) ?? [];
expect(matches.length).toBe(1);
});

it("does not render duplicate H1 when content starts with matching title", () => {
const html = theme.renderHtml!(makePageContext("# Getting Started\n\nWelcome."));
// Title H1 should appear exactly once
const h1Count = (html.match(/<h1[\s>]/g) ?? []).length;
expect(h1Count).toBe(1);
expect(html).toContain("Welcome.");
});

it("uses the content H1 when it differs from the stored title", () => {
const html = theme.renderHtml!(makePageContext("# Onboarding Guide\n\nBody."));
expect(html).toContain('class="mt-title">Onboarding Guide</h1>');
const h1Count = (html.match(/<h1[\s>]/g) ?? []).length;
expect(h1Count).toBe(1);
});

it("uses the target page title as wiki-link label when available", () => {
const html = theme.renderHtml!(makePageContext("See [[onboarding-guide]] for details."));
expect(html).toContain('class="mt-page-link"');
expect(html).toContain("Onboarding Guide for New Engineers");
// The slug should not appear as the visible label (only inside the URL fragment).
expect(html).not.toMatch(/>onboarding-guide</);
});

it("merges indented continuation lines into the previous list item", () => {
const md = "* **Droplet**\n Shut down the droplet.\n* **IP**\n Release the reserved IP.";
const html = theme.renderHtml!(makePageContext(md));
expect(html).toContain("<li><strong>Droplet</strong><br>Shut down the droplet.</li>");
expect(html).toContain("<li><strong>IP</strong><br>Release the reserved IP.</li>");
});

it("renders --- as a horizontal rule", () => {
const html = theme.renderHtml!(makePageContext("Above\n\n---\n\n## Below"));
expect(html).toContain('<hr class="mt-md-hr">');
expect(html).toContain("<h2");
});

it("recognises NOTE admonition with leading whitespace before '>'", () => {
const html = theme.renderHtml!(makePageContext(" > [!NOTE] \n> Legacy doco"));
expect(html).toContain("mt-md-callout-note");
expect(html).toContain("Legacy doco");
expect(html).not.toContain("[!NOTE]");
});

it("tolerates malformed blockquote lines without splitting the quote", () => {
const html = theme.renderHtml!(makePageContext("> First line\n>. \n> Third line"));
expect((html.match(/<blockquote/g) ?? []).length).toBe(1);
expect(html).toContain("First line");
expect(html).toContain("Third line");
});

it("renders Loom, YouTube, and Vimeo image-syntax URLs as iframe embeds", () => {
const l = theme.renderHtml!(makePageContext("![Demo](https://www.loom.com/share/xyz)"));
expect(l).toContain('src="https://www.loom.com/embed/xyz"');
const y = theme.renderHtml!(makePageContext("![Demo](https://www.youtube.com/watch?v=dQw4w9WgXcQ)"));
expect(y).toContain('src="https://www.youtube.com/embed/dQw4w9WgXcQ"');
const v = theme.renderHtml!(makePageContext("![Demo](https://vimeo.com/76979871)"));
expect(v).toContain('src="https://player.vimeo.com/video/76979871"');
});

it("renders Komodo recordings as a clickable play-card link (no iframe)", () => {
const k = theme.renderHtml!(makePageContext("![How to](https://komododecks.com/recordings/abc123)"));
expect(k).toContain('class="mt-md-video-link"');
expect(k).toContain('href="https://kommodo.ai/recordings/abc123"');
expect(k).toContain("How to");
expect(k).not.toContain("<iframe");
});

it("leaves video image syntax untouched in generated markdown so the viewer can render it", () => {
const md = theme.render({
entity: {
externalId: "page:1:demo",
entityType: "page",
title: "Demo",
data: {
id: 1, name: "demo", title: "Demo",
project: { id: 1, name: "TestProject" },
content: "![How](https://www.youtube.com/watch?v=dQw4w9WgXcQ)",
},
},
collectionName: "test",
crawlerType: "mantishub",
lookupEntityPath: lookup,
});
expect(md).toContain("![How](https://www.youtube.com/watch?v=dQw4w9WgXcQ)");
expect(md).not.toContain("<iframe");
});

it("renders ![alt](url) as an <img> in HTML", () => {
const html = theme.renderHtml!(makePageContext("![Screenshot](https://example.com/x.png)"));
expect(html).toContain('<img class="mt-md-image" src="https://example.com/x.png" alt="Screenshot"');
expect(html).not.toContain("!Screenshot");
expect(html).not.toContain("!<a");
});

it("renders nested ordered lists with continuous numbering", () => {
const md = "1. First\n\n2. Second\n 1. Sub a\n 2. Sub b\n\n3. Third\n4. Fourth";
const html = theme.renderHtml!(makePageContext(md));
// Single outer <ol>
expect((html.match(/<ol[\s>]/g) ?? []).length).toBe(2); // outer + 1 nested
expect((html.match(/<\/ol>/g) ?? []).length).toBe(2);
// Outer should contain First, Second, Third, Fourth in one list
expect(html).toMatch(/<ol class="mt-md-list">.*First.*Second.*Third.*Fourth.*<\/ol>/s);
// Nested ol inside Second's <li>
expect(html).toMatch(/Second<ol[^>]*><li>Sub a<\/li><li>Sub b<\/li><\/ol>/);
});

it("renders task list items as checkboxes", () => {
const md = "- [x] Done thing\n- [ ] Open thing\n- Plain item";
const html = theme.renderHtml!(makePageContext(md));
expect(html).toContain('<input type="checkbox" disabled checked>');
expect(html).toContain('<input type="checkbox" disabled>');
expect(html).toContain('class="mt-md-task mt-md-task-done"');
expect(html).toContain('class="mt-md-task"');
expect(html).toContain("mt-md-task-list");
// Plain item retains no task class
expect(html).toContain("<li>Plain item</li>");
// No literal bracket prefix on task items
expect(html).not.toMatch(/<li[^>]*>\[/);
});

it("renders GFM > [!NOTE] admonitions as styled callouts with icons", () => {
const md = "> [!NOTE]\n> Heads up.\n\n> [!WARNING]\n> Careful here.";
const html = theme.renderHtml!(makePageContext(md));
expect(html).toContain("mt-md-callout-note");
expect(html).toContain('class="mt-md-callout-title"');
expect(html).toContain("<svg");
expect(html).toContain(">Note<");
expect(html).toContain("Heads up.");
expect(html).toContain("mt-md-callout-warning");
expect(html).toContain("Careful here.");
expect(html).not.toContain("[!NOTE]");
});

it("recognises shorthand [Note] / [Warning] without the GFM '!' prefix", () => {
const md = "> [Note]\n> Plain note text.\n\n> [Warning]\n> Plain warning text.";
const html = theme.renderHtml!(makePageContext(md));
expect(html).toContain("mt-md-callout-note");
expect(html).toContain("mt-md-callout-warning");
expect(html).toContain("Plain note text.");
expect(html).toContain("Plain warning text.");
expect(html).not.toContain("[Note]");
expect(html).not.toContain("[Warning]");
});

it("renders nested bullet lists with proper indentation", () => {
const md = "- Top one\n - Child A\n - Child B\n - Grand\n- Top two";
const html = theme.renderHtml!(makePageContext(md));
// Outer list with class, inner list without
expect(html).toContain('<ul class="mt-md-list">');
// Three <ul> opens (root + 2 nested) and three closes
expect((html.match(/<ul/g) ?? []).length).toBe(3);
expect((html.match(/<\/ul>/g) ?? []).length).toBe(3);
// The grand-child should be nested inside Child B's <li>
expect(html).toContain("Child B<ul><li>Grand</li></ul>");
});

it("renders unresolved [[wiki-link]] as a missing-page indicator (not literal)", () => {
const html = theme.renderHtml!(makePageContext("See [[nonexistent-page]] for details."));
expect(html).toContain("mt-page-missing");
expect(html).toContain("nonexistent-page");
expect(html).not.toContain("[[nonexistent-page]]");
});

});

describe("MantisHubTheme getFilePath", () => {
Expand Down
Loading
Loading