ink-framework 0.0.0-stage → 0.1.0-alpha.1

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.
@@ -0,0 +1,341 @@
1
+ declare const process: { env: { NODE_ENV: string } };
2
+ import { createContext, type ReactNode } from "./react";
3
+ import Reconciler from "react-reconciler";
4
+ import { ConcurrentRoot, DefaultEventPriority, DiscreteEventPriority } from "react-reconciler/constants";
5
+ import { onNativeMessage } from "./native";
6
+ import { pressHandler } from "./press";
7
+ import type { Destination } from "./navigation";
8
+ import { listPatch, type NativeListItem } from "./list-patch";
9
+
10
+ declare const __inkCommit: (operations: Operation[]) => void;
11
+ declare const __inkPost: (message: string) => void;
12
+
13
+ type Props = Record<string, unknown>;
14
+ type Instance = { id: number; type: string; props: Props; children: Instance[]; hidden?: boolean };
15
+ type Container = { id: number; children: Instance[] };
16
+ type Operation =
17
+ | { op: "create"; id: number; type: string; props: Props }
18
+ | { op: "update"; id: number; props: Props }
19
+ | { op: "text"; changes: (number | string)[] }
20
+ | { op: "insert"; id: number; parent: number; before: number | null }
21
+ | { op: "remove"; id: number; parent: number }
22
+ | { op: "hidden"; id: number; value: boolean };
23
+
24
+ const mounted = new Map<number, Instance>();
25
+ let nextId = 1;
26
+ let operations: Operation[] = [];
27
+ let textChanges: (number | string)[] | undefined;
28
+ let commitScheduled = false;
29
+ let priority = DefaultEventPriority;
30
+ const hostContext = {};
31
+
32
+ function allocateHostId() {
33
+ if (!Number.isSafeInteger(nextId)) throw new RangeError("Host identifiers exhausted");
34
+ return nextId++;
35
+ }
36
+
37
+ function queue(operation: Operation, preserveTextBatch = false) {
38
+ if (!preserveTextBatch) textChanges = undefined;
39
+ operations.push(operation);
40
+ }
41
+
42
+ function createTextBatch() {
43
+ const changes: (number | string)[] = [];
44
+ operations.push({ op: "text", changes });
45
+ return textChanges = changes;
46
+ }
47
+
48
+ function textContent(type: string, props: Props): string | null {
49
+ if (type !== "Text") return null;
50
+ const value = props.children;
51
+ return typeof value === "string" ? value : typeof value === "number" || typeof value === "bigint" ? String(value) : null;
52
+ }
53
+
54
+ function hostProps(type: string, props: Props): Props {
55
+ if (type !== "Text" || props.href === undefined && props.url === undefined) return props;
56
+ const { href, url, onPress, ...rest } = props;
57
+ return { ...rest, onPress: pressHandler({ href: href as Destination | undefined, url: url as string | undefined, onPress: onPress as (() => void) | undefined }) };
58
+ }
59
+
60
+ function nativeValue(value: unknown): unknown {
61
+ return typeof value === "function" ? true : value;
62
+ }
63
+
64
+ function nativeProps(props: Props, type: string): Props {
65
+ const result: Props = {};
66
+ for (const name of Object.keys(props)) {
67
+ if (name === "children" || name === "ref") continue;
68
+ const value = props[name];
69
+ if (value === undefined) continue;
70
+ result[name] = typeof value === "function" ? true : value;
71
+ }
72
+ const text = textContent(type, props);
73
+ if (text !== null) result.text = text;
74
+ return result;
75
+ }
76
+
77
+ function publish(instance: Instance) {
78
+ if (mounted.has(instance.id)) return;
79
+ mounted.set(instance.id, instance);
80
+ queue({ op: "create", id: instance.id, type: instance.type, props: nativeProps(instance.props, instance.type) });
81
+ if (instance.hidden) queue({ op: "hidden", id: instance.id, value: true });
82
+ for (const child of instance.children) {
83
+ publish(child);
84
+ queue({ op: "insert", id: child.id, parent: instance.id, before: null });
85
+ }
86
+ }
87
+
88
+ function insert(parent: Container, child: Instance, before?: Instance) {
89
+ const previous = parent.children.indexOf(child);
90
+ if (previous !== -1) parent.children.splice(previous, 1);
91
+ const index = before ? parent.children.indexOf(before) : parent.children.length;
92
+ parent.children.splice(index, 0, child);
93
+ publish(child);
94
+ queue({ op: "insert", id: child.id, parent: parent.id, before: before?.id ?? null });
95
+ }
96
+
97
+ function forget(instance: Instance) {
98
+ mounted.delete(instance.id);
99
+ for (const child of instance.children) forget(child);
100
+ }
101
+
102
+ function remove(parent: Container, child: Instance) {
103
+ parent.children.splice(parent.children.indexOf(child), 1);
104
+ queue({ op: "remove", id: child.id, parent: parent.id });
105
+ forget(child);
106
+ }
107
+
108
+ function sameListValue(a: unknown, b: unknown): boolean {
109
+ if (Object.is(a, b)) return true;
110
+ if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
111
+ if (Array.isArray(a)) return Array.isArray(b) && a.length === b.length && a.every((value, index) => sameListValue(value, b[index]));
112
+ if (Array.isArray(b)) return false;
113
+ const previous = a as Props, next = b as Props;
114
+ const keys = Object.keys(previous);
115
+ return keys.length === Object.keys(next).length && keys.every(key => Object.hasOwn(next, key) && sameListValue(previous[key], next[key]));
116
+ }
117
+
118
+ function compiledListUpdate(previous: Props, props: Props): Props | null {
119
+ type Item = NativeListItem;
120
+ const before = previous.items as Item[], after = props.items as Item[];
121
+ const patch = listPatch(before, after);
122
+ let byKey: Map<string, Item> | undefined;
123
+ let reordered = patch ? patch.keys !== undefined : before.length !== after.length;
124
+ const changes = patch?.changes ?? [];
125
+ for (let index = 0; !patch && before !== after && index < after.length; index++) {
126
+ const item = after[index];
127
+ let old: Item | undefined = before[index];
128
+ if (old?.key !== item.key) {
129
+ reordered = true;
130
+ byKey ??= new Map(before.map(item => [item.key, item]));
131
+ old = byKey.get(item.key);
132
+ }
133
+ if (!old || !sameListValue(old.measurementKey, item.measurementKey) || !sameListValue(old.values, item.values)) changes.push(item);
134
+ }
135
+ const next = nativeProps(props, "NativeList");
136
+ delete next.items;
137
+ delete next.template;
138
+ const metadataChanged = Object.keys({ ...previous, ...props }).some(name =>
139
+ name !== "items" && name !== "template" && !Object.is(nativeValue(previous[name]), nativeValue(props[name])));
140
+ if (!changes.length && !reordered && !metadataChanged) return null;
141
+ next.itemChanges = changes;
142
+ if (reordered) next.itemKeys = patch?.keys ?? after.map(item => item.key);
143
+ return next;
144
+ }
145
+
146
+ function update(instance: Instance, type: string, _oldProps: Props, props: Props) {
147
+ const previous = instance.props;
148
+ const isText = type === "Text";
149
+ if (isText) props = hostProps(type, props);
150
+ instance.props = props;
151
+ if (type === "NativeList" && props.keyField === "key" && previous.template === props.template) {
152
+ const next = compiledListUpdate(previous, props);
153
+ if (next) queue({ op: "update", id: instance.id, props: next }, true);
154
+ return;
155
+ }
156
+ if (isText) {
157
+ if (previous.size !== props.size || previous.width !== props.width
158
+ || previous.align !== props.align || previous.maxLines !== props.maxLines
159
+ || previous.tabularNumbers !== props.tabularNumbers
160
+ || (previous.onPress !== props.onPress && nativeValue(previous.onPress) !== nativeValue(props.onPress))) {
161
+ queue({ op: "update", id: instance.id, props: nativeProps(props, type) });
162
+ return;
163
+ }
164
+ const children = props.children;
165
+ if (children !== previous.children) {
166
+ if (typeof children === "string") return (textChanges ?? createTextBatch()).push(instance.id, children);
167
+ if (typeof children === "number" || typeof children === "bigint") return (textChanges ?? createTextBatch()).push(instance.id, String(children));
168
+ }
169
+ return;
170
+ }
171
+ let previousCount = 0;
172
+ let changed = false;
173
+ for (const name of Object.keys(previous)) {
174
+ if (name === "children" || name === "ref") continue;
175
+ const value = previous[name];
176
+ if (value === undefined) continue;
177
+ previousCount++;
178
+ const next = props[name];
179
+ if (!Object.is(
180
+ typeof value === "function" ? true : value,
181
+ typeof next === "function" ? true : next,
182
+ ) || !Object.hasOwn(props, name)) {
183
+ changed = true;
184
+ break;
185
+ }
186
+ }
187
+ if (!changed) {
188
+ let nextCount = 0;
189
+ for (const name of Object.keys(props)) {
190
+ if (name !== "children" && name !== "ref" && props[name] !== undefined) nextCount++;
191
+ }
192
+ if (previousCount === nextCount) return;
193
+ }
194
+ const next = nativeProps(props, type);
195
+ if (type === "List" && nativeValue(previous.revision) === next.revision
196
+ && nativeValue(previous.keys) === next.keys && nativeValue(previous.contentVersions) === next.contentVersions) {
197
+ delete next.keys;
198
+ delete next.contentVersions;
199
+ }
200
+ // Other node props cannot overwrite a pending text value.
201
+ queue({ op: "update", id: instance.id, props: next }, type !== "#text");
202
+ }
203
+
204
+ function hide(instance: Instance, value: boolean) {
205
+ instance.hidden = value;
206
+ if (mounted.has(instance.id)) queue({ op: "hidden", id: instance.id, value });
207
+ }
208
+
209
+ function errorMessage(error: unknown, info: { componentStack?: string | null }) {
210
+ const message = error instanceof Error ? `${error.name}: ${error.message}\n${error.stack ?? ""}` : String(error);
211
+ return info.componentStack ? `${message}\n${info.componentStack}` : message;
212
+ }
213
+
214
+ function report(error: unknown, info: { componentStack?: string | null }) {
215
+ __inkPost(JSON.stringify({ type: "error", message: errorMessage(error, info) }));
216
+ }
217
+
218
+ function reportRecovered(error: unknown, info: { componentStack?: string | null }) {
219
+ __inkPost(JSON.stringify({ type: "log", level: "error", message: errorMessage(error, info) }));
220
+ }
221
+
222
+ const reconciler = Reconciler<string, Props, Container, Instance, Instance, never, never, never,
223
+ Instance, object, never, ReturnType<typeof setTimeout>, -1, null>({
224
+ supportsMutation: true,
225
+ supportsPersistence: false,
226
+ supportsHydration: false,
227
+ isPrimaryRenderer: true,
228
+ supportsMicrotasks: true,
229
+ createInstance: (type, props) => ({ id: allocateHostId(), type, props: hostProps(type, props), children: [] }),
230
+ createTextInstance: text => ({ id: allocateHostId(), type: "#text", props: { text }, children: [] }),
231
+ appendInitialChild: (parent, child) => { parent.children.push(child); },
232
+ finalizeInitialChildren: () => false,
233
+ shouldSetTextContent: (type, props) => {
234
+ if (type !== "Text") return false;
235
+ const kind = typeof props.children;
236
+ return kind === "string" || kind === "number" || kind === "bigint";
237
+ },
238
+ getRootHostContext: () => hostContext,
239
+ getChildHostContext: () => hostContext,
240
+ getPublicInstance: instance => instance,
241
+ prepareForCommit: () => null,
242
+ resetAfterCommit: () => {
243
+ if (!operations.length || commitScheduled) return;
244
+ commitScheduled = true;
245
+ queueMicrotask(() => {
246
+ // Revealing an Activity reconnects store subscriptions in passive effects.
247
+ // Include their synchronous corrections before presenting the page.
248
+ while (reconciler.flushPassiveEffects()) {}
249
+ reconciler.flushSyncWork();
250
+ const committed = operations;
251
+ operations = [];
252
+ textChanges = undefined;
253
+ commitScheduled = false;
254
+ if (committed.length) __inkCommit(committed);
255
+ });
256
+ },
257
+ preparePortalMount: () => {},
258
+ scheduleTimeout: setTimeout,
259
+ cancelTimeout: clearTimeout,
260
+ noTimeout: -1,
261
+ scheduleMicrotask: queueMicrotask,
262
+ getInstanceFromNode: () => null,
263
+ beforeActiveInstanceBlur: () => {},
264
+ afterActiveInstanceBlur: () => {},
265
+ prepareScopeUpdate: () => {},
266
+ getInstanceFromScope: () => null,
267
+ detachDeletedInstance: () => {},
268
+ appendChild: insert,
269
+ appendChildToContainer: insert,
270
+ insertBefore: insert,
271
+ insertInContainerBefore: insert,
272
+ removeChild: remove,
273
+ removeChildFromContainer: remove,
274
+ resetTextContent: instance => { (textChanges ?? createTextBatch()).push(instance.id, ""); },
275
+ commitTextUpdate: (instance, _oldText, text) => {
276
+ instance.props = { text };
277
+ (textChanges ?? createTextBatch()).push(instance.id, text);
278
+ },
279
+ commitUpdate: update,
280
+ hideInstance: instance => hide(instance, true),
281
+ hideTextInstance: instance => hide(instance, true),
282
+ unhideInstance: instance => hide(instance, false),
283
+ unhideTextInstance: instance => hide(instance, false),
284
+ clearContainer: container => {
285
+ for (const child of [...container.children]) remove(container, child);
286
+ },
287
+ NotPendingTransition: null,
288
+ // React's public Context type omits the fields consumed by its reconciler.
289
+ HostTransitionContext: createContext(null) as unknown as Reconciler.ReactContext<null>,
290
+ setCurrentUpdatePriority: value => { priority = value; },
291
+ getCurrentUpdatePriority: () => priority,
292
+ resolveUpdatePriority: () => priority,
293
+ resetFormInstance: () => {},
294
+ requestPostPaintCallback: callback => { setTimeout(() => callback(performance.now()), 0); },
295
+ shouldAttemptEagerTransition: () => false,
296
+ trackSchedulerEvent: () => {},
297
+ resolveEventType: () => null,
298
+ resolveEventTimeStamp: () => performance.now(),
299
+ maySuspendCommit: () => false,
300
+ preloadInstance: () => true,
301
+ startSuspendingCommit: () => {},
302
+ suspendInstance: () => {},
303
+ waitForCommitToBeReady: () => null,
304
+ });
305
+
306
+ export function dispatchEvent(id: number, name: string, args: unknown[]) {
307
+ const handler = mounted.get(id)?.props[name];
308
+ if (typeof handler !== "function") return;
309
+ const previous = priority;
310
+ priority = DiscreteEventPriority;
311
+ try {
312
+ handler(...args);
313
+ } finally {
314
+ priority = previous;
315
+ }
316
+ }
317
+
318
+ let developmentRoot: ReturnType<typeof reconciler.createContainer> | undefined;
319
+ if (process.env.NODE_ENV === "development") {
320
+ reconciler.injectIntoDevTools({ bundleType: 1, version: "19.2.8", rendererPackageName: "ink" });
321
+ }
322
+
323
+ export function render(element: ReactNode) {
324
+ if (process.env.NODE_ENV === "development" && developmentRoot) {
325
+ const root = developmentRoot;
326
+ reconciler.updateContainer(element, root, null, null);
327
+ return () => reconciler.updateContainer(null, root, null, null);
328
+ }
329
+ onNativeMessage("event", message => {
330
+ if (typeof message.id !== "number" || !Number.isSafeInteger(message.id)
331
+ || typeof message.name !== "string" || !Array.isArray(message.args)) {
332
+ throw new Error("Invalid native event");
333
+ }
334
+ dispatchEvent(message.id, message.name, message.args);
335
+ });
336
+ const container: Container = { id: 0, children: [] };
337
+ const root = reconciler.createContainer(container, ConcurrentRoot, null, false, null, "", report, reportRecovered, reportRecovered, () => {});
338
+ if (process.env.NODE_ENV === "development") developmentRoot = root;
339
+ reconciler.updateContainer(element, root, null, null);
340
+ return () => reconciler.updateContainer(null, root, null, null);
341
+ }
package/src/reorder.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { createElement } from "./react";
2
+ import { Button, Stack, Text } from "./index";
3
+ import { keyboardArrowDown, keyboardArrowUp } from "./icons";
4
+ import { List } from "./list";
5
+ import { nativeListRow, nativeListTemplate } from "./native-list";
6
+
7
+ const template = /* @__PURE__ */ nativeListTemplate((node, field) => node("Stack", { axis: "horizontal", align: "center", justify: "space-between", gap: 12 }, [
8
+ node("Text", { text: field("label"), maxLines: field("maxLines") }),
9
+ node("Stack", { axis: "horizontal", align: "center", gap: 4 }, [
10
+ node("Button", { icon: keyboardArrowDown, disabled: field("last"), onPress: field("down") }),
11
+ node("Button", { icon: keyboardArrowUp, disabled: field("first"), onPress: field("up") }),
12
+ ]),
13
+ ]));
14
+
15
+ export type ReorderListProps<T> = {
16
+ items: readonly T[];
17
+ keyExtractor: (item: T) => string;
18
+ getLabel: (item: T) => string;
19
+ onChange: (items: T[]) => void;
20
+ multiline?: boolean;
21
+ gap?: number;
22
+ };
23
+
24
+ export function ReorderList<T>({ items, keyExtractor, getLabel, onChange, multiline = false, gap }: ReorderListProps<T>) {
25
+ function move(index: number, direction: -1 | 1) {
26
+ const target = index + direction;
27
+ if (target < 0 || target >= items.length) return;
28
+ const next = [...items];
29
+ [next[index], next[target]] = [next[target], next[index]];
30
+ onChange(next);
31
+ }
32
+
33
+ return createElement(List<T>, {
34
+ items,
35
+ keyExtractor,
36
+ gap,
37
+ measurementKey: multiline ? "multiline" : "single-line",
38
+ renderItem: nativeListRow((item: T, index: number) => createElement(Stack, {
39
+ axis: "horizontal", align: "center", justify: "space-between", gap: 12,
40
+ },
41
+ createElement(Text, { maxLines: multiline ? undefined : 1 }, getLabel(item)),
42
+ createElement(Stack, { axis: "horizontal", align: "center", gap: 4 },
43
+ createElement(Button, { icon: keyboardArrowDown, disabled: index === items.length - 1, onPress: () => move(index, 1) }),
44
+ createElement(Button, { icon: keyboardArrowUp, disabled: index === 0, onPress: () => move(index, -1) }),
45
+ )), (item, index) => [{ label: getLabel(item), maxLines: multiline ? undefined : 1,
46
+ first: index === 0, last: index === items.length - 1,
47
+ down: () => move(index, 1), up: () => move(index, -1) }], template),
48
+ });
49
+ }
@@ -0,0 +1,134 @@
1
+ import type { Snapshot, SnapshotSource } from "./snapshot";
2
+
3
+ export type ResourceSnapshot<T> =
4
+ | Exclude<Snapshot<T>, { status: "ready" }>
5
+ | { status: "ready"; data: T; refreshing: boolean; refreshError: Error | null };
6
+
7
+ export interface ResourceSource<T> extends SnapshotSource<T> {
8
+ getSnapshot(): ResourceSnapshot<T>;
9
+ refresh(): Promise<void>;
10
+ }
11
+
12
+ type Key = readonly (string | number | boolean | null)[];
13
+ const CACHE_TIME = 300_000;
14
+ const ERROR_BACKOFF = 60_000;
15
+
16
+ export function resource<Args extends unknown[], T>(options: {
17
+ key: (...args: Args) => Key;
18
+ load: (...args: Args) => Promise<T>;
19
+ staleTime?: number;
20
+ refreshInterval?: number;
21
+ }): (...args: Args) => ResourceSource<T> {
22
+ const staleTime = options.staleTime ?? 0;
23
+ const interval = options.refreshInterval;
24
+ if (!Number.isFinite(staleTime) || staleTime < 0 ||
25
+ (interval !== undefined && (!Number.isFinite(interval) || interval <= 0))) {
26
+ throw new Error("Resource timings must be finite; staleTime must be non-negative and refreshInterval must be positive.");
27
+ }
28
+ const entries = new Map<string, ReturnType<typeof createEntry>>();
29
+
30
+ function entryFor(key: string, args: Args) {
31
+ let entry = entries.get(key);
32
+ if (!entry) {
33
+ entry = createEntry(key, args);
34
+ entries.set(key, entry);
35
+ }
36
+ return entry;
37
+ }
38
+
39
+ function createEntry(key: string, args: Args) {
40
+ let state: ResourceSnapshot<T> = { status: "loading" };
41
+ let pending: Promise<void> | null = null;
42
+ let updatedAt = 0;
43
+ let retryAt = 0;
44
+ let polling: ReturnType<typeof setTimeout> | undefined;
45
+ let expiry: ReturnType<typeof setTimeout> | undefined;
46
+ const listeners = new Set<() => void>();
47
+
48
+ function publish(next: ResourceSnapshot<T>) {
49
+ state = next;
50
+ for (const listener of listeners) listener();
51
+ }
52
+
53
+ function expire() {
54
+ if (expiry !== undefined) clearTimeout(expiry);
55
+ expiry = setTimeout(() => {
56
+ expiry = undefined;
57
+ if (!listeners.size && !pending) {
58
+ state = { status: "loading" };
59
+ entries.delete(key);
60
+ }
61
+ }, CACHE_TIME);
62
+ }
63
+
64
+ function schedule() {
65
+ if (polling !== undefined) clearTimeout(polling);
66
+ polling = undefined;
67
+ if (listeners.size && interval !== undefined && state.status === "ready" && !state.refreshError) {
68
+ polling = setTimeout(() => { void refresh(true); }, Math.max(0, updatedAt + interval - Date.now()));
69
+ }
70
+ }
71
+
72
+ function refresh(force = false): Promise<void> {
73
+ if (pending) return pending;
74
+ if (!force && (Date.now() < retryAt ||
75
+ (state.status === "ready" && Date.now() < updatedAt + staleTime))) return Promise.resolve();
76
+ if (polling !== undefined) clearTimeout(polling);
77
+ polling = undefined;
78
+ // Establish the pending request before notifying subscribers, which may call refresh.
79
+ pending = Promise.resolve().then(() => options.load(...args)).then(data => {
80
+ updatedAt = Date.now();
81
+ retryAt = 0;
82
+ publish({ status: "ready", data, refreshing: false, refreshError: null });
83
+ }).catch(cause => {
84
+ const error = cause instanceof Error ? cause : new Error(String(cause));
85
+ retryAt = Date.now() + ERROR_BACKOFF;
86
+ publish(state.status === "ready"
87
+ ? { ...state, refreshing: false, refreshError: error }
88
+ : { status: "error", error });
89
+ }).finally(() => {
90
+ pending = null;
91
+ if (listeners.size) schedule();
92
+ else expire();
93
+ });
94
+ publish(state.status === "ready"
95
+ ? { ...state, refreshing: true, refreshError: null }
96
+ : { status: "loading" });
97
+ return pending;
98
+ }
99
+
100
+ function subscribe(listener: () => void) {
101
+ if (expiry !== undefined) clearTimeout(expiry);
102
+ expiry = undefined;
103
+ listeners.add(listener);
104
+ void refresh();
105
+ if (!pending) schedule();
106
+ return () => {
107
+ listeners.delete(listener);
108
+ if (!listeners.size) {
109
+ if (polling !== undefined) clearTimeout(polling);
110
+ polling = undefined;
111
+ expire();
112
+ }
113
+ };
114
+ }
115
+
116
+ // Old handles still select the canonical entry after inactive data has expired.
117
+ const source: ResourceSource<T> = {
118
+ getSnapshot: () => entryFor(key, args).snapshot(),
119
+ subscribe: listener => entryFor(key, args).subscribe(listener),
120
+ refresh: () => entryFor(key, args).refresh(true),
121
+ };
122
+ expire();
123
+ return { source, snapshot: () => state, subscribe, refresh };
124
+ }
125
+
126
+ return (...args) => {
127
+ const parts = options.key(...args);
128
+ if (parts.some(part => part !== null && typeof part !== "string" && typeof part !== "boolean" &&
129
+ (typeof part !== "number" || !Number.isFinite(part)))) {
130
+ throw new Error("Resource keys must contain only strings, finite numbers, booleans or null.");
131
+ }
132
+ return entryFor(JSON.stringify(parts), args).source;
133
+ };
134
+ }
@@ -0,0 +1,38 @@
1
+ export type PathParams<Path extends string> =
2
+ Path extends `${string}[${infer Name}]${infer Rest}`
3
+ ? { [Key in Name | keyof PathParams<Rest>]: string }
4
+ : Record<never, never>;
5
+
6
+ export function parameterNames(path: string): string[] {
7
+ return [...path.matchAll(/\[([A-Za-z_][A-Za-z0-9_]*)\]/g)].map(match => match[1]);
8
+ }
9
+
10
+ export function matchPath(pattern: string, path: string): Record<string, string> | undefined {
11
+ const expected = pattern.split("/");
12
+ const actual = path.split("/");
13
+ if (expected.length !== actual.length) return;
14
+ const params: Record<string, string> = {};
15
+ for (let index = 0; index < expected.length; index++) {
16
+ const name = /^\[([A-Za-z_][A-Za-z0-9_]*)\]$/.exec(expected[index])?.[1];
17
+ if (name) {
18
+ if (!actual[index]) return;
19
+ try {
20
+ Object.defineProperty(params, name, {
21
+ value: decodeURIComponent(actual[index]), enumerable: true, writable: true, configurable: true,
22
+ });
23
+ } catch { return; }
24
+ } else if (expected[index] !== actual[index]) return;
25
+ }
26
+ return params;
27
+ }
28
+
29
+ // Compare each segment so a static name wins over a parameter at the same depth.
30
+ export function comparePaths(left: string, right: string): number {
31
+ const a = left.split("/");
32
+ const b = right.split("/");
33
+ for (let index = 0; index < Math.min(a.length, b.length); index++) {
34
+ const difference = Number(a[index].startsWith("[")) - Number(b[index].startsWith("["));
35
+ if (difference) return difference;
36
+ }
37
+ return left.localeCompare(right);
38
+ }
package/src/scroll.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { createContext } from "./react";
2
+
3
+ export const ScrollToEndContext = /* @__PURE__ */ createContext<(() => void) | undefined>(undefined);
@@ -0,0 +1,18 @@
1
+ import { useSyncExternalStore } from "./react";
2
+ import type { ResourceSnapshot, ResourceSource } from "./resource";
3
+
4
+ export type Snapshot<T> =
5
+ | { status: "loading" }
6
+ | { status: "ready"; data: T }
7
+ | { status: "error"; error: Error };
8
+
9
+ export interface SnapshotSource<T> {
10
+ subscribe(listener: () => void): () => void;
11
+ getSnapshot(): Snapshot<T>;
12
+ }
13
+
14
+ export function useSnapshot<T>(source: ResourceSource<T>): ResourceSnapshot<T>;
15
+ export function useSnapshot<T>(source: SnapshotSource<T>): Snapshot<T>;
16
+ export function useSnapshot<T>(source: SnapshotSource<T>): Snapshot<T> {
17
+ return useSyncExternalStore(source.subscribe, source.getSnapshot, source.getSnapshot);
18
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "jsx": "react-jsx",
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "skipLibCheck": true,
10
+ "lib": ["ES2022", "ES2023.Array"],
11
+ "types": ["ink/runtime-types", "react"]
12
+ }
13
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "jsx": "react-jsx",
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "skipLibCheck": true,
10
+ "lib": [
11
+ "ES2022"
12
+ ],
13
+ "types": [
14
+ "react"
15
+ ]
16
+ },
17
+ "include": [
18
+ "src",
19
+ "runtime.d.ts"
20
+ ]
21
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.