@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 CHANGED
@@ -1,139 +1,154 @@
1
1
  # @texuli/core
2
2
 
3
- **Layer L0: texels.** Framework-free TypeScript behavior and policy that every
4
- Texuli component is built from. No Svelte, no runtime dependencies. Private
5
- workspace package (`"private": true`), not published.
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
- ## What exists today
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
- Every texel in the table below is implemented, tested and documented in this
10
- package. The public API is exactly what the root barrel `src/index.ts`
11
- exports; each texel is also a per-area subpath export in `package.json`
12
- (`@texuli/core/<area>`), so consumers can import narrowly and tree-shake the
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
- | Texel | Headline exports | Doc |
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
- const stack = getLayerStack();
76
- const layer = stack.register({ element: panelEl, label: 'dialog' });
77
- decideEscape(stack, { key: 'Escape' }); // reaches only the topmost layer
78
- layer.unregister();
21
+ ```bash
22
+ npm install @texuli/core@next
23
+ ```
79
24
 
80
- const stops = getTabbableElements(dialogElement);
81
- isTabbable(stops[0]!); // true
25
+ ```bash
26
+ pnpm add @texuli/core@next
27
+ # or
28
+ yarn add @texuli/core@next
82
29
  ```
83
30
 
84
- ### URL policy tiers
31
+ No peer dependencies and no CSS. The package is ESM-only and ships
32
+ TypeScript declarations.
85
33
 
86
- - **`untrusted`** (default, for agent or user content): only `https:`,
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
- Images and media use the element-specific policies: `safeImageSrc` allows
94
- HTTPS and relative assets from either tier, plus (developer tier only) HTTP,
95
- valid blob URLs, and PNG, JPEG, GIF, WebP or AVIF base64 data images up to
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
- ## Rules
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
- - Framework-free: no Svelte or other UI framework imports; standard DOM types
118
- are fine.
119
- - No runtime dependencies; no imports from any other workspace package (L0 is
120
- the bottom layer).
121
- - Policy functions fail closed and document their contract in JSDoc.
122
- - Every export ships with tests in `tests/<name>.test.ts`, including hostile
123
- inputs for anything that guards a sink.
124
- - Ids never use `Math.random` at module scope; scopes are per-root.
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
- ## Testing
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
- ```bash
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
- Core is shared by every layer above it, so a change here also runs
136
- `pnpm check`, the primitives behavior suite and `pnpm build`
137
- ([TESTING_GUIDE](../../docs/TESTING_GUIDE.md)).
138
- To add a texel, follow
139
- [`.agents/skills/add-texel/SKILL.md`](../../.agents/skills/add-texel/SKILL.md).
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.
@@ -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);
@@ -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 `//host` URLs.
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
- if (href.startsWith('//') || href.startsWith('\\'))
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 ((scheme !== undefined && BLOCKED_SCHEMES.has(`${scheme}:`)) ||
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@texuli/core",
3
- "version": "0.1.0-next.1",
3
+ "version": "0.1.0-next.3",
4
4
  "description": "Texels: framework-free behavior and policy that every Texuli component is built from.",
5
5
  "keywords": [
6
6
  "svelte",