export const ATTR = Object.freeze({
root: "data-markstream",
html: "data-markstream-html",
mode: "data-markstream-mode",
theme: "data-markstream-theme",
typewriter: "data-markstream-typewriter",
maxLiveNodes: "data-markstream-max-live-nodes",
src: "data-markstream-src",
});
export const MOUNTED = "data-markstream-mounted";
export const STATE = "data-markstream-state";
const HTML_POLICIES = ["escape", "safe", "trusted"];
const MODES = ["docs", "chat", "minimal"];
const THEMES = ["light", "dark"];
export const EVENTS = Object.freeze(["chunk", "replace", "done", "error"]);
function oneOf(value, allowed) {
return allowed.includes(value) ? value : null;
}
export const ALLOW_TRUSTED = "markstream-allow-trusted";
export function readOptions(getAttr, prefersDark, { allowTrusted = false } = {}) {
const theme = oneOf(getAttr(ATTR.theme), THEMES);
const nodes = getAttr(ATTR.maxLiveNodes);
const parsed = nodes !== null && /^\d+$/.test(nodes) ? Number(nodes) : null;
const src = getAttr(ATTR.src);
let htmlPolicy = oneOf(getAttr(ATTR.html), HTML_POLICIES) ?? "escape";
if (htmlPolicy === "trusted" && !allowTrusted) {
htmlPolicy = "safe";
}
return {
htmlPolicy,
mode: oneOf(getAttr(ATTR.mode), MODES),
isDark: theme === null ? Boolean(prefersDark) : theme === "dark",
typewriter: getAttr(ATTR.typewriter) === "true",
maxLiveNodes: parsed,
src: src ? src : null,
};
}
export function sameOriginUrl(src, baseHref) {
if (!src) {
return null;
}
try {
const url = new URL(src, baseHref);
const base = new URL(baseHref);
const web = url.protocol === "http:" || url.protocol === "https:";
return web && url.origin === base.origin ? url.href : null;
} catch {
return null;
}
}
export function decodeData(data) {
try {
const value = JSON.parse(data);
return typeof value === "string" ? value : null;
} catch {
return null;
}
}
export function initialState(content, streaming) {
return streaming
? { content, final: false, status: "streaming" }
: { content, final: true, status: "done" };
}
export function reduce(state, type, text) {
if (type === "reset") {
return initialState(text ?? "", true);
}
if (state.status !== "streaming") {
return state;
}
switch (type) {
case "chunk":
case "replace":
if (text === null) {
return { content: state.content, final: true, status: "error" };
}
return {
content: type === "chunk" ? state.content + text : text,
final: false,
status: "streaming",
};
case "done":
case "error":
return { content: state.content, final: true, status: type };
default:
return state;
}
}
export const CUSTOM_ID = "autumn-markstream";
export function rendererProps(options, state) {
const props = {
content: state.content,
final: state.final,
htmlPolicy: options.htmlPolicy,
isDark: options.isDark,
typewriter: options.typewriter,
customId: CUSTOM_ID,
};
if (options.mode !== null) {
props.mode = options.mode;
}
if (options.maxLiveNodes !== null) {
props.maxLiveNodes = options.maxLiveNodes;
}
return props;
}
export const HIGHLIGHT_LIMIT = 100000;
const C_KEYWORDS =
"as async await break case catch class const continue crate default defer do else enum export extends " +
"extern fn for from func function go if impl import in interface let loop match mod move mut new null " +
"nil package private protected pub public ref return self static struct super switch this throw trait " +
"true false try type typeof undefined union unsafe use var void where while yield";
const PY_KEYWORDS =
"and as assert async await break class continue def del elif else except finally for from global if " +
"import in is lambda None nonlocal not or pass raise return True False try while with yield";
const SH_KEYWORDS =
"case do done elif else esac fi for function if in select then until while export local return";
const SQL_KEYWORDS =
"select from where insert into values update set delete create table drop alter join left right inner " +
"outer on group by order having limit and or not null as distinct union primary key index";
const DATA_KEYWORDS = "true false null";
const FAMILIES = {
c: { keywords: C_KEYWORDS, line: ["//"], block: true, hash: false },
python: { keywords: PY_KEYWORDS, line: ["#"], block: false, hash: true },
shell: { keywords: SH_KEYWORDS, line: ["#"], block: false, hash: true },
sql: { keywords: SQL_KEYWORDS, line: ["--"], block: true, hash: false, ci: true },
data: { keywords: DATA_KEYWORDS, line: [], block: false, hash: false },
yaml: { keywords: DATA_KEYWORDS, line: ["#"], block: false, hash: true },
};
const LANGUAGE_FAMILY = {};
for (const [family, names] of Object.entries({
c: "c cpp c++ h hpp cs csharp java js jsx javascript ts tsx typescript rust rs go kotlin swift scala php dart css scss",
python: "py python rb ruby",
shell: "sh bash zsh shell console",
sql: "sql",
data: "json jsonc json5",
yaml: "yaml yml toml ini",
})) {
for (const name of names.split(" ")) {
LANGUAGE_FAMILY[name] = family;
}
}
const WORD_START = /[A-Za-z_$]/;
const WORD_CHAR = /[A-Za-z0-9_$]/;
const DIGIT = /[0-9]/;
const NUMBER_CHAR = /[0-9A-Za-z_.]/;
export function highlight(code, lang) {
const text = String(code ?? "");
if (text === "") {
return [];
}
if (text.length > HIGHLIGHT_LIMIT) {
return [{ type: "plain", text }];
}
const name = String(lang ?? "").trim().toLowerCase();
const known = Object.hasOwn(LANGUAGE_FAMILY, name);
const family = FAMILIES[known ? LANGUAGE_FAMILY[name] : "c"];
const keywords = known ? new Set(family.keywords.split(" ")) : new Set();
const out = [];
let plain = "";
let i = 0;
const flush = () => {
if (plain !== "") {
out.push({ type: "plain", text: plain });
plain = "";
}
};
const push = (type, end) => {
flush();
out.push({ type, text: text.slice(i, end) });
i = end;
};
const lineEnd = (from) => {
const at = text.indexOf("\n", from);
return at === -1 ? text.length : at;
};
while (i < text.length) {
const ch = text[i];
const marker = family.line.find((m) => text.startsWith(m, i));
if (marker !== undefined) {
push("comment", lineEnd(i));
} else if (family.block && text.startsWith("/*", i)) {
const at = text.indexOf("*/", i + 2);
push("comment", at === -1 ? text.length : at + 2);
} else if (ch === '"' || ch === "'" || (ch === "`" && family === FAMILIES.c)) {
let j = i + 1;
while (j < text.length && text[j] !== ch && !(text[j] === "\n" && ch !== "`")) {
j += text[j] === "\\" ? 2 : 1;
}
push("string", Math.min(j < text.length && text[j] === ch ? j + 1 : j, text.length));
} else if (DIGIT.test(ch) && !WORD_CHAR.test(text[i - 1] ?? " ")) {
let j = i + 1;
while (j < text.length && NUMBER_CHAR.test(text[j])) {
j += 1;
}
push("number", j);
} else if (WORD_START.test(ch)) {
let j = i + 1;
while (j < text.length && WORD_CHAR.test(text[j])) {
j += 1;
}
const word = text.slice(i, j);
if (keywords.has(family.ci ? word.toLowerCase() : word)) {
push("keyword", j);
} else {
plain += word;
i = j;
}
} else {
plain += ch;
i += 1;
}
}
flush();
return out;
}