@dropsh/plugin-tui 0.5.3 → 0.5.6

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,54 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { useState } from "react";
4
+ import { FocusRegistryProvider, useFocusRegistry } from "./link.js";
5
+ // Inner component: has access to the focus registry so Enter can read the
6
+ // focused link target and ask the router to navigate to it.
7
+ function Keys({ router, onExit, focusedIndex, setFocusedIndex, bump, setError, setNavSeq, }) {
8
+ const reg = useFocusRegistry();
9
+ useInput((input, key) => {
10
+ if (input === "q" || key.escape) {
11
+ const hasMore = router.back();
12
+ if (!hasMore)
13
+ onExit();
14
+ else {
15
+ setFocusedIndex(() => 0);
16
+ setError(null);
17
+ setNavSeq((n) => n + 1);
18
+ bump();
19
+ }
20
+ return;
21
+ }
22
+ if (key.upArrow)
23
+ setFocusedIndex((i) => Math.max(0, i - 1));
24
+ if (key.downArrow)
25
+ setFocusedIndex((i) => Math.min(Math.max(0, reg.count() - 1), i + 1));
26
+ if (key.return) {
27
+ const target = reg.targetAt(focusedIndex);
28
+ if (target) {
29
+ void router
30
+ .navigate(target.route, target.params)
31
+ .then(() => {
32
+ setFocusedIndex(() => 0);
33
+ setError(null);
34
+ setNavSeq((n) => n + 1);
35
+ bump();
36
+ })
37
+ .catch((err) => {
38
+ setError(err instanceof Error ? err.message : String(err));
39
+ bump();
40
+ });
41
+ }
42
+ }
43
+ });
44
+ return null;
45
+ }
46
+ export function Browse({ router, onExit }) {
47
+ const [focusedIndex, setFocusedIndex] = useState(0);
48
+ const [, setNonce] = useState(0);
49
+ const [error, setError] = useState(null);
50
+ const [navSeq, setNavSeq] = useState(0);
51
+ const bump = () => setNonce((n) => n + 1);
52
+ const element = router.current();
53
+ return (_jsxs(FocusRegistryProvider, { focusedIndex: focusedIndex, children: [_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { flexDirection: "column", children: element }, navSeq), error !== null ? _jsxs(Text, { color: "red", children: ["Error: ", error] }) : null, _jsx(Text, { dimColor: true, children: "(\u2191/\u2193: move, enter: open, q/esc: back)" })] }), _jsx(Keys, { router: router, onExit: onExit, focusedIndex: focusedIndex, setFocusedIndex: setFocusedIndex, bump: bump, setError: setError, setNavSeq: setNavSeq })] }));
54
+ }
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { label, splitType } from "./jsonapi-type.js";
4
+ import { TuiLink } from "./link.js";
5
+ export class TuiEntityList {
6
+ static entityType;
7
+ static bundle;
8
+ }
9
+ export class GenericEntityList extends TuiEntityList {
10
+ static entityType = "*";
11
+ build(resources, ctx) {
12
+ if (resources.length === 0)
13
+ return _jsx(Text, { dimColor: true, children: "(no results)" });
14
+ return (_jsx(Box, { flexDirection: "column", children: resources.map((res) => {
15
+ const { entityType, bundle } = splitType(res.type);
16
+ const params = { type: entityType, id: res.id };
17
+ if (bundle)
18
+ params.bundle = bundle;
19
+ return (_jsx(TuiLink, { target: ctx.link("entity.canonical", params), children: label(res) }, res.id));
20
+ }) }));
21
+ }
22
+ }
@@ -0,0 +1,27 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { splitType } from "./jsonapi-type.js";
4
+ import { TuiLink } from "./link.js";
5
+ export class TuiEntityView {
6
+ static entityType;
7
+ static bundle;
8
+ static viewModes = { default: {} };
9
+ }
10
+ export class GenericEntityView extends TuiEntityView {
11
+ static entityType = "*";
12
+ static viewModes = { default: {} };
13
+ build(entity, ctx) {
14
+ const attrs = entity.attributes ?? {};
15
+ const rels = entity.relationships ?? {};
16
+ return (_jsxs(Box, { flexDirection: "column", children: [Object.entries(attrs).map(([key, value]) => (_jsxs(Text, { children: [key, ": ", typeof value === "string" ? value : JSON.stringify(value)] }, `a:${key}`))), Object.entries(rels).map(([key, rel]) => {
17
+ const data = rel.data;
18
+ if (!data || Array.isArray(data))
19
+ return _jsxs(Text, { children: [key, ": \u2014"] }, `r:${key}`);
20
+ const { entityType, bundle } = splitType(data.type);
21
+ const params = { type: entityType, id: data.id };
22
+ if (bundle)
23
+ params.bundle = bundle;
24
+ return (_jsxs(Box, { children: [_jsxs(Text, { children: [key, ": "] }), _jsx(TuiLink, { target: ctx.link("entity.canonical", params), children: data.id })] }, `r:${key}`));
25
+ })] }));
26
+ }
27
+ }
@@ -0,0 +1,61 @@
1
+ import { ConfigError } from "dropsh/plugin";
2
+ import { splitType } from "./jsonapi-type.js";
3
+ import { buildRegistry } from "./registry.js";
4
+ import { createRouter } from "./router.js";
5
+ export { GenericEntityList, TuiEntityList } from "./entity-list.js";
6
+ export { GenericEntityView, TuiEntityView } from "./entity-view.js";
7
+ export { TuiLink } from "./link.js";
8
+ function assertTty(isTty) {
9
+ if (!isTty)
10
+ throw new ConfigError("--format tui requires an interactive terminal");
11
+ }
12
+ export function initialTarget(doc, ctx) {
13
+ if (ctx.command === "search") {
14
+ const params = { type: ctx.entityType ?? "" };
15
+ if (ctx.bundle)
16
+ params.bundle = ctx.bundle;
17
+ return { route: "entity.collection", params };
18
+ }
19
+ const first = (Array.isArray(doc.data) ? doc.data[0] : doc.data);
20
+ const { entityType, bundle } = splitType(first?.type ?? "");
21
+ const params = { type: entityType, id: first?.id ?? "" };
22
+ if (bundle)
23
+ params.bundle = bundle;
24
+ return { route: "entity.canonical", params };
25
+ }
26
+ async function runTui(doc, ctx, opts, services) {
27
+ assertTty(Boolean(process.stdout.isTTY));
28
+ if (!services)
29
+ throw new ConfigError("--format tui requires runtime services (client)");
30
+ const registry = buildRegistry(opts.plugins ?? []);
31
+ const router = createRouter({
32
+ registry,
33
+ client: services.client,
34
+ viewMode: ctx.viewMode ?? "default",
35
+ });
36
+ const target = initialTarget(doc, ctx);
37
+ await router.navigate(target.route, target.params, doc);
38
+ const [{ render }, React, { Browse }] = await Promise.all([
39
+ import("ink"),
40
+ import("react"),
41
+ import("./browse-app.js"),
42
+ ]);
43
+ const app = render(React.createElement(Browse, { router, onExit: () => app.unmount() }));
44
+ await app.waitUntilExit();
45
+ }
46
+ export function tuiPlugin(tuiOpts = {}) {
47
+ return {
48
+ id: "tui",
49
+ requiredModules: [],
50
+ async extendSchema(_entityType, _bundle, schema) {
51
+ return schema;
52
+ },
53
+ renderers: [
54
+ {
55
+ id: "tui",
56
+ interactive: true,
57
+ run: (doc, ctx, services) => runTui(doc, ctx, tuiOpts, services),
58
+ },
59
+ ],
60
+ };
61
+ }
@@ -0,0 +1,15 @@
1
+ // Splits a JSON:API type "node--article" into { entityType: "node", bundle: "article" }.
2
+ export function splitType(type) {
3
+ const [entityType, bundle] = type.split("--");
4
+ return bundle ? { entityType, bundle } : { entityType };
5
+ }
6
+ // Best-effort human label for a resource: first string-valued title/name/label attribute, else id.
7
+ export function label(res) {
8
+ const attrs = res.attributes ?? {};
9
+ for (const key of ["title", "name", "label"]) {
10
+ const v = attrs[key];
11
+ if (typeof v === "string")
12
+ return v;
13
+ }
14
+ return res.id;
15
+ }
@@ -0,0 +1,43 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Text } from "ink";
3
+ import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
4
+ const Ctx = createContext(null);
5
+ export function useFocusRegistry() {
6
+ const reg = useContext(Ctx);
7
+ if (!reg)
8
+ throw new Error("useFocusRegistry must be used within FocusRegistryProvider");
9
+ return reg;
10
+ }
11
+ export function FocusRegistryProvider({ focusedIndex = 0, children, }) {
12
+ const [entries, setEntries] = useState([]);
13
+ const reg = useMemo(() => ({
14
+ register(id, target) {
15
+ setEntries((prev) => (prev.some((e) => e.id === id) ? prev : [...prev, { id, target }]));
16
+ },
17
+ unregister(id) {
18
+ setEntries((prev) => prev.filter((e) => e.id !== id));
19
+ },
20
+ indexOf: (id) => entries.findIndex((e) => e.id === id),
21
+ count: () => entries.length,
22
+ focusedIndex,
23
+ targetAt: (i) => entries[i]?.target,
24
+ }), [entries, focusedIndex]);
25
+ return _jsx(Ctx.Provider, { value: reg, children: children });
26
+ }
27
+ export function TuiLink({ target, children, }) {
28
+ const reg = useFocusRegistry();
29
+ const idRef = useRef(null);
30
+ if (idRef.current === null)
31
+ idRef.current = Symbol("tuilink");
32
+ const id = idRef.current;
33
+ // Register once on mount, unregister on unmount. A link's target is fixed for
34
+ // its lifetime (derived from stable entity data), so no per-render update.
35
+ // biome-ignore lint/correctness/useExhaustiveDependencies: register once on mount only
36
+ useEffect(() => {
37
+ reg.register(id, target);
38
+ return () => reg.unregister(id);
39
+ }, []);
40
+ const index = reg.indexOf(id);
41
+ const isFocused = index === reg.focusedIndex;
42
+ return _jsx(Text, { inverse: isFocused, children: children });
43
+ }
@@ -0,0 +1,74 @@
1
+ import { DrupalJsonApiParams } from "drupal-jsonapi-params";
2
+ import React from "react";
3
+ import { GenericEntityList, TuiEntityList } from "./entity-list.js";
4
+ import { GenericEntityView, TuiEntityView } from "./entity-view.js";
5
+ function key(entityType, bundle) {
6
+ return `${entityType}:${bundle ?? ""}`;
7
+ }
8
+ function isView(c) {
9
+ return typeof c === "function" && c.prototype instanceof TuiEntityView;
10
+ }
11
+ function isList(c) {
12
+ return typeof c === "function" && c.prototype instanceof TuiEntityList;
13
+ }
14
+ function buildCtxLink(ctx) {
15
+ return {
16
+ viewMode: ctx.viewMode,
17
+ link: (route, params) => ({ route, params }),
18
+ };
19
+ }
20
+ function coreRoutes() {
21
+ const canonical = {
22
+ name: "entity.canonical",
23
+ path: "/{type}/{bundle}/{id}",
24
+ controller: async (params, ctx) => {
25
+ const ViewClass = ctx.resolveView(params.type, params.bundle);
26
+ const include = ViewClass?.viewModes?.[ctx.viewMode]?.include;
27
+ const path = params.bundle
28
+ ? `${params.type}/${params.bundle}/${params.id}`
29
+ : `${params.type}/${params.id}`;
30
+ const doc = (ctx.seededDoc ??
31
+ (include?.length
32
+ ? await ctx.client.get(path, new DrupalJsonApiParams().addInclude(include))
33
+ : await ctx.client.get(path)));
34
+ const entity = (Array.isArray(doc.data) ? doc.data[0] : doc.data);
35
+ const instance = new ViewClass();
36
+ return React.createElement(React.Fragment, null, instance.build(entity, { doc, ...buildCtxLink(ctx) }));
37
+ },
38
+ };
39
+ const collection = {
40
+ name: "entity.collection",
41
+ path: "/{type}",
42
+ controller: async (params, ctx) => {
43
+ const ListClass = ctx.resolveList(params.type, params.bundle);
44
+ const doc = (ctx.seededDoc ??
45
+ (await ctx.client.get(params.bundle ? `${params.type}/${params.bundle}` : params.type)));
46
+ const rows = (Array.isArray(doc.data) ? doc.data : [doc.data]);
47
+ const instance = new ListClass();
48
+ return React.createElement(React.Fragment, null, instance.build(rows, { doc, ...buildCtxLink(ctx) }));
49
+ },
50
+ };
51
+ return [canonical, collection];
52
+ }
53
+ export function buildRegistry(plugins) {
54
+ const views = new Map();
55
+ const lists = new Map();
56
+ const routes = new Map();
57
+ for (const r of coreRoutes())
58
+ routes.set(r.name, r);
59
+ for (const p of plugins) {
60
+ for (const entity of p.entities ?? []) {
61
+ if (isView(entity))
62
+ views.set(key(entity.entityType, entity.bundle), entity);
63
+ else if (isList(entity))
64
+ lists.set(key(entity.entityType, entity.bundle), entity);
65
+ }
66
+ for (const route of p.routes ?? [])
67
+ routes.set(route.name, route);
68
+ }
69
+ return {
70
+ resolveView: (t, b) => views.get(key(t, b)) ?? views.get(key(t)) ?? GenericEntityView,
71
+ resolveList: (t, b) => lists.get(key(t, b)) ?? lists.get(key(t)) ?? GenericEntityList,
72
+ routes,
73
+ };
74
+ }
@@ -0,0 +1,31 @@
1
+ export function createRouter(opts) {
2
+ const stack = [];
3
+ async function navigate(routeName, params, seededDoc) {
4
+ const route = opts.registry.routes.get(routeName);
5
+ if (!route)
6
+ throw new Error(`unknown route: ${routeName}`);
7
+ const ctx = {
8
+ client: opts.client,
9
+ viewMode: opts.viewMode,
10
+ ...(seededDoc !== undefined ? { seededDoc } : {}),
11
+ navigate: (r, p) => {
12
+ // Host-level navigation surfaces errors in the UI; a controller-initiated
13
+ // navigation failure must not become an unhandled rejection / crash.
14
+ void navigate(r, p).catch(() => { });
15
+ },
16
+ resolveView: opts.registry.resolveView,
17
+ resolveList: opts.registry.resolveList,
18
+ };
19
+ const element = await route.controller(params, ctx);
20
+ stack.push(element);
21
+ }
22
+ return {
23
+ stackDepth: () => stack.length,
24
+ current: () => stack[stack.length - 1] ?? null,
25
+ navigate,
26
+ back() {
27
+ stack.pop();
28
+ return stack.length > 0;
29
+ },
30
+ };
31
+ }
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@dropsh/plugin-tui",
3
- "version": "0.5.3",
3
+ "version": "0.5.6",
4
4
  "type": "module",
5
5
  "main": "./dist/plugins/tui/src/index.js",
6
6
  "exports": {
7
7
  ".": "./dist/plugins/tui/src/index.js"
8
8
  },
9
+ "files": [
10
+ "dist/plugins/tui/src"
11
+ ],
9
12
  "publishConfig": {
10
13
  "access": "public"
11
14
  },
@@ -15,14 +18,14 @@
15
18
  "dependencies": {
16
19
  "ink": "^5.0.0",
17
20
  "react": "^18.3.0",
18
- "@dropsh/plugin-markdown": "0.5.3"
21
+ "@dropsh/plugin-markdown": "0.5.6"
19
22
  },
20
23
  "devDependencies": {
21
24
  "@types/react": "^18.3.0",
22
25
  "ink-testing-library": "^4.0.0",
23
26
  "typescript": "^5.6.0",
24
27
  "vitest": "^2.1.0",
25
- "dropsh": "0.5.3"
28
+ "dropsh": "0.5.6"
26
29
  },
27
30
  "scripts": {
28
31
  "build": "tsc",
@@ -1,94 +0,0 @@
1
- import { Box, Text, useInput } from "ink";
2
- import type React from "react";
3
- import { useState } from "react";
4
- import { FocusRegistryProvider, useFocusRegistry } from "./link.js";
5
- import type { Router } from "./router.js";
6
-
7
- export interface BrowseProps {
8
- router: Router;
9
- onExit: () => void;
10
- }
11
-
12
- // Inner component: has access to the focus registry so Enter can read the
13
- // focused link target and ask the router to navigate to it.
14
- function Keys({
15
- router,
16
- onExit,
17
- focusedIndex,
18
- setFocusedIndex,
19
- bump,
20
- setError,
21
- setNavSeq,
22
- }: {
23
- router: Router;
24
- onExit: () => void;
25
- focusedIndex: number;
26
- setFocusedIndex: (fn: (i: number) => number) => void;
27
- bump: () => void;
28
- setError: (msg: string | null) => void;
29
- setNavSeq: (fn: (n: number) => number) => void;
30
- }): null {
31
- const reg = useFocusRegistry();
32
- useInput((input, key) => {
33
- if (input === "q" || key.escape) {
34
- const hasMore = router.back();
35
- if (!hasMore) onExit();
36
- else {
37
- setFocusedIndex(() => 0);
38
- setError(null);
39
- setNavSeq((n) => n + 1);
40
- bump();
41
- }
42
- return;
43
- }
44
- if (key.upArrow) setFocusedIndex((i) => Math.max(0, i - 1));
45
- if (key.downArrow) setFocusedIndex((i) => Math.min(Math.max(0, reg.count() - 1), i + 1));
46
- if (key.return) {
47
- const target = reg.targetAt(focusedIndex);
48
- if (target) {
49
- void router
50
- .navigate(target.route, target.params)
51
- .then(() => {
52
- setFocusedIndex(() => 0);
53
- setError(null);
54
- setNavSeq((n) => n + 1);
55
- bump();
56
- })
57
- .catch((err: unknown) => {
58
- setError(err instanceof Error ? err.message : String(err));
59
- bump();
60
- });
61
- }
62
- }
63
- });
64
- return null;
65
- }
66
-
67
- export function Browse({ router, onExit }: BrowseProps): React.ReactElement {
68
- const [focusedIndex, setFocusedIndex] = useState(0);
69
- const [, setNonce] = useState(0);
70
- const [error, setError] = useState<string | null>(null);
71
- const [navSeq, setNavSeq] = useState(0);
72
- const bump = () => setNonce((n) => n + 1);
73
- const element = router.current();
74
- return (
75
- <FocusRegistryProvider focusedIndex={focusedIndex}>
76
- <Box flexDirection="column">
77
- <Box key={navSeq} flexDirection="column">
78
- {element}
79
- </Box>
80
- {error !== null ? <Text color="red">Error: {error}</Text> : null}
81
- <Text dimColor>(↑/↓: move, enter: open, q/esc: back)</Text>
82
- </Box>
83
- <Keys
84
- router={router}
85
- onExit={onExit}
86
- focusedIndex={focusedIndex}
87
- setFocusedIndex={setFocusedIndex}
88
- bump={bump}
89
- setError={setError}
90
- setNavSeq={setNavSeq}
91
- />
92
- </FocusRegistryProvider>
93
- );
94
- }
@@ -1,34 +0,0 @@
1
- import type { JsonApiResource } from "dropsh/plugin";
2
- import { Box, Text } from "ink";
3
- import React, { type ReactElement } from "react";
4
- import { label, splitType } from "./jsonapi-type.js";
5
- import { TuiLink } from "./link.js";
6
- import type { BuildContext } from "./types.js";
7
-
8
- export abstract class TuiEntityList {
9
- static entityType: string;
10
- static bundle?: string;
11
- abstract build(resources: JsonApiResource[], ctx: BuildContext): ReactElement;
12
- }
13
-
14
- export class GenericEntityList extends TuiEntityList {
15
- static override entityType = "*";
16
-
17
- build(resources: JsonApiResource[], ctx: BuildContext): ReactElement {
18
- if (resources.length === 0) return <Text dimColor>(no results)</Text>;
19
- return (
20
- <Box flexDirection="column">
21
- {resources.map((res) => {
22
- const { entityType, bundle } = splitType(res.type);
23
- const params: Record<string, string> = { type: entityType, id: res.id };
24
- if (bundle) params.bundle = bundle;
25
- return (
26
- <TuiLink key={res.id} target={ctx.link("entity.canonical", params)}>
27
- {label(res)}
28
- </TuiLink>
29
- );
30
- })}
31
- </Box>
32
- );
33
- }
34
- }
@@ -1,45 +0,0 @@
1
- import type { JsonApiResource } from "dropsh/plugin";
2
- import { Box, Text } from "ink";
3
- import React, { type ReactElement } from "react";
4
- import { splitType } from "./jsonapi-type.js";
5
- import { TuiLink } from "./link.js";
6
- import type { BuildContext, ViewModeMap } from "./types.js";
7
-
8
- export abstract class TuiEntityView {
9
- static entityType: string;
10
- static bundle?: string;
11
- static viewModes: ViewModeMap = { default: {} };
12
- abstract build(entity: JsonApiResource, ctx: BuildContext): ReactElement;
13
- }
14
-
15
- export class GenericEntityView extends TuiEntityView {
16
- static override entityType = "*";
17
- static override viewModes: ViewModeMap = { default: {} };
18
-
19
- build(entity: JsonApiResource, ctx: BuildContext): ReactElement {
20
- const attrs = entity.attributes ?? {};
21
- const rels = entity.relationships ?? {};
22
- return (
23
- <Box flexDirection="column">
24
- {Object.entries(attrs).map(([key, value]) => (
25
- <Text key={`a:${key}`}>
26
- {key}: {typeof value === "string" ? value : JSON.stringify(value)}
27
- </Text>
28
- ))}
29
- {Object.entries(rels).map(([key, rel]) => {
30
- const data = (rel as { data?: { type: string; id: string } }).data;
31
- if (!data || Array.isArray(data)) return <Text key={`r:${key}`}>{key}: —</Text>;
32
- const { entityType, bundle } = splitType(data.type);
33
- const params: Record<string, string> = { type: entityType, id: data.id };
34
- if (bundle) params.bundle = bundle;
35
- return (
36
- <Box key={`r:${key}`}>
37
- <Text>{key}: </Text>
38
- <TuiLink target={ctx.link("entity.canonical", params)}>{data.id}</TuiLink>
39
- </Box>
40
- );
41
- })}
42
- </Box>
43
- );
44
- }
45
- }
package/src/index.ts DELETED
@@ -1,94 +0,0 @@
1
- import type {
2
- DropSHPlugin,
3
- JsonApiDocument,
4
- JsonApiResource,
5
- RenderContext,
6
- RenderServices,
7
- } from "dropsh/plugin";
8
- import { ConfigError } from "dropsh/plugin";
9
- import { splitType } from "./jsonapi-type.js";
10
- import { buildRegistry } from "./registry.js";
11
- import { createRouter } from "./router.js";
12
- import type { TuiSubPlugin } from "./types.js";
13
-
14
- export { GenericEntityList, TuiEntityList } from "./entity-list.js";
15
- export { GenericEntityView, TuiEntityView } from "./entity-view.js";
16
- export { TuiLink } from "./link.js";
17
- export type {
18
- BuildContext,
19
- LinkDescriptor,
20
- TuiController,
21
- TuiRoute,
22
- TuiSubPlugin,
23
- ViewModeMap,
24
- } from "./types.js";
25
-
26
- export interface TuiOptions {
27
- plugins?: TuiSubPlugin[];
28
- }
29
-
30
- function assertTty(isTty: boolean): void {
31
- if (!isTty) throw new ConfigError("--format tui requires an interactive terminal");
32
- }
33
-
34
- export function initialTarget(
35
- doc: JsonApiDocument,
36
- ctx: RenderContext,
37
- ): {
38
- route: string;
39
- params: Record<string, string>;
40
- } {
41
- if (ctx.command === "search") {
42
- const params: Record<string, string> = { type: ctx.entityType ?? "" };
43
- if (ctx.bundle) params.bundle = ctx.bundle;
44
- return { route: "entity.collection", params };
45
- }
46
- const first = (Array.isArray(doc.data) ? doc.data[0] : doc.data) as JsonApiResource | undefined;
47
- const { entityType, bundle } = splitType(first?.type ?? "");
48
- const params: Record<string, string> = { type: entityType, id: first?.id ?? "" };
49
- if (bundle) params.bundle = bundle;
50
- return { route: "entity.canonical", params };
51
- }
52
-
53
- async function runTui(
54
- doc: JsonApiDocument,
55
- ctx: RenderContext,
56
- opts: TuiOptions,
57
- services?: RenderServices,
58
- ): Promise<void> {
59
- assertTty(Boolean(process.stdout.isTTY));
60
- if (!services) throw new ConfigError("--format tui requires runtime services (client)");
61
- const registry = buildRegistry(opts.plugins ?? []);
62
- const router = createRouter({
63
- registry,
64
- client: services.client,
65
- viewMode: ctx.viewMode ?? "default",
66
- });
67
- const target = initialTarget(doc, ctx);
68
- await router.navigate(target.route, target.params, doc);
69
-
70
- const [{ render }, React, { Browse }] = await Promise.all([
71
- import("ink"),
72
- import("react"),
73
- import("./browse-app.js"),
74
- ]);
75
- const app = render(React.createElement(Browse, { router, onExit: () => app.unmount() }));
76
- await app.waitUntilExit();
77
- }
78
-
79
- export function tuiPlugin(tuiOpts: TuiOptions = {}): DropSHPlugin {
80
- return {
81
- id: "tui",
82
- requiredModules: [],
83
- async extendSchema(_entityType, _bundle, schema) {
84
- return schema;
85
- },
86
- renderers: [
87
- {
88
- id: "tui",
89
- interactive: true,
90
- run: (doc, ctx, services) => runTui(doc, ctx, tuiOpts, services),
91
- },
92
- ],
93
- };
94
- }
@@ -1,17 +0,0 @@
1
- import type { JsonApiResource } from "dropsh/plugin";
2
-
3
- // Splits a JSON:API type "node--article" into { entityType: "node", bundle: "article" }.
4
- export function splitType(type: string): { entityType: string; bundle?: string } {
5
- const [entityType, bundle] = type.split("--") as [string, string?];
6
- return bundle ? { entityType, bundle } : { entityType };
7
- }
8
-
9
- // Best-effort human label for a resource: first string-valued title/name/label attribute, else id.
10
- export function label(res: JsonApiResource): string {
11
- const attrs = res.attributes ?? {};
12
- for (const key of ["title", "name", "label"]) {
13
- const v = attrs[key];
14
- if (typeof v === "string") return v;
15
- }
16
- return res.id;
17
- }