diff --git a/docs/doctoring/html-to-markdown-resource-bounds.md b/docs/doctoring/html-to-markdown-resource-bounds.md
new file mode 100644
index 00000000..cf630c15
--- /dev/null
+++ b/docs/doctoring/html-to-markdown-resource-bounds.md
@@ -0,0 +1,82 @@
+# HTML-to-Markdown resource bounds
+
+Status: Implemented on active PR
+
+## Purpose
+
+Inkspan's standalone `htmlToMarkdown()` boundary accepts caller-provided HTML and
+parses it through either a detached browser template or Turndown's browserless
+parser. Protected shipped truth is currently
+`main@3b38ead2d00f44eb578d0689087b9293b3dabe1e`; this active PR carries the
+next-release bounded reliability contract and remains unintegrated. The contract
+does not change transport, persistence, credentials, models, tenancy,
+authorization, collaboration-provider, or network authority.
+
+## Active-PR contract
+
+`HtmlToMarkdownOptions.maxHtmlBytes` is an optional positive safe integer. The
+active implementation defaults to 16 MiB and rejects configured values above a
+64 MiB hard maximum. The public runtime option bag is snapshotted before source
+sizing or parser work: only ordinary/null-prototype enumerable data properties
+for `includeImageAlt` and `maxHtmlBytes` are accepted. Accessor-backed options
+are rejected without invoking their getters. Exotic prototypes, symbol or
+unknown keys, non-enumerable properties, malformed `includeImageAlt`, and
+hostile reflection failures also fail closed through the stable resource error.
+
+JavaScript Proxy meta-object traps are a distinct boundary: inspecting an
+untrusted option object's prototype/descriptors necessarily performs language
+reflection and can execute a Proxy `getPrototypeOf`, `ownKeys`, or
+`getOwnPropertyDescriptor` trap. Inkspan does not claim otherwise. A thrown or
+malformed reflection result is normalized to the payload-redacted
+`HtmlToMarkdownResourceError` contract and is never reflected to diagnostics;
+callers that require a no-caller-code boundary must pass ordinary or
+null-prototype data objects rather than Proxies.
+
+The HTML source itself must be a primitive string. Non-string runtime input is
+rejected before reading caller properties, `TextEncoder` coercion, browser DOM
+materialization, or browserless parsing. For accepted strings, Inkspan first
+compares JavaScript UTF-16 code-unit length to the selected byte ceiling. Because
+each code unit contributes at least one UTF-8 byte, that check can reject inputs
+that are certainly oversized without allocating a complete encoded buffer.
+Inputs not rejected by that lower bound receive an exact UTF-8 byte-length check
+before any browser DOM or browserless Turndown parser is reached.
+
+Oversized input raises a stable redacted error with name
+`HtmlToMarkdownResourceError`, code `input_too_large`, and no caller-controlled
+HTML in its message. Non-string input uses code `invalid_input`; malformed
+runtime options/resource-limit configuration use code `invalid_configuration`.
+Accepted safe-link, strict inline-raster, image-alt, normalization,
+browserless-package, and deterministic conversion semantics remain unchanged.
+
+## Verification
+
+The active PR carries machine tests that prove:
+
+- obvious oversize does not invoke `TextEncoder.encode()` or browser template
+ creation;
+- hostile non-string input is rejected before caller property access, encoding,
+ or parser work and without leaking caller-thrown values;
+- non-ASCII input still uses exact UTF-8 byte accounting;
+- exact-boundary input remains accepted;
+- accessor-backed option properties are rejected without executing their
+ getters, while a hostile Proxy prototype-reflection trap is explicitly proven
+ to execute once and its private thrown value is normalized/redacted;
+- malformed option bags and wrong-type, fractional, zero, and above-maximum
+ limits fail closed without reflecting input content; and
+- the packed ESM runtime and strict TypeScript consumer exercise the public
+ resource-bound surface while retaining the no-network/no-credential package
+ authority check.
+
+This document is active-PR truth only. It must not be represented as protected
+behavior until the implementation is integrated into protected `main`. Issue
+#118 continues to own the exact `0.6.0` protected release-candidate operational
+boundary, so this next-release lane remains Draft and unmerged while that
+identity is active.
+
+## Rollback
+
+Before protected integration, rollback removes this active-PR option, resource
+policy module, regression tests, packed-consumer assertions, and this doctoring
+record together. After integration, reducing the documented hard ceiling or
+changing error codes/messages is a public compatibility decision and requires
+versioned release treatment.
diff --git a/docs/plain-text-projection.md b/docs/plain-text-projection.md
index ccb611dc..79272550 100644
--- a/docs/plain-text-projection.md
+++ b/docs/plain-text-projection.md
@@ -54,6 +54,30 @@ default policy. Informative image alternatives remain in reading order, which
allows indexing and AI workflows to retain author-supplied non-visual meaning
without receiving image bytes.
+## Next-release parser resource bounds
+
+Status: `implemented_on_active_pr` in #174. Protected `main` does not yet expose
+these plain-text-specific options, so this section is not a shipped-release
+claim.
+
+The active contract accepts `maxMarkdownBytes` on `markdownToPlainText()` and
+checks the exact UTF-8 byte size before the Marked lexer materializes tokens.
+The inherited Markdown parser policy defaults to 16 MiB and rejects configured
+limits above the 64 MiB hard maximum. Invalid configuration and oversized input
+fail closed through the same stable, payload-redacted Markdown resource errors
+used by the shared Markdown package boundary.
+
+`htmlToPlainText()` additionally accepts `maxHtmlBytes`. That ceiling is checked
+before HTML normalization, and `maxMarkdownBytes` is checked again on the
+normalized Markdown before it enters the plain-text lexer. The two bounds are
+intentionally independent because HTML normalization can change representation
+size. Accepted-input reading-order, list/table/code, image-alt, link-label and
+raw-HTML omission semantics remain unchanged.
+
+These local parser bounds are defense in depth, not transport or persistence
+authority. Hosts still own request/ingress limits, authorization, tenancy,
+durable storage, retention and operational admission control.
+
## Runtime and interoperability boundary
`markdownToPlainText` uses Marked's lexer and does not execute raw HTML, open a
diff --git a/scripts/verify-markdown-subpath-package.mjs b/scripts/verify-markdown-subpath-package.mjs
index d4e988f3..cfa9bf2d 100644
--- a/scripts/verify-markdown-subpath-package.mjs
+++ b/scripts/verify-markdown-subpath-package.mjs
@@ -108,26 +108,75 @@ Object.defineProperty(globalThis, 'document', {
configurable: true,
get() { throw new Error('ambient document access is forbidden'); },
});
+Object.defineProperty(globalThis, 'window', {
+ configurable: true,
+ get() { throw new Error('ambient window access is forbidden'); },
+});
const markdown = await import('${packageJson.name}/markdown');
const {
+ DEFAULT_HTML_TO_MARKDOWN_MAX_BYTES,
+ DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES,
+ MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES,
+ MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES,
+ HtmlToMarkdownResourceError,
+ MarkdownToHtmlResourceError,
htmlToMarkdown,
markdownToEmailHtml,
markdownToHtml,
markdownToPlainText,
normalizeMarkdown,
} = markdown;
+assert.equal(DEFAULT_HTML_TO_MARKDOWN_MAX_BYTES, 16_777_216);
+assert.equal(MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES, 67_108_864);
+assert.equal(DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES, 16_777_216);
+assert.equal(MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES, 67_108_864);
const safeHtml = markdownToHtml('[safe](https://example.com)');
assert.equal(safeHtml.includes('href="https://example.com"'), true);
assert.doesNotMatch(markdownToHtml('[unsafe](javascript:alert(1))'), /href=/u);
+let markdownBoundedFailure;
+try {
+ markdownToHtml('oversized', { maxMarkdownBytes: 4 });
+} catch (error) {
+ markdownBoundedFailure = error;
+}
+assert.equal(markdownBoundedFailure instanceof MarkdownToHtmlResourceError, true);
+assert.equal(markdownBoundedFailure?.name, 'MarkdownToHtmlResourceError');
+assert.equal(markdownBoundedFailure?.code, 'input_too_large');
+assert.equal(
+ markdownBoundedFailure?.message,
+ 'Markdown-to-HTML input exceeds the configured byte limit.',
+);
assert.equal(htmlToMarkdown('
Alpha Beta
'), 'Alpha **Beta**');
+let htmlBoundedFailure;
+try {
+ htmlToMarkdown('oversized
', { maxHtmlBytes: 4 });
+} catch (error) {
+ htmlBoundedFailure = error;
+}
+assert.equal(htmlBoundedFailure instanceof HtmlToMarkdownResourceError, true);
+assert.equal(htmlBoundedFailure?.name, 'HtmlToMarkdownResourceError');
+assert.equal(htmlBoundedFailure?.code, 'input_too_large');
+assert.equal(
+ htmlBoundedFailure?.message,
+ 'HTML-to-Markdown input exceeds the configured byte limit.',
+);
assert.equal(markdownToPlainText('**Alpha** [Beta](https://example.com)'), 'Alpha Beta');
assert.equal(normalizeMarkdown('**Alpha**').includes('**Alpha**'), true);
+assert.throws(
+ () => normalizeMarkdown('oversized', { maxMarkdownBytes: 4 }),
+ (error) => error instanceof MarkdownToHtmlResourceError && error.code === 'input_too_large',
+);
const email = markdownToEmailHtml('Hello', {
fullDocument: true,
languageTag: 'ko-kr',
textDirection: 'ltr',
});
assert.equal(email.includes(''), true);
+assert.throws(
+ () => markdownToEmailHtml('oversized', { maxMarkdownBytes: 4 }),
+ (error) => error instanceof MarkdownToHtmlResourceError && error.code === 'input_too_large',
+);
+delete globalThis.window;
delete globalThis.document;
`,
'utf8',
@@ -141,11 +190,34 @@ Object.defineProperty(globalThis, 'document', {
configurable: true,
get() { throw new Error('ambient document access is forbidden'); },
});
+Object.defineProperty(globalThis, 'window', {
+ configurable: true,
+ get() { throw new Error('ambient window access is forbidden'); },
+});
const markdown = require('${packageJson.name}/markdown');
assert.equal(typeof markdown.markdownToHtml, 'function');
assert.equal(markdown.htmlToMarkdown('Gamma
'), 'Gamma');
+assert.equal(markdown.DEFAULT_HTML_TO_MARKDOWN_MAX_BYTES, 16_777_216);
+assert.equal(markdown.MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES, 67_108_864);
+assert.equal(typeof markdown.HtmlToMarkdownResourceError, 'function');
+assert.equal(markdown.DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES, 16_777_216);
+assert.equal(markdown.MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES, 67_108_864);
+assert.equal(typeof markdown.MarkdownToHtmlResourceError, 'function');
+assert.throws(
+ () => markdown.markdownToHtml('oversized', { maxMarkdownBytes: 4 }),
+ (error) => error instanceof markdown.MarkdownToHtmlResourceError && error.code === 'input_too_large',
+);
+assert.throws(
+ () => markdown.normalizeMarkdown('oversized', { maxMarkdownBytes: 4 }),
+ (error) => error instanceof markdown.MarkdownToHtmlResourceError && error.code === 'input_too_large',
+);
assert.equal(typeof markdown.markdownToEmailHtml, 'function');
+assert.throws(
+ () => markdown.markdownToEmailHtml('oversized', { maxMarkdownBytes: 4 }),
+ (error) => error instanceof markdown.MarkdownToHtmlResourceError && error.code === 'input_too_large',
+);
assert.equal(markdown.markdownToPlainText('# Title'), 'Title');
+delete globalThis.window;
delete globalThis.document;
`,
'utf8',
@@ -162,6 +234,12 @@ function verifyDeclarationConsumer() {
writeFileSync(
sourcePath,
`import {
+ DEFAULT_HTML_TO_MARKDOWN_MAX_BYTES,
+ DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES,
+ MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES,
+ MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES,
+ HtmlToMarkdownResourceError,
+ MarkdownToHtmlResourceError,
htmlToMarkdown,
htmlToPlainText,
markdownToEmailHtml,
@@ -169,20 +247,40 @@ function verifyDeclarationConsumer() {
markdownToPlainText,
normalizeMarkdown,
type HtmlToMarkdownOptions,
+ type HtmlToMarkdownResourceErrorCode,
type MarkdownToEmailHtmlOptions,
+ type MarkdownToHtmlOptions,
+ type MarkdownToHtmlResourceErrorCode,
+ type NormalizeMarkdownOptions,
type PlainTextOptions,
} from '${packageJson.name}/markdown';
-const htmlOptions: HtmlToMarkdownOptions = { includeImageAlt: false };
+const htmlOptions: HtmlToMarkdownOptions = {
+ includeImageAlt: false,
+ maxHtmlBytes: 1024,
+};
+const markdownOptions: MarkdownToHtmlOptions = { maxMarkdownBytes: 1024 };
+const normalizeOptions: NormalizeMarkdownOptions = { maxMarkdownBytes: 1024 };
+const htmlErrorCode: HtmlToMarkdownResourceErrorCode = 'input_too_large';
+const htmlResourceError = new HtmlToMarkdownResourceError(htmlErrorCode);
+const markdownErrorCode: MarkdownToHtmlResourceErrorCode = 'input_too_large';
+const markdownResourceError = new MarkdownToHtmlResourceError(markdownErrorCode);
const emailOptions: MarkdownToEmailHtmlOptions = {
fullDocument: true,
languageTag: 'en-US',
textDirection: 'ltr',
+ maxMarkdownBytes: 1024,
};
const plainOptions: PlainTextOptions = { includeImageAlt: true };
void [
- markdownToHtml('x'),
+ DEFAULT_HTML_TO_MARKDOWN_MAX_BYTES,
+ MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES,
+ DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES,
+ MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES,
+ htmlResourceError.code,
+ markdownResourceError.code,
+ markdownToHtml('x', markdownOptions),
htmlToMarkdown('x
', htmlOptions),
- normalizeMarkdown('x'),
+ normalizeMarkdown('x', normalizeOptions),
markdownToEmailHtml('x', emailOptions),
markdownToPlainText('x', plainOptions),
htmlToPlainText('x
', plainOptions),
diff --git a/src/components/editorSerialization.ts b/src/components/editorSerialization.ts
index f0d1ec43..a2a0a810 100644
--- a/src/components/editorSerialization.ts
+++ b/src/components/editorSerialization.ts
@@ -1,15 +1,23 @@
import type { EditorMode } from '../types.js';
-import {
- htmlToMarkdown,
- markdownToEditorHtml,
-} from '../markdown/serializer.js';
+import { htmlToMarkdown } from '../markdown/serializer.js';
+import { markdownToEditorHtml } from '../markdown/resourceBoundMarkdown.js';
+
+const INVALID_MODE_ERROR = 'Editor mode must be markdown or html.';
+
+function assertEditorMode(mode: EditorMode): void {
+ if (mode !== 'markdown' && mode !== 'html') {
+ throw new RangeError(INVALID_MODE_ERROR);
+ }
+}
/** Convert a host value in the selected editor mode into TipTap HTML. */
export function editorValueToHtml(value: string, mode: EditorMode): string {
+ assertEditorMode(mode);
return mode === 'markdown' ? markdownToEditorHtml(value) : value;
}
/** Convert TipTap HTML into the serialization selected by the host. */
export function editorHtmlToValue(html: string, mode: EditorMode): string {
+ assertEditorMode(mode);
return mode === 'markdown' ? htmlToMarkdown(html) : html;
}
diff --git a/src/components/editorSerializationRuntime.test.ts b/src/components/editorSerializationRuntime.test.ts
new file mode 100644
index 00000000..a3675185
--- /dev/null
+++ b/src/components/editorSerializationRuntime.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest';
+import {
+ editorHtmlToValue,
+ editorValueToHtml,
+} from './editorSerialization.js';
+
+const INVALID_MODE_ERROR = 'Editor mode must be markdown or html.';
+
+describe('editor serialization runtime mode contract', () => {
+ it('rejects an invalid runtime mode before converting a host value', () => {
+ expect(() => editorValueToHtml('# Heading', 'md' as never)).toThrowError(
+ new RangeError(INVALID_MODE_ERROR),
+ );
+ });
+
+ it('rejects an invalid runtime mode before converting editor HTML', () => {
+ expect(() => editorHtmlToValue('Body
', 'rich' as never)).toThrowError(
+ new RangeError(INVALID_MODE_ERROR),
+ );
+ });
+});
diff --git a/src/documentEnvelopeCanonical.test.ts b/src/documentEnvelopeCanonical.test.ts
index f698107a..2e4aab93 100644
--- a/src/documentEnvelopeCanonical.test.ts
+++ b/src/documentEnvelopeCanonical.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
import {
DOCUMENT_ENVELOPE_SCHEMA_ID,
DOCUMENT_ENVELOPE_SCHEMA_VERSION,
@@ -11,6 +11,10 @@ import {
} from './documentEnvelopeCanonical.js';
import * as publicApi from './index.js';
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
describe('canonical document envelope serialization', () => {
it('sorts object properties recursively while preserving array order', () => {
const envelope = createDocumentEnvelope({
@@ -105,6 +109,80 @@ describe('canonical document envelope serialization', () => {
expect([...bytes.slice(0, 3)]).not.toEqual([0xef, 0xbb, 0xbf]);
});
+ it('rejects a configured canonical output ceiling before UTF-8 allocation', () => {
+ const envelope = createDocumentEnvelope({
+ type: 'doc',
+ attrs: { label: 'bounded-output' },
+ });
+ const encode = vi.spyOn(TextEncoder.prototype, 'encode');
+ let failure: unknown;
+
+ try {
+ encodeDocumentEnvelope(envelope, { maxUtf8Bytes: 16 });
+ } catch (error) {
+ failure = error;
+ }
+
+ expect(encode).not.toHaveBeenCalled();
+ expect(failure).toBeInstanceOf(DocumentEnvelopeError);
+ expect(failure).toMatchObject({
+ message: 'Canonical document envelope exceeds the configured UTF-8 byte limit',
+ });
+ });
+
+ it('exact-checks UTF-8 bytes when code-unit length alone fits', () => {
+ const envelope = createDocumentEnvelope({
+ type: 'doc',
+ attrs: { label: 'é' },
+ });
+ const serialized = serializeDocumentEnvelope(envelope);
+ const encode = vi.spyOn(TextEncoder.prototype, 'encode');
+
+ expect(() =>
+ encodeDocumentEnvelope(envelope, { maxUtf8Bytes: serialized.length }),
+ ).toThrowError(
+ 'Canonical document envelope exceeds the configured UTF-8 byte limit',
+ );
+ expect(encode).toHaveBeenCalledTimes(1);
+ });
+
+ it('accepts canonical bytes exactly at the configured output ceiling', () => {
+ const envelope = createDocumentEnvelope({
+ type: 'doc',
+ attrs: { label: 'é' },
+ });
+ const serialized = serializeDocumentEnvelope(envelope);
+ const exactBytes = new TextEncoder().encode(serialized);
+
+ expect(
+ encodeDocumentEnvelope(envelope, { maxUtf8Bytes: exactBytes.byteLength }),
+ ).toEqual(exactBytes);
+ });
+
+ it.each([
+ ['wrong type', '16'],
+ ['fractional', 1.5],
+ ['zero', 0],
+ ])('fails closed for %s canonical output limits', (_label, maxUtf8Bytes) => {
+ const envelope = createDocumentEnvelope({
+ type: 'doc',
+ attrs: { label: 'private-document' },
+ });
+ let failure: unknown;
+
+ try {
+ encodeDocumentEnvelope(envelope, { maxUtf8Bytes } as never);
+ } catch (error) {
+ failure = error;
+ }
+
+ expect(failure).toBeInstanceOf(DocumentEnvelopeError);
+ expect(failure).toMatchObject({
+ message: 'Canonical document envelope UTF-8 byte limit must be a positive safe integer',
+ });
+ expect(String(failure)).not.toContain('private-document');
+ });
+
it.each([
{ type: 'doc', attrs: { value: '\ud800' } },
{ type: 'doc', attrs: { value: '\udc00' } },
diff --git a/src/documentEnvelopeCanonical.ts b/src/documentEnvelopeCanonical.ts
index c4dffea6..e9e98655 100644
--- a/src/documentEnvelopeCanonical.ts
+++ b/src/documentEnvelopeCanonical.ts
@@ -1,4 +1,5 @@
import {
+ DEFAULT_DOCUMENT_ENVELOPE_LIMITS,
DocumentEnvelopeError,
parseDocumentEnvelope,
type CwlEditorDocumentEnvelope,
@@ -16,10 +17,22 @@ type CanonicalJsonValue =
| readonly CanonicalJsonValue[]
| CanonicalJsonObject;
+/** Resource options for canonical document-envelope byte encoding. */
+export interface DocumentEnvelopeEncodingOptions {
+ /** Maximum canonical UTF-8 bytes returned by the encoder. Defaults to 64 MiB. */
+ readonly maxUtf8Bytes?: number;
+}
+
const INVALID_UNICODE_MESSAGE =
'Document envelope must contain valid Unicode scalar strings';
const NEGATIVE_ZERO_MESSAGE =
'Document envelope must not contain negative zero';
+const INVALID_ENCODING_OPTIONS_MESSAGE =
+ 'Canonical document envelope encoding options are invalid';
+const INVALID_OUTPUT_LIMIT_MESSAGE =
+ 'Canonical document envelope UTF-8 byte limit must be a positive safe integer';
+const OUTPUT_LIMIT_EXCEEDED_MESSAGE =
+ 'Canonical document envelope exceeds the configured UTF-8 byte limit';
/**
* Serialize a valid Inkspan envelope to deterministic RFC 8785 JSON.
@@ -33,11 +46,15 @@ export function serializeDocumentEnvelope(source: unknown): string {
return serializeValidatedDocumentEnvelope(parseDocumentEnvelope(source));
}
-/** Encode a canonical Inkspan envelope as UTF-8 bytes without a BOM. */
+/** Encode a canonical Inkspan envelope as bounded UTF-8 bytes without a BOM. */
export function encodeDocumentEnvelope(
source: unknown,
+ options: DocumentEnvelopeEncodingOptions = {},
): Uint8Array {
- return encodeValidatedDocumentEnvelope(parseDocumentEnvelope(source));
+ return encodeValidatedDocumentEnvelope(
+ parseDocumentEnvelope(source),
+ options,
+ );
}
/**
@@ -57,10 +74,75 @@ export function serializeValidatedDocumentEnvelope(
/** Encode an already-validated envelope without repeating graph validation. */
export function encodeValidatedDocumentEnvelope(
envelope: CwlEditorDocumentEnvelope,
+ options: DocumentEnvelopeEncodingOptions = {},
): Uint8Array {
- return new TextEncoder().encode(
- serializeValidatedDocumentEnvelope(envelope),
- );
+ const maxUtf8Bytes = resolveCanonicalOutputMaxBytes(options);
+ const serialized = serializeValidatedDocumentEnvelope(envelope);
+
+ // Every valid UTF-8 encoding uses at least one byte per UTF-16 code unit.
+ // Reject the common/obvious oversize case before allocating encoded bytes.
+ if (serialized.length > maxUtf8Bytes) {
+ throw new DocumentEnvelopeError(OUTPUT_LIMIT_EXCEEDED_MESSAGE);
+ }
+
+ const encoded = new TextEncoder().encode(serialized);
+ if (encoded.byteLength > maxUtf8Bytes) {
+ throw new DocumentEnvelopeError(OUTPUT_LIMIT_EXCEEDED_MESSAGE);
+ }
+ return encoded;
+}
+
+function resolveCanonicalOutputMaxBytes(
+ options: DocumentEnvelopeEncodingOptions,
+): number {
+ const configuredMaxUtf8Bytes = readCanonicalOutputMaxBytesOption(options);
+ if (configuredMaxUtf8Bytes === undefined) {
+ return DEFAULT_DOCUMENT_ENVELOPE_LIMITS.maxUtf8Bytes;
+ }
+ if (
+ typeof configuredMaxUtf8Bytes !== 'number' ||
+ !Number.isSafeInteger(configuredMaxUtf8Bytes) ||
+ configuredMaxUtf8Bytes <= 0
+ ) {
+ throw new DocumentEnvelopeError(INVALID_OUTPUT_LIMIT_MESSAGE);
+ }
+ return configuredMaxUtf8Bytes;
+}
+
+function readCanonicalOutputMaxBytesOption(
+ options: DocumentEnvelopeEncodingOptions,
+): unknown {
+ try {
+ if (
+ typeof options !== 'object' ||
+ options === null ||
+ Array.isArray(options)
+ ) {
+ throw new TypeError('invalid encoding options container');
+ }
+
+ const prototype = Object.getPrototypeOf(options);
+ if (prototype !== Object.prototype && prototype !== null) {
+ throw new TypeError('invalid encoding options prototype');
+ }
+
+ const keys = Reflect.ownKeys(options);
+ if (keys.some((key) => key !== 'maxUtf8Bytes')) {
+ throw new TypeError('unsupported encoding option');
+ }
+ if (keys.length === 0) return undefined;
+
+ const descriptor = Object.getOwnPropertyDescriptor(
+ options,
+ 'maxUtf8Bytes',
+ ) as PropertyDescriptor;
+ if (!descriptor.enumerable || !('value' in descriptor)) {
+ throw new TypeError('invalid encoding option property');
+ }
+ return descriptor.value as unknown;
+ } catch {
+ throw new DocumentEnvelopeError(INVALID_ENCODING_OPTIONS_MESSAGE);
+ }
}
function serializeCanonicalValue(value: CanonicalJsonValue): string {
diff --git a/src/documentEnvelopeEncodingConsolidation.test.ts b/src/documentEnvelopeEncodingConsolidation.test.ts
new file mode 100644
index 00000000..577ead63
--- /dev/null
+++ b/src/documentEnvelopeEncodingConsolidation.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it, vi } from 'vitest';
+import {
+ DocumentEnvelopeError,
+ createDocumentEnvelope,
+} from './documentEnvelope.js';
+import { encodeDocumentEnvelope } from './documentEnvelopeCanonical.js';
+
+const ENVELOPE = createDocumentEnvelope({
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Canonical output boundary' }],
+ },
+ ],
+});
+
+type RuntimeEncodingOptions = {
+ readonly maxUtf8Bytes?: number;
+};
+
+const encodeWithOptions = encodeDocumentEnvelope as unknown as (
+ source: unknown,
+ options?: RuntimeEncodingOptions,
+) => Uint8Array;
+
+describe('canonical envelope encoding consolidation', () => {
+ it('rejects an impossible output ceiling before UTF-8 allocation', () => {
+ const encode = vi.spyOn(TextEncoder.prototype, 'encode');
+
+ expect(() =>
+ encodeWithOptions(ENVELOPE, { maxUtf8Bytes: 1 }),
+ ).toThrowError(
+ new DocumentEnvelopeError(
+ 'Canonical document envelope exceeds the configured UTF-8 byte limit',
+ ),
+ );
+ expect(encode).not.toHaveBeenCalled();
+ });
+
+ it('rejects unknown runtime option keys instead of silently defaulting', () => {
+ expect(() =>
+ encodeWithOptions(
+ ENVELOPE,
+ { maxUTF8Bytes: 1024 } as unknown as RuntimeEncodingOptions,
+ ),
+ ).toThrowError(
+ new DocumentEnvelopeError(
+ 'Canonical document envelope encoding options are invalid',
+ ),
+ );
+ });
+
+ it('rejects accessor-backed options without evaluating the accessor', () => {
+ let getterCalls = 0;
+ const options = {};
+ Object.defineProperty(options, 'maxUtf8Bytes', {
+ enumerable: true,
+ get() {
+ getterCalls += 1;
+ throw new Error('private option getter detail');
+ },
+ });
+
+ expect(() =>
+ encodeWithOptions(ENVELOPE, options as RuntimeEncodingOptions),
+ ).toThrowError(
+ new DocumentEnvelopeError(
+ 'Canonical document envelope encoding options are invalid',
+ ),
+ );
+ expect(getterCalls).toBe(0);
+ });
+});
diff --git a/src/documentEnvelopeEncodingOptionsRuntime.test.ts b/src/documentEnvelopeEncodingOptionsRuntime.test.ts
new file mode 100644
index 00000000..ffd7ca76
--- /dev/null
+++ b/src/documentEnvelopeEncodingOptionsRuntime.test.ts
@@ -0,0 +1,111 @@
+import { describe, expect, it } from 'vitest';
+import {
+ DocumentEnvelopeError,
+ createDocumentEnvelope,
+} from './documentEnvelope.js';
+import {
+ encodeDocumentEnvelope,
+ type DocumentEnvelopeEncodingOptions,
+} from './documentEnvelopeCanonical.js';
+
+const ENVELOPE = createDocumentEnvelope({
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Canonical option boundary' }],
+ },
+ ],
+});
+const INVALID_OPTIONS_MESSAGE =
+ 'Canonical document envelope encoding options are invalid';
+
+function expectInvalidOptions(options: unknown): void {
+ expect(() =>
+ encodeDocumentEnvelope(
+ ENVELOPE,
+ options as DocumentEnvelopeEncodingOptions,
+ ),
+ ).toThrowError(new DocumentEnvelopeError(INVALID_OPTIONS_MESSAGE));
+}
+
+describe('document envelope encoding option runtime boundary', () => {
+ it('rejects malformed option containers through one redacted error', () => {
+ expectInvalidOptions(null);
+ expectInvalidOptions(7);
+ expectInvalidOptions([]);
+ });
+
+ it('rejects exotic object prototypes instead of treating them as empty options', () => {
+ expectInvalidOptions(new Date(0));
+
+ class HostOptions {
+ maxUtf8Bytes = 1024;
+ }
+ expectInvalidOptions(new HostOptions());
+ });
+
+ it('rejects unknown string and symbol keys instead of silently defaulting', () => {
+ expectInvalidOptions({ maxUTF8Bytes: 1024 });
+ expectInvalidOptions({ [Symbol('private option')]: 1024 });
+ });
+
+ it('rejects accessor and non-enumerable option properties without reading them', () => {
+ let getterCalls = 0;
+ const accessorOptions = {};
+ Object.defineProperty(accessorOptions, 'maxUtf8Bytes', {
+ enumerable: true,
+ get() {
+ getterCalls += 1;
+ throw new Error('private option getter detail');
+ },
+ });
+ expectInvalidOptions(accessorOptions);
+ expect(getterCalls).toBe(0);
+
+ const hiddenOptions = {};
+ Object.defineProperty(hiddenOptions, 'maxUtf8Bytes', {
+ enumerable: false,
+ value: 1024,
+ });
+ expectInvalidOptions(hiddenOptions);
+ });
+
+ it('redacts option reflection failures before serialization', () => {
+ const hostileOptions = new Proxy(
+ {},
+ {
+ ownKeys() {
+ throw new Error('private reflection detail');
+ },
+ },
+ );
+
+ expectInvalidOptions(hostileOptions);
+ });
+
+ it('preserves omitted, empty, exact data-property, and null-prototype options', () => {
+ expect(encodeDocumentEnvelope(ENVELOPE).byteLength).toBeGreaterThan(0);
+ expect(encodeDocumentEnvelope(ENVELOPE, {}).byteLength).toBeGreaterThan(0);
+ expect(
+ encodeDocumentEnvelope(ENVELOPE, {
+ maxUtf8Bytes: 1024,
+ }).byteLength,
+ ).toBeGreaterThan(0);
+
+ const nullPrototypeOptions = Object.create(null) as Record<
+ string,
+ unknown
+ >;
+ Object.defineProperty(nullPrototypeOptions, 'maxUtf8Bytes', {
+ enumerable: true,
+ value: 1024,
+ });
+ expect(
+ encodeDocumentEnvelope(
+ ENVELOPE,
+ nullPrototypeOptions as DocumentEnvelopeEncodingOptions,
+ ).byteLength,
+ ).toBeGreaterThan(0);
+ });
+});
diff --git a/src/extensions/SafeLink.test.ts b/src/extensions/SafeLink.test.ts
index 845d723a..e78673b8 100644
--- a/src/extensions/SafeLink.test.ts
+++ b/src/extensions/SafeLink.test.ts
@@ -1,4 +1,4 @@
-import { afterEach, describe, expect, it } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
import { Editor } from '@tiptap/react';
import { buildExtensions } from './kit.js';
import {
@@ -23,6 +23,7 @@ function makeEditor(content = 'alpha
omega
'): Editor {
}
afterEach(() => {
+ vi.restoreAllMocks();
for (const editor of openEditors.splice(0)) {
if (!editor.isDestroyed) editor.destroy();
}
@@ -46,6 +47,148 @@ describe('validateSafeLinkHref', () => {
expect(isSafeLinkHref(href)).toBe(true);
});
+ it('rejects obvious oversize before UTF-8 allocation and URL parsing', () => {
+ const encodeSpy = vi.spyOn(TextEncoder.prototype, 'encode');
+ const href = 'https://example.com/path';
+
+ let error: unknown;
+ try {
+ validateSafeLinkHref(href, { maxHrefBytes: 8 });
+ } catch (caught) {
+ error = caught;
+ }
+
+ expect(error).toBeInstanceOf(SafeLinkHrefError);
+ expect(error).toMatchObject({
+ code: 'input_too_large',
+ hrefPreview: '',
+ });
+ expect(String(error)).not.toContain('example.com');
+ expect(encodeSpy).not.toHaveBeenCalled();
+ expect(isSafeLinkHref(href, { maxHrefBytes: 8 })).toBe(false);
+ });
+
+ it('enforces the default bound before allocating an oversized UTF-8 copy', () => {
+ const encodeSpy = vi.spyOn(TextEncoder.prototype, 'encode');
+ const href = `https://example.com/${'a'.repeat(65_536)}`;
+
+ expect(() => validateSafeLinkHref(href)).toThrow(SafeLinkHrefError);
+ expect(encodeSpy).not.toHaveBeenCalled();
+ });
+
+ it('enforces exact UTF-8 byte counts when code-unit length alone can fit', () => {
+ expect(() =>
+ validateSafeLinkHref('/é', { maxHrefBytes: 2 }),
+ ).toThrow(SafeLinkHrefError);
+ expect(validateSafeLinkHref('/é', { maxHrefBytes: 3 })).toBe('/é');
+ });
+
+ it('accepts omitted configured limits through explicit options objects', () => {
+ expect(validateSafeLinkHref('/safe', {})).toBe('/safe');
+ expect(validateSafeLinkHref('/safe', { maxHrefBytes: undefined })).toBe(
+ '/safe',
+ );
+ });
+
+ it.each([
+ null,
+ 8,
+ [],
+ { maxHrefBytes: '8' },
+ { maxHrefBytes: 1.5 },
+ { maxHrefBytes: 0 },
+ { maxHrefBytes: 1_048_577 },
+ ])('fails closed for invalid resource configuration %#', (options) => {
+ let error: unknown;
+ try {
+ validateSafeLinkHref('/private/path?token=secret', options as never);
+ } catch (caught) {
+ error = caught;
+ }
+
+ expect(error).toBeInstanceOf(SafeLinkHrefError);
+ expect(error).toMatchObject({
+ code: 'invalid_configuration',
+ hrefPreview: '',
+ });
+ expect(String(error)).not.toContain('private/path');
+ });
+
+ it('rejects accessor-backed resource configuration without evaluating the accessor', () => {
+ const getter = vi.fn(() => 8);
+ const options = Object.defineProperty({}, 'maxHrefBytes', {
+ enumerable: true,
+ get: getter,
+ });
+
+ let error: unknown;
+ try {
+ validateSafeLinkHref('/private/path?token=secret', options as never);
+ } catch (caught) {
+ error = caught;
+ }
+
+ expect(error).toBeInstanceOf(SafeLinkHrefError);
+ expect(error).toMatchObject({
+ code: 'invalid_configuration',
+ hrefPreview: '',
+ });
+ expect(String(error)).not.toContain('private/path');
+ expect(getter).not.toHaveBeenCalled();
+ });
+
+ it('rejects unknown string and symbol configuration keys before reading values', () => {
+ const getter = vi.fn(() => 8);
+ const unknownStringKey = Object.defineProperty({}, 'unexpected', {
+ enumerable: true,
+ get: getter,
+ });
+ const unknownSymbolKey = { [Symbol('maxHrefBytes')]: 8 };
+
+ for (const options of [unknownStringKey, unknownSymbolKey]) {
+ let error: unknown;
+ try {
+ validateSafeLinkHref('/private/path?token=secret', options as never);
+ } catch (caught) {
+ error = caught;
+ }
+
+ expect(error).toBeInstanceOf(SafeLinkHrefError);
+ expect(error).toMatchObject({
+ code: 'invalid_configuration',
+ hrefPreview: '',
+ });
+ expect(String(error)).not.toContain('private/path');
+ }
+ expect(getter).not.toHaveBeenCalled();
+ });
+
+ it('converts reflection failures into payload-redacted configuration errors', () => {
+ const options = new Proxy(
+ {},
+ {
+ ownKeys() {
+ throw new Error('proxy-owned secret');
+ },
+ },
+ );
+
+ let error: unknown;
+ try {
+ validateSafeLinkHref('/private/path?token=secret', options as never);
+ } catch (caught) {
+ error = caught;
+ }
+
+ expect(error).toBeInstanceOf(SafeLinkHrefError);
+ expect(error).toMatchObject({
+ code: 'invalid_configuration',
+ hrefPreview: '',
+ });
+ expect(String(error)).not.toContain('proxy-owned secret');
+ expect(String(error)).not.toContain('private/path');
+ });
+
it.each([
null,
42,
@@ -68,6 +211,7 @@ describe('validateSafeLinkHref', () => {
'https://',
'https://user:secret@example.com/path',
'http://user@example.com/path',
+ 'https://:secret@example.com/path',
])('rejects unsafe link target %s', (href) => {
expect(() => validateSafeLinkHref(href)).toThrow(SafeLinkHrefError);
expect(isSafeLinkHref(href)).toBe(false);
@@ -80,8 +224,17 @@ describe('validateSafeLinkHref', () => {
const fragment = new SafeLinkHrefError('#private-section');
const scheme = new SafeLinkHrefError('JAVASCRIPT:secret()');
const relative = new SafeLinkHrefError('private/path?token=secret');
+ const oversized = new SafeLinkHrefError(
+ 'https://secret.example/token',
+ 'input_too_large',
+ );
+ const invalidConfiguration = new SafeLinkHrefError(
+ 'https://secret.example/token',
+ 'invalid_configuration',
+ );
expect(nonString.name).toBe('SafeLinkHrefError');
+ expect(nonString.code).toBe('invalid_href');
expect(nonString.hrefPreview).toBe('');
expect(empty.hrefPreview).toBe('');
expect(protocolRelative.hrefPreview).toBe('//');
@@ -89,6 +242,10 @@ describe('validateSafeLinkHref', () => {
expect(scheme.hrefPreview).toBe('javascript:');
expect(relative.hrefPreview).toBe('');
expect(relative.message).not.toContain('private/path');
+ expect(oversized.hrefPreview).toBe('');
+ expect(oversized.message).not.toContain('secret.example');
+ expect(invalidConfiguration.hrefPreview).toBe('');
+ expect(invalidConfiguration.message).not.toContain('secret.example');
});
});
diff --git a/src/extensions/SafeLink.ts b/src/extensions/SafeLink.ts
index 2af0cbfd..0ae2a520 100644
--- a/src/extensions/SafeLink.ts
+++ b/src/extensions/SafeLink.ts
@@ -18,10 +18,16 @@ import {
} from '../policy/safeLinkPolicy.js';
export {
+ DEFAULT_SAFE_LINK_MAX_HREF_BYTES,
+ MAXIMUM_SAFE_LINK_MAX_HREF_BYTES,
SafeLinkHrefError,
isSafeLinkHref,
validateSafeLinkHref,
} from '../policy/safeLinkPolicy.js';
+export type {
+ SafeLinkHrefErrorCode,
+ SafeLinkValidationOptions,
+} from '../policy/safeLinkPolicy.js';
/** ProseMirror plugin key for the direct-transaction safety boundary. */
export const safeLinkPluginKey = new PluginKey('cwlSafeLink');
diff --git a/src/extensions/SafeLinkConsolidation.test.ts b/src/extensions/SafeLinkConsolidation.test.ts
new file mode 100644
index 00000000..dd1a285b
--- /dev/null
+++ b/src/extensions/SafeLinkConsolidation.test.ts
@@ -0,0 +1,80 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import {
+ SafeLinkHrefError,
+ validateSafeLinkHref,
+} from './SafeLink.js';
+
+type RuntimeSafeLinkOptions = {
+ readonly maxHrefBytes?: number;
+};
+
+const validateWithOptions = validateSafeLinkHref as unknown as (
+ href: unknown,
+ options?: RuntimeSafeLinkOptions,
+) => string;
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('safe-link resource consolidation', () => {
+ it('rejects an obviously oversized web target before URL construction', () => {
+ const urlConstructor = vi.fn(() => {
+ throw new Error('URL parser must not run for an obvious oversize target');
+ });
+ vi.stubGlobal('URL', urlConstructor);
+
+ let failure: unknown;
+ try {
+ validateWithOptions('https://example.com/path', { maxHrefBytes: 8 });
+ } catch (error) {
+ failure = error;
+ }
+
+ expect(failure).toBeInstanceOf(SafeLinkHrefError);
+ expect(failure).toMatchObject({
+ code: 'input_too_large',
+ hrefPreview: '',
+ });
+ expect(urlConstructor).not.toHaveBeenCalled();
+ });
+
+ it('rejects unknown runtime option keys instead of silently defaulting', () => {
+ expect(() =>
+ validateWithOptions(
+ 'https://example.com/',
+ { maxHREFBytes: 8 } as unknown as RuntimeSafeLinkOptions,
+ ),
+ ).toThrowError(
+ expect.objectContaining({
+ code: 'invalid_configuration',
+ hrefPreview: '',
+ }),
+ );
+ });
+
+ it('rejects accessor-backed options without evaluating the accessor', () => {
+ let getterCalls = 0;
+ const options = {};
+ Object.defineProperty(options, 'maxHrefBytes', {
+ enumerable: true,
+ get() {
+ getterCalls += 1;
+ throw new Error('private option getter detail');
+ },
+ });
+
+ expect(() =>
+ validateWithOptions(
+ 'https://example.com/',
+ options as RuntimeSafeLinkOptions,
+ ),
+ ).toThrowError(
+ expect.objectContaining({
+ code: 'invalid_configuration',
+ hrefPreview: '',
+ }),
+ );
+ expect(getterCalls).toBe(0);
+ });
+});
diff --git a/src/index.test.ts b/src/index.test.ts
index 506096ad..c03980f9 100644
--- a/src/index.test.ts
+++ b/src/index.test.ts
@@ -47,6 +47,12 @@ describe('package entry point', () => {
expect(typeof api.markdownToEmailHtml).toBe('function');
expect(typeof api.markdownToPlainText).toBe('function');
expect(typeof api.htmlToPlainText).toBe('function');
+ expect(api.DEFAULT_HTML_TO_MARKDOWN_MAX_BYTES).toBe(16_777_216);
+ expect(api.MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES).toBe(67_108_864);
+ expect(typeof api.HtmlToMarkdownResourceError).toBe('function');
+ expect(api.DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES).toBe(16_777_216);
+ expect(api.MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES).toBe(67_108_864);
+ expect(typeof api.MarkdownToHtmlResourceError).toBe('function');
});
it('re-exports the standalone base64 converter', () => {
diff --git a/src/index.ts b/src/index.ts
index f7905449..042afc2a 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -73,6 +73,7 @@ export {
encodeDocumentEnvelope,
serializeDocumentEnvelope,
} from './documentEnvelopeCanonical.js';
+export type { DocumentEnvelopeEncodingOptions } from './documentEnvelopeCanonical.js';
export {
restoreDocumentEnvelopeBytesIfMatch,
restoreDocumentEnvelopeIfMatch,
@@ -138,26 +139,46 @@ export {
} from './extensions/SafeClipboardExtension.js';
export type { SafeClipboardOptions } from './extensions/SafeClipboardExtension.js';
export {
+ DEFAULT_SAFE_LINK_MAX_HREF_BYTES,
+ MAXIMUM_SAFE_LINK_MAX_HREF_BYTES,
SafeLink,
SafeLinkHrefError,
isSafeLinkHref,
safeLinkPluginKey,
validateSafeLinkHref,
} from './extensions/SafeLink.js';
+export type {
+ SafeLinkHrefErrorCode,
+ SafeLinkValidationOptions,
+} from './extensions/SafeLink.js';
export { buildExtensions } from './extensions/kit.js';
export type { BuildExtensionsOptions } from './extensions/kit.js';
// Markdown <-> HTML serialization (base64 image round-trip safe).
+export { htmlToMarkdown } from './markdown/serializer.js';
+export type { HtmlToMarkdownOptions } from './markdown/serializer.js';
export {
+ markdownToEmailHtml,
markdownToHtml,
- htmlToMarkdown,
normalizeMarkdown,
- markdownToEmailHtml,
-} from './markdown/serializer.js';
+} from './markdown/resourceBoundMarkdown.js';
export type {
- HtmlToMarkdownOptions,
MarkdownToEmailHtmlOptions,
-} from './markdown/serializer.js';
+ MarkdownToHtmlOptions,
+ NormalizeMarkdownOptions,
+} from './markdown/resourceBoundMarkdown.js';
+export {
+ DEFAULT_HTML_TO_MARKDOWN_MAX_BYTES,
+ MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES,
+ HtmlToMarkdownResourceError,
+} from './markdown/htmlToMarkdownResourcePolicy.js';
+export type { HtmlToMarkdownResourceErrorCode } from './markdown/htmlToMarkdownResourcePolicy.js';
+export {
+ DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES,
+ MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES,
+ MarkdownToHtmlResourceError,
+} from './markdown/markdownToHtmlResourcePolicy.js';
+export type { MarkdownToHtmlResourceErrorCode } from './markdown/markdownToHtmlResourcePolicy.js';
// Deterministic Markdown/HTML -> plain-text projection for AI/indexing paths.
export {
diff --git a/src/markdown/emailFullDocumentRuntime.test.ts b/src/markdown/emailFullDocumentRuntime.test.ts
new file mode 100644
index 00000000..537e1f4f
--- /dev/null
+++ b/src/markdown/emailFullDocumentRuntime.test.ts
@@ -0,0 +1,48 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { Lexer } from 'marked';
+import { markdownToEmailHtml } from './resourceBoundMarkdown.js';
+
+const INVALID_FULL_DOCUMENT_MESSAGE =
+ 'Email document fullDocument must be a boolean when provided.';
+const INVALID_TEXT_DIRECTION_MESSAGE =
+ 'Email document direction must be ltr, rtl, or auto.';
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('email full-document runtime contract', () => {
+ it.each([
+ ['truthy string', 'true'],
+ ['falsey number', 0],
+ ['null', null],
+ ])('rejects a %s instead of coercing document representation', (_label, value) => {
+ expect(() =>
+ markdownToEmailHtml('Hello', {
+ fullDocument: value as unknown as boolean,
+ }),
+ ).toThrowError(new RangeError(INVALID_FULL_DOCUMENT_MESSAGE));
+ });
+
+ it('rejects invalid text direction before Markdown parser materialization', () => {
+ const lex = vi.spyOn(Lexer, 'lex');
+
+ expect(() =>
+ markdownToEmailHtml('Hello', {
+ fullDocument: true,
+ textDirection: 'sideways' as never,
+ }),
+ ).toThrowError(new RangeError(INVALID_TEXT_DIRECTION_MESSAGE));
+ expect(lex).not.toHaveBeenCalled();
+ });
+
+ it('preserves omitted, fragment, and full-document boolean modes', () => {
+ expect(markdownToEmailHtml('Hello')).toBe('Hello
');
+ expect(markdownToEmailHtml('Hello', { fullDocument: false })).toBe(
+ 'Hello
',
+ );
+ expect(markdownToEmailHtml('Hello', { fullDocument: true })).toContain(
+ '',
+ );
+ });
+});
\ No newline at end of file
diff --git a/src/markdown/emailLanguageResourceBoundary.test.ts b/src/markdown/emailLanguageResourceBoundary.test.ts
new file mode 100644
index 00000000..dd16ed9b
--- /dev/null
+++ b/src/markdown/emailLanguageResourceBoundary.test.ts
@@ -0,0 +1,45 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import {
+ markdownToEmailHtml,
+ type MarkdownToEmailHtmlOptions,
+} from './resourceBoundMarkdown.js';
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('email document language resource boundary', () => {
+ it('rejects an obviously oversized language tag before Intl canonicalization', () => {
+ const canonicalize = vi.spyOn(Intl, 'getCanonicalLocales');
+
+ expect(() =>
+ markdownToEmailHtml('bounded metadata', {
+ fullDocument: true,
+ languageTag: 'a'.repeat(257),
+ }),
+ ).toThrow(RangeError);
+ expect(canonicalize).not.toHaveBeenCalled();
+ });
+
+ it('fails closed with the stable RangeError contract for non-string runtime metadata', () => {
+ const languageTag = 42 as unknown as MarkdownToEmailHtmlOptions['languageTag'];
+
+ expect(() =>
+ markdownToEmailHtml('runtime metadata', {
+ fullDocument: true,
+ languageTag,
+ }),
+ ).toThrow(RangeError);
+ });
+
+ it('preserves omitted full-document language metadata without Intl work', () => {
+ const canonicalize = vi.spyOn(Intl, 'getCanonicalLocales');
+
+ const html = markdownToEmailHtml('no language metadata', {
+ fullDocument: true,
+ });
+
+ expect(html).toContain('');
+ expect(canonicalize).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/markdown/emailTitleResourceBoundary.test.ts b/src/markdown/emailTitleResourceBoundary.test.ts
new file mode 100644
index 00000000..bf19e13d
--- /dev/null
+++ b/src/markdown/emailTitleResourceBoundary.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from 'vitest';
+import {
+ markdownToEmailHtml,
+ type MarkdownToEmailHtmlOptions,
+} from './resourceBoundMarkdown.js';
+
+const EMAIL_TITLE_MAX_CODE_UNITS = 65_536;
+const INVALID_EMAIL_TITLE_MESSAGE =
+ 'Email document title must be a string within the supported length.';
+
+describe('email document title resource boundary', () => {
+ it('rejects an oversized full-document title with a stable redacted error', () => {
+ const privateMarker = 'customer-case-private-marker';
+ const title = `${privateMarker}${'x'.repeat(EMAIL_TITLE_MAX_CODE_UNITS)}`;
+ let failure: unknown;
+
+ try {
+ markdownToEmailHtml('bounded body', {
+ fullDocument: true,
+ title,
+ });
+ } catch (error) {
+ failure = error;
+ }
+
+ expect(failure).toBeInstanceOf(RangeError);
+ expect(failure).toMatchObject({ message: INVALID_EMAIL_TITLE_MESSAGE });
+ expect(String(failure)).not.toContain(privateMarker);
+ });
+
+ it('rejects non-string full-document title metadata through the same contract', () => {
+ const title = 42 as unknown as MarkdownToEmailHtmlOptions['title'];
+
+ expect(() =>
+ markdownToEmailHtml('runtime metadata', {
+ fullDocument: true,
+ title,
+ }),
+ ).toThrowError(new RangeError(INVALID_EMAIL_TITLE_MESSAGE));
+ });
+
+ it('accepts a title exactly at the local metadata ceiling', () => {
+ const title = 'x'.repeat(EMAIL_TITLE_MAX_CODE_UNITS);
+
+ const html = markdownToEmailHtml('', {
+ fullDocument: true,
+ title,
+ });
+
+ expect(html).toContain(`${title}`);
+ });
+
+ it('preserves fragment mode where title metadata is not consumed', () => {
+ const html = markdownToEmailHtml('fragment body', {
+ title: 'x'.repeat(EMAIL_TITLE_MAX_CODE_UNITS + 1),
+ });
+
+ expect(html).toContain('fragment body
');
+ expect(html).not.toContain('');
+ });
+});
diff --git a/src/markdown/htmlToMarkdownDomAllocation.test.ts b/src/markdown/htmlToMarkdownDomAllocation.test.ts
new file mode 100644
index 00000000..ab8775f9
--- /dev/null
+++ b/src/markdown/htmlToMarkdownDomAllocation.test.ts
@@ -0,0 +1,23 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { htmlToMarkdown } from './serializer.js';
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('HTML-to-Markdown DOM allocation', () => {
+ it('sanitizes accepted HTML without copying the complete element NodeList', () => {
+ const arrayFrom = vi.spyOn(Array, 'from');
+
+ expect(
+ htmlToMarkdown('Alpha Beta
', {
+ maxHtmlBytes: 1024,
+ }),
+ ).toBe('Alpha **Beta**');
+
+ const copiedElementNodeList = arrayFrom.mock.calls.some(([value]) =>
+ Object.prototype.toString.call(value) === '[object NodeList]',
+ );
+ expect(copiedElementNodeList).toBe(false);
+ });
+});
diff --git a/src/markdown/htmlToMarkdownOptionBagRuntime.test.ts b/src/markdown/htmlToMarkdownOptionBagRuntime.test.ts
new file mode 100644
index 00000000..64defe6c
--- /dev/null
+++ b/src/markdown/htmlToMarkdownOptionBagRuntime.test.ts
@@ -0,0 +1,117 @@
+import { describe, expect, it, vi } from 'vitest';
+import { htmlToMarkdown, type HtmlToMarkdownOptions } from './serializer.js';
+
+const INVALID_CONFIGURATION = {
+ name: 'HtmlToMarkdownResourceError',
+ code: 'invalid_configuration',
+ message: 'HTML-to-Markdown resource configuration is invalid.',
+};
+
+const INVALID_INPUT = {
+ name: 'HtmlToMarkdownResourceError',
+ code: 'invalid_input',
+ message: 'HTML-to-Markdown input must be a string.',
+};
+
+describe('HTML-to-Markdown runtime option bag boundary', () => {
+ it.each([
+ ['primitive', 42],
+ ['null', null],
+ ['array', []],
+ ['custom prototype', Object.create({ inherited: true })],
+ ['unknown key', { unexpectedPolicy: true }],
+ ['non-boolean image-alt mode', { includeImageAlt: 'false' }],
+ ['symbol key', { [Symbol('policy')]: true }],
+ [
+ 'non-enumerable option',
+ Object.defineProperty({}, 'maxHtmlBytes', {
+ enumerable: false,
+ value: 1024,
+ }),
+ ],
+ ])('rejects %s option bags through the stable resource error', (_label, options) => {
+ expect(() =>
+ htmlToMarkdown('safe
', options as HtmlToMarkdownOptions),
+ ).toThrowError(expect.objectContaining(INVALID_CONFIGURATION));
+ });
+
+ it('rejects accessor-backed options without executing caller code or parsing HTML', () => {
+ const privateFailure = new Error('private-html-option-sentinel');
+ const maxHtmlBytes = vi.fn(() => {
+ throw privateFailure;
+ });
+ const createElement = vi.spyOn(document, 'createElement');
+ const options = Object.defineProperty({}, 'maxHtmlBytes', {
+ enumerable: true,
+ get: maxHtmlBytes,
+ }) as HtmlToMarkdownOptions;
+ let failure: unknown;
+
+ try {
+ htmlToMarkdown('private body
', options);
+ } catch (error) {
+ failure = error;
+ }
+
+ expect(maxHtmlBytes).not.toHaveBeenCalled();
+ expect(createElement).not.toHaveBeenCalledWith('template');
+ expect(failure).toMatchObject(INVALID_CONFIGURATION);
+ expect(String(failure)).not.toContain(privateFailure.message);
+ });
+
+ it('normalizes an executed hostile reflection trap without leaking caller failures', () => {
+ const privateFailure = new Error('private-reflection-sentinel');
+ const getPrototypeOf = vi.fn(() => {
+ throw privateFailure;
+ });
+ const options = new Proxy({}, { getPrototypeOf }) as HtmlToMarkdownOptions;
+ let failure: unknown;
+
+ try {
+ htmlToMarkdown('private body
', options);
+ } catch (error) {
+ failure = error;
+ }
+
+ expect(getPrototypeOf).toHaveBeenCalledOnce();
+ expect(failure).toMatchObject(INVALID_CONFIGURATION);
+ expect(String(failure)).not.toContain(privateFailure.message);
+ });
+
+ it('rejects hostile non-string HTML before caller access, encoding, or parser work', () => {
+ const privateFailure = new Error('private-html-input-sentinel');
+ const inputAccess = vi.fn(() => {
+ throw privateFailure;
+ });
+ const html = new Proxy({}, { get: inputAccess });
+ const encode = vi.spyOn(TextEncoder.prototype, 'encode');
+ const createElement = vi.spyOn(document, 'createElement');
+ let failure: unknown;
+
+ try {
+ htmlToMarkdown(html as never);
+ } catch (error) {
+ failure = error;
+ }
+
+ expect(inputAccess).not.toHaveBeenCalled();
+ expect(encode).not.toHaveBeenCalled();
+ expect(createElement).not.toHaveBeenCalledWith('template');
+ expect(failure).toMatchObject(INVALID_INPUT);
+ expect(String(failure)).not.toContain(privateFailure.message);
+ });
+
+ it('preserves null-prototype data option bags and accepted conversion behavior', () => {
+ const options = Object.assign(Object.create(null), {
+ includeImageAlt: false,
+ maxHtmlBytes: 1024,
+ }) as HtmlToMarkdownOptions;
+
+ expect(
+ htmlToMarkdown(
+ '
text
',
+ options,
+ ),
+ ).toBe('text');
+ });
+});
diff --git a/src/markdown/htmlToMarkdownResourcePolicy.ts b/src/markdown/htmlToMarkdownResourcePolicy.ts
new file mode 100644
index 00000000..807d4977
--- /dev/null
+++ b/src/markdown/htmlToMarkdownResourcePolicy.ts
@@ -0,0 +1,67 @@
+/** Default UTF-8 byte ceiling for one standalone HTML-to-Markdown conversion. */
+export const DEFAULT_HTML_TO_MARKDOWN_MAX_BYTES = 16_777_216;
+
+/** Hard public ceiling for an explicitly raised HTML-to-Markdown input limit. */
+export const MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES = 67_108_864;
+
+/** Stable redacted resource-bound failures from standalone HTML conversion. */
+export type HtmlToMarkdownResourceErrorCode =
+ | 'input_too_large'
+ | 'invalid_input'
+ | 'invalid_configuration';
+
+const ERROR_MESSAGES: Readonly> =
+ Object.freeze({
+ input_too_large:
+ 'HTML-to-Markdown input exceeds the configured byte limit.',
+ invalid_input: 'HTML-to-Markdown input must be a string.',
+ invalid_configuration:
+ 'HTML-to-Markdown resource configuration is invalid.',
+ });
+
+/** Error whose stable code/message never disclose caller-controlled HTML. */
+export class HtmlToMarkdownResourceError extends Error {
+ /** Machine-readable rejection category safe for host telemetry. */
+ readonly code: HtmlToMarkdownResourceErrorCode;
+
+ /** Create one stable resource-bound conversion error. */
+ constructor(code: HtmlToMarkdownResourceErrorCode) {
+ super(ERROR_MESSAGES[code]);
+ this.name = 'HtmlToMarkdownResourceError';
+ this.code = code;
+ }
+}
+
+/** Resolve one optional per-call byte ceiling within the public hard maximum. */
+export function resolveHtmlToMarkdownMaxBytes(candidate: unknown): number {
+ if (candidate === undefined) return DEFAULT_HTML_TO_MARKDOWN_MAX_BYTES;
+ if (
+ typeof candidate !== 'number' ||
+ !Number.isSafeInteger(candidate) ||
+ candidate < 1 ||
+ candidate > MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES
+ ) {
+ throw new HtmlToMarkdownResourceError('invalid_configuration');
+ }
+ return candidate;
+}
+
+/** Reject invalid or oversized HTML before any parser/DOM materialization. */
+export function assertHtmlToMarkdownInputSize(
+ html: string,
+ maxHtmlBytes: number,
+): void {
+ if (typeof html !== 'string') {
+ throw new HtmlToMarkdownResourceError('invalid_input');
+ }
+
+ // Every UTF-16 code unit contributes at least one UTF-8 byte. This lower
+ // bound avoids allocating a complete TextEncoder result when oversize is
+ // already certain.
+ if (html.length > maxHtmlBytes) {
+ throw new HtmlToMarkdownResourceError('input_too_large');
+ }
+ if (new TextEncoder().encode(html).byteLength > maxHtmlBytes) {
+ throw new HtmlToMarkdownResourceError('input_too_large');
+ }
+}
diff --git a/src/markdown/markdownInputResourceBounds.test.ts b/src/markdown/markdownInputResourceBounds.test.ts
new file mode 100644
index 00000000..75801b22
--- /dev/null
+++ b/src/markdown/markdownInputResourceBounds.test.ts
@@ -0,0 +1,176 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { Lexer } from 'marked';
+import {
+ markdownToEditorHtml,
+ markdownToEmailHtml,
+ markdownToHtml,
+ normalizeMarkdown,
+} from './resourceBoundMarkdown.js';
+import {
+ DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES,
+ MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES,
+} from './markdownToHtmlResourcePolicy.js';
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('Markdown parser resource bounds', () => {
+ it('uses the owned default ceiling without changing accepted Markdown', () => {
+ expect(markdownToHtml('plain')).toBe('plain
\n');
+ });
+
+ it('rejects configured oversized Markdown before encoding or Marked lexer materialization', () => {
+ const encode = vi.spyOn(TextEncoder.prototype, 'encode');
+ const lex = vi.spyOn(Lexer, 'lex');
+ let failure: unknown;
+
+ try {
+ markdownToHtml('12345', { maxMarkdownBytes: 4 });
+ } catch (error) {
+ failure = error;
+ }
+
+ expect(encode).not.toHaveBeenCalled();
+ expect(lex).not.toHaveBeenCalled();
+ expect(failure).toMatchObject({
+ name: 'MarkdownToHtmlResourceError',
+ code: 'input_too_large',
+ message: 'Markdown-to-HTML input exceeds the configured byte limit.',
+ });
+ });
+
+ it('uses exact UTF-8 bytes when code-unit length alone does not prove oversize', () => {
+ const lex = vi.spyOn(Lexer, 'lex');
+
+ expect(() => markdownToHtml('é', { maxMarkdownBytes: 1 })).toThrowError(
+ expect.objectContaining({
+ name: 'MarkdownToHtmlResourceError',
+ code: 'input_too_large',
+ }),
+ );
+ expect(lex).not.toHaveBeenCalled();
+ });
+
+ it('accepts input exactly at the configured UTF-8 ceiling', () => {
+ expect(markdownToHtml('é', { maxMarkdownBytes: 2 })).toBe('é
\n');
+ });
+
+ it.each([
+ ['wrong type', '4'],
+ ['fractional', 1.5],
+ ['zero', 0],
+ ['above hard maximum', MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES + 1],
+ ])('fails closed for %s resource configuration', (_label, maxMarkdownBytes) => {
+ const secret = 'private-markdown';
+ let failure: unknown;
+
+ try {
+ markdownToHtml(secret, { maxMarkdownBytes } as never);
+ } catch (error) {
+ failure = error;
+ }
+
+ expect(failure).toMatchObject({
+ name: 'MarkdownToHtmlResourceError',
+ code: 'invalid_configuration',
+ message: 'Markdown-to-HTML resource configuration is invalid.',
+ });
+ expect(String(failure)).not.toContain(secret);
+ });
+
+ it('accepts the hard maximum without changing ordinary conversion semantics', () => {
+ expect(
+ markdownToHtml('**safe**', {
+ maxMarkdownBytes: MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES,
+ }),
+ ).toBe('safe
\n');
+ });
+
+ it('applies the owned default ceiling to editor Markdown ingress', () => {
+ const encode = vi.spyOn(TextEncoder.prototype, 'encode');
+ const lex = vi.spyOn(Lexer, 'lex');
+ const oversized = 'x'.repeat(DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES + 1);
+
+ expect(() => markdownToEditorHtml(oversized)).toThrowError(
+ expect.objectContaining({
+ name: 'MarkdownToHtmlResourceError',
+ code: 'input_too_large',
+ }),
+ );
+ expect(encode).not.toHaveBeenCalled();
+ expect(lex).not.toHaveBeenCalled();
+ });
+
+ it('honors a caller ceiling for Markdown normalization before Marked materialization', () => {
+ const lex = vi.spyOn(Lexer, 'lex');
+ const normalizeWithOptions = normalizeMarkdown as unknown as (
+ markdown: string,
+ options: { maxMarkdownBytes: number },
+ ) => string;
+ let failure: unknown;
+
+ try {
+ normalizeWithOptions('12345', { maxMarkdownBytes: 4 });
+ } catch (error) {
+ failure = error;
+ }
+
+ expect(lex).not.toHaveBeenCalled();
+ expect(failure).toMatchObject({
+ name: 'MarkdownToHtmlResourceError',
+ code: 'input_too_large',
+ message: 'Markdown-to-HTML input exceeds the configured byte limit.',
+ });
+ });
+
+ it('uses exact caller UTF-8 ceilings for Markdown normalization', () => {
+ const lex = vi.spyOn(Lexer, 'lex');
+ const normalizeWithOptions = normalizeMarkdown as unknown as (
+ markdown: string,
+ options: { maxMarkdownBytes: number },
+ ) => string;
+
+ expect(() =>
+ normalizeWithOptions('é', { maxMarkdownBytes: 1 }),
+ ).toThrowError(
+ expect.objectContaining({
+ name: 'MarkdownToHtmlResourceError',
+ code: 'input_too_large',
+ }),
+ );
+ expect(lex).not.toHaveBeenCalled();
+ });
+
+ it('honors a caller ceiling for email conversion before Marked materialization', () => {
+ const lex = vi.spyOn(Lexer, 'lex');
+ let failure: unknown;
+
+ try {
+ markdownToEmailHtml('12345', { maxMarkdownBytes: 4 } as never);
+ } catch (error) {
+ failure = error;
+ }
+
+ expect(lex).not.toHaveBeenCalled();
+ expect(failure).toMatchObject({
+ name: 'MarkdownToHtmlResourceError',
+ code: 'input_too_large',
+ message: 'Markdown-to-HTML input exceeds the configured byte limit.',
+ });
+ });
+
+ it('uses exact caller UTF-8 ceilings for email conversion', () => {
+ const lex = vi.spyOn(Lexer, 'lex');
+
+ expect(() =>
+ markdownToEmailHtml('é', { maxMarkdownBytes: 1 } as never),
+ ).toThrowError(
+ expect.objectContaining({
+ name: 'MarkdownToHtmlResourceError',
+ code: 'input_too_large',
+ }),
+ );
+ expect(lex).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/markdown/markdownOptionBagsRuntime.test.ts b/src/markdown/markdownOptionBagsRuntime.test.ts
new file mode 100644
index 00000000..0b0b36ac
--- /dev/null
+++ b/src/markdown/markdownOptionBagsRuntime.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, it } from 'vitest';
+import {
+ markdownToEmailHtml,
+ markdownToHtml,
+ normalizeMarkdown,
+} from './resourceBoundMarkdown.js';
+
+const INVALID_CONFIGURATION = {
+ name: 'MarkdownToHtmlResourceError',
+ code: 'invalid_configuration',
+ message: 'Markdown-to-HTML resource configuration is invalid.',
+};
+
+type RuntimeAdapter = (markdown: string, options?: unknown) => string;
+
+const adapters: ReadonlyArray = [
+ ['HTML conversion', markdownToHtml as unknown as RuntimeAdapter],
+ ['normalization', normalizeMarkdown as unknown as RuntimeAdapter],
+ ['email conversion', markdownToEmailHtml as unknown as RuntimeAdapter],
+];
+
+function expectInvalidConfiguration(run: () => unknown): void {
+ let failure: unknown;
+ try {
+ run();
+ } catch (error) {
+ failure = error;
+ }
+
+ expect(failure).toMatchObject(INVALID_CONFIGURATION);
+}
+
+describe('Markdown public option-bag runtime contracts', () => {
+ it.each(adapters)('rejects malformed containers for %s', (_label, convert) => {
+ for (const options of [null, 1, 'options', [], new Date(0)]) {
+ expectInvalidConfiguration(() => convert('hello', options));
+ }
+ });
+
+ it.each(adapters)('rejects unknown string and symbol keys for %s', (_label, convert) => {
+ expectInvalidConfiguration(() => convert('hello', { maxMarkdownByte: 1 }));
+ expectInvalidConfiguration(() => convert('hello', { [Symbol('option')]: true }));
+ });
+
+ it.each(adapters)('rejects accessors without evaluating them for %s', (_label, convert) => {
+ let getterCalled = false;
+ const options = Object.create(null) as Record;
+ Object.defineProperty(options, 'maxMarkdownBytes', {
+ enumerable: true,
+ configurable: true,
+ get() {
+ getterCalled = true;
+ throw new Error('getter executed');
+ },
+ });
+
+ expectInvalidConfiguration(() => convert('hello', options));
+ expect(getterCalled).toBe(false);
+ });
+
+ it.each(adapters)('rejects non-enumerable own properties for %s', (_label, convert) => {
+ const options = Object.create(null) as Record;
+ Object.defineProperty(options, 'maxMarkdownBytes', {
+ enumerable: false,
+ configurable: true,
+ writable: true,
+ value: 1024,
+ });
+
+ expectInvalidConfiguration(() => convert('hello', options));
+ });
+
+ it.each(adapters)('accepts null-prototype bags for %s', (_label, convert) => {
+ const options = Object.create(null) as Record;
+ options.maxMarkdownBytes = 1024;
+
+ expect(convert('hello', options)).toEqual(expect.any(String));
+ });
+
+ it('keeps the email option vocabulary exact', () => {
+ const options = Object.create(null) as Record;
+ options.maxMarkdownBytes = 1024;
+ options.fullDocument = true;
+ options.title = 'Title';
+ options.languageTag = 'en';
+ options.textDirection = 'ltr';
+
+ expect(markdownToEmailHtml('hello', options as never)).toContain('');
+ expectInvalidConfiguration(() =>
+ markdownToEmailHtml('hello', {
+ maxMarkdownBytes: 1024,
+ unexpected: true,
+ } as never),
+ );
+ });
+});
diff --git a/src/markdown/markdownSourceRuntime.test.ts b/src/markdown/markdownSourceRuntime.test.ts
new file mode 100644
index 00000000..e5642169
--- /dev/null
+++ b/src/markdown/markdownSourceRuntime.test.ts
@@ -0,0 +1,54 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { Lexer } from 'marked';
+import {
+ markdownToEditorHtml,
+ markdownToEmailHtml,
+ markdownToHtml,
+ normalizeMarkdown,
+} from './resourceBoundMarkdown.js';
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('Markdown source runtime contract', () => {
+ it.each([
+ ['HTML conversion', markdownToHtml],
+ ['normalization', normalizeMarkdown],
+ ['email conversion', markdownToEmailHtml],
+ ['editor ingress', markdownToEditorHtml],
+ ])('rejects non-string input before caller code or parser work for %s', (_label, convert) => {
+ const encode = vi.spyOn(TextEncoder.prototype, 'encode');
+ const lex = vi.spyOn(Lexer, 'lex');
+ let propertyRead = false;
+ let coerced = false;
+ const hostile = {
+ get length() {
+ propertyRead = true;
+ throw new Error('private-length-getter');
+ },
+ toString() {
+ coerced = true;
+ throw new Error('private-string-coercion');
+ },
+ };
+ let failure: unknown;
+
+ try {
+ (convert as unknown as (markdown: unknown) => string)(hostile);
+ } catch (error) {
+ failure = error;
+ }
+
+ expect(propertyRead).toBe(false);
+ expect(coerced).toBe(false);
+ expect(encode).not.toHaveBeenCalled();
+ expect(lex).not.toHaveBeenCalled();
+ expect(failure).toMatchObject({
+ name: 'MarkdownToHtmlResourceError',
+ code: 'invalid_input',
+ message: 'Markdown-to-HTML input must be a string.',
+ });
+ expect(String(failure)).not.toContain('private-');
+ });
+});
diff --git a/src/markdown/markdownToHtmlResourcePolicy.ts b/src/markdown/markdownToHtmlResourcePolicy.ts
new file mode 100644
index 00000000..52d9fb7a
--- /dev/null
+++ b/src/markdown/markdownToHtmlResourcePolicy.ts
@@ -0,0 +1,67 @@
+/** Default UTF-8 byte ceiling for one Markdown-to-HTML conversion. */
+export const DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES = 16_777_216;
+
+/** Hard public ceiling for an explicitly raised Markdown-to-HTML input limit. */
+export const MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES = 67_108_864;
+
+/** Stable redacted Markdown input/resource failures from conversion. */
+export type MarkdownToHtmlResourceErrorCode =
+ | 'input_too_large'
+ | 'invalid_input'
+ | 'invalid_configuration';
+
+const ERROR_MESSAGES: Readonly> =
+ Object.freeze({
+ input_too_large:
+ 'Markdown-to-HTML input exceeds the configured byte limit.',
+ invalid_input: 'Markdown-to-HTML input must be a string.',
+ invalid_configuration:
+ 'Markdown-to-HTML resource configuration is invalid.',
+ });
+
+/** Error whose stable code/message never disclose caller-controlled Markdown. */
+export class MarkdownToHtmlResourceError extends Error {
+ /** Machine-readable rejection category safe for host telemetry. */
+ readonly code: MarkdownToHtmlResourceErrorCode;
+
+ /** Create one stable resource-bound conversion error. */
+ constructor(code: MarkdownToHtmlResourceErrorCode) {
+ super(ERROR_MESSAGES[code]);
+ this.name = 'MarkdownToHtmlResourceError';
+ this.code = code;
+ }
+}
+
+/** Resolve one optional per-call byte ceiling within the public hard maximum. */
+export function resolveMarkdownToHtmlMaxBytes(candidate: unknown): number {
+ if (candidate === undefined) return DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES;
+ if (
+ typeof candidate !== 'number' ||
+ !Number.isSafeInteger(candidate) ||
+ candidate < 1 ||
+ candidate > MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES
+ ) {
+ throw new MarkdownToHtmlResourceError('invalid_configuration');
+ }
+ return candidate;
+}
+
+/** Reject invalid or oversized Markdown before parser materialization. */
+export function assertMarkdownToHtmlInputSize(
+ markdown: string,
+ maxMarkdownBytes: number,
+): void {
+ if (typeof markdown !== 'string') {
+ throw new MarkdownToHtmlResourceError('invalid_input');
+ }
+
+ // Every UTF-16 code unit contributes at least one UTF-8 byte. This lower
+ // bound avoids allocating a complete TextEncoder result when oversize is
+ // already certain.
+ if (markdown.length > maxMarkdownBytes) {
+ throw new MarkdownToHtmlResourceError('input_too_large');
+ }
+ if (new TextEncoder().encode(markdown).byteLength > maxMarkdownBytes) {
+ throw new MarkdownToHtmlResourceError('input_too_large');
+ }
+}
diff --git a/src/markdown/package.ts b/src/markdown/package.ts
index d943703d..724bdf7d 100644
--- a/src/markdown/package.ts
+++ b/src/markdown/package.ts
@@ -5,16 +5,30 @@
* runtime, collaboration providers, transport, persistence, credentials, and
* model authority. It exposes only deterministic conversion/projection APIs.
*/
+export { htmlToMarkdown } from './serializer.js';
+export type { HtmlToMarkdownOptions } from './serializer.js';
export {
- htmlToMarkdown,
markdownToEmailHtml,
markdownToHtml,
normalizeMarkdown,
-} from './serializer.js';
+} from './resourceBoundMarkdown.js';
export type {
- HtmlToMarkdownOptions,
MarkdownToEmailHtmlOptions,
-} from './serializer.js';
+ MarkdownToHtmlOptions,
+ NormalizeMarkdownOptions,
+} from './resourceBoundMarkdown.js';
+export {
+ DEFAULT_HTML_TO_MARKDOWN_MAX_BYTES,
+ MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES,
+ HtmlToMarkdownResourceError,
+} from './htmlToMarkdownResourcePolicy.js';
+export type { HtmlToMarkdownResourceErrorCode } from './htmlToMarkdownResourcePolicy.js';
+export {
+ DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES,
+ MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES,
+ MarkdownToHtmlResourceError,
+} from './markdownToHtmlResourcePolicy.js';
+export type { MarkdownToHtmlResourceErrorCode } from './markdownToHtmlResourcePolicy.js';
export {
htmlToPlainText,
markdownToPlainText,
diff --git a/src/markdown/plainText.resourceBounds.test.ts b/src/markdown/plainText.resourceBounds.test.ts
new file mode 100644
index 00000000..b6788d65
--- /dev/null
+++ b/src/markdown/plainText.resourceBounds.test.ts
@@ -0,0 +1,102 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { Lexer } from 'marked';
+import {
+ htmlToPlainText,
+ markdownToPlainText,
+} from './plainText.js';
+import { MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES } from './markdownToHtmlResourcePolicy.js';
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('plain-text Markdown resource bounds', () => {
+ it('rejects oversized Markdown before Marked lexer materialization', () => {
+ const lex = vi.spyOn(Lexer, 'lex');
+ let failure: unknown;
+
+ try {
+ markdownToPlainText('12345', { maxMarkdownBytes: 4 });
+ } catch (error) {
+ failure = error;
+ }
+
+ expect(lex).not.toHaveBeenCalled();
+ expect(failure).toMatchObject({
+ name: 'MarkdownToHtmlResourceError',
+ code: 'input_too_large',
+ message: 'Markdown-to-HTML input exceeds the configured byte limit.',
+ });
+ });
+
+ it('uses exact UTF-8 bytes when code-unit length alone does not prove oversize', () => {
+ const lex = vi.spyOn(Lexer, 'lex');
+
+ expect(() =>
+ markdownToPlainText('é', { maxMarkdownBytes: 1 }),
+ ).toThrowError(
+ expect.objectContaining({
+ name: 'MarkdownToHtmlResourceError',
+ code: 'input_too_large',
+ }),
+ );
+ expect(lex).not.toHaveBeenCalled();
+ });
+
+ it('accepts Markdown exactly at the configured UTF-8 ceiling', () => {
+ expect(markdownToPlainText('é', { maxMarkdownBytes: 2 })).toBe('é');
+ });
+
+ it.each([
+ ['wrong type', '4'],
+ ['fractional', 1.5],
+ ['zero', 0],
+ ['above hard maximum', MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES + 1],
+ ])('fails closed for %s Markdown resource configuration', (_label, maxMarkdownBytes) => {
+ const secret = 'private-markdown';
+ let failure: unknown;
+
+ try {
+ // Invalid runtime configuration is intentionally forced past TypeScript
+ // so the public fail-closed validation remains regression-tested.
+ markdownToPlainText(secret, { maxMarkdownBytes } as never);
+ } catch (error) {
+ failure = error;
+ }
+
+ expect(failure).toMatchObject({
+ name: 'MarkdownToHtmlResourceError',
+ code: 'invalid_configuration',
+ message: 'Markdown-to-HTML resource configuration is invalid.',
+ });
+ expect(String(failure)).not.toContain(secret);
+ });
+
+ it('forwards the HTML parser ceiling before HTML normalization', () => {
+ expect(() =>
+ htmlToPlainText('hello
', { maxHtmlBytes: 4 }),
+ ).toThrowError(
+ expect.objectContaining({
+ name: 'HtmlToMarkdownResourceError',
+ code: 'input_too_large',
+ }),
+ );
+ });
+
+ it('bounds generated Markdown before the plain-text lexer', () => {
+ const lex = vi.spyOn(Lexer, 'lex');
+
+ expect(() =>
+ htmlToPlainText('hello
', {
+ maxHtmlBytes: 32,
+ maxMarkdownBytes: 4,
+ }),
+ ).toThrowError(
+ expect.objectContaining({
+ name: 'MarkdownToHtmlResourceError',
+ code: 'input_too_large',
+ }),
+ );
+ expect(lex).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/markdown/plainText.test.ts b/src/markdown/plainText.test.ts
index 625b6f1a..923242a1 100644
--- a/src/markdown/plainText.test.ts
+++ b/src/markdown/plainText.test.ts
@@ -9,6 +9,8 @@ const PNG_DATA_URI = bytesToDataUri(
]),
);
+const INVALID_PLAIN_TEXT_OPTIONS_MESSAGE = 'plain-text options are invalid.';
+
describe('markdownToPlainText', () => {
it('preserves authored reading order without Markdown syntax or destinations', () => {
const plainText = markdownToPlainText(`
@@ -108,6 +110,94 @@ const total = 120;
expect(plainText).toBe('Before after.');
});
+ it('accepts null-prototype option bags without weakening validation', () => {
+ const options = Object.assign(Object.create(null), {
+ includeImageAlt: false,
+ });
+
+ expect(
+ markdownToPlainText('Before  after.', options),
+ ).toBe('Before after.');
+ });
+
+ it('rejects invalid runtime image-alt policy instead of coercing it', () => {
+ const privateMarker = 'private-image-alt-policy';
+ let failure: unknown;
+
+ try {
+ markdownToPlainText('', {
+ includeImageAlt: privateMarker,
+ } as never);
+ } catch (error) {
+ failure = error;
+ }
+
+ expect(failure).toEqual(
+ new RangeError(INVALID_PLAIN_TEXT_OPTIONS_MESSAGE),
+ );
+ expect(String(failure)).not.toContain(privateMarker);
+ });
+
+ it('rejects accessor-backed options without invoking caller code', () => {
+ let getterCalls = 0;
+ const options = Object.defineProperty({}, 'includeImageAlt', {
+ enumerable: true,
+ get() {
+ getterCalls += 1;
+ throw new Error('private getter failure');
+ },
+ });
+
+ expect(() => markdownToPlainText('Visible', options as never)).toThrowError(
+ new RangeError(INVALID_PLAIN_TEXT_OPTIONS_MESSAGE),
+ );
+ expect(getterCalls).toBe(0);
+ });
+
+ it('rejects invalid containers and exotic option prototypes', () => {
+ const exoticOptions = Object.create({ inherited: true });
+
+ for (const options of [42, [], exoticOptions]) {
+ expect(() =>
+ markdownToPlainText('Visible', options as never),
+ ).toThrowError(new RangeError(INVALID_PLAIN_TEXT_OPTIONS_MESSAGE));
+ }
+ });
+
+ it('rejects non-enumerable and descriptor-hostile option properties', () => {
+ const hiddenOptions = Object.defineProperty({}, 'includeImageAlt', {
+ configurable: true,
+ enumerable: false,
+ value: false,
+ });
+ const missingDescriptorOptions = new Proxy(
+ {},
+ {
+ ownKeys: () => ['includeImageAlt'],
+ getOwnPropertyDescriptor: () => undefined,
+ },
+ );
+
+ expect(() =>
+ markdownToPlainText('Visible', hiddenOptions as never),
+ ).toThrowError(new RangeError(INVALID_PLAIN_TEXT_OPTIONS_MESSAGE));
+ expect(() =>
+ markdownToPlainText('Visible', missingDescriptorOptions as never),
+ ).toThrowError(new RangeError(INVALID_PLAIN_TEXT_OPTIONS_MESSAGE));
+ });
+
+ it('rejects unknown and symbol option keys fail-closed', () => {
+ expect(() =>
+ markdownToPlainText('Visible', { maxMarkdownByte: 4 } as never),
+ ).toThrowError(new RangeError(INVALID_PLAIN_TEXT_OPTIONS_MESSAGE));
+ expect(() =>
+ markdownToPlainText(
+ 'Visible',
+ { [Symbol('private')]: true } as never,
+ ),
+ ).toThrowError(new RangeError(INVALID_PLAIN_TEXT_OPTIONS_MESSAGE));
+ });
+
it('omits raw HTML blocks and link-definition records instead of interpreting them', () => {
const plainText = markdownToPlainText(`
@@ -148,4 +238,10 @@ describe('htmlToPlainText', () => {
}),
).toBe('Beforeafter');
});
-});
+
+ it('rejects malformed option containers before HTML normalization', () => {
+ expect(() => htmlToPlainText('Visible
', null as never)).toThrowError(
+ new RangeError(INVALID_PLAIN_TEXT_OPTIONS_MESSAGE),
+ );
+ });
+});
\ No newline at end of file
diff --git a/src/markdown/plainText.ts b/src/markdown/plainText.ts
index 18870bcc..244afe43 100644
--- a/src/markdown/plainText.ts
+++ b/src/markdown/plainText.ts
@@ -1,4 +1,8 @@
import { Marked } from 'marked';
+import {
+ assertMarkdownToHtmlInputSize,
+ resolveMarkdownToHtmlMaxBytes,
+} from './markdownToHtmlResourcePolicy.js';
import { htmlToMarkdown } from './serializer.js';
const plainTextMarked = new Marked({
@@ -17,6 +21,13 @@ const BLOCK_TOKEN_TYPES = new Set([
'table',
]);
+const PLAIN_TEXT_OPTION_KEYS = new Set([
+ 'includeImageAlt',
+ 'maxMarkdownBytes',
+ 'maxHtmlBytes',
+]);
+const INVALID_PLAIN_TEXT_OPTIONS_MESSAGE = 'plain-text options are invalid.';
+
interface PlainTextToken {
type: string;
text?: string;
@@ -60,6 +71,12 @@ interface PlainTextSegment {
value: string;
}
+interface ResolvedPlainTextOptions {
+ includeImageAlt: boolean;
+ maxMarkdownBytes: number | undefined;
+ maxHtmlBytes: number | undefined;
+}
+
/** Options for Markdown/HTML plain-text projection. */
export interface PlainTextOptions {
/**
@@ -67,6 +84,67 @@ export interface PlainTextOptions {
* Decorative images with an empty alternative remain silent.
*/
includeImageAlt?: boolean;
+ /**
+ * Maximum UTF-8 Markdown bytes accepted before plain-text lexing.
+ * Defaults to 16 MiB; values above the 64 MiB hard maximum are rejected.
+ */
+ maxMarkdownBytes?: number;
+ /**
+ * Maximum UTF-8 HTML bytes accepted before HTML normalization by
+ * `htmlToPlainText`. Defaults to 16 MiB; values above the 64 MiB hard
+ * maximum are rejected.
+ */
+ maxHtmlBytes?: number;
+}
+
+/**
+ * Read the public option bag without executing caller accessors or silently
+ * accepting misspelled/unknown keys. Resource-limit values are copied without
+ * reinterpretation so their existing policy modules retain error authority.
+ */
+function resolvePlainTextOptions(options: unknown): ResolvedPlainTextOptions {
+ let values: Record;
+ try {
+ if (
+ typeof options !== 'object' ||
+ options === null ||
+ Array.isArray(options)
+ ) {
+ throw new TypeError('invalid options container');
+ }
+
+ const prototype = Object.getPrototypeOf(options);
+ if (prototype !== Object.prototype && prototype !== null) {
+ throw new TypeError('invalid options prototype');
+ }
+
+ values = Object.create(null) as Record;
+ for (const key of Reflect.ownKeys(options)) {
+ if (typeof key !== 'string' || !PLAIN_TEXT_OPTION_KEYS.has(key)) {
+ throw new TypeError('unknown option');
+ }
+ const descriptor = Object.getOwnPropertyDescriptor(options, key);
+ if (!descriptor?.enumerable || !('value' in descriptor)) {
+ throw new TypeError('invalid option property');
+ }
+ values[key] = descriptor.value;
+ }
+
+ if (
+ values.includeImageAlt !== undefined &&
+ typeof values.includeImageAlt !== 'boolean'
+ ) {
+ throw new TypeError('invalid image alternative policy');
+ }
+ } catch {
+ throw new RangeError(INVALID_PLAIN_TEXT_OPTIONS_MESSAGE);
+ }
+
+ return {
+ includeImageAlt: values.includeImageAlt !== false,
+ maxMarkdownBytes: values.maxMarkdownBytes as number | undefined,
+ maxHtmlBytes: values.maxHtmlBytes as number | undefined,
+ };
}
/**
@@ -76,15 +154,21 @@ export interface PlainTextOptions {
* The projection keeps authored reading order, paragraph boundaries, explicit
* line breaks, code text, list structure, table cells, link labels, and image
* alternative text. Raw HTML blocks and link-definition records are omitted
- * instead of interpreted.
+ * instead of interpreted. The Markdown byte ceiling is enforced before Marked
+ * materializes lexer tokens.
*/
export function markdownToPlainText(
markdown: string,
options: PlainTextOptions = {},
): string {
+ const resolvedOptions = resolvePlainTextOptions(options);
+ const maxMarkdownBytes = resolveMarkdownToHtmlMaxBytes(
+ resolvedOptions.maxMarkdownBytes,
+ );
+ assertMarkdownToHtmlInputSize(markdown, maxMarkdownBytes);
const tokens = plainTextMarked.lexer(markdown) as unknown as PlainTextToken[];
const state: PlainTextRenderState = {
- includeImageAlt: options.includeImageAlt !== false,
+ includeImageAlt: resolvedOptions.includeImageAlt,
listDepth: 0,
};
return normalizePlainText(renderTokenSequence(tokens, state));
@@ -95,16 +179,23 @@ export function markdownToPlainText(
* HTML-to-Markdown normalization boundary.
*
* Element names, attributes, hyperlink destinations, and image sources are not
- * emitted. Image alternative text is included unless explicitly disabled.
+ * emitted. Image alternative text is included unless explicitly disabled. The
+ * HTML ceiling applies before normalization and the Markdown ceiling applies
+ * to the normalized Markdown before plain-text lexing.
*/
export function htmlToPlainText(
html: string,
options: PlainTextOptions = {},
): string {
- return markdownToPlainText(
- htmlToMarkdown(html, { includeImageAlt: options.includeImageAlt }),
- options,
- );
+ const resolvedOptions = resolvePlainTextOptions(options);
+ const markdown = htmlToMarkdown(html, {
+ includeImageAlt: resolvedOptions.includeImageAlt,
+ maxHtmlBytes: resolvedOptions.maxHtmlBytes,
+ });
+ return markdownToPlainText(markdown, {
+ includeImageAlt: resolvedOptions.includeImageAlt,
+ maxMarkdownBytes: resolvedOptions.maxMarkdownBytes,
+ });
}
/** Render an ordered token sequence while preserving block boundaries. */
@@ -254,4 +345,4 @@ function trimSegmentEdges(segments: PlainTextSegment[]): PlainTextSegment[] {
if (index === segments.length - 1) value = value.trimEnd();
return { kind: 'text', value };
});
-}
+}
\ No newline at end of file
diff --git a/src/markdown/resourceBoundMarkdown.ts b/src/markdown/resourceBoundMarkdown.ts
new file mode 100644
index 00000000..90bbb2ee
--- /dev/null
+++ b/src/markdown/resourceBoundMarkdown.ts
@@ -0,0 +1,173 @@
+import {
+ markdownToEditorHtml as serializeMarkdownToEditorHtml,
+ markdownToEmailHtml as serializeMarkdownToEmailHtml,
+ markdownToHtml as serializeMarkdownToHtml,
+ normalizeMarkdown as serializeNormalizedMarkdown,
+} from './serializer.js';
+import type { MarkdownToEmailHtmlOptions as SerializerMarkdownToEmailHtmlOptions } from './serializer.js';
+import {
+ DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES,
+ MarkdownToHtmlResourceError,
+ assertMarkdownToHtmlInputSize,
+ resolveMarkdownToHtmlMaxBytes,
+} from './markdownToHtmlResourcePolicy.js';
+
+const EMAIL_LANGUAGE_TAG_MAX_CODE_UNITS = 256;
+const EMAIL_TITLE_MAX_CODE_UNITS = 65_536;
+const INVALID_EMAIL_FULL_DOCUMENT_MESSAGE =
+ 'Email document fullDocument must be a boolean when provided.';
+const INVALID_EMAIL_LANGUAGE_MESSAGE =
+ 'Email document language must be a valid BCP 47 language tag within the supported length.';
+const INVALID_EMAIL_TITLE_MESSAGE =
+ 'Email document title must be a string within the supported length.';
+const MARKDOWN_OPTION_KEYS = new Set(['maxMarkdownBytes']);
+const EMAIL_OPTION_KEYS = new Set([
+ 'maxMarkdownBytes',
+ 'fullDocument',
+ 'title',
+ 'languageTag',
+ 'textDirection',
+]);
+
+type ResolvedOptionBag = Record;
+
+/** Options for public Markdown-to-HTML conversion. */
+export interface MarkdownToHtmlOptions {
+ /** Maximum UTF-8 bytes accepted before Marked lexing. Defaults to 16 MiB. */
+ maxMarkdownBytes?: number;
+}
+
+/** Options for public Markdown normalization. */
+export interface NormalizeMarkdownOptions {
+ /** Maximum UTF-8 bytes accepted before Marked lexing. Defaults to 16 MiB. */
+ maxMarkdownBytes?: number;
+}
+
+/** Options for public Markdown-to-email-HTML conversion. */
+export interface MarkdownToEmailHtmlOptions
+ extends SerializerMarkdownToEmailHtmlOptions {
+ /** Maximum UTF-8 bytes accepted before Marked lexing. Defaults to 16 MiB. */
+ maxMarkdownBytes?: number;
+}
+
+function resolveOptionBag(
+ options: unknown,
+ allowedKeys: ReadonlySet,
+): ResolvedOptionBag {
+ try {
+ if (typeof options !== 'object' || options === null || Array.isArray(options)) {
+ throw new MarkdownToHtmlResourceError('invalid_configuration');
+ }
+ const prototype = Object.getPrototypeOf(options);
+ if (prototype !== Object.prototype && prototype !== null) {
+ throw new MarkdownToHtmlResourceError('invalid_configuration');
+ }
+ const descriptors = Object.getOwnPropertyDescriptors(options);
+ const resolved = Object.create(null) as ResolvedOptionBag;
+ for (const key of Reflect.ownKeys(descriptors)) {
+ if (typeof key !== 'string' || !allowedKeys.has(key)) {
+ throw new MarkdownToHtmlResourceError('invalid_configuration');
+ }
+ const descriptor = descriptors[key];
+ if (!descriptor.enumerable || !('value' in descriptor)) {
+ throw new MarkdownToHtmlResourceError('invalid_configuration');
+ }
+ resolved[key] = descriptor.value;
+ }
+ return resolved;
+ } catch {
+ throw new MarkdownToHtmlResourceError('invalid_configuration');
+ }
+}
+
+/** Apply the owned default Markdown ceiling before an internal conversion. */
+function assertDefaultMarkdownInputSize(markdown: string): void {
+ assertMarkdownToHtmlInputSize(markdown, DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES);
+}
+
+/** Apply one caller-selectable Markdown ceiling before Marked materialization. */
+function assertConfiguredMarkdownInputSize(
+ markdown: string,
+ maxMarkdownBytes: unknown,
+): void {
+ const resolvedMaxBytes = resolveMarkdownToHtmlMaxBytes(maxMarkdownBytes);
+ assertMarkdownToHtmlInputSize(markdown, resolvedMaxBytes);
+}
+
+/** Reject malformed runtime document-mode values without coercing representation. */
+function assertEmailFullDocumentMode(
+ value: unknown,
+): asserts value is boolean | undefined {
+ if (value !== undefined && typeof value !== 'boolean') {
+ throw new RangeError(INVALID_EMAIL_FULL_DOCUMENT_MESSAGE);
+ }
+}
+
+/** Reject invalid or oversized full-document title metadata before HTML escaping. */
+function assertBoundedEmailTitle(value: unknown): void {
+ if (value === undefined) return;
+ if (typeof value !== 'string' || value.length > EMAIL_TITLE_MAX_CODE_UNITS) {
+ throw new RangeError(INVALID_EMAIL_TITLE_MESSAGE);
+ }
+}
+
+/** Reject invalid or oversized full-document language metadata before Intl work. */
+function assertBoundedEmailLanguageTag(value: unknown): void {
+ if (value === undefined) return;
+ if (
+ typeof value !== 'string' ||
+ value.length > EMAIL_LANGUAGE_TAG_MAX_CODE_UNITS
+ ) {
+ throw new RangeError(INVALID_EMAIL_LANGUAGE_MESSAGE);
+ }
+}
+
+/** Convert bounded Markdown to parser HTML for TipTap ingress. */
+export function markdownToEditorHtml(markdown: string): string {
+ assertDefaultMarkdownInputSize(markdown);
+ return serializeMarkdownToEditorHtml(markdown);
+}
+
+/** Convert bounded Markdown to safe standalone HTML. */
+export function markdownToHtml(
+ markdown: string,
+ options: MarkdownToHtmlOptions = {},
+): string {
+ const resolvedOptions = resolveOptionBag(options, MARKDOWN_OPTION_KEYS);
+ assertConfiguredMarkdownInputSize(markdown, resolvedOptions.maxMarkdownBytes);
+ return serializeMarkdownToHtml(markdown);
+}
+
+/** Normalize bounded Markdown through the existing deterministic serializer. */
+export function normalizeMarkdown(
+ markdown: string,
+ options: NormalizeMarkdownOptions = {},
+): string {
+ const resolvedOptions = resolveOptionBag(options, MARKDOWN_OPTION_KEYS);
+ assertConfiguredMarkdownInputSize(markdown, resolvedOptions.maxMarkdownBytes);
+ return serializeNormalizedMarkdown(markdown);
+}
+
+/** Convert bounded Markdown to the existing safe email HTML representation. */
+export function markdownToEmailHtml(
+ markdown: string,
+ options: MarkdownToEmailHtmlOptions = {},
+): string {
+ const resolvedOptions = resolveOptionBag(options, EMAIL_OPTION_KEYS);
+ assertConfiguredMarkdownInputSize(markdown, resolvedOptions.maxMarkdownBytes);
+ const fullDocument = resolvedOptions.fullDocument;
+ assertEmailFullDocumentMode(fullDocument);
+ if (fullDocument === true) {
+ assertBoundedEmailTitle(resolvedOptions.title);
+ assertBoundedEmailLanguageTag(resolvedOptions.languageTag);
+ }
+ const serializerOptions: SerializerMarkdownToEmailHtmlOptions = {
+ fullDocument,
+ title: resolvedOptions.title as SerializerMarkdownToEmailHtmlOptions['title'],
+ languageTag:
+ resolvedOptions.languageTag as SerializerMarkdownToEmailHtmlOptions['languageTag'],
+ textDirection:
+ resolvedOptions.textDirection as SerializerMarkdownToEmailHtmlOptions['textDirection'],
+ };
+ return serializeMarkdownToEmailHtml(markdown, serializerOptions);
+}
diff --git a/src/markdown/serializer.resourceBounds.test.ts b/src/markdown/serializer.resourceBounds.test.ts
new file mode 100644
index 00000000..d51f592d
--- /dev/null
+++ b/src/markdown/serializer.resourceBounds.test.ts
@@ -0,0 +1,78 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { htmlToMarkdown } from './serializer.js';
+import {
+ MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES,
+} from './htmlToMarkdownResourcePolicy.js';
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('HTML-to-Markdown resource bounds', () => {
+ it('rejects obvious oversize before UTF-8 encoding or browser parser materialization', () => {
+ const encode = vi.spyOn(TextEncoder.prototype, 'encode');
+ const createElement = vi.spyOn(document, 'createElement');
+ let failure: unknown;
+
+ try {
+ htmlToMarkdown('12345
', { maxHtmlBytes: 4 });
+ } catch (error) {
+ failure = error;
+ }
+
+ expect(encode).not.toHaveBeenCalled();
+ expect(createElement).not.toHaveBeenCalledWith('template');
+ expect(failure).toMatchObject({
+ name: 'HtmlToMarkdownResourceError',
+ code: 'input_too_large',
+ message: 'HTML-to-Markdown input exceeds the configured byte limit.',
+ });
+ });
+
+ it('uses exact UTF-8 bytes when code-unit length alone does not prove oversize', () => {
+ const createElement = vi.spyOn(document, 'createElement');
+
+ expect(() => htmlToMarkdown('é', { maxHtmlBytes: 1 })).toThrowError(
+ expect.objectContaining({
+ name: 'HtmlToMarkdownResourceError',
+ code: 'input_too_large',
+ }),
+ );
+ expect(createElement).not.toHaveBeenCalledWith('template');
+ });
+
+ it('accepts input exactly at the configured UTF-8 ceiling', () => {
+ expect(htmlToMarkdown('x
', { maxHtmlBytes: 8 })).toBe('x');
+ });
+
+ it.each([
+ ['wrong type', '4'],
+ ['fractional', 1.5],
+ ['zero', 0],
+ ['above hard maximum', MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES + 1],
+ ])('fails closed for %s resource configuration', (_label, maxHtmlBytes) => {
+ const secret = 'private-content
';
+ let failure: unknown;
+
+ try {
+ htmlToMarkdown(secret, { maxHtmlBytes } as never);
+ } catch (error) {
+ failure = error;
+ }
+
+ expect(failure).toMatchObject({
+ name: 'HtmlToMarkdownResourceError',
+ code: 'invalid_configuration',
+ message: 'HTML-to-Markdown resource configuration is invalid.',
+ });
+ expect(String(failure)).not.toContain(secret);
+ });
+
+ it('accepts the hard maximum without changing ordinary conversion semantics', () => {
+ expect(
+ htmlToMarkdown('safe', {
+ maxHtmlBytes: MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES,
+ }),
+ ).toBe('**safe**');
+ });
+});
diff --git a/src/markdown/serializer.ts b/src/markdown/serializer.ts
index 4141d32a..784dab55 100644
--- a/src/markdown/serializer.ts
+++ b/src/markdown/serializer.ts
@@ -14,6 +14,11 @@ import TurndownService from 'turndown';
import { gfm } from 'turndown-plugin-gfm';
import { validateInlineImageSource } from '../policy/inlineImagePolicy.js';
import { isSafeLinkHref } from '../policy/safeLinkPolicy.js';
+import {
+ HtmlToMarkdownResourceError,
+ assertHtmlToMarkdownInputSize,
+ resolveHtmlToMarkdownMaxBytes,
+} from './htmlToMarkdownResourcePolicy.js';
const SERIALIZED_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
@@ -142,7 +147,7 @@ function formatMarkdownTitle(value: string | null): string {
* inert browser template fragment before Turndown sees it.
*/
function sanitizeInertHtmlFragment(fragment: DocumentFragment): DocumentFragment {
- for (const element of Array.from(fragment.querySelectorAll('*'))) {
+ for (const element of fragment.querySelectorAll('*')) {
const elementName = element.localName;
if (REMOVED_HTML_ELEMENTS.has(elementName)) {
element.remove();
@@ -187,9 +192,11 @@ function sanitizeInertHtmlFragment(fragment: DocumentFragment): DocumentFragment
/** Parse browser HTML into an inert, detached template document fragment. */
function createInertBrowserFragment(html: string): DocumentFragment | null {
+ const browserWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
+ ?.value as Window | undefined;
/* v8 ignore next -- browserless runtimes must not touch ambient document. */
- if (typeof window === 'undefined') return null;
- const template = window.document.createElement('template');
+ if (!browserWindow) return null;
+ const template = browserWindow.document.createElement('template');
template.innerHTML = html;
return sanitizeInertHtmlFragment(template.content);
}
@@ -246,12 +253,59 @@ const turndownWithoutImageAlt = createTurndown(false);
export interface HtmlToMarkdownOptions {
/** Include image alternative text in converted Markdown. Defaults to true. */
includeImageAlt?: boolean;
+ /** Maximum UTF-8 bytes accepted before parsing. Defaults to 16 MiB. */
+ maxHtmlBytes?: number;
+}
+
+type ResolvedHtmlToMarkdownOptions = Record & {
+ includeImageAlt?: boolean;
+ maxHtmlBytes?: unknown;
+};
+
+const HTML_TO_MARKDOWN_OPTION_KEYS = new Set(['includeImageAlt', 'maxHtmlBytes']);
+
+/** Snapshot one runtime option bag without invoking caller accessors or coercions. */
+function resolveHtmlToMarkdownOptions(options: unknown): ResolvedHtmlToMarkdownOptions {
+ try {
+ if (typeof options !== 'object' || options === null || Array.isArray(options)) {
+ throw new HtmlToMarkdownResourceError('invalid_configuration');
+ }
+ const prototype = Object.getPrototypeOf(options);
+ if (prototype !== Object.prototype && prototype !== null) {
+ throw new HtmlToMarkdownResourceError('invalid_configuration');
+ }
+ const descriptors = Object.getOwnPropertyDescriptors(options);
+ const resolved = Object.create(null) as ResolvedHtmlToMarkdownOptions;
+ for (const key of Reflect.ownKeys(descriptors)) {
+ if (typeof key !== 'string' || !HTML_TO_MARKDOWN_OPTION_KEYS.has(key)) {
+ throw new HtmlToMarkdownResourceError('invalid_configuration');
+ }
+ const descriptor = descriptors[key];
+ if (!descriptor.enumerable || !('value' in descriptor)) {
+ throw new HtmlToMarkdownResourceError('invalid_configuration');
+ }
+ resolved[key] = descriptor.value;
+ }
+ if (
+ resolved.includeImageAlt !== undefined &&
+ typeof resolved.includeImageAlt !== 'boolean'
+ ) {
+ throw new HtmlToMarkdownResourceError('invalid_configuration');
+ }
+ return resolved;
+ } catch {
+ throw new HtmlToMarkdownResourceError('invalid_configuration');
+ }
}
/**
* Convert an HTML string to Markdown through an inert, fail-closed boundary.
*
- * In browsers, the raw string is parsed only inside a detached `` and
+ * The runtime option bag is snapshotted before source sizing or parser work so
+ * accessors, unknown keys, exotic prototypes, and malformed values cannot run
+ * caller code or silently alter policy. The input byte ceiling is then enforced
+ * before any browser DOM or browserless Turndown parser materialization. In
+ * browsers, accepted raw input is parsed only inside a detached `` and
* sanitized before the resulting `DocumentFragment` reaches Turndown. In
* browserless Node runtimes, Turndown 7 uses its non-fetching Domino parser.
* Both paths emit Markdown links only for Inkspan-safe targets and Markdown
@@ -261,8 +315,11 @@ export function htmlToMarkdown(
html: string,
options: HtmlToMarkdownOptions = {},
): string {
+ const resolvedOptions = resolveHtmlToMarkdownOptions(options);
+ const maxHtmlBytes = resolveHtmlToMarkdownMaxBytes(resolvedOptions.maxHtmlBytes);
+ assertHtmlToMarkdownInputSize(html, maxHtmlBytes);
const fragment = createInertBrowserFragment(html);
- const turndown = options.includeImageAlt === false
+ const turndown = resolvedOptions.includeImageAlt === false
? turndownWithoutImageAlt
: turndownWithImageAlt;
/* v8 ignore next 3 -- packed Node consumer verification exercises this DOM-free fallback. */
@@ -347,11 +404,11 @@ export function markdownToEmailHtml(
markdown: string,
options: MarkdownToEmailHtmlOptions = {},
): string {
- const body = markdownToHtml(markdown).trim();
- if (!options.fullDocument) return body;
+ if (!options.fullDocument) return markdownToHtml(markdown).trim();
const title = escapeHtml(options.title ?? 'Message');
const languageTag = canonicalizeEmailLanguageTag(options.languageTag);
const textDirection = validateEmailTextDirection(options.textDirection);
+ const body = markdownToHtml(markdown).trim();
const languageAttribute = languageTag
? ` lang="${escapeHtml(languageTag)}"`
: '';
@@ -376,4 +433,4 @@ function escapeHtml(value: string): string {
.replace(//g, '>')
.replace(/"/g, '"');
-}
+}
\ No newline at end of file
diff --git a/src/markdownPackage.test.ts b/src/markdownPackage.test.ts
index d46215d8..9f792876 100644
--- a/src/markdownPackage.test.ts
+++ b/src/markdownPackage.test.ts
@@ -3,6 +3,12 @@ import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
+ DEFAULT_HTML_TO_MARKDOWN_MAX_BYTES,
+ DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES,
+ MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES,
+ MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES,
+ HtmlToMarkdownResourceError,
+ MarkdownToHtmlResourceError,
htmlToMarkdown,
htmlToPlainText,
markdownToEmailHtml,
@@ -25,6 +31,26 @@ describe('headless deterministic Markdown package contract', () => {
it('executes the intended deterministic conversion surface through the source barrel', () => {
expect(markdownToHtml('**Alpha**')).toContain('Alpha');
expect(htmlToMarkdown('Alpha
')).toBe('Alpha');
+ expect(DEFAULT_HTML_TO_MARKDOWN_MAX_BYTES).toBe(16_777_216);
+ expect(MAXIMUM_HTML_TO_MARKDOWN_MAX_BYTES).toBe(67_108_864);
+ expect(HtmlToMarkdownResourceError).toBeInstanceOf(Function);
+ expect(DEFAULT_MARKDOWN_TO_HTML_MAX_BYTES).toBe(16_777_216);
+ expect(MAXIMUM_MARKDOWN_TO_HTML_MAX_BYTES).toBe(67_108_864);
+ expect(MarkdownToHtmlResourceError).toBeInstanceOf(Function);
+ expect(() =>
+ htmlToMarkdown('Alpha
', { maxHtmlBytes: 4 }),
+ ).toThrowError(
+ expect.objectContaining({
+ name: 'HtmlToMarkdownResourceError',
+ code: 'input_too_large',
+ }),
+ );
+ expect(() => markdownToHtml('Alpha', { maxMarkdownBytes: 4 })).toThrowError(
+ expect.objectContaining({
+ name: 'MarkdownToHtmlResourceError',
+ code: 'input_too_large',
+ }),
+ );
expect(normalizeMarkdown('**Alpha**')).toContain('**Alpha**');
expect(markdownToPlainText('[Alpha](https://example.com)')).toBe('Alpha');
expect(htmlToPlainText('Alpha
')).toBe('Alpha');
@@ -106,6 +132,10 @@ describe('headless deterministic Markdown package contract', () => {
expect(verifier).toContain('moduleAuthority.length');
expect(verifier).toContain('ambientAuthorityPattern');
expect(verifier).toContain('ambient document access is forbidden');
+ expect(verifier).toContain('maxHtmlBytes');
+ expect(verifier).toContain('HtmlToMarkdownResourceError');
+ expect(verifier).toContain('maxMarkdownBytes');
+ expect(verifier).toContain('MarkdownToHtmlResourceError');
expect(verifier).toContain('React');
expect(verifier).toContain('@tiptap');
expect(verifier).toContain('yjs');
diff --git a/src/policy/safeLinkPolicy.ts b/src/policy/safeLinkPolicy.ts
index 82ea5570..66d8c0d6 100644
--- a/src/policy/safeLinkPolicy.ts
+++ b/src/policy/safeLinkPolicy.ts
@@ -10,8 +10,28 @@ const SAFE_ABSOLUTE_SCHEMES = new Set(['http', 'https', 'mailto', 'tel']);
const URI_SCHEME_PATTERN = /^([a-z][a-z0-9+.-]*):/i;
const FORBIDDEN_LINK_CHARACTER_PATTERN = /[\u0000-\u0020\u007f-\u009f\\]/u;
+/** Default maximum UTF-8 byte length accepted for one hyperlink target. */
+export const DEFAULT_SAFE_LINK_MAX_HREF_BYTES = 65_536;
+/** Hard maximum caller-selected UTF-8 byte length for one hyperlink target. */
+export const MAXIMUM_SAFE_LINK_MAX_HREF_BYTES = 1_048_576;
+const SAFE_LINK_CONFIGURATION_KEYS = ['maxHrefBytes'] as const;
+
+/** Runtime configuration for one safe-link validation operation. */
+export interface SafeLinkValidationOptions {
+ /** Maximum UTF-8 bytes accepted before URI parsing. Defaults to 64 KiB. */
+ readonly maxHrefBytes?: number;
+}
+
+/** Machine-readable safe-link failure category. */
+export type SafeLinkHrefErrorCode =
+ | 'invalid_href'
+ | 'input_too_large'
+ | 'invalid_configuration';
+
/** Return a bounded, secret-free category for an untrusted hyperlink target. */
-function redactLinkHref(href: unknown): string {
+function redactLinkHref(href: unknown, code: SafeLinkHrefErrorCode): string {
+ if (code === 'input_too_large') return '';
+ if (code === 'invalid_configuration') return '';
if (typeof href !== 'string') return `<${typeof href}>`;
if (href.length === 0) return '';
if (href.startsWith('//')) return '//';
@@ -23,17 +43,87 @@ function redactLinkHref(href: unknown): string {
/** Error thrown when a hyperlink target violates Inkspan's safe-URI policy. */
export class SafeLinkHrefError extends Error {
+ /** Stable machine-readable failure category. */
+ readonly code: SafeLinkHrefErrorCode;
/** Redacted target category safe for logs and host telemetry. */
readonly hrefPreview: string;
- constructor(href: unknown) {
- const hrefPreview = redactLinkHref(href);
- super(`Link target violates the Inkspan safe-URI policy (${hrefPreview}).`);
+ constructor(
+ href: unknown,
+ code: SafeLinkHrefErrorCode = 'invalid_href',
+ ) {
+ const hrefPreview = redactLinkHref(href, code);
+ const message =
+ code === 'input_too_large'
+ ? `Link target exceeds the Inkspan resource boundary (${hrefPreview}).`
+ : code === 'invalid_configuration'
+ ? `Link validation configuration is invalid (${hrefPreview}).`
+ : `Link target violates the Inkspan safe-URI policy (${hrefPreview}).`;
+ super(message);
this.name = 'SafeLinkHrefError';
+ this.code = code;
this.hrefPreview = hrefPreview;
}
}
+/** Resolve and validate the caller-selected resource ceiling without invoking accessors. */
+function resolveSafeLinkMaxHrefBytes(options: unknown): number {
+ if (options === undefined) return DEFAULT_SAFE_LINK_MAX_HREF_BYTES;
+ try {
+ if (
+ typeof options !== 'object' ||
+ options === null ||
+ Array.isArray(options)
+ ) {
+ throw new TypeError('invalid configuration container');
+ }
+
+ const keys = Reflect.ownKeys(options);
+ if (
+ keys.some(
+ (key) =>
+ typeof key !== 'string' ||
+ !SAFE_LINK_CONFIGURATION_KEYS.includes(
+ key as (typeof SAFE_LINK_CONFIGURATION_KEYS)[number],
+ ),
+ )
+ ) {
+ throw new TypeError('unknown configuration key');
+ }
+ if (!keys.includes('maxHrefBytes')) return DEFAULT_SAFE_LINK_MAX_HREF_BYTES;
+
+ const descriptor = Object.getOwnPropertyDescriptor(options, 'maxHrefBytes');
+ if (!descriptor || !descriptor.enumerable || !('value' in descriptor)) {
+ throw new TypeError('invalid configuration property');
+ }
+ const value = descriptor.value as unknown;
+ if (value === undefined) return DEFAULT_SAFE_LINK_MAX_HREF_BYTES;
+ if (
+ typeof value !== 'number' ||
+ !Number.isSafeInteger(value) ||
+ value <= 0 ||
+ value > MAXIMUM_SAFE_LINK_MAX_HREF_BYTES
+ ) {
+ throw new TypeError('invalid resource ceiling');
+ }
+ return value;
+ } catch {
+ throw new SafeLinkHrefError(undefined, 'invalid_configuration');
+ }
+}
+
+/** Enforce one UTF-8 byte ceiling before URI parsing. */
+function assertSafeLinkResourceBound(href: string, maxHrefBytes: number): void {
+ // UTF-8 uses at least one byte per UTF-16 code unit. Reject the obvious
+ // oversize case before allocating a complete encoded copy.
+ if (href.length > maxHrefBytes) {
+ throw new SafeLinkHrefError(href, 'input_too_large');
+ }
+ if (new TextEncoder().encode(href).byteLength > maxHrefBytes) {
+ throw new SafeLinkHrefError(href, 'input_too_large');
+ }
+}
+
/** Validate an HTTP(S) URL and reject deceptive embedded credentials. */
function validateWebHref(href: string): void {
let parsed: URL;
@@ -54,8 +144,17 @@ function validateWebHref(href: string): void {
* document-relative/query/fragment references. Literal whitespace, control
* characters, and backslashes are rejected rather than canonicalized so an
* obfuscated executable scheme cannot acquire a different browser meaning.
+ * The complete target must fit the configured local UTF-8 resource ceiling
+ * before any HTTP(S) URL parser is invoked.
*/
-export function validateSafeLinkHref(href: unknown): string {
+export function validateSafeLinkHref(
+ href: unknown,
+ options?: SafeLinkValidationOptions,
+): string {
+ const maxHrefBytes = resolveSafeLinkMaxHrefBytes(options);
+ if (typeof href === 'string') {
+ assertSafeLinkResourceBound(href, maxHrefBytes);
+ }
if (
typeof href !== 'string' ||
href.length === 0 ||
@@ -85,9 +184,12 @@ export function validateSafeLinkHref(href: unknown): string {
}
/** Boolean adapter for callers that need predicate-style URI validation. */
-export function isSafeLinkHref(href: unknown): href is string {
+export function isSafeLinkHref(
+ href: unknown,
+ options?: SafeLinkValidationOptions,
+): href is string {
try {
- validateSafeLinkHref(href);
+ validateSafeLinkHref(href, options);
return true;
} catch {
return false;
diff --git a/vite.markdown.config.ts b/vite.markdown.config.ts
index b07d1d31..41f93fcc 100644
--- a/vite.markdown.config.ts
+++ b/vite.markdown.config.ts
@@ -8,6 +8,33 @@ const turndownStandaloneEntry = nodeRequire.resolve('turndown/lib/turndown.es.js
const dominoStandaloneEntry = createRequire(turndownStandaloneEntry).resolve(
'@mixmark-io/domino',
);
+const turndownAmbientWindowProbe =
+ /\bvar root = typeof window !== ['"]undefined['"] \? window : \{\};/u;
+
+// Turndown's standalone build still probes the ambient `window` binding during
+// module evaluation before it decides whether to use its bundled Domino parser.
+// A hostile accessor-backed global therefore executes caller code merely by
+// importing Inkspan's framework-free `/markdown` subpath. Replace only that
+// exact upstream probe with the empty root that selects the already-pinned,
+// non-fetching Domino path. Fail the build if the pinned upstream source shape
+// changes so a dependency update cannot silently restore ambient authority.
+const boundTurndownStandaloneParser = {
+ name: 'inkspan-bound-turndown-standalone-parser',
+ enforce: 'pre' as const,
+ transform(source: string, id: string) {
+ if (id.split('?', 1)[0] !== turndownStandaloneEntry) return null;
+ const matches = source.match(new RegExp(turndownAmbientWindowProbe.source, 'gu'));
+ if (matches?.length !== 1) {
+ throw new Error(
+ `Expected exactly one Turndown ambient window probe, found ${matches?.length ?? 0}.`,
+ );
+ }
+ return {
+ code: source.replace(turndownAmbientWindowProbe, 'var root = {};'),
+ map: null,
+ };
+ },
+};
// Headless deterministic serializer build: bundle the conversion dependencies so
// consumers can use the public subpath without importing the React/TipTap graph.
@@ -26,6 +53,7 @@ export default defineConfig({
mainFields: ['module', 'jsnext:main', 'jsnext', 'main'],
},
plugins: [
+ boundTurndownStandaloneParser,
dts({
include: [
'src/markdown',