Files
kandev/apps/web/lib/entity-references/message-references.ts
T
2026-09-21 18:22:29 +01:00

291 lines
9.2 KiB
TypeScript

import type { EntityReference } from "@/lib/types/entity-reference";
import { linkToTask } from "@/lib/links";
const IDENTITY_NAME = /^[a-z0-9][a-z0-9._:-]{0,127}$/;
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/u;
const UNPAIRED_SURROGATE = /[\uD800-\uDFFF]/u;
const MAX_IDENTITY_LENGTH = 512;
const MAX_TITLE_LENGTH = 500;
const MAX_KEY_LENGTH = 200;
const MAX_URL_LENGTH = 2048;
const REFERENCE_STRING_FIELDS = ["ref", "provider", "kind", "id", "title", "url", "scope"] as const;
function codePointLength(value: string): number {
return Array.from(value).length;
}
function isBoundedIdentity(value: string): boolean {
const length = codePointLength(value);
return (
length > 0 &&
length <= MAX_IDENTITY_LENGTH &&
!CONTROL_CHARACTER.test(value) &&
!UNPAIRED_SURROGATE.test(value)
);
}
function isNormalizedDisplayText(value: string, maxLength: number): boolean {
return (
value === value.trim().replace(/\s+/gu, " ") &&
codePointLength(value) <= maxLength &&
!UNPAIRED_SURROGATE.test(value)
);
}
function queryEscape(value: string): string | null {
try {
return encodeURIComponent(value)
.replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`)
.replace(/%20/g, "+");
} catch {
return null;
}
}
function canonicalRef(reference: EntityReference): string | null {
const fields = [reference.provider, reference.kind, reference.scope, reference.id];
const encoded = fields.map(queryEscape);
if (encoded.some((field) => field === null)) return null;
return `mention:v1:${encoded.join(":")}`;
}
function hasSafeURL(reference: EntityReference): boolean {
if (
reference.url.length === 0 ||
codePointLength(reference.url) > MAX_URL_LENGTH ||
UNPAIRED_SURROGATE.test(reference.url)
) {
return false;
}
if (reference.provider === "kandev" && reference.kind === "task") {
return reference.url === linkToTask(reference.id);
}
try {
const parsed = new URL(reference.url);
return (
(parsed.protocol === "http:" || parsed.protocol === "https:") &&
parsed.hostname.length > 0 &&
parsed.username === "" &&
parsed.password === ""
);
} catch {
return false;
}
}
function hasValidIdentityFields(reference: EntityReference): boolean {
return (
IDENTITY_NAME.test(reference.provider) &&
IDENTITY_NAME.test(reference.kind) &&
isBoundedIdentity(reference.id) &&
isBoundedIdentity(reference.scope) &&
reference.ref === canonicalRef(reference)
);
}
function hasValidDisplayFields(reference: EntityReference): boolean {
return (
reference.title.length > 0 &&
isNormalizedDisplayText(reference.title, MAX_TITLE_LENGTH) &&
isNormalizedDisplayText(reference.key ?? "", MAX_KEY_LENGTH)
);
}
export function isEntityReference(value: unknown): value is EntityReference {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const reference = value as Record<string, unknown>;
if (reference.version !== 1) return false;
if (!REFERENCE_STRING_FIELDS.every((field) => typeof reference[field] === "string")) return false;
if (reference.key !== undefined && typeof reference.key !== "string") return false;
const typed = reference as EntityReference;
return hasValidIdentityFields(typed) && hasValidDisplayFields(typed) && hasSafeURL(typed);
}
export function normalizeEntityReferences(value: unknown): EntityReference[] {
if (!Array.isArray(value)) return [];
const result: EntityReference[] = [];
const seen = new Set<string>();
for (const candidate of value) {
if (!isEntityReference(candidate) || seen.has(candidate.ref)) continue;
seen.add(candidate.ref);
result.push(candidate);
}
return result;
}
export function entityReferencesFromMetadata(metadata: unknown): EntityReference[] {
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return [];
return normalizeEntityReferences((metadata as Record<string, unknown>).entity_references);
}
function escapeMarkdownLabel(value: string): string {
return value.replace(/([\\[\]])/g, "\\$1");
}
export function entityReferenceHref(reference: EntityReference): string {
const value = reference.url;
return encodeURI(value).replace(/\(/g, "%28").replace(/\)/g, "%29");
}
export function entityReferenceLabel(reference: EntityReference): string {
return `#${reference.key || reference.title}`;
}
export function entityReferenceMarkdown(reference: EntityReference): string {
return `[${escapeMarkdownLabel(entityReferenceLabel(reference))}](${entityReferenceHref(reference)})`;
}
export function matchEntityReferenceLink(
references: readonly EntityReference[],
label: string,
href: string | undefined,
): EntityReference | null {
if (!href) return null;
let match: EntityReference | null = null;
for (const reference of normalizeEntityReferences(references)) {
if (entityReferenceLabel(reference) !== label || entityReferenceHref(reference) !== href) {
continue;
}
if (match) return null;
match = reference;
}
return match;
}
type Fence = { marker: "`" | "~"; length: number };
function fenceAtLineStart(line: string): Fence | null {
const match = /^ {0,3}(`{3,}|~{3,})/u.exec(line);
if (!match) return null;
const run = match[1];
return { marker: run[0] as Fence["marker"], length: run.length };
}
function closesFence(line: string, fence: Fence): boolean {
const trimmed = line.replace(/^ {0,3}/u, "").trimEnd();
if (!trimmed.startsWith(fence.marker)) return false;
let runLength = 0;
while (trimmed[runLength] === fence.marker) runLength++;
return runLength >= fence.length && trimmed.length === runLength;
}
function markRange(mask: Uint8Array, start: number, end: number): void {
mask.fill(1, start, end);
}
function backtickRunLength(line: string, start: number): number {
let end = start;
while (line[end] === "`") end++;
return end - start;
}
function findClosingBackticks(line: string, start: number, runLength: number): number {
let cursor = start;
while (cursor < line.length) {
const next = line.indexOf("`", cursor);
if (next === -1) return -1;
const candidateLength = backtickRunLength(line, next);
if (candidateLength === runLength) return next;
cursor = next + candidateLength;
}
return -1;
}
function markInlineCode(line: string, lineStart: number, mask: Uint8Array): void {
let cursor = 0;
while (cursor < line.length) {
const opener = line.indexOf("`", cursor);
if (opener === -1) return;
const runLength = backtickRunLength(line, opener);
const closer = findClosingBackticks(line, opener + runLength, runLength);
if (closer === -1) {
cursor = opener + runLength;
continue;
}
markRange(mask, lineStart + opener, lineStart + closer + runLength);
cursor = closer + runLength;
}
}
function markdownCodeMask(content: string): Uint8Array {
const mask = new Uint8Array(content.length);
let fence: Fence | null = null;
let lineStart = 0;
while (lineStart < content.length) {
const newline = content.indexOf("\n", lineStart);
const lineEnd = newline === -1 ? content.length : newline;
const line = content.slice(lineStart, lineEnd);
if (fence) {
markRange(mask, lineStart, lineEnd);
if (closesFence(line, fence)) fence = null;
} else {
const opener = fenceAtLineStart(line);
if (opener) {
markRange(mask, lineStart, lineEnd);
fence = opener;
} else if (/^(?: {4}|\t)/u.test(line)) {
markRange(mask, lineStart, lineEnd);
} else {
markInlineCode(line, lineStart, mask);
}
}
lineStart = lineEnd + 1;
}
return mask;
}
function isEscaped(content: string, index: number): boolean {
let backslashes = 0;
for (let cursor = index - 1; cursor >= 0 && content[cursor] === "\\"; cursor--) backslashes++;
return backslashes % 2 === 1;
}
function findUsableOccurrence(
content: string,
markdown: string,
cursor: number,
codeMask: Uint8Array,
): number {
let index = content.indexOf(markdown, cursor);
while (index !== -1) {
const isImage = index > 0 && content[index - 1] === "!" && !isEscaped(content, index - 1);
if (!codeMask[index] && !isEscaped(content, index) && !isImage) return index;
index = content.indexOf(markdown, index + markdown.length);
}
return -1;
}
export function survivingEntityReferences(
content: string,
references: readonly EntityReference[],
): EntityReference[] {
const pending = normalizeEntityReferences(references).map((reference) => ({
reference,
markdown: entityReferenceMarkdown(reference),
}));
const result: EntityReference[] = [];
const codeMask = markdownCodeMask(content);
let cursor = 0;
while (pending.length > 0) {
let nextIndex = -1;
let pendingIndex = -1;
for (let index = 0; index < pending.length; index++) {
const candidateIndex = findUsableOccurrence(
content,
pending[index].markdown,
cursor,
codeMask,
);
if (candidateIndex !== -1 && (nextIndex === -1 || candidateIndex < nextIndex)) {
nextIndex = candidateIndex;
pendingIndex = index;
}
}
if (pendingIndex === -1) break;
const [match] = pending.splice(pendingIndex, 1);
result.push(match.reference);
cursor = nextIndex + match.markdown.length;
}
return result;
}