@texuli/core 0.5.0 → 0.7.0
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 +29 -5
- package/dist/editor/document.js +8 -1
- package/dist/editor/extensions.d.ts +64 -0
- package/dist/editor/extensions.js +116 -0
- package/dist/editor/inline.d.ts +5 -3
- package/dist/editor/inline.js +24 -12
- package/dist/editor/load.d.ts +10 -0
- package/dist/editor/load.js +16 -0
- package/dist/editor/markdown.d.ts +10 -1
- package/dist/editor/markdown.js +193 -83
- package/dist/editor/model.d.ts +3 -1
- package/dist/editor/model.js +3 -1
- package/dist/editor/transforms.d.ts +1 -1
- package/dist/editor/tree.d.ts +11 -1
- package/dist/editor/tree.js +11 -2
- package/dist/editor/types.d.ts +10 -0
- package/dist/editor/types.js +1 -0
- package/dist/inert/inert.d.ts +11 -0
- package/dist/inert/inert.js +5 -3
- package/dist/markup/index.d.ts +5 -0
- package/dist/markup/index.js +5 -0
- package/dist/markup/markup.d.ts +72 -0
- package/dist/markup/markup.js +233 -0
- package/dist/motion/animate.d.ts +109 -0
- package/dist/motion/animate.js +225 -0
- package/dist/motion/flip.d.ts +58 -0
- package/dist/motion/flip.js +167 -0
- package/dist/motion/index.d.ts +15 -0
- package/dist/motion/index.js +15 -0
- package/dist/motion/preferences.d.ts +68 -0
- package/dist/motion/preferences.js +111 -0
- package/dist/motion/presence.d.ts +61 -0
- package/dist/motion/presence.js +231 -0
- package/dist/motion/sequence.d.ts +70 -0
- package/dist/motion/sequence.js +159 -0
- package/dist/motion/shared.d.ts +45 -0
- package/dist/motion/shared.js +144 -0
- package/dist/motion/spring.d.ts +109 -0
- package/dist/motion/spring.js +166 -0
- package/dist/overlay/core.js +14 -3
- package/dist/zip/index.d.ts +5 -0
- package/dist/zip/index.js +5 -0
- package/dist/zip/zip.d.ts +35 -0
- package/dist/zip/zip.js +190 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -12,9 +12,9 @@ when you need Texuli's URL policy to sanitize links and media from agents
|
|
|
12
12
|
or users. It has no runtime dependencies and imports no UI framework.
|
|
13
13
|
|
|
14
14
|
> **Status: 0.x release.** Texuli is in development. The packages are
|
|
15
|
-
> published to npm as `0.
|
|
16
|
-
> stable release). Under 0.x, a new minor (`0.
|
|
17
|
-
> changes, so pin the exact version or use `^0.
|
|
15
|
+
> published to npm as `0.7.0`, a pre-1.0 release (1.0.0 will be the first
|
|
16
|
+
> stable release). Under 0.x, a new minor (`0.8.0`) may contain breaking
|
|
17
|
+
> changes, so pin the exact version or use `^0.7.0`, which accepts only 0.7.x.
|
|
18
18
|
|
|
19
19
|
## Install
|
|
20
20
|
|
|
@@ -59,8 +59,8 @@ window.addEventListener('keydown', (event) => {
|
|
|
59
59
|
|
|
60
60
|
Everything in the table below is available from the root entry
|
|
61
61
|
(`@texuli/core`) and from its own subpath (`@texuli/core/<subpath>`), except
|
|
62
|
-
the editor
|
|
63
|
-
keep bundles narrow.
|
|
62
|
+
the editor, lazy-chunk, motion, markup and ZIP texels, which are
|
|
63
|
+
subpath-only. Import subpaths to keep bundles narrow.
|
|
64
64
|
|
|
65
65
|
## What's inside
|
|
66
66
|
|
|
@@ -88,6 +88,8 @@ keep bundles narrow.
|
|
|
88
88
|
| Environment | `env` | `isBrowser`, `getOwnerDocument`, `listen`, `matchesMedia`, `requestFrame` |
|
|
89
89
|
| Lazy chunks | `lazy` | `lazyChunk`, `hasLazyChunk` (subpath only) |
|
|
90
90
|
| Rich text editor model | `editor-model`, `editor-transforms`, `editor-history`, `editor-keymap`, `editor-markdown` | Document model, transforms, undo history, keymap, Markdown import and export (subpath only) |
|
|
91
|
+
| Markup builders | `markup` | `el`, `text`, `linkUrl`, `imageUrl`, `serializeXml`, `serializeHtml`: escaped XML and HTML with no raw-markup API (subpath only) |
|
|
92
|
+
| ZIP writer | `zip` | `writeZip`, `writeZipAsync`, `crc32`, `compressBytes`: deterministic archives, deflated where the platform can (subpath only) |
|
|
91
93
|
|
|
92
94
|
The table names the headline exports; each subpath's TypeScript
|
|
93
95
|
declarations list the full surface.
|
|
@@ -123,6 +125,26 @@ through this policy, and you can use it the same way. Rejected values return
|
|
|
123
125
|
- **Right-to-left aware.** Direction helpers map logical sides to physical
|
|
124
126
|
ones, and positioning resolves placements for the current direction.
|
|
125
127
|
|
|
128
|
+
## New in 0.6.0
|
|
129
|
+
|
|
130
|
+
Ships in `0.6.0`; the `0.5.0` release predates it:
|
|
131
|
+
|
|
132
|
+
- `@texuli/core/motion` and `@texuli/core/motion/spring`: presence, FLIP
|
|
133
|
+
layout, shared elements, sequences and timelines, and an owned spring
|
|
134
|
+
solver over the Web Animations API, instant on the server and under
|
|
135
|
+
reduced motion ([core-motion](https://github.com/DailybotHQ/texuli/blob/main/packages/core/docs/core-motion.md)).
|
|
136
|
+
- `@texuli/core/markup` and `@texuli/core/zip`: escaped XML and HTML
|
|
137
|
+
builders and a deterministic ZIP writer for static outputs
|
|
138
|
+
([core-markup](https://github.com/DailybotHQ/texuli/blob/main/packages/core/docs/core-markup.md),
|
|
139
|
+
[core-zip](https://github.com/DailybotHQ/texuli/blob/main/packages/core/docs/core-zip.md)).
|
|
140
|
+
- Inline extensions in the editor model and Markdown codec (G48:
|
|
141
|
+
`createInlineExtensions`, the `extension` mark and `<sigil>{id|label}`
|
|
142
|
+
tokens), and a reader that is linear on long plain text
|
|
143
|
+
([core-editor](https://github.com/DailybotHQ/texuli/blob/main/packages/core/docs/core-editor.md)).
|
|
144
|
+
- Inert exemptions take `raise: false`, so an overlay opened inside a modal
|
|
145
|
+
dialog stays above its backdrop (G75,
|
|
146
|
+
[core-inert](https://github.com/DailybotHQ/texuli/blob/main/packages/core/docs/core-inert.md)).
|
|
147
|
+
|
|
126
148
|
## Documentation
|
|
127
149
|
|
|
128
150
|
- Website: [texuli.com](https://texuli.com) and the
|
|
@@ -134,6 +156,8 @@ through this policy, and you can use it the same way. Rejected values return
|
|
|
134
156
|
- Positioning: [core-position-math.md](https://github.com/DailybotHQ/texuli/blob/main/packages/core/docs/core-position-math.md)
|
|
135
157
|
and [core-position-dom.md](https://github.com/DailybotHQ/texuli/blob/main/packages/core/docs/core-position-dom.md)
|
|
136
158
|
- Rich text editor model: [core-editor.md](https://github.com/DailybotHQ/texuli/blob/main/packages/core/docs/core-editor.md)
|
|
159
|
+
- Static outputs: [core-markup.md](https://github.com/DailybotHQ/texuli/blob/main/packages/core/docs/core-markup.md) (escaped XML and HTML builders) and [core-zip.md](https://github.com/DailybotHQ/texuli/blob/main/packages/core/docs/core-zip.md) (the ZIP writer)
|
|
160
|
+
- Motion (`@texuli/core/motion`: animate, presence, FLIP, shared elements, timelines, springs): [core-motion.md](https://github.com/DailybotHQ/texuli/blob/main/packages/core/docs/core-motion.md)
|
|
137
161
|
- Every texel's contract: [packages/core/docs](https://github.com/DailybotHQ/texuli/tree/main/packages/core/docs)
|
|
138
162
|
- Security model: [SECURITY.md](https://github.com/DailybotHQ/texuli/blob/main/docs/SECURITY.md)
|
|
139
163
|
|
package/dist/editor/document.js
CHANGED
|
@@ -303,7 +303,14 @@ export function validateEditorDocument(doc) {
|
|
|
303
303
|
if (mark?.type === 'link' && (typeof mark.href !== 'string' || mark.href === '')) {
|
|
304
304
|
issues.push(issue('invalid-shape', 'A link mark has an href.', id));
|
|
305
305
|
}
|
|
306
|
-
else if (
|
|
306
|
+
else if (mark?.type === 'extension' &&
|
|
307
|
+
(typeof mark.name !== 'string' ||
|
|
308
|
+
mark.name === '' ||
|
|
309
|
+
typeof mark.id !== 'string' ||
|
|
310
|
+
mark.id === '')) {
|
|
311
|
+
issues.push(issue('invalid-shape', 'An extension mark has a name and an id.', id));
|
|
312
|
+
}
|
|
313
|
+
else if (!['link', 'strong', 'em', 'strike', 'code', 'extension'].includes(mark?.type)) {
|
|
307
314
|
issues.push(issue('unknown-mark', `Unknown mark "${String(mark?.type)}".`, id));
|
|
308
315
|
}
|
|
309
316
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type HrefTrust } from '../url/safe-href.js';
|
|
2
|
+
/**
|
|
3
|
+
* Host inline extensions (G48): closed, host-registered inline entities such
|
|
4
|
+
* as mentions (`@{person-1|Ada}`) and references (`#{rec-7|Roadmap}`). In the
|
|
5
|
+
* model an entity is an `extension` mark (`name` and `id`) over its label
|
|
6
|
+
* text, so offsets, selections and transforms stay plain text. In Markdown
|
|
7
|
+
* it is the delimiter grammar `<sigil>{<id>|<label>}`.
|
|
8
|
+
*
|
|
9
|
+
* The grammar is fixed and read in linear time: a host registers a name and
|
|
10
|
+
* a sigil from a closed set, never a pattern. A host `accepts` check sees
|
|
11
|
+
* only ids that already passed the bounded id charset; a host `href` is
|
|
12
|
+
* always checked by the URL policy (`untrusted` by default).
|
|
13
|
+
*/
|
|
14
|
+
/** The sigils an extension can open with. */
|
|
15
|
+
export declare const EDITOR_EXTENSION_SIGILS: readonly ["@", "#", "$", "%", "&", "+", "=", "^"];
|
|
16
|
+
export type EditorExtensionSigil = (typeof EDITOR_EXTENSION_SIGILS)[number];
|
|
17
|
+
export declare const EDITOR_EXTENSION_LIMITS: Readonly<{
|
|
18
|
+
/** Registered extensions. */
|
|
19
|
+
extensions: 16;
|
|
20
|
+
/** Longest entity id: `[A-Za-z0-9_.:-]`. */
|
|
21
|
+
id: 128;
|
|
22
|
+
/** Longest label read from Markdown. */
|
|
23
|
+
label: 512;
|
|
24
|
+
}>;
|
|
25
|
+
/** The id charset, one character at a time (no regex on untrusted runs). */
|
|
26
|
+
export declare function isExtensionIdChar(code: number): boolean;
|
|
27
|
+
export interface EditorInlineExtension {
|
|
28
|
+
/** Stored on the mark: `[a-z][a-z0-9-]{0,31}`, unique in a registry. */
|
|
29
|
+
name: string;
|
|
30
|
+
/** Opens `<sigil>{id|label}`; one of `EDITOR_EXTENSION_SIGILS`, unique in a registry. */
|
|
31
|
+
sigil: EditorExtensionSigil;
|
|
32
|
+
/** Optional host check of an id that already passed the charset and length cap. */
|
|
33
|
+
accepts?: (id: string) => boolean;
|
|
34
|
+
/** Optional link for an entity; `extensionHref` checks it with the URL policy. */
|
|
35
|
+
href?: (id: string) => string | undefined;
|
|
36
|
+
}
|
|
37
|
+
/** A validated, frozen set of extensions (from `createInlineExtensions`). */
|
|
38
|
+
export interface EditorInlineExtensions {
|
|
39
|
+
readonly list: readonly EditorInlineExtension[];
|
|
40
|
+
readonly bySigil: ReadonlyMap<string, EditorInlineExtension>;
|
|
41
|
+
readonly byName: ReadonlyMap<string, EditorInlineExtension>;
|
|
42
|
+
/** Characters that can start an inline construct, sigils included. Internal. */
|
|
43
|
+
readonly special: RegExp;
|
|
44
|
+
/** A sigil followed by `{` in plain text (escaped when written). Internal. */
|
|
45
|
+
readonly literal: RegExp | null;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Validate a host's extensions once. Throws a `TypeError` on a bad name or
|
|
49
|
+
* sigil, a duplicate, or more than 16 entries: these are developer errors.
|
|
50
|
+
*/
|
|
51
|
+
export declare function createInlineExtensions(list?: readonly EditorInlineExtension[]): EditorInlineExtensions;
|
|
52
|
+
/** A registry from either form; `undefined` gives the empty one. */
|
|
53
|
+
export declare function toInlineExtensions(value: EditorInlineExtensions | readonly EditorInlineExtension[] | undefined): EditorInlineExtensions;
|
|
54
|
+
/** Whether `id` is a usable entity id for the registered `name`. */
|
|
55
|
+
export declare function acceptsExtensionId(registry: EditorInlineExtensions, name: unknown, id: unknown): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* The policy-checked link of an entity, or `undefined` when the extension
|
|
58
|
+
* has no `href`, the host throws, or the URL policy refuses it. Untrusted by
|
|
59
|
+
* default: entity ids come from agent or user content.
|
|
60
|
+
*/
|
|
61
|
+
export declare function extensionHref(registry: EditorInlineExtensions, mark: {
|
|
62
|
+
name: string;
|
|
63
|
+
id: string;
|
|
64
|
+
}, trust?: HrefTrust): string | undefined;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { safeHref } from "../url/safe-href.js";
|
|
2
|
+
/**
|
|
3
|
+
* Host inline extensions (G48): closed, host-registered inline entities such
|
|
4
|
+
* as mentions (`@{person-1|Ada}`) and references (`#{rec-7|Roadmap}`). In the
|
|
5
|
+
* model an entity is an `extension` mark (`name` and `id`) over its label
|
|
6
|
+
* text, so offsets, selections and transforms stay plain text. In Markdown
|
|
7
|
+
* it is the delimiter grammar `<sigil>{<id>|<label>}`.
|
|
8
|
+
*
|
|
9
|
+
* The grammar is fixed and read in linear time: a host registers a name and
|
|
10
|
+
* a sigil from a closed set, never a pattern. A host `accepts` check sees
|
|
11
|
+
* only ids that already passed the bounded id charset; a host `href` is
|
|
12
|
+
* always checked by the URL policy (`untrusted` by default).
|
|
13
|
+
*/
|
|
14
|
+
/** The sigils an extension can open with. */
|
|
15
|
+
export const EDITOR_EXTENSION_SIGILS = ['@', '#', '$', '%', '&', '+', '=', '^'];
|
|
16
|
+
export const EDITOR_EXTENSION_LIMITS = Object.freeze({
|
|
17
|
+
/** Registered extensions. */
|
|
18
|
+
extensions: 16,
|
|
19
|
+
/** Longest entity id: `[A-Za-z0-9_.:-]`. */
|
|
20
|
+
id: 128,
|
|
21
|
+
/** Longest label read from Markdown. */
|
|
22
|
+
label: 512,
|
|
23
|
+
});
|
|
24
|
+
const NAME = /^[a-z][a-z0-9-]{0,31}$/;
|
|
25
|
+
const ID = /^[A-Za-z0-9_.:-]+$/;
|
|
26
|
+
/** The id charset, one character at a time (no regex on untrusted runs). */
|
|
27
|
+
export function isExtensionIdChar(code) {
|
|
28
|
+
return ((code >= 48 && code <= 58) || // 0-9 and ':'
|
|
29
|
+
(code >= 65 && code <= 90) ||
|
|
30
|
+
(code >= 97 && code <= 122) ||
|
|
31
|
+
code === 95 || // _
|
|
32
|
+
code === 46 || // .
|
|
33
|
+
code === 45 // -
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
const BASE_SPECIAL = '\\\\\\n`*_~!\\[<';
|
|
37
|
+
const reEscape = (char) => char.replace(/[$^+\\]/g, '\\$&');
|
|
38
|
+
const EMPTY = Object.freeze({
|
|
39
|
+
list: Object.freeze([]),
|
|
40
|
+
bySigil: new Map(),
|
|
41
|
+
byName: new Map(),
|
|
42
|
+
special: new RegExp(`[${BASE_SPECIAL}]`, 'g'),
|
|
43
|
+
literal: null,
|
|
44
|
+
});
|
|
45
|
+
/**
|
|
46
|
+
* Validate a host's extensions once. Throws a `TypeError` on a bad name or
|
|
47
|
+
* sigil, a duplicate, or more than 16 entries: these are developer errors.
|
|
48
|
+
*/
|
|
49
|
+
export function createInlineExtensions(list = []) {
|
|
50
|
+
if (list.length === 0)
|
|
51
|
+
return EMPTY;
|
|
52
|
+
if (list.length > EDITOR_EXTENSION_LIMITS.extensions)
|
|
53
|
+
throw new TypeError('Too many extensions.');
|
|
54
|
+
const bySigil = new Map();
|
|
55
|
+
const byName = new Map();
|
|
56
|
+
for (const extension of list) {
|
|
57
|
+
const { name, sigil } = extension ?? {};
|
|
58
|
+
if (typeof name !== 'string' ||
|
|
59
|
+
!NAME.test(name) ||
|
|
60
|
+
!EDITOR_EXTENSION_SIGILS.includes(sigil) ||
|
|
61
|
+
byName.has(name) ||
|
|
62
|
+
bySigil.has(sigil))
|
|
63
|
+
throw new TypeError(`Bad or repeated inline extension "${String(name)}" (${String(sigil)}).`);
|
|
64
|
+
const frozen = Object.freeze({ ...extension });
|
|
65
|
+
byName.set(name, frozen);
|
|
66
|
+
bySigil.set(sigil, frozen);
|
|
67
|
+
}
|
|
68
|
+
const sigils = [...bySigil.keys()].map(reEscape).join('');
|
|
69
|
+
return Object.freeze({
|
|
70
|
+
list: Object.freeze([...byName.values()]),
|
|
71
|
+
bySigil,
|
|
72
|
+
byName,
|
|
73
|
+
special: new RegExp(`[${BASE_SPECIAL}${sigils}]`, 'g'),
|
|
74
|
+
literal: new RegExp(`([${sigils}])\\{`, 'g'),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/** A registry from either form; `undefined` gives the empty one. */
|
|
78
|
+
export function toInlineExtensions(value) {
|
|
79
|
+
if (!value)
|
|
80
|
+
return EMPTY;
|
|
81
|
+
return Array.isArray(value)
|
|
82
|
+
? createInlineExtensions(value)
|
|
83
|
+
: value;
|
|
84
|
+
}
|
|
85
|
+
/** Whether `id` is a usable entity id for the registered `name`. */
|
|
86
|
+
export function acceptsExtensionId(registry, name, id) {
|
|
87
|
+
if (typeof name !== 'string' || typeof id !== 'string')
|
|
88
|
+
return false;
|
|
89
|
+
const extension = registry.byName.get(name);
|
|
90
|
+
if (!extension || id.length === 0 || id.length > EDITOR_EXTENSION_LIMITS.id || !ID.test(id))
|
|
91
|
+
return false;
|
|
92
|
+
try {
|
|
93
|
+
return extension.accepts ? extension.accepts(id) === true : true;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The policy-checked link of an entity, or `undefined` when the extension
|
|
101
|
+
* has no `href`, the host throws, or the URL policy refuses it. Untrusted by
|
|
102
|
+
* default: entity ids come from agent or user content.
|
|
103
|
+
*/
|
|
104
|
+
export function extensionHref(registry, mark, trust = 'untrusted') {
|
|
105
|
+
const extension = registry.byName.get(mark.name);
|
|
106
|
+
if (!extension?.href || !acceptsExtensionId(registry, mark.name, mark.id))
|
|
107
|
+
return undefined;
|
|
108
|
+
let href;
|
|
109
|
+
try {
|
|
110
|
+
href = extension.href(mark.id);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
return typeof href === 'string' ? safeHref(href, trust) : undefined;
|
|
116
|
+
}
|
package/dist/editor/inline.d.ts
CHANGED
|
@@ -11,12 +11,13 @@ export declare function cleanText(text: string, keep?: string): {
|
|
|
11
11
|
text: string;
|
|
12
12
|
stripped: boolean;
|
|
13
13
|
};
|
|
14
|
-
/** The identity of a mark: its type, plus the href for links. */
|
|
14
|
+
/** The identity of a mark: its type, plus the href for links and the entity for extensions. */
|
|
15
15
|
export declare const markKey: (mark: EditorMark) => string;
|
|
16
16
|
export declare const hasMark: (marks: readonly EditorMark[], type: EditorMarkType) => boolean;
|
|
17
17
|
/**
|
|
18
18
|
* Canonical mark set: one mark per type (the first wins), sorted in
|
|
19
|
-
* `EDITOR_MARK_ORDER
|
|
19
|
+
* `EDITOR_MARK_ORDER`; `extension` excludes `link` and `code`, and `code`
|
|
20
|
+
* excludes every other mark but `link`.
|
|
20
21
|
*/
|
|
21
22
|
export declare function sortMarks(marks: readonly EditorMark[]): readonly EditorMark[];
|
|
22
23
|
export declare const sameMarks: (a: readonly EditorMark[], b: readonly EditorMark[]) => boolean;
|
|
@@ -38,7 +39,8 @@ export declare const concatInline: (...parts: (readonly EditorInline[])[]) => re
|
|
|
38
39
|
export declare const spliceInline: (content: readonly EditorInline[], from: number, to: number, insert?: readonly EditorInline[]) => readonly EditorInline[];
|
|
39
40
|
/**
|
|
40
41
|
* The marks text typed at `offset` gets: those of the character before (the
|
|
41
|
-
* first character at offset 0). A link
|
|
42
|
+
* first character at offset 0). A link or an extension entity continues only
|
|
43
|
+
* strictly inside it.
|
|
42
44
|
*/
|
|
43
45
|
export declare function inheritedMarks(content: readonly EditorInline[], offset: number): readonly EditorMark[];
|
|
44
46
|
/** Apply `update` to the marks of every text character in `[from, to)`. */
|
package/dist/editor/inline.js
CHANGED
|
@@ -36,22 +36,29 @@ export function cleanText(text, keep = '') {
|
|
|
36
36
|
}
|
|
37
37
|
return { text: out, stripped };
|
|
38
38
|
}
|
|
39
|
-
/** The identity of a mark: its type, plus the href for links. */
|
|
40
|
-
export const markKey = (mark) => mark.type === 'link'
|
|
39
|
+
/** The identity of a mark: its type, plus the href for links and the entity for extensions. */
|
|
40
|
+
export const markKey = (mark) => mark.type === 'link'
|
|
41
|
+
? `link:${mark.href}`
|
|
42
|
+
: mark.type === 'extension'
|
|
43
|
+
? `extension:${mark.name}:${mark.id}`
|
|
44
|
+
: mark.type;
|
|
41
45
|
export const hasMark = (marks, type) => marks.some((mark) => mark.type === type);
|
|
42
46
|
/**
|
|
43
47
|
* Canonical mark set: one mark per type (the first wins), sorted in
|
|
44
|
-
* `EDITOR_MARK_ORDER
|
|
48
|
+
* `EDITOR_MARK_ORDER`; `extension` excludes `link` and `code`, and `code`
|
|
49
|
+
* excludes every other mark but `link`.
|
|
45
50
|
*/
|
|
46
51
|
export function sortMarks(marks) {
|
|
47
52
|
const byType = new Map();
|
|
48
53
|
for (const mark of marks)
|
|
49
54
|
if (!byType.has(mark.type))
|
|
50
55
|
byType.set(mark.type, mark);
|
|
51
|
-
const
|
|
56
|
+
const extension = byType.has('extension');
|
|
57
|
+
const code = !extension && byType.has('code');
|
|
58
|
+
const keep = (type) => extension ? type !== 'link' && type !== 'code' : !code || type === 'link' || type === 'code';
|
|
52
59
|
const out = EDITOR_MARK_ORDER.flatMap((type) => {
|
|
53
60
|
const mark = byType.get(type);
|
|
54
|
-
return mark && (
|
|
61
|
+
return mark && keep(type) ? [mark] : [];
|
|
55
62
|
});
|
|
56
63
|
return out.length === marks.length && out.every((mark, i) => mark === marks[i]) ? marks : out;
|
|
57
64
|
}
|
|
@@ -130,17 +137,22 @@ function textAt(content, offset) {
|
|
|
130
137
|
}
|
|
131
138
|
/**
|
|
132
139
|
* The marks text typed at `offset` gets: those of the character before (the
|
|
133
|
-
* first character at offset 0). A link
|
|
140
|
+
* first character at offset 0). A link or an extension entity continues only
|
|
141
|
+
* strictly inside it.
|
|
134
142
|
*/
|
|
135
143
|
export function inheritedMarks(content, offset) {
|
|
136
144
|
const before = offset > 0 ? textAt(content, offset - 1) : undefined;
|
|
137
145
|
const after = textAt(content, offset);
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
146
|
+
let marks = (offset > 0 ? before : after)?.marks ?? [];
|
|
147
|
+
for (const type of ['link', 'extension']) {
|
|
148
|
+
const edge = marks.find((mark) => mark.type === type);
|
|
149
|
+
const inside = edge !== undefined &&
|
|
150
|
+
before !== undefined &&
|
|
151
|
+
after?.marks.some((mark) => markKey(mark) === markKey(edge)) === true;
|
|
152
|
+
if (edge && !inside)
|
|
153
|
+
marks = withoutMark(marks, type);
|
|
154
|
+
}
|
|
155
|
+
return marks;
|
|
144
156
|
}
|
|
145
157
|
/** Apply `update` to the marks of every text character in `[from, to)`. */
|
|
146
158
|
export function mapMarks(content, from, to, update) {
|
package/dist/editor/load.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { HrefTrust } from '../url/safe-href.js';
|
|
2
2
|
import { type EditorIdSource } from './document.js';
|
|
3
|
+
import { type EditorInlineExtension, type EditorInlineExtensions } from './extensions.js';
|
|
3
4
|
import { type EditorDocument, type EditorInline, type EditorIssue, type EditorIssueCode, type EditorLimits, type EditorMark } from './types.js';
|
|
4
5
|
export interface EditorLoadOptions {
|
|
5
6
|
/** URL policy tier for link hrefs. Default `untrusted`. */
|
|
@@ -7,6 +8,12 @@ export interface EditorLoadOptions {
|
|
|
7
8
|
newId?: EditorIdSource;
|
|
8
9
|
/** Lower caps (a blocks `text` field holds 4,000 characters). */
|
|
9
10
|
limits?: Partial<EditorLimits>;
|
|
11
|
+
/**
|
|
12
|
+
* Host inline extensions whose `extension` marks are kept (mentions,
|
|
13
|
+
* references). Marks of other names, or with a refused id, are dropped and
|
|
14
|
+
* keep their label text. None by default.
|
|
15
|
+
*/
|
|
16
|
+
extensions?: EditorInlineExtensions | readonly EditorInlineExtension[];
|
|
10
17
|
}
|
|
11
18
|
export interface EditorLoadResult {
|
|
12
19
|
doc: EditorDocument;
|
|
@@ -18,10 +25,13 @@ export declare class LoadBudget {
|
|
|
18
25
|
readonly limits: EditorLimits;
|
|
19
26
|
readonly trust: HrefTrust;
|
|
20
27
|
readonly newId: EditorIdSource;
|
|
28
|
+
readonly extensions: EditorInlineExtensions;
|
|
21
29
|
blocks: number;
|
|
22
30
|
chars: number;
|
|
23
31
|
private readonly once;
|
|
24
32
|
constructor(options: EditorLoadOptions);
|
|
33
|
+
/** An extension mark for a registered name and an accepted id, or `undefined` (reported). */
|
|
34
|
+
extension(name: unknown, id: unknown): EditorMark | undefined;
|
|
25
35
|
report(code: EditorIssueCode, message: string, onlyOnce?: boolean): void;
|
|
26
36
|
/** Reserve one block; false (reported) once the cap is reached. */
|
|
27
37
|
block(): boolean;
|
package/dist/editor/load.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { safeHref } from "../url/safe-href.js";
|
|
2
2
|
import { LANG_PATTERN, nextEditorId, normalizeDocument, paragraph, } from "./document.js";
|
|
3
|
+
import { acceptsExtensionId, toInlineExtensions, } from "./extensions.js";
|
|
3
4
|
import { cleanText, normalizeInline, textToInline } from "./inline.js";
|
|
4
5
|
import { EDITOR_LIMITS, } from "./types.js";
|
|
5
6
|
const MAX_DIAGNOSTICS = 50;
|
|
@@ -9,6 +10,7 @@ export class LoadBudget {
|
|
|
9
10
|
limits;
|
|
10
11
|
trust;
|
|
11
12
|
newId;
|
|
13
|
+
extensions;
|
|
12
14
|
blocks = 0;
|
|
13
15
|
chars = 0;
|
|
14
16
|
once = new Set();
|
|
@@ -16,6 +18,14 @@ export class LoadBudget {
|
|
|
16
18
|
this.limits = { ...EDITOR_LIMITS, ...options.limits };
|
|
17
19
|
this.trust = options.trust === 'developer' ? 'developer' : 'untrusted';
|
|
18
20
|
this.newId = options.newId ?? nextEditorId;
|
|
21
|
+
this.extensions = toInlineExtensions(options.extensions);
|
|
22
|
+
}
|
|
23
|
+
/** An extension mark for a registered name and an accepted id, or `undefined` (reported). */
|
|
24
|
+
extension(name, id) {
|
|
25
|
+
if (acceptsExtensionId(this.extensions, name, id))
|
|
26
|
+
return { type: 'extension', name: name, id: id };
|
|
27
|
+
this.report('mark-dropped', 'Unregistered or refused inline extensions kept only their text.');
|
|
28
|
+
return undefined;
|
|
19
29
|
}
|
|
20
30
|
report(code, message, onlyOnce = true) {
|
|
21
31
|
if (onlyOnce && this.once.has(code))
|
|
@@ -96,6 +106,12 @@ function loadMarks(raw, budget) {
|
|
|
96
106
|
if (href)
|
|
97
107
|
marks.push({ type: 'link', href });
|
|
98
108
|
}
|
|
109
|
+
else if (type === 'extension') {
|
|
110
|
+
const record = mark;
|
|
111
|
+
const extension = budget.extension(record.name, record.id);
|
|
112
|
+
if (extension)
|
|
113
|
+
marks.push(extension);
|
|
114
|
+
}
|
|
99
115
|
else
|
|
100
116
|
budget.report('unknown-mark', 'Unknown marks were dropped.');
|
|
101
117
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type EditorInlineExtension, type EditorInlineExtensions } from './extensions.js';
|
|
1
2
|
import { type EditorLoadOptions, type EditorLoadResult } from './load.js';
|
|
2
3
|
import type { EditorDocument } from './types.js';
|
|
3
4
|
/**
|
|
@@ -17,6 +18,14 @@ export interface MarkdownReadOptions extends EditorLoadOptions {
|
|
|
17
18
|
/** Override the source cap (a blocks `text` field holds 4,000 characters). */
|
|
18
19
|
maxSource?: number;
|
|
19
20
|
}
|
|
21
|
+
export interface MarkdownWriteOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Host inline extensions written as `<sigil>{id|label}`. Extension marks of
|
|
24
|
+
* other names are written as their label text, and plain text that would
|
|
25
|
+
* read as a token gets its `{` escaped.
|
|
26
|
+
*/
|
|
27
|
+
extensions?: EditorInlineExtensions | readonly EditorInlineExtension[];
|
|
28
|
+
}
|
|
20
29
|
/**
|
|
21
30
|
* Read Markdown (untrusted by default) into a valid document. Never throws:
|
|
22
31
|
* nesting deeper than the depth cap and oversized input degrade to text,
|
|
@@ -30,4 +39,4 @@ export declare function fromMarkdown(source: unknown, options?: MarkdownReadOpti
|
|
|
30
39
|
* paragraphs, breaks in headings (written as spaces), breaks at the edges of
|
|
31
40
|
* a block, and whitespace at the edges of a paragraph or a mark.
|
|
32
41
|
*/
|
|
33
|
-
export declare function toMarkdown(doc: EditorDocument): string;
|
|
42
|
+
export declare function toMarkdown(doc: EditorDocument, options?: MarkdownWriteOptions): string;
|