@tooee/view 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -14,4 +14,6 @@ export type { AnyContent, Content, ContentChunk, ContentFormat, ContentProvider,
14
14
  export { getTextContent, isBuiltinContent, isCustomContent } from "./types.js";
15
15
  export { CodeBlockChrome } from "@tooee/renderers";
16
16
  export type { CodeBlockRenderer, CodeBlockRendererProps, CodeBlockHScroll } from "@tooee/renderers";
17
+ export { localFileLinkHandler } from "./link-handlers.js";
18
+ export type { LinkHandler, LinkHandlerContext, LinkTarget } from "./link-handlers.js";
17
19
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACtD,YAAY,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AACrD,OAAO,EACL,kBAAkB,EAClB,mBAAmB,EACnB,uBAAuB,EACvB,wBAAwB,GACzB,MAAM,uBAAuB,CAAC;AAC/B,YAAY,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAC;AACnE,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAC7D,YAAY,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAC9D,OAAO,EACL,wBAAwB,EACxB,4BAA4B,GAC7B,MAAM,qCAAqC,CAAC;AAC7C,YAAY,EACV,+BAA+B,EAC/B,gCAAgC,EAChC,kBAAkB,GACnB,MAAM,qCAAqC,CAAC;AAC7C,YAAY,EAAE,kBAAkB,EAAE,MAAM,+BAA+B,CAAC;AACxE,YAAY,EAAE,qBAAqB,EAAE,MAAM,iCAAiC,CAAC;AAC7E,YAAY,EACV,UAAU,EACV,OAAO,EACP,YAAY,EACZ,aAAa,EACb,eAAe,EACf,eAAe,EACf,oBAAoB,EACpB,aAAa,EACb,WAAW,EACX,YAAY,EACZ,eAAe,EACf,2BAA2B,EAC3B,WAAW,EACX,WAAW,EACX,YAAY,EACZ,WAAW,EACX,mBAAmB,EACnB,SAAS,EACT,QAAQ,GACT,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAC/E,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACnD,YAAY,EAAE,iBAAiB,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACtD,YAAY,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AACrD,OAAO,EACL,kBAAkB,EAClB,mBAAmB,EACnB,uBAAuB,EACvB,wBAAwB,GACzB,MAAM,uBAAuB,CAAC;AAC/B,YAAY,EAAE,qBAAqB,EAAE,MAAM,uBAAuB,CAAC;AACnE,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAC7D,YAAY,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAC9D,OAAO,EACL,wBAAwB,EACxB,4BAA4B,GAC7B,MAAM,qCAAqC,CAAC;AAC7C,YAAY,EACV,+BAA+B,EAC/B,gCAAgC,EAChC,kBAAkB,GACnB,MAAM,qCAAqC,CAAC;AAC7C,YAAY,EAAE,kBAAkB,EAAE,MAAM,+BAA+B,CAAC;AACxE,YAAY,EAAE,qBAAqB,EAAE,MAAM,iCAAiC,CAAC;AAC7E,YAAY,EACV,UAAU,EACV,OAAO,EACP,YAAY,EACZ,aAAa,EACb,eAAe,EACf,eAAe,EACf,oBAAoB,EACpB,aAAa,EACb,WAAW,EACX,YAAY,EACZ,eAAe,EACf,2BAA2B,EAC3B,WAAW,EACX,WAAW,EACX,YAAY,EACZ,WAAW,EACX,mBAAmB,EACnB,SAAS,EACT,QAAQ,GACT,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAC/E,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACnD,YAAY,EAAE,iBAAiB,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACpG,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,YAAY,EAAE,WAAW,EAAE,kBAAkB,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC"}
package/dist/index.js CHANGED
@@ -6,4 +6,5 @@ export { listDirectoryFiles } from "./directory-provider.js";
6
6
  export { createViewCommandContext, useProvideViewCommandContext, } from "./hooks/use-view-command-context.js";
7
7
  export { getTextContent, isBuiltinContent, isCustomContent } from "./types.js";
8
8
  export { CodeBlockChrome } from "@tooee/renderers";
9
+ export { localFileLinkHandler } from "./link-handlers.js";
9
10
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAEtD,OAAO,EACL,kBAAkB,EAClB,mBAAmB,EACnB,uBAAuB,EACvB,wBAAwB,GACzB,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAE7D,OAAO,EACL,wBAAwB,EACxB,4BAA4B,GAC7B,MAAM,qCAAqC,CAAC;AA6B7C,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAC/E,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAEtD,OAAO,EACL,kBAAkB,EAClB,mBAAmB,EACnB,uBAAuB,EACvB,wBAAwB,GACzB,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAE7D,OAAO,EACL,wBAAwB,EACxB,4BAA4B,GAC7B,MAAM,qCAAqC,CAAC;AA6B7C,OAAO,EAAE,cAAc,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAC/E,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAEnD,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC"}
package/dist/launch.d.ts CHANGED
@@ -1,8 +1,13 @@
1
1
  import type { ActionDefinition } from "@tooee/commands";
2
2
  import type { CodeBlockRenderer } from "@tooee/renderers";
3
+ import type { LinkHandler } from "./link-handlers.js";
3
4
  import type { ContentProvider, ContentRenderer } from "./types.js";
4
5
  export interface ViewLaunchOptions {
5
6
  contentProvider: ContentProvider;
7
+ /** Source file for standalone local Markdown navigation; omitted for stdin. */
8
+ filePath?: string;
9
+ /** Synchronous handlers tried before local file navigation. */
10
+ linkHandlers?: readonly LinkHandler[];
6
11
  actions?: ActionDefinition[];
7
12
  renderers?: Record<string, ContentRenderer>;
8
13
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"launch.d.ts","sourceRoot":"","sources":["../src/launch.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACxD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAG1D,OAAO,KAAK,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAGnE,MAAM,WAAW,iBAAiB;IAChC,eAAe,EAAE,eAAe,CAAC;IACjC,OAAO,CAAC,EAAE,gBAAgB,EAAE,CAAC;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;IAC5C;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC;CACxD;AAED,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,gBAAgB,EAAE,CAAC;CAC9B;AAED,eAAO,MAAM,MAAM,YAAkC,iBAAiB,KAAG,OAAO,CAAC,IAAI,CAWpF,CAAC;AAEF,eAAO,MAAM,eAAe,YACjB,sBAAsB,KAC9B,OAAO,CAAC,IAAI,CAEd,CAAC"}
1
+ {"version":3,"file":"launch.d.ts","sourceRoot":"","sources":["../src/launch.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AACxD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAG1D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEtD,OAAO,KAAK,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAGnE,MAAM,WAAW,iBAAiB;IAChC,eAAe,EAAE,eAAe,CAAC;IACjC,+EAA+E;IAC/E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,+DAA+D;IAC/D,YAAY,CAAC,EAAE,SAAS,WAAW,EAAE,CAAC;IACtC,OAAO,CAAC,EAAE,gBAAgB,EAAE,CAAC;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;IAC5C;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC;CACxD;AAED,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,gBAAgB,EAAE,CAAC;CAC9B;AAED,eAAO,MAAM,MAAM,YAAkC,iBAAiB,KAAG,OAAO,CAAC,IAAI,CAiBpF,CAAC;AAEF,eAAO,MAAM,eAAe,YACjB,sBAAsB,KAC9B,OAAO,CAAC,IAAI,CAId,CAAC"}
package/dist/launch.js CHANGED
@@ -1,11 +1,22 @@
1
1
  import { jsx as _jsx } from "@opentui/react/jsx-runtime";
2
2
  import { launchCli, runCliSession } from "@tooee/shell";
3
- import { View } from "./view.js";
3
+ import { Outlet, RouterProvider } from "@tooee/router";
4
+ import { createStandaloneRouter } from "./standalone-router.js";
4
5
  import { DirectoryView } from "./directory-view.js";
5
6
  export const launch = async function launch(options) {
6
- await runCliSession(() => (_jsx(View, { contentProvider: options.contentProvider, actions: options.actions, renderers: options.renderers, codeBlockRenderers: options.codeBlockRenderers })));
7
+ const { router } = createStandaloneRouter(options);
8
+ const startup = await router.start();
9
+ if (startup.status !== "committed") {
10
+ throw new Error(`View router startup ${startup.status}`);
11
+ }
12
+ await runCliSession(() => (_jsx(RouterProvider, { router: router, children: _jsx(Outlet, {}) })), {
13
+ stdinPolicy: "tty-if-piped",
14
+ stdoutPolicy: "tty-if-redirected",
15
+ });
7
16
  };
8
17
  export const launchDirectory = async function launchDirectory(options) {
9
- await launchCli(_jsx(DirectoryView, { dirPath: options.dirPath, actions: options.actions }));
18
+ await launchCli(_jsx(DirectoryView, { dirPath: options.dirPath, actions: options.actions }), {
19
+ stdoutPolicy: "tty-if-redirected",
20
+ });
10
21
  };
11
22
  //# sourceMappingURL=launch.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"launch.js","sourceRoot":"","sources":["../src/launch.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAGxD,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAqBpD,MAAM,CAAC,MAAM,MAAM,GAAG,KAAK,UAAU,MAAM,CAAC,OAA0B;IACpE,MAAM,aAAa,CACjB,GAAc,EAAE,CAAC,CACf,KAAC,IAAI,IACH,eAAe,EAAE,OAAO,CAAC,eAAe,EACxC,OAAO,EAAE,OAAO,CAAC,OAAO,EACxB,SAAS,EAAE,OAAO,CAAC,SAAS,EAC5B,kBAAkB,EAAE,OAAO,CAAC,kBAAkB,GAC9C,CACH,CACF,CAAC;AACJ,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,eAAe,GAAG,KAAK,UAAU,eAAe,CAC3D,OAA+B;IAE/B,MAAM,SAAS,CAAC,KAAC,aAAa,IAAC,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,GAAI,CAAC,CAAC;AACzF,CAAC,CAAC"}
1
+ {"version":3,"file":"launch.js","sourceRoot":"","sources":["../src/launch.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAGxD,OAAO,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AACvD,OAAO,EAAE,sBAAsB,EAAE,MAAM,wBAAwB,CAAC;AAEhE,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAyBpD,MAAM,CAAC,MAAM,MAAM,GAAG,KAAK,UAAU,MAAM,CAAC,OAA0B;IACpE,MAAM,EAAE,MAAM,EAAE,GAAG,sBAAsB,CAAC,OAAO,CAAC,CAAC;IACnD,MAAM,OAAO,GAAG,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC;IACrC,IAAI,OAAO,CAAC,MAAM,KAAK,WAAW,EAAE,CAAC;QACnC,MAAM,IAAI,KAAK,CAAC,uBAAuB,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAC3D,CAAC;IACD,MAAM,aAAa,CACjB,GAAc,EAAE,CAAC,CACf,KAAC,cAAc,IAAC,MAAM,EAAE,MAAM,YAC5B,KAAC,MAAM,KAAG,GACK,CAClB,EACD;QACE,WAAW,EAAE,cAAc;QAC3B,YAAY,EAAE,mBAAmB;KAClC,CACF,CAAC;AACJ,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,eAAe,GAAG,KAAK,UAAU,eAAe,CAC3D,OAA+B;IAE/B,MAAM,SAAS,CAAC,KAAC,aAAa,IAAC,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,GAAI,EAAE;QACrF,YAAY,EAAE,mBAAmB;KAClC,CAAC,CAAC;AACL,CAAC,CAAC"}
@@ -0,0 +1,21 @@
1
+ import type { CommandContext } from "@tooee/commands";
2
+ import type { NavigateHandle, RouteDefinition } from "@tooee/router";
3
+ export interface LinkTarget {
4
+ href: string;
5
+ /** Directory that relative targets resolve against. */
6
+ baseDir: string;
7
+ /** Source document for fragment-only links. */
8
+ currentPath?: string;
9
+ }
10
+ export interface LinkHandlerContext {
11
+ navigate: NavigateHandle;
12
+ documentRoute: RouteDefinition<{
13
+ path?: string;
14
+ }>;
15
+ command: CommandContext;
16
+ }
17
+ /** Decide synchronously: true consumes the link, false tries the next handler. */
18
+ export type LinkHandler = (link: LinkTarget, context: LinkHandlerContext) => boolean;
19
+ export declare const localFileLinkHandler: LinkHandler;
20
+ export declare const runLinkHandlers: (handlers: readonly LinkHandler[], link: LinkTarget, context: LinkHandlerContext) => boolean;
21
+ //# sourceMappingURL=link-handlers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"link-handlers.d.ts","sourceRoot":"","sources":["../src/link-handlers.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACtD,OAAO,KAAK,EAAE,cAAc,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAGrE,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,uDAAuD;IACvD,OAAO,EAAE,MAAM,CAAC;IAChB,+CAA+C;IAC/C,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,cAAc,CAAC;IACzB,aAAa,EAAE,eAAe,CAAC;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAClD,OAAO,EAAE,cAAc,CAAC;CACzB;AAED,kFAAkF;AAClF,MAAM,MAAM,WAAW,GAAG,CAAC,IAAI,EAAE,UAAU,EAAE,OAAO,EAAE,kBAAkB,KAAK,OAAO,CAAC;AAErF,eAAO,MAAM,oBAAoB,EAAE,WAmBlC,CAAC;AAEF,eAAO,MAAM,eAAe,aAChB,SAAS,WAAW,EAAE,QAC1B,UAAU,WACP,kBAAkB,KAC1B,OAYF,CAAC"}
@@ -0,0 +1,35 @@
1
+ import path from "node:path";
2
+ import { resolveMarkdownLink } from "./markdown-links.js";
3
+ export const localFileLinkHandler = function localFileLinkHandler(link, context) {
4
+ const result = resolveMarkdownLink(link.href, link.baseDir, link.currentPath);
5
+ if (result.status === "unsupported") {
6
+ return false;
7
+ }
8
+ if (result.status === "file") {
9
+ void context.navigate.push(context.documentRoute, { path: result.path });
10
+ return true;
11
+ }
12
+ const relative = path.relative(link.baseDir, result.path);
13
+ const target = relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)
14
+ ? result.path
15
+ : relative || ".";
16
+ context.command.toast?.toast({
17
+ level: "warning",
18
+ message: `${result.status === "missing" ? "File not found" : "Not a file"}: ${target}`,
19
+ });
20
+ return true;
21
+ };
22
+ export const runLinkHandlers = function runLinkHandlers(handlers, link, context) {
23
+ for (const handler of handlers) {
24
+ // oxlint-disable-next-line typescript/no-unnecessary-boolean-literal-compare -- preserve exact-true consumption for JavaScript callers too
25
+ if (handler(link, context) === true) {
26
+ return true;
27
+ }
28
+ }
29
+ if (localFileLinkHandler(link, context)) {
30
+ return true;
31
+ }
32
+ context.command.toast?.toast({ level: "warning", message: `Unsupported link: ${link.href}` });
33
+ return true;
34
+ };
35
+ //# sourceMappingURL=link-handlers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"link-handlers.js","sourceRoot":"","sources":["../src/link-handlers.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,WAAW,CAAC;AAG7B,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAmB1D,MAAM,CAAC,MAAM,oBAAoB,GAAgB,SAAS,oBAAoB,CAAC,IAAI,EAAE,OAAO;IAC1F,MAAM,MAAM,GAAG,mBAAmB,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,WAAW,CAAC,CAAC;IAC9E,IAAI,MAAM,CAAC,MAAM,KAAK,aAAa,EAAE,CAAC;QACpC,OAAO,KAAK,CAAC;IACf,CAAC;IACD,IAAI,MAAM,CAAC,MAAM,KAAK,MAAM,EAAE,CAAC;QAC7B,KAAK,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,aAAa,EAAE,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;QACzE,OAAO,IAAI,CAAC;IACd,CAAC;IACD,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC;IAC1D,MAAM,MAAM,GACV,QAAQ,KAAK,IAAI,IAAI,QAAQ,CAAC,UAAU,CAAC,KAAK,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC;QACpF,CAAC,CAAC,MAAM,CAAC,IAAI;QACb,CAAC,CAAC,QAAQ,IAAI,GAAG,CAAC;IACtB,OAAO,CAAC,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC;QAC3B,KAAK,EAAE,SAAS;QAChB,OAAO,EAAE,GAAG,MAAM,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,gBAAgB,CAAC,CAAC,CAAC,YAAY,KAAK,MAAM,EAAE;KACvF,CAAC,CAAC;IACH,OAAO,IAAI,CAAC;AACd,CAAC,CAAC;AAEF,MAAM,CAAC,MAAM,eAAe,GAAG,SAAS,eAAe,CACrD,QAAgC,EAChC,IAAgB,EAChB,OAA2B;IAE3B,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;QAC/B,2IAA2I;QAC3I,IAAI,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,KAAK,IAAI,EAAE,CAAC;YACpC,OAAO,IAAI,CAAC;QACd,CAAC;IACH,CAAC;IACD,IAAI,oBAAoB,CAAC,IAAI,EAAE,OAAO,CAAC,EAAE,CAAC;QACxC,OAAO,IAAI,CAAC;IACd,CAAC;IACD,OAAO,CAAC,OAAO,CAAC,KAAK,EAAE,KAAK,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,qBAAqB,IAAI,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;IAC9F,OAAO,IAAI,CAAC;AACd,CAAC,CAAC"}
@@ -0,0 +1,21 @@
1
+ export type LinkResolution = {
2
+ status: "file";
3
+ path: string;
4
+ } | {
5
+ status: "missing";
6
+ path: string;
7
+ } | {
8
+ status: "directory";
9
+ path: string;
10
+ } | {
11
+ status: "unsupported";
12
+ };
13
+ /** Existing local files only. Fragments are validated, then open at the file top. */
14
+ export declare const resolveMarkdownLink: (href: string, baseDir: string, currentPath?: string) => LinkResolution;
15
+ export interface MarkdownLink {
16
+ /** Link text as written in the source. */
17
+ text: string;
18
+ href: string;
19
+ }
20
+ export declare const markdownLinks: (line: string) => MarkdownLink[];
21
+ //# sourceMappingURL=markdown-links.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"markdown-links.d.ts","sourceRoot":"","sources":["../src/markdown-links.ts"],"names":[],"mappings":"AAMA,MAAM,MAAM,cAAc,GACtB;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAChC;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACnC;IAAE,MAAM,EAAE,WAAW,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACrC;IAAE,MAAM,EAAE,aAAa,CAAA;CAAE,CAAC;AAE9B,qFAAqF;AACrF,eAAO,MAAM,mBAAmB,SACxB,MAAM,WACH,MAAM,gBACD,MAAM,KACnB,cA6CF,CAAC;AAEF,MAAM,WAAW,YAAY;IAC3B,0CAA0C;IAC1C,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAeD,eAAO,MAAM,aAAa,SAAgC,MAAM,KAAG,YAAY,EAI9E,CAAC"}
@@ -0,0 +1,74 @@
1
+ import { statSync } from "node:fs";
2
+ import nodePath from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { Lexer } from "marked";
5
+ /** Existing local files only. Fragments are validated, then open at the file top. */
6
+ export const resolveMarkdownLink = function resolveMarkdownLink(href, baseDir, currentPath) {
7
+ try {
8
+ const decoded = decodeURIComponent(href);
9
+ if (/[\p{Cc}\\]/u.test(decoded)) {
10
+ return { status: "unsupported" };
11
+ }
12
+ const target = href.trim();
13
+ if (!target || target.startsWith("//")) {
14
+ return { status: "unsupported" };
15
+ }
16
+ const [rawPath] = target.split("#", 1);
17
+ if (rawPath.includes("?")) {
18
+ return { status: "unsupported" };
19
+ }
20
+ let path;
21
+ if (/^[a-zA-Z][a-zA-Z\d+.-]*:/u.test(rawPath)) {
22
+ // Check the authority before URL normalizes localhost into an empty host.
23
+ if (!/^file:\/\/\//iu.test(rawPath)) {
24
+ return { status: "unsupported" };
25
+ }
26
+ const url = new URL(rawPath);
27
+ if (url.hostname || url.search) {
28
+ return { status: "unsupported" };
29
+ }
30
+ path = fileURLToPath(url);
31
+ }
32
+ else {
33
+ path = rawPath
34
+ ? nodePath.resolve(baseDir, decodeURIComponent(rawPath))
35
+ : (currentPath ?? baseDir);
36
+ }
37
+ const absolute = nodePath.resolve(path);
38
+ try {
39
+ const stat = statSync(absolute);
40
+ if (stat.isFile()) {
41
+ return { path: absolute, status: "file" };
42
+ }
43
+ return stat.isDirectory()
44
+ ? { path: absolute, status: "directory" }
45
+ : { status: "unsupported" };
46
+ }
47
+ catch {
48
+ return { path: absolute, status: "missing" };
49
+ }
50
+ }
51
+ catch {
52
+ return { status: "unsupported" };
53
+ }
54
+ };
55
+ /** Ordinary inline links in source order, skipping images and code spans. */
56
+ const collect = (tokens, links) => {
57
+ for (const token of tokens) {
58
+ if (token.type === "link" && "href" in token) {
59
+ // SAFETY: Marked's Generic token prevents discriminator narrowing.
60
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- checked Marked link discriminator
61
+ const { href, text } = token;
62
+ links.push({ href, text });
63
+ }
64
+ else if (["strong", "em", "del"].includes(token.type) && "tokens" in token && token.tokens) {
65
+ collect(token.tokens, links);
66
+ }
67
+ }
68
+ };
69
+ export const markdownLinks = function markdownLinks(line) {
70
+ const links = [];
71
+ collect(Lexer.lexInline(line), links);
72
+ return links;
73
+ };
74
+ //# sourceMappingURL=markdown-links.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"markdown-links.js","sourceRoot":"","sources":["../src/markdown-links.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,11 @@
1
+ import type { RouteDefinition, RouterInstance } from "@tooee/router";
2
+ import type { ViewLaunchOptions } from "./launch.js";
3
+ interface StandaloneRouter {
4
+ documentRoute: RouteDefinition<{
5
+ path?: string;
6
+ }>;
7
+ router: RouterInstance;
8
+ }
9
+ export declare const createStandaloneRouter: (options: ViewLaunchOptions) => StandaloneRouter;
10
+ export {};
11
+ //# sourceMappingURL=standalone-router.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"standalone-router.d.ts","sourceRoot":"","sources":["../src/standalone-router.tsx"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAS,eAAe,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAO5E,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAoBrD,UAAU,gBAAgB;IACxB,aAAa,EAAE,eAAe,CAAC;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAClD,MAAM,EAAE,cAAc,CAAC;CACxB;AAED,eAAO,MAAM,sBAAsB,YACxB,iBAAiB,KACzB,gBAyEF,CAAC"}
@@ -0,0 +1,85 @@
1
+ import { jsx as _jsx } from "@opentui/react/jsx-runtime";
2
+ /* oxlint-disable no-use-before-define -- the screen closes over the route initialized before router startup */
3
+ import { useMemo } from "react";
4
+ import { useChooseDialog } from "@tooee/choose";
5
+ import { useCommand } from "@tooee/commands";
6
+ import { createRoute, createRouter, useNavigate, useParams, useRouter, useRouterCommands, } from "@tooee/router";
7
+ import path from "node:path";
8
+ import { createFileProvider } from "./default-provider.js";
9
+ import { runLinkHandlers } from "./link-handlers.js";
10
+ import { markdownLinks } from "./markdown-links.js";
11
+ import { getTextContent } from "./types.js";
12
+ import { View } from "./view.js";
13
+ const documentParams = {
14
+ parse: (value) => {
15
+ /* oxlint-disable anti-slop/no-runtime-typeof -- route params are decoded at this boundary */
16
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
17
+ throw new TypeError("Expected document params");
18
+ }
19
+ if (!("path" in value) || value.path === undefined) {
20
+ return {};
21
+ }
22
+ if (typeof value.path !== "string" || !path.isAbsolute(value.path)) {
23
+ throw new TypeError("Expected an absolute document path");
24
+ }
25
+ /* oxlint-enable anti-slop/no-runtime-typeof */
26
+ return { path: value.path };
27
+ },
28
+ };
29
+ export const createStandaloneRouter = function createStandaloneRouter(options) {
30
+ const DocumentScreen = function DocumentScreen() {
31
+ useRouterCommands();
32
+ const { path: documentPath } = useParams(documentRoute);
33
+ const navigate = useNavigate();
34
+ const { stack } = useRouter();
35
+ const chooseLink = useChooseDialog();
36
+ const provider = useMemo(() => documentPath === undefined ? options.contentProvider : createFileProvider(documentPath), [documentPath]);
37
+ const activate = (href, command) => {
38
+ if (options.filePath === undefined) {
39
+ command.toast?.toast({ level: "info", message: "Links need a source file" });
40
+ return true;
41
+ }
42
+ const currentPath = path.resolve(documentPath ?? options.filePath);
43
+ return runLinkHandlers(options.linkHandlers ?? [], { baseDir: path.dirname(currentPath), currentPath, href }, { command, documentRoute, navigate });
44
+ };
45
+ useCommand({
46
+ handler: async (context) => {
47
+ // Source positions are zero-based, unlike the displayed line numbers.
48
+ const line = context.document?.activeAnchor?.source?.primary?.start.line;
49
+ const content = context.view?.content;
50
+ if (line === undefined || content === undefined) {
51
+ return;
52
+ }
53
+ const links = markdownLinks(getTextContent(content).split("\n")[line] ?? "");
54
+ // One link follows directly; several open a chooser so none is silently preferred.
55
+ const link = links.length > 1
56
+ ? await chooseLink.open({
57
+ items: links,
58
+ prompt: "Follow link",
59
+ toItem: (item) => ({ description: item.href, text: item.text }),
60
+ })
61
+ : (links[0] ?? null);
62
+ if (link !== null) {
63
+ activate(link.href, context);
64
+ }
65
+ },
66
+ hotkey: "enter",
67
+ id: "view.follow-link",
68
+ modes: ["cursor"],
69
+ title: "Follow Markdown link",
70
+ when: (context) => context.view?.format === "markdown",
71
+ });
72
+ return (_jsx(View, { actions: options.actions, codeBlockRenderers: options.codeBlockRenderers, contentProvider: provider, onMarkdownLinkActivate: activate, renderers: options.renderers }, `${stack.length}:${documentPath ?? ""}`));
73
+ };
74
+ const documentRoute = createRoute({
75
+ component: DocumentScreen,
76
+ id: "view.document",
77
+ params: documentParams,
78
+ });
79
+ const router = createRouter({
80
+ initial: { routeId: documentRoute.id },
81
+ routes: [documentRoute],
82
+ });
83
+ return { documentRoute, router };
84
+ };
85
+ //# sourceMappingURL=standalone-router.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"standalone-router.js","sourceRoot":"","sources":["../src/standalone-router.tsx"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tooee/view",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Terminal content viewer for markdown, code, text, images, and tables",
5
5
  "keywords": [
6
6
  "cli",
@@ -40,16 +40,18 @@
40
40
  "typecheck": "tsc --noEmit"
41
41
  },
42
42
  "dependencies": {
43
- "@tooee/commands": "0.8.0",
44
- "@tooee/config": "0.8.0",
45
- "@tooee/diff": "0.8.0",
46
- "@tooee/layout": "0.8.0",
47
- "@tooee/marks": "0.8.0",
48
- "@tooee/overlays": "0.8.0",
49
- "@tooee/renderers": "0.8.0",
50
- "@tooee/search": "0.8.0",
51
- "@tooee/shell": "0.8.0",
52
- "@tooee/themes": "0.8.0",
43
+ "@tooee/choose": "0.9.0",
44
+ "@tooee/commands": "0.9.0",
45
+ "@tooee/config": "0.9.0",
46
+ "@tooee/diff": "0.9.0",
47
+ "@tooee/layout": "0.9.0",
48
+ "@tooee/marks": "0.9.0",
49
+ "@tooee/overlays": "0.9.0",
50
+ "@tooee/renderers": "0.9.0",
51
+ "@tooee/router": "0.9.0",
52
+ "@tooee/search": "0.9.0",
53
+ "@tooee/shell": "0.9.0",
54
+ "@tooee/themes": "0.9.0",
53
55
  "@xstate/store": "^4",
54
56
  "@xstate/store-react": "^2",
55
57
  "marked": "^17.0.4"
package/src/index.ts CHANGED
@@ -46,3 +46,5 @@ export type {
46
46
  export { getTextContent, isBuiltinContent, isCustomContent } from "./types.js";
47
47
  export { CodeBlockChrome } from "@tooee/renderers";
48
48
  export type { CodeBlockRenderer, CodeBlockRendererProps, CodeBlockHScroll } from "@tooee/renderers";
49
+ export { localFileLinkHandler } from "./link-handlers.js";
50
+ export type { LinkHandler, LinkHandlerContext, LinkTarget } from "./link-handlers.js";
package/src/launch.tsx CHANGED
@@ -1,13 +1,19 @@
1
1
  import { launchCli, runCliSession } from "@tooee/shell";
2
2
  import type { ActionDefinition } from "@tooee/commands";
3
3
  import type { CodeBlockRenderer } from "@tooee/renderers";
4
- import { View } from "./view.js";
4
+ import { Outlet, RouterProvider } from "@tooee/router";
5
+ import { createStandaloneRouter } from "./standalone-router.js";
6
+ import type { LinkHandler } from "./link-handlers.js";
5
7
  import { DirectoryView } from "./directory-view.js";
6
8
  import type { ContentProvider, ContentRenderer } from "./types.js";
7
9
  import type { ReactNode } from "react";
8
10
 
9
11
  export interface ViewLaunchOptions {
10
12
  contentProvider: ContentProvider;
13
+ /** Source file for standalone local Markdown navigation; omitted for stdin. */
14
+ filePath?: string;
15
+ /** Synchronous handlers tried before local file navigation. */
16
+ linkHandlers?: readonly LinkHandler[];
11
17
  actions?: ActionDefinition[];
12
18
  renderers?: Record<string, ContentRenderer>;
13
19
  /**
@@ -24,20 +30,28 @@ export interface DirectoryLaunchOptions {
24
30
  }
25
31
 
26
32
  export const launch = async function launch(options: ViewLaunchOptions): Promise<void> {
33
+ const { router } = createStandaloneRouter(options);
34
+ const startup = await router.start();
35
+ if (startup.status !== "committed") {
36
+ throw new Error(`View router startup ${startup.status}`);
37
+ }
27
38
  await runCliSession<undefined>(
28
39
  (): ReactNode => (
29
- <View
30
- contentProvider={options.contentProvider}
31
- actions={options.actions}
32
- renderers={options.renderers}
33
- codeBlockRenderers={options.codeBlockRenderers}
34
- />
40
+ <RouterProvider router={router}>
41
+ <Outlet />
42
+ </RouterProvider>
35
43
  ),
44
+ {
45
+ stdinPolicy: "tty-if-piped",
46
+ stdoutPolicy: "tty-if-redirected",
47
+ },
36
48
  );
37
49
  };
38
50
 
39
51
  export const launchDirectory = async function launchDirectory(
40
52
  options: DirectoryLaunchOptions,
41
53
  ): Promise<void> {
42
- await launchCli(<DirectoryView dirPath={options.dirPath} actions={options.actions} />);
54
+ await launchCli(<DirectoryView dirPath={options.dirPath} actions={options.actions} />, {
55
+ stdoutPolicy: "tty-if-redirected",
56
+ });
43
57
  };
@@ -0,0 +1,60 @@
1
+ import path from "node:path";
2
+ import type { CommandContext } from "@tooee/commands";
3
+ import type { NavigateHandle, RouteDefinition } from "@tooee/router";
4
+ import { resolveMarkdownLink } from "./markdown-links.js";
5
+
6
+ export interface LinkTarget {
7
+ href: string;
8
+ /** Directory that relative targets resolve against. */
9
+ baseDir: string;
10
+ /** Source document for fragment-only links. */
11
+ currentPath?: string;
12
+ }
13
+
14
+ export interface LinkHandlerContext {
15
+ navigate: NavigateHandle;
16
+ documentRoute: RouteDefinition<{ path?: string }>;
17
+ command: CommandContext;
18
+ }
19
+
20
+ /** Decide synchronously: true consumes the link, false tries the next handler. */
21
+ export type LinkHandler = (link: LinkTarget, context: LinkHandlerContext) => boolean;
22
+
23
+ export const localFileLinkHandler: LinkHandler = function localFileLinkHandler(link, context) {
24
+ const result = resolveMarkdownLink(link.href, link.baseDir, link.currentPath);
25
+ if (result.status === "unsupported") {
26
+ return false;
27
+ }
28
+ if (result.status === "file") {
29
+ void context.navigate.push(context.documentRoute, { path: result.path });
30
+ return true;
31
+ }
32
+ const relative = path.relative(link.baseDir, result.path);
33
+ const target =
34
+ relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)
35
+ ? result.path
36
+ : relative || ".";
37
+ context.command.toast?.toast({
38
+ level: "warning",
39
+ message: `${result.status === "missing" ? "File not found" : "Not a file"}: ${target}`,
40
+ });
41
+ return true;
42
+ };
43
+
44
+ export const runLinkHandlers = function runLinkHandlers(
45
+ handlers: readonly LinkHandler[],
46
+ link: LinkTarget,
47
+ context: LinkHandlerContext,
48
+ ): boolean {
49
+ for (const handler of handlers) {
50
+ // oxlint-disable-next-line typescript/no-unnecessary-boolean-literal-compare -- preserve exact-true consumption for JavaScript callers too
51
+ if (handler(link, context) === true) {
52
+ return true;
53
+ }
54
+ }
55
+ if (localFileLinkHandler(link, context)) {
56
+ return true;
57
+ }
58
+ context.command.toast?.toast({ level: "warning", message: `Unsupported link: ${link.href}` });
59
+ return true;
60
+ };
@@ -0,0 +1,88 @@
1
+ import { statSync } from "node:fs";
2
+ import nodePath from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { Lexer } from "marked";
5
+ import type { Token, Tokens } from "marked";
6
+
7
+ export type LinkResolution =
8
+ | { status: "file"; path: string }
9
+ | { status: "missing"; path: string }
10
+ | { status: "directory"; path: string }
11
+ | { status: "unsupported" };
12
+
13
+ /** Existing local files only. Fragments are validated, then open at the file top. */
14
+ export const resolveMarkdownLink = function resolveMarkdownLink(
15
+ href: string,
16
+ baseDir: string,
17
+ currentPath?: string,
18
+ ): LinkResolution {
19
+ try {
20
+ const decoded = decodeURIComponent(href);
21
+ if (/[\p{Cc}\\]/u.test(decoded)) {
22
+ return { status: "unsupported" };
23
+ }
24
+ const target = href.trim();
25
+ if (!target || target.startsWith("//")) {
26
+ return { status: "unsupported" };
27
+ }
28
+ const [rawPath] = target.split("#", 1);
29
+ if (rawPath.includes("?")) {
30
+ return { status: "unsupported" };
31
+ }
32
+ let path: string;
33
+ if (/^[a-zA-Z][a-zA-Z\d+.-]*:/u.test(rawPath)) {
34
+ // Check the authority before URL normalizes localhost into an empty host.
35
+ if (!/^file:\/\/\//iu.test(rawPath)) {
36
+ return { status: "unsupported" };
37
+ }
38
+ const url = new URL(rawPath);
39
+ if (url.hostname || url.search) {
40
+ return { status: "unsupported" };
41
+ }
42
+ path = fileURLToPath(url);
43
+ } else {
44
+ path = rawPath
45
+ ? nodePath.resolve(baseDir, decodeURIComponent(rawPath))
46
+ : (currentPath ?? baseDir);
47
+ }
48
+ const absolute = nodePath.resolve(path);
49
+ try {
50
+ const stat = statSync(absolute);
51
+ if (stat.isFile()) {
52
+ return { path: absolute, status: "file" };
53
+ }
54
+ return stat.isDirectory()
55
+ ? { path: absolute, status: "directory" }
56
+ : { status: "unsupported" };
57
+ } catch {
58
+ return { path: absolute, status: "missing" };
59
+ }
60
+ } catch {
61
+ return { status: "unsupported" };
62
+ }
63
+ };
64
+
65
+ export interface MarkdownLink {
66
+ /** Link text as written in the source. */
67
+ text: string;
68
+ href: string;
69
+ }
70
+
71
+ /** Ordinary inline links in source order, skipping images and code spans. */
72
+ const collect = (tokens: Token[], links: MarkdownLink[]): void => {
73
+ for (const token of tokens) {
74
+ if (token.type === "link" && "href" in token) {
75
+ // SAFETY: Marked's Generic token prevents discriminator narrowing.
76
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- checked Marked link discriminator
77
+ const { href, text } = token as Tokens.Link;
78
+ links.push({ href, text });
79
+ } else if (["strong", "em", "del"].includes(token.type) && "tokens" in token && token.tokens) {
80
+ collect(token.tokens, links);
81
+ }
82
+ }
83
+ };
84
+ export const markdownLinks = function markdownLinks(line: string): MarkdownLink[] {
85
+ const links: MarkdownLink[] = [];
86
+ collect(Lexer.lexInline(line), links);
87
+ return links;
88
+ };
@@ -0,0 +1,122 @@
1
+ /* oxlint-disable no-use-before-define -- the screen closes over the route initialized before router startup */
2
+ import { useMemo } from "react";
3
+ import type { ReactNode } from "react";
4
+ import { useChooseDialog } from "@tooee/choose";
5
+ import { useCommand } from "@tooee/commands";
6
+ import type { CommandContext } from "@tooee/commands";
7
+ import {
8
+ createRoute,
9
+ createRouter,
10
+ useNavigate,
11
+ useParams,
12
+ useRouter,
13
+ useRouterCommands,
14
+ } from "@tooee/router";
15
+ import type { Codec, RouteDefinition, RouterInstance } from "@tooee/router";
16
+ import path from "node:path";
17
+ import { createFileProvider } from "./default-provider.js";
18
+ import { runLinkHandlers } from "./link-handlers.js";
19
+ import { markdownLinks } from "./markdown-links.js";
20
+ import type { MarkdownLink } from "./markdown-links.js";
21
+ import { getTextContent } from "./types.js";
22
+ import type { ViewLaunchOptions } from "./launch.js";
23
+ import { View } from "./view.js";
24
+
25
+ const documentParams: Codec<{ path?: string }> = {
26
+ parse: (value) => {
27
+ /* oxlint-disable anti-slop/no-runtime-typeof -- route params are decoded at this boundary */
28
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
29
+ throw new TypeError("Expected document params");
30
+ }
31
+ if (!("path" in value) || value.path === undefined) {
32
+ return {};
33
+ }
34
+ if (typeof value.path !== "string" || !path.isAbsolute(value.path)) {
35
+ throw new TypeError("Expected an absolute document path");
36
+ }
37
+ /* oxlint-enable anti-slop/no-runtime-typeof */
38
+ return { path: value.path };
39
+ },
40
+ };
41
+
42
+ interface StandaloneRouter {
43
+ documentRoute: RouteDefinition<{ path?: string }>;
44
+ router: RouterInstance;
45
+ }
46
+
47
+ export const createStandaloneRouter = function createStandaloneRouter(
48
+ options: ViewLaunchOptions,
49
+ ): StandaloneRouter {
50
+ const DocumentScreen = function DocumentScreen(): ReactNode {
51
+ useRouterCommands();
52
+ const { path: documentPath } = useParams(documentRoute);
53
+ const navigate = useNavigate();
54
+ const { stack } = useRouter();
55
+ const chooseLink = useChooseDialog<MarkdownLink>();
56
+ const provider = useMemo(
57
+ () =>
58
+ documentPath === undefined ? options.contentProvider : createFileProvider(documentPath),
59
+ [documentPath],
60
+ );
61
+ const activate = (href: string, command: CommandContext): boolean => {
62
+ if (options.filePath === undefined) {
63
+ command.toast?.toast({ level: "info", message: "Links need a source file" });
64
+ return true;
65
+ }
66
+ const currentPath = path.resolve(documentPath ?? options.filePath);
67
+ return runLinkHandlers(
68
+ options.linkHandlers ?? [],
69
+ { baseDir: path.dirname(currentPath), currentPath, href },
70
+ { command, documentRoute, navigate },
71
+ );
72
+ };
73
+ useCommand({
74
+ handler: async (context) => {
75
+ // Source positions are zero-based, unlike the displayed line numbers.
76
+ const line = context.document?.activeAnchor?.source?.primary?.start.line;
77
+ const content = context.view?.content;
78
+ if (line === undefined || content === undefined) {
79
+ return;
80
+ }
81
+ const links = markdownLinks(getTextContent(content).split("\n")[line] ?? "");
82
+ // One link follows directly; several open a chooser so none is silently preferred.
83
+ const link =
84
+ links.length > 1
85
+ ? await chooseLink.open({
86
+ items: links,
87
+ prompt: "Follow link",
88
+ toItem: (item) => ({ description: item.href, text: item.text }),
89
+ })
90
+ : (links[0] ?? null);
91
+ if (link !== null) {
92
+ activate(link.href, context);
93
+ }
94
+ },
95
+ hotkey: "enter",
96
+ id: "view.follow-link",
97
+ modes: ["cursor"],
98
+ title: "Follow Markdown link",
99
+ when: (context) => context.view?.format === "markdown",
100
+ });
101
+ return (
102
+ <View
103
+ key={`${stack.length}:${documentPath ?? ""}`}
104
+ actions={options.actions}
105
+ codeBlockRenderers={options.codeBlockRenderers}
106
+ contentProvider={provider}
107
+ onMarkdownLinkActivate={activate}
108
+ renderers={options.renderers}
109
+ />
110
+ );
111
+ };
112
+ const documentRoute = createRoute({
113
+ component: DocumentScreen,
114
+ id: "view.document",
115
+ params: documentParams,
116
+ });
117
+ const router = createRouter({
118
+ initial: { routeId: documentRoute.id },
119
+ routes: [documentRoute],
120
+ });
121
+ return { documentRoute, router };
122
+ };