@texuli/core 0.1.0-next.0 → 0.1.0-next.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -4
- package/dist/editor/document.d.ts +65 -0
- package/dist/editor/document.js +394 -0
- package/dist/editor/edit.d.ts +7 -0
- package/dist/editor/edit.js +7 -0
- package/dist/editor/graphemes.d.ts +17 -0
- package/dist/editor/graphemes.js +71 -0
- package/dist/editor/history.d.ts +57 -0
- package/dist/editor/history.js +83 -0
- package/dist/editor/inline.d.ts +56 -0
- package/dist/editor/inline.js +166 -0
- package/dist/editor/input-rules.d.ts +17 -0
- package/dist/editor/input-rules.js +151 -0
- package/dist/editor/keymap.d.ts +30 -0
- package/dist/editor/keymap.js +69 -0
- package/dist/editor/load.d.ts +44 -0
- package/dist/editor/load.js +248 -0
- package/dist/editor/markdown.d.ts +33 -0
- package/dist/editor/markdown.js +705 -0
- package/dist/editor/model.d.ts +13 -0
- package/dist/editor/model.js +13 -0
- package/dist/editor/selection.d.ts +27 -0
- package/dist/editor/selection.js +85 -0
- package/dist/editor/transforms.d.ts +93 -0
- package/dist/editor/transforms.js +847 -0
- package/dist/editor/tree.d.ts +35 -0
- package/dist/editor/tree.js +103 -0
- package/dist/editor/types.d.ts +147 -0
- package/dist/editor/types.js +39 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/url/safe-href.d.ts +1 -20
- package/dist/url/safe-href.js +29 -2
- package/dist/url-policy/index.d.ts +1 -0
- package/dist/url-policy/index.js +1 -0
- package/dist/url-policy/safe-image-src.d.ts +1 -1
- package/dist/url-policy/safe-image-src.js +4 -4
- package/dist/url-policy/safe-image-srcset.d.ts +1 -1
- package/dist/url-policy/safe-image-srcset.js +3 -1
- package/dist/url-policy/safe-media-src.d.ts +1 -1
- package/dist/url-policy/safe-media-src.js +4 -4
- package/dist/url-policy/shared.d.ts +8 -2
- package/dist/url-policy/shared.js +11 -6
- package/dist/url-policy/trust.d.ts +8 -0
- package/dist/url-policy/trust.js +9 -0
- package/package.json +22 -6
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type EditorLoadOptions } from './load.js';
|
|
2
|
+
import type { EditorInline, EditorIssue } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* The nested inline shape the sink-free renderers walk. Catalog rich text and
|
|
5
|
+
* blocks Markdown trees map to it one to one (`em`/`strong`/`link` nest;
|
|
6
|
+
* blocks call strike `del`, the catalog stores text in `value`).
|
|
7
|
+
*/
|
|
8
|
+
export type EditorInlineNode = {
|
|
9
|
+
type: 'text';
|
|
10
|
+
text: string;
|
|
11
|
+
} | {
|
|
12
|
+
type: 'code';
|
|
13
|
+
text: string;
|
|
14
|
+
} | {
|
|
15
|
+
type: 'break';
|
|
16
|
+
} | {
|
|
17
|
+
type: 'strong' | 'em' | 'strike';
|
|
18
|
+
children: EditorInlineNode[];
|
|
19
|
+
} | {
|
|
20
|
+
type: 'link';
|
|
21
|
+
href: string;
|
|
22
|
+
children: EditorInlineNode[];
|
|
23
|
+
};
|
|
24
|
+
/** Fold runs into nested nodes, outermost mark first (`link`, `strong`, `em`, `strike`). */
|
|
25
|
+
export declare function inlineToTree(content: readonly EditorInline[]): EditorInlineNode[];
|
|
26
|
+
/**
|
|
27
|
+
* Flatten a nested inline tree (this shape, the catalog's or the blocks
|
|
28
|
+
* Markdown tree) into runs. Untrusted by default: every href passes
|
|
29
|
+
* `safeHref` at `trust` (a refused link keeps its text), nesting past 6
|
|
30
|
+
* levels becomes text, and unknown nodes keep their text. Never throws.
|
|
31
|
+
*/
|
|
32
|
+
export declare function inlineFromTree(nodes: unknown, options?: EditorLoadOptions): {
|
|
33
|
+
content: readonly EditorInline[];
|
|
34
|
+
diagnostics: readonly EditorIssue[];
|
|
35
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { markKey, normalizeInline, sortMarks } from "./inline.js";
|
|
2
|
+
import { LoadBudget } from "./load.js";
|
|
3
|
+
/** Fold runs into nested nodes, outermost mark first (`link`, `strong`, `em`, `strike`). */
|
|
4
|
+
export function inlineToTree(content) {
|
|
5
|
+
const root = [];
|
|
6
|
+
const stack = [];
|
|
7
|
+
const children = () => stack.at(-1)?.node.children ?? root;
|
|
8
|
+
for (const run of content) {
|
|
9
|
+
if (run.type === 'break') {
|
|
10
|
+
stack.length = 0;
|
|
11
|
+
root.push({ type: 'break' });
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
const wanted = run.marks.filter((mark) => mark.type !== 'code');
|
|
15
|
+
let common = 0;
|
|
16
|
+
while (common < stack.length &&
|
|
17
|
+
common < wanted.length &&
|
|
18
|
+
markKey(stack[common].mark) ===
|
|
19
|
+
markKey(wanted[common]))
|
|
20
|
+
common += 1;
|
|
21
|
+
stack.length = common;
|
|
22
|
+
for (const mark of wanted.slice(common)) {
|
|
23
|
+
const node = mark.type === 'link'
|
|
24
|
+
? { type: 'link', href: mark.href, children: [] }
|
|
25
|
+
: { type: mark.type, children: [] };
|
|
26
|
+
children().push(node);
|
|
27
|
+
stack.push({ mark, node });
|
|
28
|
+
}
|
|
29
|
+
const code = run.marks.some((mark) => mark.type === 'code');
|
|
30
|
+
children().push({ type: code ? 'code' : 'text', text: run.text });
|
|
31
|
+
}
|
|
32
|
+
return root;
|
|
33
|
+
}
|
|
34
|
+
const TREE_DEPTH = 6;
|
|
35
|
+
/** Plain text of a node, at most 64 levels down (deeper text is dropped). */
|
|
36
|
+
function textOf(node, depth = 0) {
|
|
37
|
+
if (depth > 64 || typeof node !== 'object' || node === null)
|
|
38
|
+
return '';
|
|
39
|
+
const { text, value, children } = node;
|
|
40
|
+
if (typeof text === 'string')
|
|
41
|
+
return text;
|
|
42
|
+
if (typeof value === 'string')
|
|
43
|
+
return value;
|
|
44
|
+
return Array.isArray(children)
|
|
45
|
+
? children
|
|
46
|
+
.slice(0, 4_096)
|
|
47
|
+
.map((child) => textOf(child, depth + 1))
|
|
48
|
+
.join('')
|
|
49
|
+
: '';
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Flatten a nested inline tree (this shape, the catalog's or the blocks
|
|
53
|
+
* Markdown tree) into runs. Untrusted by default: every href passes
|
|
54
|
+
* `safeHref` at `trust` (a refused link keeps its text), nesting past 6
|
|
55
|
+
* levels becomes text, and unknown nodes keep their text. Never throws.
|
|
56
|
+
*/
|
|
57
|
+
export function inlineFromTree(nodes, options = {}) {
|
|
58
|
+
const budget = new LoadBudget(options);
|
|
59
|
+
const out = [];
|
|
60
|
+
const visit = (list, marks, depth) => {
|
|
61
|
+
if (!Array.isArray(list))
|
|
62
|
+
return;
|
|
63
|
+
for (const raw of list) {
|
|
64
|
+
if (typeof raw !== 'object' || raw === null)
|
|
65
|
+
continue;
|
|
66
|
+
const node = raw;
|
|
67
|
+
const text = typeof node.text === 'string'
|
|
68
|
+
? node.text
|
|
69
|
+
: typeof node.value === 'string'
|
|
70
|
+
? node.value
|
|
71
|
+
: undefined;
|
|
72
|
+
if (node.type === 'break') {
|
|
73
|
+
out.push({ type: 'break' });
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if ((node.type === 'text' || node.type === 'code') && text !== undefined) {
|
|
77
|
+
out.push(...budget.inline(text, node.type === 'code' ? sortMarks([...marks, { type: 'code' }]) : marks));
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (depth >= TREE_DEPTH) {
|
|
81
|
+
budget.report('depth-capped', 'Inline nesting deeper than 6 levels became text.');
|
|
82
|
+
out.push(...budget.inline(textOf(node), marks));
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const type = node.type === 'del' ? 'strike' : node.type;
|
|
86
|
+
if (type === 'strong' || type === 'em' || type === 'strike') {
|
|
87
|
+
visit(node.children, sortMarks([...marks, { type }]), depth + 1);
|
|
88
|
+
}
|
|
89
|
+
else if (type === 'link') {
|
|
90
|
+
const href = budget.href(node.href);
|
|
91
|
+
visit(node.children, href ? sortMarks([...marks, { type: 'link', href }]) : marks, depth + 1);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
budget.report('unknown-node', 'Unknown inline nodes kept only their text.');
|
|
95
|
+
if (text !== undefined)
|
|
96
|
+
out.push(...budget.inline(text, marks));
|
|
97
|
+
visit(node.children, marks, depth + 1);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
visit(nodes, [], 0);
|
|
102
|
+
return { content: normalizeInline(out), diagnostics: budget.diagnostics };
|
|
103
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rich text editor document model, version 1 (ADR-003). Framework-free
|
|
3
|
+
* data: no DOM, no HTML. A document is a tree of blocks; text blocks hold
|
|
4
|
+
* flat runs of text with mark sets. Link hrefs are data here: they passed
|
|
5
|
+
* `safeHref` when they entered the model, and the binding renders them
|
|
6
|
+
* through the URL policy again.
|
|
7
|
+
*/
|
|
8
|
+
/** Runtime-only block id: assigned on load, never serialized. */
|
|
9
|
+
export type EditorBlockId = string;
|
|
10
|
+
export type EditorMark = {
|
|
11
|
+
type: 'strong';
|
|
12
|
+
} | {
|
|
13
|
+
type: 'em';
|
|
14
|
+
} | {
|
|
15
|
+
type: 'strike';
|
|
16
|
+
} | {
|
|
17
|
+
type: 'code';
|
|
18
|
+
} | {
|
|
19
|
+
type: 'link';
|
|
20
|
+
href: string;
|
|
21
|
+
};
|
|
22
|
+
export type EditorMarkType = EditorMark['type'];
|
|
23
|
+
export type EditorInline = {
|
|
24
|
+
type: 'text';
|
|
25
|
+
text: string;
|
|
26
|
+
marks: readonly EditorMark[];
|
|
27
|
+
} | {
|
|
28
|
+
type: 'break';
|
|
29
|
+
};
|
|
30
|
+
export type EditorHeadingLevel = 1 | 2 | 3 | 4;
|
|
31
|
+
export type EditorParagraph = {
|
|
32
|
+
id: EditorBlockId;
|
|
33
|
+
type: 'paragraph';
|
|
34
|
+
content: readonly EditorInline[];
|
|
35
|
+
};
|
|
36
|
+
export type EditorHeading = {
|
|
37
|
+
id: EditorBlockId;
|
|
38
|
+
type: 'heading';
|
|
39
|
+
level: EditorHeadingLevel;
|
|
40
|
+
content: readonly EditorInline[];
|
|
41
|
+
};
|
|
42
|
+
export type EditorCodeBlock = {
|
|
43
|
+
id: EditorBlockId;
|
|
44
|
+
type: 'code';
|
|
45
|
+
lang?: string;
|
|
46
|
+
text: string;
|
|
47
|
+
};
|
|
48
|
+
/** Blocks that hold inline content (marks allowed). */
|
|
49
|
+
export type EditorRichBlock = EditorParagraph | EditorHeading;
|
|
50
|
+
/** Blocks a caret can be in. */
|
|
51
|
+
export type EditorTextBlock = EditorRichBlock | EditorCodeBlock;
|
|
52
|
+
export type EditorListKind = 'bullet' | 'ordered' | 'todo';
|
|
53
|
+
export type EditorListItem = {
|
|
54
|
+
id: EditorBlockId;
|
|
55
|
+
/** Todo lists only. */
|
|
56
|
+
checked?: boolean;
|
|
57
|
+
/** The first block is always a paragraph. */
|
|
58
|
+
blocks: readonly EditorBlock[];
|
|
59
|
+
};
|
|
60
|
+
export type EditorList = {
|
|
61
|
+
id: EditorBlockId;
|
|
62
|
+
type: 'list';
|
|
63
|
+
kind: EditorListKind;
|
|
64
|
+
/** Ordered lists only; 1 when omitted. */
|
|
65
|
+
start?: number;
|
|
66
|
+
items: readonly EditorListItem[];
|
|
67
|
+
};
|
|
68
|
+
export type EditorQuote = {
|
|
69
|
+
id: EditorBlockId;
|
|
70
|
+
type: 'quote';
|
|
71
|
+
blocks: readonly EditorBlock[];
|
|
72
|
+
};
|
|
73
|
+
export type EditorDivider = {
|
|
74
|
+
id: EditorBlockId;
|
|
75
|
+
type: 'divider';
|
|
76
|
+
};
|
|
77
|
+
/** A host-resolved media reference; the model never loads it. */
|
|
78
|
+
export type EditorMedia = {
|
|
79
|
+
id: EditorBlockId;
|
|
80
|
+
type: 'media';
|
|
81
|
+
ref: string;
|
|
82
|
+
alt: string;
|
|
83
|
+
};
|
|
84
|
+
export type EditorBlock = EditorTextBlock | EditorList | EditorQuote | EditorDivider | EditorMedia;
|
|
85
|
+
export type EditorBlockType = EditorBlock['type'];
|
|
86
|
+
export type EditorDocument = {
|
|
87
|
+
version: 1;
|
|
88
|
+
blocks: readonly EditorBlock[];
|
|
89
|
+
};
|
|
90
|
+
/** A caret position: a text block and a UTF-16 offset into its text (a break counts 1). */
|
|
91
|
+
export type EditorPoint = {
|
|
92
|
+
block: EditorBlockId;
|
|
93
|
+
offset: number;
|
|
94
|
+
};
|
|
95
|
+
export type EditorSelection = {
|
|
96
|
+
kind: 'text';
|
|
97
|
+
anchor: EditorPoint;
|
|
98
|
+
focus: EditorPoint;
|
|
99
|
+
} | {
|
|
100
|
+
kind: 'node';
|
|
101
|
+
block: EditorBlockId;
|
|
102
|
+
};
|
|
103
|
+
/** What transforms and history work on. */
|
|
104
|
+
export interface EditorState {
|
|
105
|
+
doc: EditorDocument;
|
|
106
|
+
selection: EditorSelection;
|
|
107
|
+
/** Marks the next typed text gets (set by a mark toggle at a caret). */
|
|
108
|
+
storedMarks?: readonly EditorMark[] | null;
|
|
109
|
+
}
|
|
110
|
+
export type EditorIssueCode = 'invalid-shape' | 'unknown-node' | 'unknown-mark' | 'version-unsupported' | 'depth-capped' | 'blocks-capped' | 'text-truncated' | 'link-rejected' | 'mark-dropped' | 'characters-stripped' | 'heading-clamped' | 'not-normalized' | 'duplicate-id' | 'invalid-selection' | 'not-allowed-here' | 'nothing-to-do' | 'limit-reached';
|
|
111
|
+
export interface EditorIssue {
|
|
112
|
+
code: EditorIssueCode;
|
|
113
|
+
message: string;
|
|
114
|
+
/** The block concerned, when there is one. */
|
|
115
|
+
block?: EditorBlockId;
|
|
116
|
+
}
|
|
117
|
+
export type EditorTransformResult = {
|
|
118
|
+
ok: true;
|
|
119
|
+
state: EditorState;
|
|
120
|
+
diagnostics?: readonly EditorIssue[];
|
|
121
|
+
} | {
|
|
122
|
+
ok: false;
|
|
123
|
+
errors: readonly EditorIssue[];
|
|
124
|
+
};
|
|
125
|
+
/** Caps: hostile input degrades (diagnostics), never throws or recurses without bound. */
|
|
126
|
+
export declare const EDITOR_LIMITS: Readonly<{
|
|
127
|
+
/** Blocks in a document, containers included. */
|
|
128
|
+
blocks: 2000;
|
|
129
|
+
/** Characters of text in a document. */
|
|
130
|
+
chars: 200000;
|
|
131
|
+
/** Nested lists and quotes around a block. */
|
|
132
|
+
depth: 4;
|
|
133
|
+
/** Longest link href or media ref kept. */
|
|
134
|
+
href: 2048;
|
|
135
|
+
/** Longest code block language name. */
|
|
136
|
+
lang: 32;
|
|
137
|
+
/** History snapshots kept. */
|
|
138
|
+
history: 200;
|
|
139
|
+
}>;
|
|
140
|
+
export type EditorLimits = {
|
|
141
|
+
blocks: number;
|
|
142
|
+
chars: number;
|
|
143
|
+
depth: number;
|
|
144
|
+
};
|
|
145
|
+
/** Canonical mark order: a run's marks are always sorted this way. */
|
|
146
|
+
export declare const EDITOR_MARK_ORDER: readonly EditorMarkType[];
|
|
147
|
+
export declare const EDITOR_BLOCK_TYPES: readonly EditorBlockType[];
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rich text editor document model, version 1 (ADR-003). Framework-free
|
|
3
|
+
* data: no DOM, no HTML. A document is a tree of blocks; text blocks hold
|
|
4
|
+
* flat runs of text with mark sets. Link hrefs are data here: they passed
|
|
5
|
+
* `safeHref` when they entered the model, and the binding renders them
|
|
6
|
+
* through the URL policy again.
|
|
7
|
+
*/
|
|
8
|
+
/** Caps: hostile input degrades (diagnostics), never throws or recurses without bound. */
|
|
9
|
+
export const EDITOR_LIMITS = Object.freeze({
|
|
10
|
+
/** Blocks in a document, containers included. */
|
|
11
|
+
blocks: 2_000,
|
|
12
|
+
/** Characters of text in a document. */
|
|
13
|
+
chars: 200_000,
|
|
14
|
+
/** Nested lists and quotes around a block. */
|
|
15
|
+
depth: 4,
|
|
16
|
+
/** Longest link href or media ref kept. */
|
|
17
|
+
href: 2_048,
|
|
18
|
+
/** Longest code block language name. */
|
|
19
|
+
lang: 32,
|
|
20
|
+
/** History snapshots kept. */
|
|
21
|
+
history: 200,
|
|
22
|
+
});
|
|
23
|
+
/** Canonical mark order: a run's marks are always sorted this way. */
|
|
24
|
+
export const EDITOR_MARK_ORDER = [
|
|
25
|
+
'link',
|
|
26
|
+
'strong',
|
|
27
|
+
'em',
|
|
28
|
+
'strike',
|
|
29
|
+
'code',
|
|
30
|
+
];
|
|
31
|
+
export const EDITOR_BLOCK_TYPES = [
|
|
32
|
+
'paragraph',
|
|
33
|
+
'heading',
|
|
34
|
+
'code',
|
|
35
|
+
'list',
|
|
36
|
+
'quote',
|
|
37
|
+
'divider',
|
|
38
|
+
'media',
|
|
39
|
+
];
|
package/dist/index.d.ts
CHANGED
|
@@ -23,3 +23,4 @@ export { safeImageSrc } from './url-policy/safe-image-src.js';
|
|
|
23
23
|
export { safeImageSrcset } from './url-policy/safe-image-srcset.js';
|
|
24
24
|
export { type SafeMediaSrcOptions, safeMediaSrc } from './url-policy/safe-media-src.js';
|
|
25
25
|
export { MAX_DATA_IMAGE_BYTES } from './url-policy/shared.js';
|
|
26
|
+
export { resolveTrust } from './url-policy/trust.js';
|
package/dist/index.js
CHANGED
|
@@ -22,3 +22,4 @@ export { safeImageSrc } from "./url-policy/safe-image-src.js";
|
|
|
22
22
|
export { safeImageSrcset } from "./url-policy/safe-image-srcset.js";
|
|
23
23
|
export { safeMediaSrc } from "./url-policy/safe-media-src.js";
|
|
24
24
|
export { MAX_DATA_IMAGE_BYTES } from "./url-policy/shared.js";
|
|
25
|
+
export { resolveTrust } from "./url-policy/trust.js";
|
package/dist/url/safe-href.d.ts
CHANGED
|
@@ -1,22 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* URL policy for every link a Texuli component renders.
|
|
3
|
-
*
|
|
4
|
-
* Two trust tiers:
|
|
5
|
-
* - `developer`: props written by the app's own developers. Every scheme is
|
|
6
|
-
* allowed except the ones that execute or smuggle content: `javascript:`,
|
|
7
|
-
* `vbscript:`, `data:` and protocol-relative `//host` URLs.
|
|
8
|
-
* - `untrusted`: content an agent or a user produced. Only `https:`,
|
|
9
|
-
* `mailto:` and same-document `#fragment` links pass, and additionally:
|
|
10
|
-
* - no invisible formatting characters anywhere (zero-width, bidi
|
|
11
|
-
* overrides and isolates, BOM, soft hyphen): they make a link read
|
|
12
|
-
* differently from where it goes;
|
|
13
|
-
* - `https:` links use the authority form (`https://host`) and parse to a
|
|
14
|
-
* host, so `https:evil.example` is refused;
|
|
15
|
-
* - no credentials before the host (`https://bank.example@evil.example`
|
|
16
|
-
* shows a trusted name while it opens another origin).
|
|
17
|
-
*
|
|
18
|
-
* A rejected value returns `undefined`, so the caller renders inert text.
|
|
19
|
-
*/
|
|
20
1
|
export type HrefTrust = 'developer' | 'untrusted';
|
|
21
2
|
/**
|
|
22
3
|
* Whether `value` holds an invisible formatting character: soft hyphen,
|
|
@@ -25,4 +6,4 @@ export type HrefTrust = 'developer' | 'untrusted';
|
|
|
25
6
|
* operators, or a byte-order mark. Checked by code point.
|
|
26
7
|
*/
|
|
27
8
|
export declare function hasInvisibleFormatting(value: string): boolean;
|
|
28
|
-
export declare function safeHref(value: unknown,
|
|
9
|
+
export declare function safeHref(value: unknown, requested?: HrefTrust): string | undefined;
|
package/dist/url/safe-href.js
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL policy for every link a Texuli component renders.
|
|
3
|
+
*
|
|
4
|
+
* Two trust tiers:
|
|
5
|
+
* - `developer`: props written by the app's own developers. Every scheme is
|
|
6
|
+
* allowed except the ones that execute or smuggle content: `javascript:`,
|
|
7
|
+
* `vbscript:`, `data:` and protocol-relative URLs: any value opening with
|
|
8
|
+
* two of `/` and `\` (`//host`, `/\host`, `\/host`, `\\host`) or with a
|
|
9
|
+
* backslash.
|
|
10
|
+
* - `untrusted`: content an agent or a user produced. Only `https:`,
|
|
11
|
+
* `mailto:` and same-document `#fragment` links pass, and additionally:
|
|
12
|
+
* - no invisible formatting characters anywhere (zero-width, bidi
|
|
13
|
+
* overrides and isolates, BOM, soft hyphen): they make a link read
|
|
14
|
+
* differently from where it goes;
|
|
15
|
+
* - `https:` links use the authority form (`https://host`) and parse to a
|
|
16
|
+
* host, so `https:evil.example` is refused;
|
|
17
|
+
* - no credentials before the host (`https://bank.example@evil.example`
|
|
18
|
+
* shows a trusted name while it opens another origin).
|
|
19
|
+
*
|
|
20
|
+
* Any trust value other than the exact string `'developer'` is treated as
|
|
21
|
+
* `untrusted` (`resolveTrust`). A rejected value returns `undefined`, so the
|
|
22
|
+
* caller renders inert text.
|
|
23
|
+
*/
|
|
24
|
+
import { hasAuthorityPrefix } from "../url-policy/shared.js";
|
|
25
|
+
import { resolveTrust } from "../url-policy/trust.js";
|
|
1
26
|
const BLOCKED_SCHEMES = new Set(['javascript:', 'vbscript:', 'data:']);
|
|
2
27
|
const UNTRUSTED_SCHEMES = new Set(['https:', 'mailto:']);
|
|
3
28
|
/**
|
|
@@ -32,7 +57,8 @@ function isPlainHttpsLink(href) {
|
|
|
32
57
|
return false;
|
|
33
58
|
}
|
|
34
59
|
}
|
|
35
|
-
export function safeHref(value,
|
|
60
|
+
export function safeHref(value, requested = 'untrusted') {
|
|
61
|
+
const trust = resolveTrust(requested);
|
|
36
62
|
if (typeof value !== 'string')
|
|
37
63
|
return undefined;
|
|
38
64
|
// Browsers drop ASCII whitespace and control characters inside a scheme.
|
|
@@ -44,7 +70,8 @@ export function safeHref(value, trust = 'untrusted') {
|
|
|
44
70
|
return undefined;
|
|
45
71
|
if (href.startsWith('#'))
|
|
46
72
|
return href;
|
|
47
|
-
|
|
73
|
+
// `//`, `/\`, `\/` and `\\` all open another origin; so does any leading `\`.
|
|
74
|
+
if (hasAuthorityPrefix(href))
|
|
48
75
|
return undefined;
|
|
49
76
|
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(href)?.[1]?.toLowerCase();
|
|
50
77
|
if (trust === 'untrusted') {
|
|
@@ -3,3 +3,4 @@ export { safeImageSrc } from './safe-image-src.js';
|
|
|
3
3
|
export { safeImageSrcset } from './safe-image-srcset.js';
|
|
4
4
|
export { type SafeMediaSrcOptions, safeMediaSrc } from './safe-media-src.js';
|
|
5
5
|
export { MAX_DATA_IMAGE_BYTES } from './shared.js';
|
|
6
|
+
export { resolveTrust } from './trust.js';
|
package/dist/url-policy/index.js
CHANGED
|
@@ -4,4 +4,4 @@ import { type HrefTrust } from '../url/safe-href.js';
|
|
|
4
4
|
* relative asset path; developer-authored images may also use HTTP, blob URLs,
|
|
5
5
|
* or a bounded base64 raster data image. Unsafe values return `undefined`.
|
|
6
6
|
*/
|
|
7
|
-
export declare function safeImageSrc(value: unknown,
|
|
7
|
+
export declare function safeImageSrc(value: unknown, requested?: HrefTrust): string | undefined;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { hasInvisibleFormatting } from "../url/safe-href.js";
|
|
2
|
-
import { cleanSource, isAllowedRelativeOrWebSource, isBlockedSource, isSafeDataImage, isValidBlobSource,
|
|
2
|
+
import { cleanSource, isAllowedRelativeOrWebSource, isBlockedSource, isSafeDataImage, isValidBlobSource, sourceScheme, } from "./shared.js";
|
|
3
|
+
import { resolveTrust } from "./trust.js";
|
|
3
4
|
/**
|
|
4
5
|
* A relative image path an agent may use: no invisible formatting
|
|
5
6
|
* characters, and no colon in its first segment. RFC 3986 already requires
|
|
@@ -20,9 +21,8 @@ function isPlainRelativePath(source) {
|
|
|
20
21
|
* relative asset path; developer-authored images may also use HTTP, blob URLs,
|
|
21
22
|
* or a bounded base64 raster data image. Unsafe values return `undefined`.
|
|
22
23
|
*/
|
|
23
|
-
export function safeImageSrc(value,
|
|
24
|
-
|
|
25
|
-
return undefined;
|
|
24
|
+
export function safeImageSrc(value, requested = 'untrusted') {
|
|
25
|
+
const trust = resolveTrust(requested);
|
|
26
26
|
if (typeof value === 'string') {
|
|
27
27
|
const dataSource = value.trim();
|
|
28
28
|
if (sourceScheme(dataSource) === 'data') {
|
|
@@ -4,4 +4,4 @@ import type { HrefTrust } from '../url/safe-href.js';
|
|
|
4
4
|
* standard comma-separated URL plus optional `x` or `w` descriptor form. If
|
|
5
5
|
* any part is malformed or unsafe, the entire value returns `undefined`.
|
|
6
6
|
*/
|
|
7
|
-
export declare function safeImageSrcset(value: unknown,
|
|
7
|
+
export declare function safeImageSrcset(value: unknown, requested?: HrefTrust): string | undefined;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { safeImageSrc } from "./safe-image-src.js";
|
|
2
|
+
import { resolveTrust } from "./trust.js";
|
|
2
3
|
/**
|
|
3
4
|
* Validate every URL and descriptor in an image `srcset`. Candidates use the
|
|
4
5
|
* standard comma-separated URL plus optional `x` or `w` descriptor form. If
|
|
5
6
|
* any part is malformed or unsafe, the entire value returns `undefined`.
|
|
6
7
|
*/
|
|
7
|
-
export function safeImageSrcset(value,
|
|
8
|
+
export function safeImageSrcset(value, requested = 'untrusted') {
|
|
9
|
+
const trust = resolveTrust(requested);
|
|
8
10
|
if (typeof value !== 'string' || value.trim() === '' || value.length > 16_384)
|
|
9
11
|
return undefined;
|
|
10
12
|
const candidates = value.split(',').map((candidate) => candidate.trim());
|
|
@@ -9,4 +9,4 @@ export type SafeMediaSrcOptions = {
|
|
|
9
9
|
* come from an explicitly allowlisted HTTPS origin. Developer media may also
|
|
10
10
|
* use HTTP and relative asset paths. `data:` is never accepted for media.
|
|
11
11
|
*/
|
|
12
|
-
export declare function safeMediaSrc(value: unknown,
|
|
12
|
+
export declare function safeMediaSrc(value: unknown, requested?: HrefTrust, options?: SafeMediaSrcOptions): string | undefined;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import { cleanSource, isBlockedSource, isValidBlobSource,
|
|
1
|
+
import { cleanSource, isBlockedSource, isValidBlobSource, normalizedHttpsOrigins, sourceScheme, } from "./shared.js";
|
|
2
|
+
import { resolveTrust } from "./trust.js";
|
|
2
3
|
/**
|
|
3
4
|
* Validate a URL for audio or video. Untrusted media must be a blob URL or
|
|
4
5
|
* come from an explicitly allowlisted HTTPS origin. Developer media may also
|
|
5
6
|
* use HTTP and relative asset paths. `data:` is never accepted for media.
|
|
6
7
|
*/
|
|
7
|
-
export function safeMediaSrc(value,
|
|
8
|
-
|
|
9
|
-
return undefined;
|
|
8
|
+
export function safeMediaSrc(value, requested = 'untrusted', options = {}) {
|
|
9
|
+
const trust = resolveTrust(requested);
|
|
10
10
|
const source = cleanSource(value);
|
|
11
11
|
if (!source || isBlockedSource(source))
|
|
12
12
|
return undefined;
|
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
import type { HrefTrust } from '../url/safe-href.js';
|
|
2
1
|
export declare function cleanSource(value: unknown): string | undefined;
|
|
2
|
+
/**
|
|
3
|
+
* Whether a URL opens with a backslash or with `//` or `/\`. Browsers read
|
|
4
|
+
* `\` as `/` in http(s) URLs, so `//host`, `/\host`, `\/host` and `\\host`
|
|
5
|
+
* are all protocol-relative and leave the page's origin (`/\evil.example`
|
|
6
|
+
* resolves to `https://evil.example/`). Callers pass the value after
|
|
7
|
+
* trimming and stripping control characters.
|
|
8
|
+
*/
|
|
9
|
+
export declare function hasAuthorityPrefix(value: string): boolean;
|
|
3
10
|
export declare function sourceScheme(value: string): string | undefined;
|
|
4
11
|
export declare function isBlockedSource(value: string): boolean;
|
|
5
12
|
export declare function isAllowedRelativeOrWebSource(value: string): boolean;
|
|
6
13
|
export declare function isValidBlobSource(value: string): boolean;
|
|
7
|
-
export declare function isValidTrust(trust: HrefTrust): boolean;
|
|
8
14
|
export declare function normalizedHttpsOrigins(origins: readonly string[] | undefined): Set<string>;
|
|
9
15
|
export declare function isSafeDataImage(value: string): boolean;
|
|
10
16
|
/** Maximum decoded size of a developer-authored base64 data image. */
|
|
@@ -11,14 +11,22 @@ export function cleanSource(value) {
|
|
|
11
11
|
}
|
|
12
12
|
return source;
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Whether a URL opens with a backslash or with `//` or `/\`. Browsers read
|
|
16
|
+
* `\` as `/` in http(s) URLs, so `//host`, `/\host`, `\/host` and `\\host`
|
|
17
|
+
* are all protocol-relative and leave the page's origin (`/\evil.example`
|
|
18
|
+
* resolves to `https://evil.example/`). Callers pass the value after
|
|
19
|
+
* trimming and stripping control characters.
|
|
20
|
+
*/
|
|
21
|
+
export function hasAuthorityPrefix(value) {
|
|
22
|
+
return /^(\\|\/[/\\])/.test(value);
|
|
23
|
+
}
|
|
14
24
|
export function sourceScheme(value) {
|
|
15
25
|
return /^([a-z][a-z0-9+.-]*):/i.exec(value)?.[1]?.toLowerCase();
|
|
16
26
|
}
|
|
17
27
|
export function isBlockedSource(value) {
|
|
18
28
|
const scheme = sourceScheme(value);
|
|
19
|
-
return (
|
|
20
|
-
value.startsWith('//') ||
|
|
21
|
-
value.startsWith('\\'));
|
|
29
|
+
return (scheme !== undefined && BLOCKED_SCHEMES.has(`${scheme}:`)) || hasAuthorityPrefix(value);
|
|
22
30
|
}
|
|
23
31
|
export function isAllowedRelativeOrWebSource(value) {
|
|
24
32
|
const scheme = sourceScheme(value);
|
|
@@ -45,9 +53,6 @@ export function isValidBlobSource(value) {
|
|
|
45
53
|
return false;
|
|
46
54
|
}
|
|
47
55
|
}
|
|
48
|
-
export function isValidTrust(trust) {
|
|
49
|
-
return trust === 'developer' || trust === 'untrusted';
|
|
50
|
-
}
|
|
51
56
|
export function normalizedHttpsOrigins(origins) {
|
|
52
57
|
const normalized = new Set();
|
|
53
58
|
if (!origins)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { HrefTrust } from '../url/safe-href.js';
|
|
2
|
+
/**
|
|
3
|
+
* The trust tier a caller asked for, failing closed: only the exact string
|
|
4
|
+
* `'developer'` is the developer tier. Anything else (another case, an empty
|
|
5
|
+
* string, `null`, a value read from config) is `'untrusted'`. Every URL policy
|
|
6
|
+
* entry point resolves its tier through this one rule.
|
|
7
|
+
*/
|
|
8
|
+
export declare function resolveTrust(value: unknown): HrefTrust;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The trust tier a caller asked for, failing closed: only the exact string
|
|
3
|
+
* `'developer'` is the developer tier. Anything else (another case, an empty
|
|
4
|
+
* string, `null`, a value read from config) is `'untrusted'`. Every URL policy
|
|
5
|
+
* entry point resolves its tier through this one rule.
|
|
6
|
+
*/
|
|
7
|
+
export function resolveTrust(value) {
|
|
8
|
+
return value === 'developer' ? 'developer' : 'untrusted';
|
|
9
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@texuli/core",
|
|
3
|
-
"version": "0.1.0-next.
|
|
3
|
+
"version": "0.1.0-next.2",
|
|
4
4
|
"description": "Texels: framework-free behavior and policy that every Texuli component is built from.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"svelte",
|
|
7
|
+
"svelte-5",
|
|
8
|
+
"components",
|
|
9
|
+
"design-system",
|
|
10
|
+
"texuli"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/DailybotHQ/texuli#readme",
|
|
13
|
+
"bugs": "https://github.com/DailybotHQ/texuli/issues",
|
|
5
14
|
"license": "MIT",
|
|
15
|
+
"author": "Dailybot, Inc.",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/DailybotHQ/texuli.git",
|
|
19
|
+
"directory": "packages/core"
|
|
20
|
+
},
|
|
6
21
|
"type": "module",
|
|
7
22
|
"sideEffects": [
|
|
8
23
|
"**/*.css"
|
|
@@ -13,6 +28,11 @@
|
|
|
13
28
|
"./controllable": "./dist/controllable/controllable-state.js",
|
|
14
29
|
"./dir-locale": "./dist/dir-locale/dir-locale.js",
|
|
15
30
|
"./dismiss": "./dist/dismiss/index.js",
|
|
31
|
+
"./editor-history": "./dist/editor/history.js",
|
|
32
|
+
"./editor-keymap": "./dist/editor/keymap.js",
|
|
33
|
+
"./editor-markdown": "./dist/editor/markdown.js",
|
|
34
|
+
"./editor-model": "./dist/editor/model.js",
|
|
35
|
+
"./editor-transforms": "./dist/editor/edit.js",
|
|
16
36
|
"./env": "./dist/env/index.js",
|
|
17
37
|
"./focus-scope": "./dist/focus-scope/index.js",
|
|
18
38
|
"./form-bridge": "./dist/form-bridge/form-bridge.js",
|
|
@@ -34,9 +54,5 @@
|
|
|
34
54
|
},
|
|
35
55
|
"files": [
|
|
36
56
|
"dist"
|
|
37
|
-
]
|
|
38
|
-
"publishConfig": {
|
|
39
|
-
"access": "public",
|
|
40
|
-
"tag": "next"
|
|
41
|
-
}
|
|
57
|
+
]
|
|
42
58
|
}
|