import { escapeHtml } from "../inline/render.js"; import { isFalsyAttrValue, renderAttrs as renderAttrsRaw } from "./attribute.js"; import type { BlockHandler } from "./handler.type.js"; const FORM_BOOLEAN_ATTRS: ReadonlySet = new Set([ "checked", "disabled", "readonly", "required", "multiple", "autofocus", ]); function renderAttrs(attrs?: Record): string { return renderAttrsRaw(attrs, FORM_BOOLEAN_ATTRS); } const ALLOWED_TAGS = new Set([ "input", "select", "textarea", "output", "button", "meter", "progress", ]); /** * Parse an Emmet-style abbreviation into tag name and type attribute. * * Examples: * "input:date" → { tag: "input", type: "date" } * "input:text" → { tag: "input", type: "text" } * "select" → { tag: "select", type: undefined } * "textarea" → { tag: "textarea", type: undefined } */ function parseEmmet(abbr: string): { tag: string; type?: string } { const colon = abbr.indexOf(":"); if (colon === -1) return { tag: abbr }; return { tag: abbr.slice(0, colon), type: abbr.slice(colon + 1) }; } /** * Factory: create a BlockHandler for `html:` fenced code blocks. * * Only allows form-related elements: input, select, textarea, * output, button, meter, progress. * Throws on unknown/disallowed elements. */ export function createHtmlHandler(emmetAbbr: string): BlockHandler { const { tag, type } = parseEmmet(emmetAbbr); if (!ALLOWED_TAGS.has(tag)) { throw new Error(`html: block — disallowed element "${tag}"`); } return { render(_raw: string, attrs?: Record): string { switch (tag) { case "select": return renderSelect(attrs); case "textarea": return renderTextarea(attrs); case "output": return renderOutput(attrs); case "button": return renderButton(type, attrs); case "meter": return renderMeter(attrs); case "progress": return renderProgress(attrs); default: return renderInput(type, attrs); } }, serialize(raw: string, attrs?: Record): string { const body = raw || (attrs && Object.keys(attrs).length > 0 ? JSON.stringify(attrs, null, 2) : ""); return `\`\`\`html:${emmetAbbr}\n${body}\n\`\`\``; }, }; } function renderInput(type: string | undefined, attrs?: Record): string { // The Emmet type wins over a `type` key in the body, which would otherwise // emit a second, invalid `type` attribute. const { type: attrType, ...rest } = attrs ?? {}; const resolvedType = type ?? attrType; const parts = [""); return parts.join(""); } function renderSelect(attrs?: Record): string { const { options: optionsJson, value, ...rest } = attrs ?? {}; const selected = selectedValues(value, !isFalsyAttrValue(rest.multiple)); const parts = [""); if (optionsJson) { try { const options: unknown[] = JSON.parse(optionsJson); for (const opt of options) { // Coerce: a non-string entry used to throw inside the loop and the // catch below silently dropped *every* option. const raw = String(opt); const label = escapeHtml(raw); const mark = selected.has(raw) ? " selected" : ""; parts.push(``); } } catch { // Not valid JSON array — skip options } } parts.push(""); return parts.join(""); } function selectedValues(value: string | undefined, multiple: boolean): ReadonlySet { if (value === undefined) return new Set(); if (multiple) { try { const parsed: unknown = JSON.parse(value); if (Array.isArray(parsed)) return new Set(parsed.map(String)); } catch {} } return new Set([value]); } function renderTextarea(attrs?: Record): string { const { value, ...rest } = attrs ?? {}; const parts = [""); if (value) parts.push(escapeHtml(value)); parts.push(""); return parts.join(""); } function renderOutput(attrs?: Record): string { const { value, ...rest } = attrs ?? {}; const parts = [""); if (value) parts.push(escapeHtml(value)); parts.push(""); return parts.join(""); } function renderButton(type: string | undefined, attrs?: Record): string { const { label, type: attrType, ...rest } = attrs ?? {}; const resolvedType = type ?? attrType; const parts = [""); parts.push(escapeHtml(label ?? "Button")); parts.push(""); return parts.join(""); } function renderMeter(attrs?: Record): string { const { label, ...rest } = attrs ?? {}; const parts = [""); if (label) parts.push(escapeHtml(label)); parts.push(""); return parts.join(""); } function renderProgress(attrs?: Record): string { const { label, ...rest } = attrs ?? {}; const parts = [""); if (label) parts.push(escapeHtml(label)); parts.push(""); return parts.join(""); }