import type { MarkdownNode } from "react-native-nitro-markdown/headless";
import { isMarkdownFileLinkLabel } from "@t3tools/client-runtime/markdown-links";
import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens";
import { imageMimeType } from "@t3tools/shared/image";
import { isWindowsAbsolutePath } from "@t3tools/shared/path";
import { videoMimeType } from "@t3tools/shared/video";
/**
* Every accent shares a lightness so no kind reads heavier than another; only hue carries
* identity. These are the sRGB form of the same OKLCH set web uses, so a chip looks the
* same on every surface. See `composerInlineChip.ts`.
*/
const CONTEXT_CHIP_PRESENTATIONS = {
image: { accent: "#d55665", symbol: "photo" },
video: { accent: "#d06217", symbol: "play.rectangle" },
file: { accent: "#0090cd", symbol: "doc" },
mention: { accent: "#0096af", symbol: "doc" },
terminal: { accent: "#009f6e", symbol: "terminal" },
element: { accent: "#b87501", symbol: "cursorarrow.click" },
"preview-annotation": { accent: "#b87501", symbol: "cursorarrow.click" },
"review-comment": { accent: "#8a70dd", symbol: "text.bubble" },
"pull-request": { accent: "#7079e4", symbol: "git-pull-request" },
skill: { accent: "#b261be", symbol: "cube" },
thread: { accent: "#009c96", symbol: "text.bubble" },
} as const;
/**
* The size an attachment chip reports beside its name, matching web. Rendered as its own
* smaller run, so it carries no separator. Only attachment-backed records have bytes.
*/
export function composerChipSizeSuffix(record?: {
readonly kind?: string;
readonly sizeBytes?: number;
}): string {
if (record?.kind !== "file" && record?.kind !== "image") return "";
return typeof record.sizeBytes === "number" ? formatAttachmentSize(record.sizeBytes) : "";
}
/**
* A pull request chip is coloured by what the pull request *is*, the way web colours it and
* the way the forge itself does: green open, grey draft, purple merged, red closed. The glyph
* stays the same across all four, as it does on web — state is carried by colour alone.
*/
const PULL_REQUEST_CHIP_PRESENTATIONS = {
open: { accent: "#009f6e", symbol: "git-pull-request" },
draft: { accent: "#7f8793", symbol: "git-pull-request" },
merged: { accent: "#8a70dd", symbol: "git-pull-request" },
closed: { accent: "#d55665", symbol: "git-pull-request" },
} as const;
export function contextChipPresentation(
kind: string,
record?: {
readonly kind?: string;
readonly name?: string;
readonly mimeType?: string;
readonly sectionId?: string;
readonly pullRequest?: {
readonly state?: string;
readonly isDraft?: boolean;
};
},
) {
const presentationKind =
kind === "file" &&
videoMimeType({
name: record?.name ?? "",
mimeType: record?.mimeType ?? "",
})
? "video"
: // A picture chosen through the file picker is typed `file`, but it is still a
// picture: it reads as one to the user and should not wear the generic file chip.
kind === "file" &&
imageMimeType({ name: record?.name ?? "", mimeType: record?.mimeType ?? "" }) !== null
? "image"
: kind === "review-comment" && record?.sectionId?.startsWith("pull-request:")
? "pull-request"
: kind;
if (presentationKind === "pull-request") {
const pullRequest = record?.pullRequest;
const state =
pullRequest?.state === "open" && pullRequest.isDraft === true
? "draft"
: (pullRequest?.state ?? "");
if (Object.hasOwn(PULL_REQUEST_CHIP_PRESENTATIONS, state)) {
return PULL_REQUEST_CHIP_PRESENTATIONS[state as keyof typeof PULL_REQUEST_CHIP_PRESENTATIONS];
}
}
return Object.hasOwn(CONTEXT_CHIP_PRESENTATIONS, presentationKind)
? CONTEXT_CHIP_PRESENTATIONS[presentationKind as keyof typeof CONTEXT_CHIP_PRESENTATIONS]
: CONTEXT_CHIP_PRESENTATIONS.file;
}
import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
import {
formatComposerContextReference,
parseComposerContextHref,
} from "@t3tools/shared/composerContextReferences";
/** Native selections count UTF-16 display units, including each inline image placeholder. */
export function nativeMarkdownContextCopyRanges(
runs: ReadonlyArray<{
readonly run: {
readonly href?: string;
readonly text: string;
readonly skillName?: string;
readonly fileIcon?: string;
readonly sourceText?: string;
};
readonly text: string;
readonly inlineImageLength: number;
}>,
) {
let offset = 0;
const ranges: Array<{ start: number; end: number; text: string }> = [];
for (const [index, { run, text, inlineImageLength }] of runs.entries()) {
const start = offset;
offset += text.length + inlineImageLength;
const reference = parseComposerContextHref(run.href ?? "");
const source = reference
? formatComposerContextReference({ ...reference, label: run.text })
: run.skillName
? `$${run.skillName}`
: run.sourceText !== undefined
? run.sourceText
: run.fileIcon && run.href
? `[${run.text}](<${run.href}>)`
: null;
if (source === null) continue;
const previous = ranges.at(-1);
if (
run.sourceText !== undefined &&
!runs[index - 1]?.run.fileIcon &&
previous?.end === start &&
previous.text === source
) {
previous.end = offset;
} else {
ranges.push({ start, end: offset, text: source });
}
}
return ranges;
}
import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types";
import {
resolveMarkdownInlineCodePresentation,
resolveMarkdownLinkPresentation,
type MarkdownFileIcon,
} from "./markdownLinks";
export interface NativeMarkdownTextRun {
readonly text: string;
readonly bold?: boolean;
readonly italic?: boolean;
readonly strikethrough?: boolean;
readonly code?: boolean;
readonly href?: string;
readonly externalHost?: string;
readonly fileIcon?: MarkdownFileIcon;
readonly skillName?: string;
readonly skillLabel?: string;
readonly sourceText?: string;
readonly role?:
| "body"
| "heading"
| "list-marker"
| "list-break"
| "quote-marker"
| "code-block"
| "code-language"
| "divider"
| "spacer";
readonly headingLevel?: number;
readonly depth?: number;
readonly spacing?: number;
readonly firstLineHeadIndent?: number;
readonly headIndent?: number;
readonly paragraphSpacing?: number;
}
export type NativeMarkdownDocumentChunk =
| {
readonly kind: "selectable";
readonly key: string;
readonly node: MarkdownNode;
}
| {
readonly kind: "rich";
readonly key: string;
readonly node: MarkdownNode;
};
interface RunContext {
readonly bold: boolean;
readonly italic: boolean;
readonly strikethrough: boolean;
readonly code: boolean;
readonly href?: string;
readonly externalHost?: string;
readonly fileIcon?: MarkdownFileIcon;
readonly sourceText?: string;
readonly role?: NativeMarkdownTextRun["role"];
readonly headingLevel?: number;
readonly depth?: number;
readonly spacing?: number;
readonly firstLineHeadIndent?: number;
readonly headIndent?: number;
readonly paragraphSpacing?: number;
}
const EMPTY_CONTEXT: RunContext = {
bold: false,
italic: false,
strikethrough: false,
code: false,
};
const INLINE_HTML_TAG_PATTERN = /<\/?(?:kbd|mark|sub|sup|u)(?:\s[^>]*)?>/gi;
function decodeCodePoint(codePoint: number, entity: string): string {
if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
return entity;
}
return String.fromCodePoint(codePoint);
}
function decodeHtmlEntitiesOnce(value: string): string {
return value.replace(
/&(?:#(\d+)|#x([0-9a-f]+)|amp|apos|gt|lt|nbsp|quot);/gi,
(entity, decimal: string | undefined, hexadecimal: string | undefined) => {
if (decimal) {
return decodeCodePoint(Number.parseInt(decimal, 10), entity);
}
if (hexadecimal) {
return decodeCodePoint(Number.parseInt(hexadecimal, 16), entity);
}
switch (entity.toLowerCase()) {
case "&":
return "&";
case "'":
return "'";
case ">":
return ">";
case "<":
return "<";
case " ":
return "\u00a0";
case """:
return '"';
default:
return entity;
}
},
);
}
function decodeHtmlEntities(value: string): string {
let decoded = value;
for (let pass = 0; pass < 2; pass += 1) {
const next = decodeHtmlEntitiesOnce(decoded);
if (next === decoded) {
break;
}
decoded = next;
}
return decoded;
}
function textNodeContent(value: string): string {
return decodeHtmlEntities(value).replace(INLINE_HTML_TAG_PATTERN, "");
}
function inlineHtmlText(value: string): string {
if (/^
$/i.test(value.trim())) {
return "\n";
}
return decodeHtmlEntities(value.replace(/<[^>]+>/g, ""));
}
function sameRunStyle(left: NativeMarkdownTextRun, right: NativeMarkdownTextRun): boolean {
return (
left.bold === right.bold &&
left.italic === right.italic &&
left.strikethrough === right.strikethrough &&
left.code === right.code &&
left.href === right.href &&
left.externalHost === right.externalHost &&
left.fileIcon === right.fileIcon &&
left.sourceText === right.sourceText &&
left.skillName === right.skillName &&
left.skillLabel === right.skillLabel &&
left.role === right.role &&
left.headingLevel === right.headingLevel &&
left.depth === right.depth &&
left.spacing === right.spacing &&
left.firstLineHeadIndent === right.firstLineHeadIndent &&
left.headIndent === right.headIndent &&
left.paragraphSpacing === right.paragraphSpacing
);
}
function appendRun(
runs: NativeMarkdownTextRun[],
text: string,
context: RunContext,
): NativeMarkdownTextRun[] {
if (text.length === 0) {
return runs;
}
const run: NativeMarkdownTextRun = {
text,
...(context.bold ? { bold: true } : {}),
...(context.italic ? { italic: true } : {}),
...(context.strikethrough ? { strikethrough: true } : {}),
...(context.code ? { code: true } : {}),
...(context.href ? { href: context.href } : {}),
...(context.externalHost ? { externalHost: context.externalHost } : {}),
...(context.fileIcon ? { fileIcon: context.fileIcon } : {}),
...(context.sourceText !== undefined ? { sourceText: context.sourceText } : {}),
...(context.role ? { role: context.role } : {}),
...(context.headingLevel ? { headingLevel: context.headingLevel } : {}),
...(context.depth ? { depth: context.depth } : {}),
...(context.spacing ? { spacing: context.spacing } : {}),
...(context.firstLineHeadIndent !== undefined
? { firstLineHeadIndent: context.firstLineHeadIndent }
: {}),
...(context.headIndent !== undefined ? { headIndent: context.headIndent } : {}),
...(context.paragraphSpacing !== undefined
? { paragraphSpacing: context.paragraphSpacing }
: {}),
};
const previous = runs.at(-1);
if (previous && sameRunStyle(previous, run)) {
runs[runs.length - 1] = { ...previous, text: previous.text + run.text };
return runs;
}
runs.push(run);
return runs;
}
const SKILL_TOKEN_REGEX =
/(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu;
function formatSkillLabel(skill: SelectableMarkdownSkill): string {
const displayName = skill.displayName?.trim();
if (displayName) {
return displayName;
}
return skill.name
.split(/[\s:_-]+/)
.filter(Boolean)
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
.join(" ");
}
function decorateSkillRuns(
runs: ReadonlyArray,
skills: ReadonlyArray,
): ReadonlyArray {
if (skills.length === 0) {
return runs;
}
const skillByName = new Map(skills.map((skill) => [skill.name, skill]));
const decorated: NativeMarkdownTextRun[] = [];
for (const run of runs) {
if (run.code || run.href || run.fileIcon || run.role === "code-block") {
decorated.push(run);
continue;
}
let cursor = 0;
let matched = false;
for (const match of run.text.matchAll(SKILL_TOKEN_REGEX)) {
const prefix = match[1] ?? "";
const name = match[2] ?? "";
const skill = skillByName.get(name);
if (!skill) {
continue;
}
const start = (match.index ?? 0) + prefix.length;
const end = (match.index ?? 0) + match[0].length;
if (start > cursor) {
decorated.push({ ...run, text: run.text.slice(cursor, start) });
}
decorated.push({
...run,
text: run.text.slice(start, end),
skillName: name,
skillLabel: formatSkillLabel(skill),
});
cursor = end;
matched = true;
}
if (!matched) {
decorated.push(run);
} else if (cursor < run.text.length) {
decorated.push({ ...run, text: run.text.slice(cursor) });
}
}
return decorated;
}
function decorateMentionRuns(runs: ReadonlyArray) {
return runs.flatMap((run) => {
if (run.code || run.href || run.skillName || run.role === "code-block") return [run];
const decorated: NativeMarkdownTextRun[] = [];
let cursor = 0;
for (const token of collectComposerInlineTokens(`${run.text} `)) {
if (token.type !== "mention" || !token.source.startsWith("@")) continue;
// Sentence punctuation is not part of an unquoted file reference.
const path = token.source.startsWith('@"')
? token.value
: token.value.replace(/[.,;!?]+$/, "");
const presentation = resolveMarkdownLinkPresentation(path);
if (presentation.kind !== "file") continue;
const end = token.end - (token.value.length - path.length);
if (token.start > cursor)
decorated.push({ ...run, text: run.text.slice(cursor, token.start) });
decorated.push({
...run,
text: presentation.label,
href: presentation.href,
fileIcon: presentation.icon,
sourceText: run.text.slice(token.start, end),
});
cursor = end;
}
if (cursor < run.text.length) decorated.push({ ...run, text: run.text.slice(cursor) });
return decorated;
});
}
function appendChildren(
runs: NativeMarkdownTextRun[],
node: MarkdownNode,
context: RunContext,
): NativeMarkdownTextRun[] {
for (const child of node.children ?? []) {
appendNode(runs, child, context);
}
return runs;
}
function nodeTextContent(node: MarkdownNode): string {
if (node.content !== undefined) {
return node.content;
}
return (node.children ?? []).map(nodeTextContent).join("");
}
function fileLinkLabelMarkdown(node: MarkdownNode): string {
const children = (node.children ?? []).map(fileLinkLabelMarkdown).join("");
switch (node.type) {
case "bold":
return `**${children}**`;
case "italic":
return `*${children}*`;
case "strikethrough":
return `~~${children}~~`;
case "code_inline": {
const content = nodeTextContent(node);
const fence = "`".repeat(
Math.max(0, ...(content.match(/`+/g) ?? []).map((run) => run.length)) + 1,
);
const padding = /^`|`$|^ .* $/.test(content) && content.trim().length > 0 ? " " : "";
return `${fence}${padding}${content}${padding}${fence}`;
}
case "soft_break":
return "\n";
case "line_break":
return " \n";
case "text":
return textNodeContent(nodeTextContent(node)).replace(/[\\[\]*_`~]/g, "\\$&");
case "image":
return `![${(node.alt ?? "").replace(/[\\[\]]/g, "\\$&")}](<${node.href ?? ""}>)`;
default:
return node.content ?? children;
}
}
function appendNode(
runs: NativeMarkdownTextRun[],
node: MarkdownNode,
context: RunContext,
): NativeMarkdownTextRun[] {
switch (node.type) {
case "text":
case "math_inline":
return appendRun(runs, textNodeContent(nodeTextContent(node)), context);
case "html_inline":
return appendRun(runs, inlineHtmlText(nodeTextContent(node)), context);
case "code_inline": {
const content = nodeTextContent(node);
const presentation = context.href ? null : resolveMarkdownInlineCodePresentation(content);
return presentation
? appendRun(runs, presentation.label, {
...context,
href: presentation.href,
fileIcon: presentation.icon,
})
: appendRun(runs, content, { ...context, code: true });
}
case "soft_break":
return appendRun(runs, " ", context);
case "line_break":
return appendRun(runs, "\n", context);
case "bold":
return appendChildren(runs, node, { ...context, bold: true });
case "italic":
return appendChildren(runs, node, { ...context, italic: true });
case "strikethrough":
return appendChildren(runs, node, { ...context, strikethrough: true });
case "link": {
const reference = parseComposerContextHref(node.href ?? "");
if (reference) {
// Build the link in isolation: adjacent links with the same href and
// style would otherwise merge into one run, collapsing two chips and
// their copy ranges into a single reference with a combined label.
const referenceRuns: NativeMarkdownTextRun[] = [];
appendChildren(referenceRuns, node, {
...context,
href: node.href,
fileIcon:
reference.kind === "image" ? "image" : reference.kind === "terminal" ? "bash" : "text",
});
runs.push(...referenceRuns);
return runs;
}
const presentation = resolveMarkdownLinkPresentation(node.href ?? "");
if (presentation.kind === "file") {
const label = textNodeContent(nodeTextContent(node));
if (!isMarkdownFileLinkLabel(label, presentation.href)) {
const fileRuns: NativeMarkdownTextRun[] = [];
const fileContext = {
...context,
href: presentation.href,
sourceText: `[${fileLinkLabelMarkdown(node)}](<${presentation.href}>)`,
};
appendChildren(fileRuns, node, fileContext);
appendRun(fileRuns, " ", fileContext);
appendRun(fileRuns, presentation.label, {
...fileContext,
fileIcon: presentation.icon,
});
runs.push(...fileRuns);
return runs;
}
return appendRun(runs, presentation.label, {
...context,
href: presentation.href,
fileIcon: presentation.icon,
});
}
if (presentation.kind === "external") {
return appendChildren(runs, node, {
...context,
href: presentation.href,
externalHost: presentation.host,
});
}
return appendChildren(runs, node, {
...context,
...(presentation.href ? { href: presentation.href } : {}),
});
}
case "image":
return appendRun(runs, node.alt ?? node.title ?? "", context);
default:
return appendChildren(runs, node, context);
}
}
export function nativeMarkdownTextRuns(node: MarkdownNode): ReadonlyArray {
return appendChildren([], node, EMPTY_CONTEXT);
}
export function nativeMarkdownWithPreservedSoftBreaks(node: MarkdownNode): MarkdownNode {
const children = node.children?.map(nativeMarkdownWithPreservedSoftBreaks);
return {
...node,
...(node.type === "soft_break" ? { type: "line_break" as const } : {}),
...(children ? { children } : {}),
};
}
const WINDOWS_DESTINATION_PATTERN = /\](?:\(|:)\s*((?:[A-Za-z]:|\\\\)(?:\\.|[^\s()<>\\])*)/g;
const MARKDOWN_ESCAPE_PATTERN = /\\([!-/:-@[-`{-~])/g;
/**
* md4c reads the `\.` in `C:\me\.t3\shot.png` as an escape, even in a link destination.
* Every backslash in a Windows path is a separator, so link and image paths go back to
* the destination as written in `markdown`. A parsed path that more than one written
* destination could have produced stays as parsed.
*/
export function nativeMarkdownWithAuthoredWindowsPaths(
node: MarkdownNode,
markdown: string,
): MarkdownNode {
const authoredByParsed = new Map();
for (const [, authored = ""] of markdown.matchAll(WINDOWS_DESTINATION_PATTERN)) {
if (!isWindowsAbsolutePath(authored)) continue;
const parsed = authored.replace(MARKDOWN_ESCAPE_PATTERN, "$1");
const known = authoredByParsed.get(parsed);
authoredByParsed.set(parsed, known === undefined || known === authored ? authored : null);
}
if (authoredByParsed.size === 0) return node;
const restore = (current: MarkdownNode): MarkdownNode => {
const href = current.href && authoredByParsed.get(current.href);
const children = current.children?.map(restore);
return { ...current, ...(href ? { href } : {}), ...(children ? { children } : {}) };
};
return restore(node);
}
function appendBlockTerminator(
runs: NativeMarkdownTextRun[],
context: RunContext,
): NativeMarkdownTextRun[] {
return appendRun(runs, "\n", context);
}
function appendSpacer(runs: NativeMarkdownTextRun[], spacing: number): NativeMarkdownTextRun[] {
return appendRun(runs, "\n", { ...EMPTY_CONTEXT, role: "spacer", spacing });
}
function appendInlineChildren(
runs: NativeMarkdownTextRun[],
node: MarkdownNode,
context: RunContext,
): NativeMarkdownTextRun[] {
for (const child of node.children ?? []) {
appendNode(runs, child, context);
}
return runs;
}
function isInlineNode(node: MarkdownNode): boolean {
return (
node.type === "text" ||
node.type === "bold" ||
node.type === "italic" ||
node.type === "strikethrough" ||
node.type === "link" ||
node.type === "image" ||
node.type === "code_inline" ||
node.type === "math_inline" ||
node.type === "html_inline" ||
node.type === "soft_break" ||
node.type === "line_break"
);
}
export function nativeMarkdownListItemBlocks(node: MarkdownNode): ReadonlyArray {
const blocks: MarkdownNode[] = [];
let inlineNodes: MarkdownNode[] = [];
const flushInlineNodes = () => {
if (inlineNodes.length === 0) {
return;
}
blocks.push({ type: "paragraph", children: inlineNodes });
inlineNodes = [];
};
for (const child of node.children ?? []) {
if (isInlineNode(child)) {
inlineNodes.push(child);
continue;
}
flushInlineNodes();
blocks.push(child);
}
flushInlineNodes();
return blocks;
}
function appendListItem(
runs: NativeMarkdownTextRun[],
node: MarkdownNode,
marker: string,
depth: number,
markerColumnWidth: number,
): NativeMarkdownTextRun[] {
const firstLineHeadIndent = Math.max(0, depth - 1) * 20;
appendRun(runs, `${marker}\t`, {
...EMPTY_CONTEXT,
role: "list-marker",
depth,
firstLineHeadIndent,
headIndent: firstLineHeadIndent + markerColumnWidth,
paragraphSpacing: 2,
});
const children = node.children ?? [];
let wroteInlineContent = false;
for (const child of children) {
if (child.type === "paragraph") {
appendInlineChildren(runs, child, {
...EMPTY_CONTEXT,
role: "body",
depth,
});
wroteInlineContent = true;
continue;
}
if (child.type === "list") {
if (wroteInlineContent) {
appendBlockTerminator(runs, {
...EMPTY_CONTEXT,
role: "list-break",
depth,
spacing: 1,
});
}
appendList(runs, child, depth + 1);
wroteInlineContent = false;
continue;
}
if (isInlineNode(child)) {
appendNode(runs, child, {
...EMPTY_CONTEXT,
role: "body",
depth,
});
wroteInlineContent = true;
continue;
}
appendDocumentBlock(runs, child, depth);
wroteInlineContent = true;
}
if (wroteInlineContent) {
appendBlockTerminator(runs, {
...EMPTY_CONTEXT,
role: "list-break",
depth,
spacing: depth === 1 ? 4 : 2,
});
}
return runs;
}
function appendList(
runs: NativeMarkdownTextRun[],
node: MarkdownNode,
depth: number,
): NativeMarkdownTextRun[] {
const ordered = node.ordered ?? false;
const start = node.start ?? 1;
const children = node.children ?? [];
const markers = children.map((child, index) =>
child.type === "task_list_item"
? child.checked
? "☑︎"
: "☐︎"
: ordered
? `${start + index}.`
: depth % 3 === 2
? "◦"
: depth % 3 === 0
? "▪︎"
: "•",
);
const markerWidth = ordered
? Math.max(0, ...markers.map((marker) => Array.from(marker).length))
: 0;
for (const [index, child] of children.entries()) {
const marker = markers[index] ?? "•";
const alignedMarker =
child.type === "task_list_item"
? marker
: ordered
? `${"\u2007".repeat(Math.max(0, markerWidth - Array.from(marker).length))}${marker}`
: marker;
const markerColumnWidth =
child.type === "task_list_item" ? 28 : ordered ? 10 + markerWidth * 8 : 24;
appendListItem(runs, child, alignedMarker, depth, markerColumnWidth);
}
return runs;
}
function appendQuoteBlock(
runs: NativeMarkdownTextRun[],
node: MarkdownNode,
depth: number,
): NativeMarkdownTextRun[] {
for (const [index, child] of (node.children ?? []).entries()) {
if (index > 0) {
appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth });
}
appendRun(runs, "│\u00a0", {
...EMPTY_CONTEXT,
role: "quote-marker",
depth,
});
if (child.type === "paragraph") {
appendInlineChildren(runs, child, {
...EMPTY_CONTEXT,
role: "body",
depth,
});
} else {
appendDocumentBlock(runs, child, depth);
}
}
appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth });
return runs;
}
function appendTableRow(
runs: NativeMarkdownTextRun[],
node: MarkdownNode,
depth: number,
): NativeMarkdownTextRun[] {
const cells = node.children ?? [];
for (const [index, cell] of cells.entries()) {
if (index > 0) {
appendRun(runs, "\u00a0│\u00a0", {
...EMPTY_CONTEXT,
role: "divider",
depth,
});
}
appendInlineChildren(runs, cell, {
...EMPTY_CONTEXT,
role: "body",
bold: cell.isHeader ?? false,
depth,
});
}
appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth });
return runs;
}
function appendTable(
runs: NativeMarkdownTextRun[],
node: MarkdownNode,
depth: number,
): NativeMarkdownTextRun[] {
const visit = (child: MarkdownNode) => {
if (child.type === "table_row") {
appendTableRow(runs, child, depth);
return;
}
for (const nested of child.children ?? []) {
visit(nested);
}
};
visit(node);
return runs;
}
function appendDocumentBlock(
runs: NativeMarkdownTextRun[],
node: MarkdownNode,
depth = 0,
): NativeMarkdownTextRun[] {
switch (node.type) {
case "document": {
const children = node.children ?? [];
for (const [index, child] of children.entries()) {
if (index > 0) {
const previous = children[index - 1];
appendSpacer(
runs,
child.type === "heading" ? 20 : previous?.type === "heading" ? 10 : 12,
);
}
appendDocumentBlock(runs, child, depth);
}
return runs;
}
case "heading": {
const context: RunContext = {
...EMPTY_CONTEXT,
role: "heading",
headingLevel: node.level ?? 1,
depth,
};
appendInlineChildren(runs, node, context);
return appendBlockTerminator(runs, context);
}
case "paragraph": {
const context: RunContext = { ...EMPTY_CONTEXT, role: "body", depth };
appendInlineChildren(runs, node, context);
return appendBlockTerminator(runs, context);
}
case "list":
return appendList(runs, node, depth + 1);
case "blockquote":
return appendQuoteBlock(runs, node, depth);
case "code_block": {
if (node.language) {
appendRun(runs, `${node.language.toUpperCase()}\n`, {
...EMPTY_CONTEXT,
role: "code-language",
code: true,
depth,
});
}
const content = nodeTextContent(node);
appendRun(runs, content, {
...EMPTY_CONTEXT,
role: "code-block",
code: true,
depth,
});
if (!content.endsWith("\n")) {
appendBlockTerminator(runs, {
...EMPTY_CONTEXT,
role: "code-block",
code: true,
depth,
});
}
return runs;
}
case "horizontal_rule":
appendRun(runs, "────────────────────────\n", {
...EMPTY_CONTEXT,
role: "divider",
depth,
});
return runs;
case "table":
return appendTable(runs, node, depth);
case "html_block":
appendRun(runs, inlineHtmlText(nodeTextContent(node)), {
...EMPTY_CONTEXT,
role: "body",
depth,
});
return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth });
case "math_block":
appendRun(runs, nodeTextContent(node), { ...EMPTY_CONTEXT, role: "body", depth });
return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth });
default:
appendInlineChildren(runs, node, { ...EMPTY_CONTEXT, role: "body", depth });
return appendBlockTerminator(runs, { ...EMPTY_CONTEXT, role: "body", depth });
}
}
function containsRichBlock(node: MarkdownNode): boolean {
if (
node.type === "code_block" ||
node.type === "blockquote" ||
node.type === "table" ||
node.type === "image" ||
node.type === "horizontal_rule" ||
node.type === "html_block" ||
node.type === "math_block"
) {
return true;
}
return (node.children ?? []).some(containsRichBlock);
}
/**
* Sibling identity for React keys. A source offset survives appends while the
* document streams; the child index is the fallback for offset-free nodes. The
* two never share a namespace, so a positioned node cannot collide with an
* offset-free sibling whose index happens to equal its offset.
*/
export function nativeMarkdownNodePosition(node: MarkdownNode, index: number): string {
return node.beg === undefined ? `index:${index}` : `offset:${node.beg}`;
}
export function nativeMarkdownDocumentChunks(
document: MarkdownNode,
): ReadonlyArray {
const chunks: NativeMarkdownDocumentChunk[] = [];
let selectableNodes: MarkdownNode[] = [];
let selectableStart = 0;
const flushSelectable = () => {
const first = selectableNodes[0];
if (!first) {
return;
}
chunks.push({
kind: "selectable",
key: `selectable:${nativeMarkdownNodePosition(first, selectableStart)}`,
node: {
type: "document",
children: selectableNodes,
},
});
selectableNodes = [];
};
for (const [index, child] of (document.children ?? []).entries()) {
if (!containsRichBlock(child)) {
if (selectableNodes.length === 0) {
selectableStart = index;
}
selectableNodes.push(child);
continue;
}
flushSelectable();
chunks.push({
kind: "rich",
key: `rich:${child.type}:${nativeMarkdownNodePosition(child, index)}`,
node: child,
});
}
flushSelectable();
return chunks;
}
function topLevelNodes(node: MarkdownNode): ReadonlyArray {
return node.type === "document" ? (node.children ?? []) : [node];
}
export function nativeMarkdownChunkSpacing(
previous: NativeMarkdownDocumentChunk | undefined,
current: NativeMarkdownDocumentChunk,
): number {
if (!previous) {
return 0;
}
const previousLast = topLevelNodes(previous.node).at(-1);
const currentFirst = topLevelNodes(current.node)[0];
if (currentFirst?.type === "heading") {
return 20;
}
if (previousLast?.type === "heading") {
return 10;
}
if (previousLast?.type === "list" && currentFirst?.type === "list") {
return 12;
}
return 14;
}
export function nativeMarkdownDocumentRuns(
node: MarkdownNode,
skills: ReadonlyArray = [],
): ReadonlyArray {
const runs = appendDocumentBlock([], node);
while (runs.length > 0) {
const lastIndex = runs.length - 1;
const last = runs[lastIndex];
if (!last?.text.endsWith("\n")) {
break;
}
const text = last.text.slice(0, -1);
if (text.length === 0) {
runs.pop();
} else {
runs[lastIndex] = { ...last, text };
}
}
return decorateMentionRuns(decorateSkillRuns(runs, skills));
}