import { QueryDescriptor, ResourceView, requiresSource, DEFAULT_SELECT, DEFAULT_LIST_SELECT, SelectEntry, SelectInput, normalizeSelectEntry, } from '.'; import { RenderContext } from './render-context'; import { URI } from '../model/uri'; /** * Markdown → HTML callback injected by the host. When omitted, markdown-bearing * cells fall back to escaped raw text (CLI / MCP behaviour). `sourceUri` * identifies the resource the markdown came from — used by adapters to * rewrite intra-note links and to guard against rendering cycles. */ export interface MarkdownRenderOptions { sourceUri?: URI; } export type MarkdownRenderer = ( markdown: string, opts?: MarkdownRenderOptions ) => string; /** * Builds the `href` value for a note link in query output. Returning `null` * tells `noteLink` to render the title as plain text (no anchor) — used by * the report renderer to drop links to notes that aren't part of the report. * * The hook receives the full `URI`, including any `fragment`, so callers can * route section-targeted query results (e.g. `[[note#section]]` results) to * section anchors when their output scheme supports them. */ export type ToHref = (uri: URI) => string | null; function renderCell( field: string, value: unknown, row: ResourceView, renderMarkdown?: MarkdownRenderer, context?: RenderContext ): string { if (value == null) return ''; if (renderMarkdown && requiresSource(field)) { const sourceUri = row.uri; const entered = context ? context.enter(sourceUri) : false; if (context && !entered) { return `cycle: ${escapeHtml( sourceUri.path )}`; } try { return renderMarkdown(String(value), { sourceUri }); } finally { if (entered) context!.exit(sourceUri); } } if (Array.isArray(value)) return escapeHtml(value.join(', ')); return escapeHtml(String(value)); } export function escapeHtml(text: string): string { return String(text) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * Renders a query result as an anchor (or plain text). The host's `toHref` * owns the full href shape — `noteLink` doesn't prefix, encode, or otherwise * massage the returned string. * * Returning `null` from `toHref` is the explicit "no link" signal: useful * when the target is out of the host's output scope (e.g. not included in a * report). A thrown error is still caught as a safety net and falls back to * plain text too, but `null` is the documented path. */ export function noteLink( title: string, uri: URI, toHref: ToHref ): string { let href: string | null; try { href = toHref(uri); } catch { href = null; } if (href === null) { return escapeHtml(title); } const safeHref = escapeHtml(href); return `${escapeHtml(title)}`; } /** Title → basename → ``, so a title link is never visibly empty. */ function titleText(value: unknown, uriPath: string): string { if (value != null && String(value).length > 0) return String(value); const basename = uriPath.split('/').filter(Boolean).pop(); return basename && basename.length > 0 ? basename : ''; } function cellValue( entry: SelectEntry, value: unknown, row: ResourceView, toHref: ToHref, renderMarkdown?: MarkdownRenderer, context?: RenderContext ): string { if (entry.link) { // `title` falls back through frontmatter→H1→basename so a row never // produces a visibly empty anchor; other fields just use their value // and only fall back when null/empty. const text = entry.field === 'title' || value == null || String(value).length === 0 ? titleText(value, row.uri.path) : String(value); return noteLink(text, row.uri, toHref); } return renderCell(entry.field, value, row, renderMarkdown, context); } /** * Coarse description of the HTML a query renderer produced. Consumers of the * `onDidRender` hook on `markdownItFoamQuery` use this to decide how (or * whether) to wrap the result without having to inspect the HTML string. * * - `'table'` / `'list'` / `'count'`: shape of the successful render. * - `'empty'`: the renderer produced the empty-state placeholder because the * underlying result set was empty. * - `'unknown'`: emitted by `renderJsQuery`, where the user's script can call * `render(value)` arbitrarily many times with arbitrary inputs, so the * output is an opaque concatenation. */ export type QueryResultShape = | 'table' | 'list' | 'count' | 'empty' | 'unknown'; /** What every query renderer in this module returns: the HTML plus the * shape that produced it. Errors are not represented here — they're a * fence-rule concern, surfaced via `FoamQueryRenderEvent.shape === 'error'`. */ export interface QueryRender { html: string; shape: QueryResultShape; } /** * Event fired by the `markdownItFoamQuery` plugin after a single foam-query * (or foam-query-js) fence has been rendered. Consumers receive the rendered * HTML plus a coarse shape tag so they can wrap, decorate, or strip the * output without inspecting it. * * Note on warnings: DQL prepends a `foam-query-warning` block to its result * HTML when validation issues come up. The block is included in `html` (we * don't split it out); `shape` describes the underlying result, not the * warnings. */ export interface FoamQueryRenderEvent { /** Which fence kind produced this. */ info: 'foam-query' | 'foam-query-js'; /** The HTML the renderer produced. */ html: string; /** * Result shape, with the added `'error'` case for when the fence rule's * try/catch caught an exception — those don't appear in a renderer's own * return value (since the renderer never finished). */ shape: QueryResultShape | 'error'; } const EMPTY_RESULT: QueryRender = { html: '

No results

', shape: 'empty', }; export function renderList( results: ResourceView[], fields: SelectInput[], toHref: ToHref, renderMarkdown?: MarkdownRenderer, context?: RenderContext ): QueryRender { if (results.length === 0) { return EMPTY_RESULT; } const entries = fields.map(normalizeSelectEntry); const items = results .map(r => { const parts = entries .map(entry => cellValue(entry, r[entry.field], r, toHref, renderMarkdown, context)) .filter(Boolean); return parts.length > 0 ? `
  • ${parts.join(' · ')}
  • ` : null; }) .filter((item): item is string => item !== null) .join('\n'); if (items.length === 0) { return EMPTY_RESULT; } return { html: ``, shape: 'list', }; } export function renderTable( results: ResourceView[], fields: SelectInput[], toHref: ToHref, renderMarkdown?: MarkdownRenderer, context?: RenderContext ): QueryRender { if (results.length === 0) { return EMPTY_RESULT; } const entries = fields.map(normalizeSelectEntry); const headers = entries .map(e => `${escapeHtml(e.label)}`) .join(''); const rows = results .map(r => { const cells = entries .map( entry => `${cellValue( entry, r[entry.field], r, toHref, renderMarkdown, context )}` ) .join(''); return `${cells}`; }) .join('\n'); return { html: `\n` + `${headers}\n` + `\n${rows}\n\n` + `
    `, shape: 'table', }; } export function renderCount(results: ResourceView[]): QueryRender { const n = results.length; return { html: `${n} note${ n === 1 ? '' : 's' }`, shape: 'count', }; } /** * Renders query results as HTML based on the descriptor's format. */ export function renderResults( results: ResourceView[], descriptor: QueryDescriptor, toHref: ToHref, renderMarkdown?: MarkdownRenderer, context?: RenderContext ): QueryRender { const format = descriptor.format ?? (descriptor.select && descriptor.select.length > 1 ? 'table' : 'list'); switch (format) { case 'table': return renderTable( results, descriptor.select ?? DEFAULT_SELECT, toHref, renderMarkdown, context ); case 'count': return renderCount(results); default: return renderList( results, descriptor.select ?? DEFAULT_LIST_SELECT, toHref, renderMarkdown, context ); } }