@texuli/core 0.1.0-next.1 → 0.1.0-next.3
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 +138 -123
- package/dist/editor/history.js +6 -0
- package/dist/url/safe-href.js +6 -2
- package/dist/url-policy/shared.d.ts +8 -0
- package/dist/url-policy/shared.js +11 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,139 +1,154 @@
|
|
|
1
1
|
# @texuli/core
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
The framework-free foundation of [Texuli](https://texuli.com), an MIT-licensed
|
|
4
|
+
Svelte 5 component library by Dailybot, Inc. (*Small pieces. Infinite
|
|
5
|
+
possibilities.*). `@texuli/core` holds the **texels**: small, tested
|
|
6
|
+
TypeScript units of behavior and policy (focus management, roving focus,
|
|
7
|
+
layering and Escape handling, positioning, ids, keyboard shortcuts, URL
|
|
8
|
+
safety) that every Texuli primitive and component is built from.
|
|
6
9
|
|
|
7
|
-
|
|
10
|
+
Use it directly when you build your own components in any framework, or
|
|
11
|
+
when you need Texuli's URL policy to sanitize links and media from agents
|
|
12
|
+
or users. It has no runtime dependencies and imports no UI framework.
|
|
8
13
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
rest.
|
|
14
|
+
> **Status: preview.** Texuli is in development. The packages are published
|
|
15
|
+
> to npm as prereleases under the `next` dist-tag, and there is no stable
|
|
16
|
+
> release yet, so install with `@next`. APIs may still change between
|
|
17
|
+
> previews.
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
| --- | --- | --- |
|
|
17
|
-
| Announcer | `createAnnouncer`, `createLiveRegion`, `getAnnouncer`, `appendRegion` | [core-announcer](docs/core-announcer.md) |
|
|
18
|
-
| Controllable state | `createControllableState` | [core-controllable](docs/core-controllable.md) |
|
|
19
|
-
| Direction and locale | `directionOf`, `inlineSideToPhysical`, `createCollator`, `compareLocale`, `resolveMessages` | [core-dir-locale](docs/core-dir-locale.md) |
|
|
20
|
-
| Dismiss | `bindDismissLayer`, `isPointerOutside`, `isFocusOutside`, `createDismissOutsideEvent`, `createOpenGestureGuard` | [core-dismiss](docs/core-dismiss.md) |
|
|
21
|
-
| Editor model | `loadEditorDocument`, `normalizeDocument`, `validateEditorDocument`, `caret`, `clampSelection`, `inlineToTree`; transforms (`insertText`, `splitBlock`, `deleteBackward`, `toggleMark`, `setLink`, `replaceSelection`, `applyInputRules`…), `editorHistoryReducer`, `createEditorKeymap`, `fromMarkdown`/`toMarkdown` (subpaths `@texuli/core/editor-model`, `editor-transforms`, `editor-history`, `editor-keymap`, `editor-markdown`; not in the root barrel) | [core-editor](docs/core-editor.md) |
|
|
22
|
-
| Environment | `isBrowser`, `getOwnerDocument`, `getOwnerWindow`, `listen`, `matchesMedia`, `requestFrame` | [core-env](docs/core-env.md) |
|
|
23
|
-
| Focus scope | `createFocusScope` | [core-focus-scope](docs/core-focus-scope.md) |
|
|
24
|
-
| Form bridge | `createFormBridge` | [core-form-bridge](docs/core-form-bridge.md) |
|
|
25
|
-
| Ids | `createIdScope`, `createIdFamily`, `joinIds`, `ariaLabelledBy`, `ariaDescribedBy`, `ariaControls`, `ensureElementId`, `sanitizeIdPart` | [core-ids](docs/core-ids.md) |
|
|
26
|
-
| Inert | `createInertRegistry`, `getInertRegistry`, `computeInertTargets` | [core-inert](docs/core-inert.md) |
|
|
27
|
-
| Keyboard | `parseShortcut`, `matchesShortcut`, `formatShortcut`, `createShortcutMap`, `getNavigationIntent`, `normalizeKey`, `detectPlatform` | [core-keyboard](docs/core-keyboard.md) |
|
|
28
|
-
| Layer stack | `createLayerStack`, `getLayerStack`, `decideEscape`, `bindLayerStackDocument`, `layerReceivesEscape` | [core-layer-stack](docs/core-layer-stack.md) |
|
|
29
|
-
| Lazy chunks | `lazyChunk`, `hasLazyChunk`, `clearLazyChunk(s)` (subpath `@texuli/core/lazy`; not in the root barrel) | — |
|
|
30
|
-
| Overlay engine | `createOverlay` (`OVERLAY_MODE_ATTR`, `OVERLAY_Z_BASE`); the reduced surface is the `@texuli/core/overlay-core` subpath | [core-overlay-core](docs/core-overlay-core.md), [overlay-engine](docs/overlay-engine.md) |
|
|
31
|
-
| Portal | `createPortal` | [core-portal](docs/core-portal.md) |
|
|
32
|
-
| Positioning (DOM) | `positionFloating`, `applyPosition`, `autoUpdate`, `observeMove`, `cssAnchorDeclarations`, `supportsCssAnchorPositioning` | [core-position-dom](docs/core-position-dom.md) |
|
|
33
|
-
| Positioning (math) | `computePosition`, `place`, `shiftIntoBoundary`, `sizeToAvailable`, `listPlacements`, `resolvePlacementForDirection`, `rect` | [core-position-math](docs/core-position-math.md) |
|
|
34
|
-
| Presence | `createPresence`, `prefersReducedMotion`, `hasPendingMotion`, `parseCssTimeList` | [core-presence](docs/core-presence.md) |
|
|
35
|
-
| Roving focus + typeahead | `createRovingFocus`, `createTypeahead`, `stepRovingIndex`, `setRovingTabIndex`, `indexOfSelected` | [core-roving](docs/core-roving.md) |
|
|
36
|
-
| Scroll lock | `lockScroll` | [core-scroll-lock](docs/core-scroll-lock.md) |
|
|
37
|
-
| Tabbable | `isFocusable`, `isTabbable`, `getFocusableElements`, `getTabbableElements` | [core-tabbable](docs/core-tabbable.md) |
|
|
38
|
-
| URL policy (links) | `safeHref`, `type HrefTrust` | [core-url-policy](docs/core-url-policy.md) |
|
|
39
|
-
| URL policy (media) | `safeImageSrc`, `safeImageSrcset`, `safeMediaSrc`, `MAX_DATA_IMAGE_BYTES` | [core-url-policy](docs/core-url-policy.md) |
|
|
40
|
-
|
|
41
|
-
The editor texels are not in the root barrel: the barrel measures 22.63 of
|
|
42
|
-
its 24 KiB budget. `pnpm size --filter core/editor` (2026-10-25) measures
|
|
43
|
-
`editor-history` at 754 B and `editor-keymap` at 2.49 KiB, both within the 4
|
|
44
|
-
KiB per-texel default. `editor-markdown` (7.03 KiB), `editor-model` (7.81
|
|
45
|
-
KiB) and `editor-transforms` (10.36 KiB) are over it, so their budget rows
|
|
46
|
-
are pending with the owner.
|
|
47
|
-
|
|
48
|
-
The tables above name the headline exports per texel, not the full surface:
|
|
49
|
-
read `src/index.ts` (and the per-area `index.ts`) for every exported symbol
|
|
50
|
-
and type. The threat model behind the URL policy lives in
|
|
51
|
-
[docs/SECURITY.md](../../docs/SECURITY.md).
|
|
52
|
-
|
|
53
|
-
## Examples
|
|
54
|
-
|
|
55
|
-
```ts
|
|
56
|
-
import {
|
|
57
|
-
safeHref,
|
|
58
|
-
createIdScope,
|
|
59
|
-
ariaLabelledBy,
|
|
60
|
-
getLayerStack,
|
|
61
|
-
decideEscape,
|
|
62
|
-
getTabbableElements,
|
|
63
|
-
isTabbable,
|
|
64
|
-
} from '@texuli/core';
|
|
65
|
-
|
|
66
|
-
safeHref('https://example.com/a'); // 'https://example.com/a'
|
|
67
|
-
safeHref('http://example.com'); // undefined (untrusted is the default)
|
|
68
|
-
safeHref('/docs/button', 'developer'); // '/docs/button'
|
|
69
|
-
safeHref('javascript:alert(1)', 'developer'); // undefined in every tier
|
|
70
|
-
|
|
71
|
-
const ids = createIdScope({ prefix: 'dialog' });
|
|
72
|
-
const titleId = ids.next('title'); // scoped, SSR-stable
|
|
73
|
-
ariaLabelledBy(titleId); // the id list for aria-labelledby
|
|
19
|
+
## Install
|
|
74
20
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
layer.unregister();
|
|
21
|
+
```bash
|
|
22
|
+
npm install @texuli/core@next
|
|
23
|
+
```
|
|
79
24
|
|
|
80
|
-
|
|
81
|
-
|
|
25
|
+
```bash
|
|
26
|
+
pnpm add @texuli/core@next
|
|
27
|
+
# or
|
|
28
|
+
yarn add @texuli/core@next
|
|
82
29
|
```
|
|
83
30
|
|
|
84
|
-
|
|
31
|
+
No peer dependencies and no CSS. The package is ESM-only and ships
|
|
32
|
+
TypeScript declarations.
|
|
85
33
|
|
|
86
|
-
|
|
87
|
-
`mailto:` and `#fragment`.
|
|
88
|
-
- **`developer`** (for app-authored props): anything except `javascript:`,
|
|
89
|
-
`vbscript:`, `data:` and protocol-relative `//host` URLs.
|
|
90
|
-
- Whitespace and ASCII control characters are stripped before the scheme is
|
|
91
|
-
read, so `java\tscript:` is caught.
|
|
34
|
+
## Quick start
|
|
92
35
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
1 MiB decoded; SVG and non-image data URLs are always refused.
|
|
97
|
-
`safeImageSrcset` accepts a comma-separated `srcset` only when every candidate
|
|
98
|
-
is safe. `safeMediaSrc` restricts untrusted audio/video to blob URLs or exact
|
|
99
|
-
origins from an HTTPS `allowedOrigins` list. Full contract:
|
|
100
|
-
[core-url-policy](docs/core-url-policy.md).
|
|
101
|
-
|
|
102
|
-
## Layout
|
|
103
|
-
|
|
104
|
-
```text
|
|
105
|
-
packages/core/
|
|
106
|
-
├── package.json exports "." and one subpath per texel; script: typecheck
|
|
107
|
-
├── tsconfig.json extends ../../tsconfig.base.json
|
|
108
|
-
├── docs/ one contract doc per texel (core-*.md) + overlay-engine.md
|
|
109
|
-
├── src/index.ts the public barrel (only what is exported here is public)
|
|
110
|
-
├── src/<area>/ one directory per texel (announcer, roving, layer-stack, …)
|
|
111
|
-
└── tests/ 26 test files, one or more per texel, including hostile
|
|
112
|
-
inputs for every policy that guards a sink
|
|
113
|
-
```
|
|
36
|
+
```ts
|
|
37
|
+
import { ariaLabelledBy, createIdScope, safeHref } from '@texuli/core';
|
|
38
|
+
import { detectPlatform, matchesShortcut } from '@texuli/core/keyboard';
|
|
114
39
|
|
|
115
|
-
|
|
40
|
+
// URL policy: untrusted (agent or user) content is the default tier.
|
|
41
|
+
safeHref('https://example.com/report'); // 'https://example.com/report'
|
|
42
|
+
safeHref('http://example.com'); // undefined (https only)
|
|
43
|
+
safeHref('/docs/button', 'developer'); // '/docs/button'
|
|
44
|
+
safeHref('javascript:alert(1)', 'developer'); // undefined in every tier
|
|
116
45
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
46
|
+
// Ids that match on the server and the client.
|
|
47
|
+
const ids = createIdScope({ prefix: 'dialog' });
|
|
48
|
+
const titleId = ids.next('title'); // 'dialog-0-title'
|
|
49
|
+
ariaLabelledBy(titleId); // 'dialog-0-title'
|
|
50
|
+
|
|
51
|
+
// Keyboard shortcuts written as data; Mod is Cmd on Apple platforms, Ctrl elsewhere.
|
|
52
|
+
const platform = detectPlatform(navigator);
|
|
53
|
+
window.addEventListener('keydown', (event) => {
|
|
54
|
+
if (matchesShortcut(event, 'Mod+K', platform)) {
|
|
55
|
+
// open your command palette
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
```
|
|
125
59
|
|
|
126
|
-
|
|
60
|
+
Everything in the table below is available from the root entry
|
|
61
|
+
(`@texuli/core`) and from its own subpath (`@texuli/core/<subpath>`), except
|
|
62
|
+
the editor and lazy-chunk texels, which are subpath-only. Import subpaths to
|
|
63
|
+
keep bundles narrow.
|
|
127
64
|
|
|
128
|
-
|
|
129
|
-
pnpm vitest run packages/core # every core texel test
|
|
130
|
-
pnpm vitest run packages/core/tests/roving.test.ts # one texel
|
|
131
|
-
pnpm --filter @texuli/core typecheck # tsc -p tsconfig.json
|
|
132
|
-
npx biome check packages/core # lint + format
|
|
133
|
-
```
|
|
65
|
+
## What's inside
|
|
134
66
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
67
|
+
| Texel | Subpath | Headline exports |
|
|
68
|
+
| --- | --- | --- |
|
|
69
|
+
| URL policy | `url-policy` | `safeHref`, `safeImageSrc`, `safeImageSrcset`, `safeMediaSrc` |
|
|
70
|
+
| Ids | `ids` | `createIdScope`, `createIdFamily`, `ariaLabelledBy`, `ariaDescribedBy`, `ensureElementId` |
|
|
71
|
+
| Keyboard | `keyboard` | `parseShortcut`, `matchesShortcut`, `formatShortcut`, `createShortcutMap`, `getNavigationIntent` |
|
|
72
|
+
| Roving focus and typeahead | `roving` | `createRovingFocus`, `createTypeahead`, `setRovingTabIndex` |
|
|
73
|
+
| Focus scope | `focus-scope` | `createFocusScope` (trap and restore focus) |
|
|
74
|
+
| Tabbable | `tabbable` | `isFocusable`, `isTabbable`, `getTabbableElements` |
|
|
75
|
+
| Layer stack | `layer-stack` | `getLayerStack`, `decideEscape` (Escape reaches only the topmost layer) |
|
|
76
|
+
| Dismiss | `dismiss` | `bindDismissLayer`, `isPointerOutside`, `isFocusOutside` |
|
|
77
|
+
| Inert | `inert` | `createInertRegistry`, `computeInertTargets` |
|
|
78
|
+
| Scroll lock | `scroll-lock` | `lockScroll` |
|
|
79
|
+
| Portal | `portal` | `createPortal` |
|
|
80
|
+
| Overlay engine | `overlay`, `overlay-core` | `createOverlay` (combines the texels above for dialogs, popovers and menus); `createOverlayCore` is the reduced surface |
|
|
81
|
+
| Positioning (math) | `position-math` | `computePosition`, `place`, `shiftIntoBoundary`, `resolvePlacementForDirection` |
|
|
82
|
+
| Positioning (DOM) | `position-dom` | `positionFloating`, `autoUpdate`, `cssAnchorDeclarations` |
|
|
83
|
+
| Presence | `presence` | `createPresence`, `prefersReducedMotion` (enter and exit animations) |
|
|
84
|
+
| Announcer | `announcer` | `createAnnouncer`, `createLiveRegion` (screen reader announcements) |
|
|
85
|
+
| Controllable state | `controllable` | `createControllableState` (controlled and uncontrolled values) |
|
|
86
|
+
| Form bridge | `form-bridge` | `createFormBridge` (custom controls in native forms) |
|
|
87
|
+
| Direction and locale | `dir-locale` | `directionOf`, `inlineSideToPhysical`, `createCollator`, `resolveMessages` |
|
|
88
|
+
| Environment | `env` | `isBrowser`, `getOwnerDocument`, `listen`, `matchesMedia`, `requestFrame` |
|
|
89
|
+
| Lazy chunks | `lazy` | `lazyChunk`, `hasLazyChunk` (subpath only) |
|
|
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
|
+
|
|
92
|
+
The table names the headline exports; each subpath's TypeScript
|
|
93
|
+
declarations list the full surface.
|
|
94
|
+
|
|
95
|
+
## URL policy
|
|
96
|
+
|
|
97
|
+
Every Texuli component that renders a link or a media source passes it
|
|
98
|
+
through this policy, and you can use it the same way. Rejected values return
|
|
99
|
+
`undefined` (the policy fails closed).
|
|
100
|
+
|
|
101
|
+
- **`untrusted`** (the default, for agent or user content): only `https:`,
|
|
102
|
+
`mailto:` and `#fragment` links.
|
|
103
|
+
- **`developer`** (for app-authored values): anything except `javascript:`,
|
|
104
|
+
`vbscript:`, `data:`, and values that start with two slashes or
|
|
105
|
+
backslashes in any mix, or with any backslash, which browsers resolve
|
|
106
|
+
off-origin.
|
|
107
|
+
- Whitespace and ASCII control characters are stripped before the scheme is
|
|
108
|
+
read, so `java\tscript:` is caught.
|
|
109
|
+
- `safeImageSrc` refuses SVG and non-image `data:` URLs in every tier;
|
|
110
|
+
`safeMediaSrc` limits untrusted audio and video to blob URLs or exact
|
|
111
|
+
HTTPS origins you allow.
|
|
112
|
+
|
|
113
|
+
## Key guarantees
|
|
114
|
+
|
|
115
|
+
- **Framework-free.** No Svelte, React or other UI framework imports, and no
|
|
116
|
+
runtime dependencies. Standard DOM types only.
|
|
117
|
+
- **SSR-friendly ids.** Id scopes are per render root with no module-level
|
|
118
|
+
randomness, so the server and the client produce the same ids; `isBrowser`
|
|
119
|
+
and the owner-document helpers let code skip DOM work on the server.
|
|
120
|
+
- **Accessibility building blocks.** Roving focus, focus scopes, inert
|
|
121
|
+
backgrounds and layered Escape handling implement the behavior the
|
|
122
|
+
WAI-ARIA Authoring Practices patterns require.
|
|
123
|
+
- **Right-to-left aware.** Direction helpers map logical sides to physical
|
|
124
|
+
ones, and positioning resolves placements for the current direction.
|
|
125
|
+
|
|
126
|
+
## Documentation
|
|
127
|
+
|
|
128
|
+
- Website: [texuli.com](https://texuli.com) and the
|
|
129
|
+
[accessibility guide](https://texuli.com/guides/accessibility/)
|
|
130
|
+
- URL policy contract: [core-url-policy.md](https://github.com/DailybotHQ/texuli/blob/main/packages/core/docs/core-url-policy.md)
|
|
131
|
+
- Overlay engine: [overlay-engine.md](https://github.com/DailybotHQ/texuli/blob/main/packages/core/docs/overlay-engine.md)
|
|
132
|
+
- Roving focus: [core-roving.md](https://github.com/DailybotHQ/texuli/blob/main/packages/core/docs/core-roving.md)
|
|
133
|
+
- Keyboard shortcuts: [core-keyboard.md](https://github.com/DailybotHQ/texuli/blob/main/packages/core/docs/core-keyboard.md)
|
|
134
|
+
- Positioning: [core-position-math.md](https://github.com/DailybotHQ/texuli/blob/main/packages/core/docs/core-position-math.md)
|
|
135
|
+
and [core-position-dom.md](https://github.com/DailybotHQ/texuli/blob/main/packages/core/docs/core-position-dom.md)
|
|
136
|
+
- Rich text editor model: [core-editor.md](https://github.com/DailybotHQ/texuli/blob/main/packages/core/docs/core-editor.md)
|
|
137
|
+
- Every texel's contract: [packages/core/docs](https://github.com/DailybotHQ/texuli/tree/main/packages/core/docs)
|
|
138
|
+
- Security model: [SECURITY.md](https://github.com/DailybotHQ/texuli/blob/main/docs/SECURITY.md)
|
|
139
|
+
|
|
140
|
+
## Related packages
|
|
141
|
+
|
|
142
|
+
- [`@texuli/primitives`](https://www.npmjs.com/package/@texuli/primitives): headless Svelte 5 primitives built on these texels.
|
|
143
|
+
- [`texuli`](https://www.npmjs.com/package/texuli): styled, ready-to-use components.
|
|
144
|
+
- [`@texuli/tokens`](https://www.npmjs.com/package/@texuli/tokens): the theme engine and design tokens.
|
|
145
|
+
|
|
146
|
+
## Contributing
|
|
147
|
+
|
|
148
|
+
Source, issues and contribution guidelines live at
|
|
149
|
+
[github.com/DailybotHQ/texuli](https://github.com/DailybotHQ/texuli). Report
|
|
150
|
+
bugs at [github.com/DailybotHQ/texuli/issues](https://github.com/DailybotHQ/texuli/issues).
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
MIT © Dailybot, Inc.
|
package/dist/editor/history.js
CHANGED
|
@@ -18,6 +18,12 @@ export function editorHistoryReducer(history, action) {
|
|
|
18
18
|
if (!action.result.ok)
|
|
19
19
|
return { ...history, errors: action.result.errors };
|
|
20
20
|
const state = action.result.state;
|
|
21
|
+
// A change that leaves the document as it was (a caret mark toggle) is
|
|
22
|
+
// no undo step, but like a selection change it ends the running typing
|
|
23
|
+
// step: undo after "a **b**" stops at the format change, not at "".
|
|
24
|
+
if (state.doc === history.present.doc) {
|
|
25
|
+
return { ...history, present: state, errors: [], last: null };
|
|
26
|
+
}
|
|
21
27
|
const kind = action.kind ?? 'other';
|
|
22
28
|
const at = action.at ?? 0;
|
|
23
29
|
const block = focusBlock(state);
|
package/dist/url/safe-href.js
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* Two trust tiers:
|
|
5
5
|
* - `developer`: props written by the app's own developers. Every scheme is
|
|
6
6
|
* allowed except the ones that execute or smuggle content: `javascript:`,
|
|
7
|
-
* `vbscript:`, `data:` and protocol-relative
|
|
7
|
+
* `vbscript:`, `data:` and protocol-relative URLs: any value opening with
|
|
8
|
+
* two of `/` and `\` (`//host`, `/\host`, `\/host`, `\\host`) or with a
|
|
9
|
+
* backslash.
|
|
8
10
|
* - `untrusted`: content an agent or a user produced. Only `https:`,
|
|
9
11
|
* `mailto:` and same-document `#fragment` links pass, and additionally:
|
|
10
12
|
* - no invisible formatting characters anywhere (zero-width, bidi
|
|
@@ -19,6 +21,7 @@
|
|
|
19
21
|
* `untrusted` (`resolveTrust`). A rejected value returns `undefined`, so the
|
|
20
22
|
* caller renders inert text.
|
|
21
23
|
*/
|
|
24
|
+
import { hasAuthorityPrefix } from "../url-policy/shared.js";
|
|
22
25
|
import { resolveTrust } from "../url-policy/trust.js";
|
|
23
26
|
const BLOCKED_SCHEMES = new Set(['javascript:', 'vbscript:', 'data:']);
|
|
24
27
|
const UNTRUSTED_SCHEMES = new Set(['https:', 'mailto:']);
|
|
@@ -67,7 +70,8 @@ export function safeHref(value, requested = 'untrusted') {
|
|
|
67
70
|
return undefined;
|
|
68
71
|
if (href.startsWith('#'))
|
|
69
72
|
return href;
|
|
70
|
-
|
|
73
|
+
// `//`, `/\`, `\/` and `\\` all open another origin; so does any leading `\`.
|
|
74
|
+
if (hasAuthorityPrefix(href))
|
|
71
75
|
return undefined;
|
|
72
76
|
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(href)?.[1]?.toLowerCase();
|
|
73
77
|
if (trust === 'untrusted') {
|
|
@@ -1,4 +1,12 @@
|
|
|
1
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;
|
|
2
10
|
export declare function sourceScheme(value: string): string | undefined;
|
|
3
11
|
export declare function isBlockedSource(value: string): boolean;
|
|
4
12
|
export declare function isAllowedRelativeOrWebSource(value: string): boolean;
|
|
@@ -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);
|