@elabs-ai/components-viewer 4.0.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/LICENSE +21 -0
- package/README.md +143 -0
- package/dist/chunk-2NQ4RSJ3.js +18 -0
- package/dist/chunk-2NQ4RSJ3.js.map +1 -0
- package/dist/chunk-3U3MESBU.js +21 -0
- package/dist/chunk-3U3MESBU.js.map +1 -0
- package/dist/chunk-4OFQYALG.js +147 -0
- package/dist/chunk-4OFQYALG.js.map +1 -0
- package/dist/chunk-5OMONA2G.js +87 -0
- package/dist/chunk-5OMONA2G.js.map +1 -0
- package/dist/chunk-AORNMH77.js +58 -0
- package/dist/chunk-AORNMH77.js.map +1 -0
- package/dist/chunk-BCF3RWRL.js +18 -0
- package/dist/chunk-BCF3RWRL.js.map +1 -0
- package/dist/chunk-FELIAGKI.js +22 -0
- package/dist/chunk-FELIAGKI.js.map +1 -0
- package/dist/chunk-GGR7BNW4.js +18 -0
- package/dist/chunk-GGR7BNW4.js.map +1 -0
- package/dist/chunk-H3OAON3D.js +22 -0
- package/dist/chunk-H3OAON3D.js.map +1 -0
- package/dist/chunk-HMG2ERXH.js +17 -0
- package/dist/chunk-HMG2ERXH.js.map +1 -0
- package/dist/chunk-KOMDRWOU.js +21 -0
- package/dist/chunk-KOMDRWOU.js.map +1 -0
- package/dist/chunk-NMA57QZ7.js +117 -0
- package/dist/chunk-NMA57QZ7.js.map +1 -0
- package/dist/chunk-RGWDIQJR.js +26 -0
- package/dist/chunk-RGWDIQJR.js.map +1 -0
- package/dist/chunk-SLXRUYT3.js +91 -0
- package/dist/chunk-SLXRUYT3.js.map +1 -0
- package/dist/chunk-UJIJECEF.js +21 -0
- package/dist/chunk-UJIJECEF.js.map +1 -0
- package/dist/chunk-UL43NGUG.js +68 -0
- package/dist/chunk-UL43NGUG.js.map +1 -0
- package/dist/chunk-VO2273Z2.js +55 -0
- package/dist/chunk-VO2273Z2.js.map +1 -0
- package/dist/code-adapter-ADZ4UOGN.js +226 -0
- package/dist/code-adapter-ADZ4UOGN.js.map +1 -0
- package/dist/csv-adapter-6VU3FFVU.js +100 -0
- package/dist/csv-adapter-6VU3FFVU.js.map +1 -0
- package/dist/docx-adapter-5CQDHWTD.js +383 -0
- package/dist/docx-adapter-5CQDHWTD.js.map +1 -0
- package/dist/image-adapter-WOHZR24J.js +132 -0
- package/dist/image-adapter-WOHZR24J.js.map +1 -0
- package/dist/index.d.ts +1353 -0
- package/dist/index.js +1364 -0
- package/dist/index.js.map +1 -0
- package/dist/json-adapter-ZUW5GQHE.js +90 -0
- package/dist/json-adapter-ZUW5GQHE.js.map +1 -0
- package/dist/markdown-adapter-YC6WTBS4.js +214 -0
- package/dist/markdown-adapter-YC6WTBS4.js.map +1 -0
- package/dist/media-adapter-MCTB4GBH.js +78 -0
- package/dist/media-adapter-MCTB4GBH.js.map +1 -0
- package/dist/pdf-adapter-5PMKEXUD.js +424 -0
- package/dist/pdf-adapter-5PMKEXUD.js.map +1 -0
- package/dist/pptx-adapter-6GEQLS2Z.js +351 -0
- package/dist/pptx-adapter-6GEQLS2Z.js.map +1 -0
- package/dist/text-adapter-NFNWB5W3.js +81 -0
- package/dist/text-adapter-NFNWB5W3.js.map +1 -0
- package/dist/xlsx-adapter-CM2Y6AKQ.js +167 -0
- package/dist/xlsx-adapter-CM2Y6AKQ.js.map +1 -0
- package/package.json +103 -0
- package/src/adapters/code/code-adapter.test.tsx +221 -0
- package/src/adapters/code/code-adapter.tsx +272 -0
- package/src/adapters/code/code-language.test.ts +45 -0
- package/src/adapters/code/code-language.ts +100 -0
- package/src/adapters/code/code-manifest.ts +19 -0
- package/src/adapters/code/code-theme.test.ts +46 -0
- package/src/adapters/code/code-theme.ts +99 -0
- package/src/adapters/csv/csv-adapter.test.tsx +169 -0
- package/src/adapters/csv/csv-adapter.tsx +146 -0
- package/src/adapters/csv/csv-manifest.ts +20 -0
- package/src/adapters/docx/docx-adapter.test.tsx +192 -0
- package/src/adapters/docx/docx-adapter.tsx +328 -0
- package/src/adapters/docx/docx-manifest.ts +18 -0
- package/src/adapters/docx/docx-model.test.ts +181 -0
- package/src/adapters/docx/docx-model.ts +294 -0
- package/src/adapters/image/image-adapter.test.tsx +141 -0
- package/src/adapters/image/image-adapter.tsx +191 -0
- package/src/adapters/image/image-manifest.ts +15 -0
- package/src/adapters/index.ts +64 -0
- package/src/adapters/json/json-adapter.test.tsx +77 -0
- package/src/adapters/json/json-adapter.tsx +141 -0
- package/src/adapters/json/json-manifest.ts +11 -0
- package/src/adapters/markdown/markdown-adapter.test.tsx +228 -0
- package/src/adapters/markdown/markdown-adapter.tsx +353 -0
- package/src/adapters/markdown/markdown-manifest.ts +21 -0
- package/src/adapters/markdown/markdown-marks.test.ts +85 -0
- package/src/adapters/markdown/markdown-marks.ts +119 -0
- package/src/adapters/media/media-adapter.test.tsx +79 -0
- package/src/adapters/media/media-adapter.tsx +123 -0
- package/src/adapters/media/media-manifest.ts +17 -0
- package/src/adapters/office-fixture.ts +239 -0
- package/src/adapters/pdf/pdf-adapter.test.tsx +489 -0
- package/src/adapters/pdf/pdf-adapter.tsx +599 -0
- package/src/adapters/pdf/pdf-engine.ts +171 -0
- package/src/adapters/pdf/pdf-fixture.ts +27 -0
- package/src/adapters/pdf/pdf-manifest.ts +30 -0
- package/src/adapters/pdf/pdf-text.test.ts +171 -0
- package/src/adapters/pdf/pdf-text.ts +202 -0
- package/src/adapters/pptx/pptx-adapter.test.tsx +241 -0
- package/src/adapters/pptx/pptx-adapter.tsx +341 -0
- package/src/adapters/pptx/pptx-manifest.ts +19 -0
- package/src/adapters/pptx/pptx-model.test.ts +219 -0
- package/src/adapters/pptx/pptx-model.ts +248 -0
- package/src/adapters/text/text-adapter.test.tsx +125 -0
- package/src/adapters/text/text-adapter.tsx +117 -0
- package/src/adapters/text/text-manifest.ts +19 -0
- package/src/adapters/xlsx/xlsx-adapter.test.tsx +159 -0
- package/src/adapters/xlsx/xlsx-adapter.tsx +250 -0
- package/src/adapters/xlsx/xlsx-manifest.ts +26 -0
- package/src/components/grid-text.test.ts +68 -0
- package/src/components/grid-text.ts +77 -0
- package/src/components/marked-text.tsx +36 -0
- package/src/components/sheet-table.tsx +142 -0
- package/src/core/errors.ts +120 -0
- package/src/core/highlight-marks.test.ts +95 -0
- package/src/core/highlight-marks.ts +95 -0
- package/src/core/highlight-resolve.test.ts +208 -0
- package/src/core/highlight-resolve.ts +191 -0
- package/src/core/highlight.ts +125 -0
- package/src/core/registry.test.ts +172 -0
- package/src/core/registry.ts +188 -0
- package/src/core/scroll-host.ts +42 -0
- package/src/core/text-index.test.ts +95 -0
- package/src/core/text-index.ts +159 -0
- package/src/core/types.ts +285 -0
- package/src/core/use-highlight-scroll.ts +59 -0
- package/src/core/use-page-control.ts +47 -0
- package/src/core/use-paged-scroll.ts +194 -0
- package/src/core/use-viewport-size.ts +54 -0
- package/src/core/zoom.ts +56 -0
- package/src/file-viewer/file-viewer-context.tsx +239 -0
- package/src/file-viewer/file-viewer-find.tsx +204 -0
- package/src/file-viewer/file-viewer-pager.tsx +120 -0
- package/src/file-viewer/file-viewer-zoom.tsx +166 -0
- package/src/file-viewer/file-viewer.stories.tsx +947 -0
- package/src/file-viewer/file-viewer.test.tsx +947 -0
- package/src/file-viewer/file-viewer.tsx +1143 -0
- package/src/index.ts +157 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built-in adapters, and the registry that has them all.
|
|
3
|
+
*
|
|
4
|
+
* Every entry is `manifest` (eager, plain data) + `() => import(…)` (lazy). The
|
|
5
|
+
* manifests are imported statically ON PURPOSE — they are a few dozen bytes of
|
|
6
|
+
* data each and answer "can this be opened, and what controls apply" with no
|
|
7
|
+
* network. The parsers and renderers behind them are not.
|
|
8
|
+
*
|
|
9
|
+
* `pnpm heavy-deps:check` enforces the split: a static import of `papaparse`
|
|
10
|
+
* (or, from P1, `pdfjs-dist`) fails CI, because those are optional peers — a
|
|
11
|
+
* static edge does not merely bloat a chunk, it makes the package unresolvable
|
|
12
|
+
* for every consumer that did not install that parser.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { codeManifest } from "./code/code-manifest";
|
|
16
|
+
import { csvManifest } from "./csv/csv-manifest";
|
|
17
|
+
import { docxManifest } from "./docx/docx-manifest";
|
|
18
|
+
import { markdownManifest } from "./markdown/markdown-manifest";
|
|
19
|
+
import { imageManifest } from "./image/image-manifest";
|
|
20
|
+
import { jsonManifest } from "./json/json-manifest";
|
|
21
|
+
import { mediaManifest } from "./media/media-manifest";
|
|
22
|
+
import { pdfManifest } from "./pdf/pdf-manifest";
|
|
23
|
+
import { pptxManifest } from "./pptx/pptx-manifest";
|
|
24
|
+
import { textManifest } from "./text/text-manifest";
|
|
25
|
+
import { xlsxManifest } from "./xlsx/xlsx-manifest";
|
|
26
|
+
import { createRegistry, type ViewerRegistry } from "../core/registry";
|
|
27
|
+
|
|
28
|
+
export {
|
|
29
|
+
codeManifest,
|
|
30
|
+
csvManifest,
|
|
31
|
+
docxManifest,
|
|
32
|
+
imageManifest,
|
|
33
|
+
jsonManifest,
|
|
34
|
+
markdownManifest,
|
|
35
|
+
mediaManifest,
|
|
36
|
+
pdfManifest,
|
|
37
|
+
pptxManifest,
|
|
38
|
+
textManifest,
|
|
39
|
+
xlsxManifest,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A registry with every built-in adapter registered.
|
|
44
|
+
*
|
|
45
|
+
* Call it per app (or per view) rather than sharing one module-level instance,
|
|
46
|
+
* so one screen's `register()` override cannot leak into another's.
|
|
47
|
+
*/
|
|
48
|
+
export function createDefaultRegistry(): ViewerRegistry {
|
|
49
|
+
const registry = createRegistry();
|
|
50
|
+
registry.register(imageManifest, () => import("./image/image-adapter"));
|
|
51
|
+
registry.register(jsonManifest, () => import("./json/json-adapter"));
|
|
52
|
+
registry.register(csvManifest, () => import("./csv/csv-adapter"));
|
|
53
|
+
registry.register(pdfManifest, () => import("./pdf/pdf-adapter"));
|
|
54
|
+
registry.register(mediaManifest, () => import("./media/media-adapter"));
|
|
55
|
+
registry.register(docxManifest, () => import("./docx/docx-adapter"));
|
|
56
|
+
registry.register(xlsxManifest, () => import("./xlsx/xlsx-adapter"));
|
|
57
|
+
registry.register(pptxManifest, () => import("./pptx/pptx-adapter"));
|
|
58
|
+
registry.register(markdownManifest, () => import("./markdown/markdown-adapter"));
|
|
59
|
+
registry.register(codeManifest, () => import("./code/code-adapter"));
|
|
60
|
+
// Registered last and claiming only broad categories, so anything above wins
|
|
61
|
+
// a file it names specifically. This is the "readable as text" backstop.
|
|
62
|
+
registry.register(textManifest, () => import("./text/text-adapter"));
|
|
63
|
+
return registry;
|
|
64
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { normalizeFileSource } from "@elabs-ai/components-ui";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { isViewerError } from "../../core/errors";
|
|
6
|
+
import jsonModule, { toTreeNodes, type JsonDocument } from "./json-adapter";
|
|
7
|
+
|
|
8
|
+
const parse = (text: string) =>
|
|
9
|
+
jsonModule
|
|
10
|
+
.create()
|
|
11
|
+
.load(
|
|
12
|
+
normalizeFileSource({ kind: "text", text, name: "data.json" }),
|
|
13
|
+
{},
|
|
14
|
+
) as Promise<JsonDocument>;
|
|
15
|
+
|
|
16
|
+
describe("toTreeNodes", () => {
|
|
17
|
+
it("uses the JSON path as the node id, so ids are stable and addressable", () => {
|
|
18
|
+
const nodes = toTreeNodes({ items: [{ name: "a" }] });
|
|
19
|
+
expect(nodes[0]?.id).toBe("$.items");
|
|
20
|
+
expect(nodes[0]?.children?.[0]?.id).toBe("$.items[0]");
|
|
21
|
+
expect(nodes[0]?.children?.[0]?.children?.[0]?.id).toBe("$.items[0].name");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("gives scalars no children, so they render as leaves", () => {
|
|
25
|
+
const nodes = toTreeNodes({ n: 1, s: "x", b: true, nul: null });
|
|
26
|
+
expect(nodes.map((node) => node.children)).toEqual([
|
|
27
|
+
undefined,
|
|
28
|
+
undefined,
|
|
29
|
+
undefined,
|
|
30
|
+
undefined,
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns nothing for a scalar root — there is no structure to expand", () => {
|
|
35
|
+
expect(toTreeNodes(42)).toEqual([]);
|
|
36
|
+
expect(toTreeNodes("hello")).toEqual([]);
|
|
37
|
+
expect(toTreeNodes(null)).toEqual([]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("keeps empty containers expandable-looking but childless", () => {
|
|
41
|
+
const nodes = toTreeNodes({ empty: {}, none: [] });
|
|
42
|
+
expect(nodes[0]?.children).toEqual([]);
|
|
43
|
+
expect(nodes[1]?.children).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("json adapter — parsing", () => {
|
|
48
|
+
it("parses to a real value and keeps the source text", async () => {
|
|
49
|
+
const doc = await parse('{"a":1}');
|
|
50
|
+
expect(doc.value).toEqual({ a: 1 });
|
|
51
|
+
expect(doc.text).toBe('{"a":1}');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("fails invalid JSON terminally rather than falling through to plain text", async () => {
|
|
55
|
+
const error = await parse("{ not json ").catch((e: unknown) => e);
|
|
56
|
+
expect(isViewerError(error) && error.code).toBe("parse-failed");
|
|
57
|
+
// The engine's own message carries the position — the one fact the user needs.
|
|
58
|
+
expect(isViewerError(error) && error.fileName).toBe("data.json");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("json adapter — rendering", () => {
|
|
63
|
+
const source = normalizeFileSource({ kind: "text", text: "", name: "data.json" });
|
|
64
|
+
|
|
65
|
+
it("renders a named tree", async () => {
|
|
66
|
+
const doc = await parse('{"items":[1,2]}');
|
|
67
|
+
render(<jsonModule.Renderer document={doc} source={source} />);
|
|
68
|
+
expect(screen.getByRole("tree", { name: "JSON structure" })).toBeInTheDocument();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("falls back to the raw text for a scalar document", async () => {
|
|
72
|
+
const doc = await parse("42");
|
|
73
|
+
const { container } = render(<jsonModule.Renderer document={doc} source={source} />);
|
|
74
|
+
expect(screen.queryByRole("tree")).not.toBeInTheDocument();
|
|
75
|
+
expect(container.querySelector("pre")).toHaveTextContent("42");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* JSON adapter — parses to a real value and renders it as a collapsible tree.
|
|
5
|
+
*
|
|
6
|
+
* Why a tree and not a pretty-printed `<pre>`: the whole reason to open a JSON
|
|
7
|
+
* file in a viewer rather than an editor is to find something in it. A tree
|
|
8
|
+
* collapses the parts you are not reading, and `@elabs-ai/components-ui`'s
|
|
9
|
+
* `Tree` already ships the keyboard model, roving tabindex and virtualization,
|
|
10
|
+
* so this adapter contributes the MAPPING and nothing else.
|
|
11
|
+
*
|
|
12
|
+
* Invalid JSON is a terminal `parse-failed`, reported with the position the
|
|
13
|
+
* engine gave us — not a silent fall-through to plain text, which would hide
|
|
14
|
+
* the one fact the user needs.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { cn, Tree, type TreeNode, useLocale } from "@elabs-ai/components-ui";
|
|
18
|
+
import type { ResolvedFileSource } from "@elabs-ai/components-ui";
|
|
19
|
+
|
|
20
|
+
import { ViewerError, toViewerError } from "../../core/errors";
|
|
21
|
+
import type {
|
|
22
|
+
AdapterDocument,
|
|
23
|
+
AdapterLoadContext,
|
|
24
|
+
AdapterModule,
|
|
25
|
+
AdapterRendererProps,
|
|
26
|
+
FileAdapter,
|
|
27
|
+
} from "../../core/types";
|
|
28
|
+
import { jsonManifest } from "./json-manifest";
|
|
29
|
+
|
|
30
|
+
export interface JsonDocument extends AdapterDocument {
|
|
31
|
+
kind: "json";
|
|
32
|
+
/** The parsed value. `unknown` on purpose — it is whatever the file held. */
|
|
33
|
+
value: unknown;
|
|
34
|
+
/** The original source text, for the raw view and for copy. */
|
|
35
|
+
text: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A one-line preview of a value, used as the collapsed label for a container. */
|
|
39
|
+
function summarize(value: unknown): string {
|
|
40
|
+
if (Array.isArray(value)) return `[${String(value.length)}]`;
|
|
41
|
+
if (value !== null && typeof value === "object") {
|
|
42
|
+
return `{${String(Object.keys(value).length)}}`;
|
|
43
|
+
}
|
|
44
|
+
if (typeof value === "string") return `"${value}"`;
|
|
45
|
+
return String(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** True for a value that has children worth expanding. */
|
|
49
|
+
function isContainer(value: unknown): value is Record<string, unknown> | unknown[] {
|
|
50
|
+
return value !== null && typeof value === "object";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Map a parsed value onto `Tree` nodes.
|
|
55
|
+
*
|
|
56
|
+
* Ids are the JSON path (`$.items[2].name`), which makes them stable across
|
|
57
|
+
* re-renders and directly usable as a "reveal this node" target later.
|
|
58
|
+
*/
|
|
59
|
+
export function toTreeNodes(value: unknown, path = "$"): TreeNode[] {
|
|
60
|
+
if (!isContainer(value)) return [];
|
|
61
|
+
|
|
62
|
+
const entries: [string, unknown][] = Array.isArray(value)
|
|
63
|
+
? value.map((item, index) => [String(index), item])
|
|
64
|
+
: Object.entries(value);
|
|
65
|
+
|
|
66
|
+
return entries.map(([key, child]) => {
|
|
67
|
+
const childPath = Array.isArray(value) ? `${path}[${key}]` : `${path}.${key}`;
|
|
68
|
+
const container = isContainer(child);
|
|
69
|
+
return {
|
|
70
|
+
id: childPath,
|
|
71
|
+
label: (
|
|
72
|
+
<span className="font-mono">
|
|
73
|
+
<span className="text-foreground">{key}</span>
|
|
74
|
+
<span className="text-muted-foreground">: </span>
|
|
75
|
+
<span className={container ? "text-muted-foreground" : "text-info-text"}>
|
|
76
|
+
{summarize(child)}
|
|
77
|
+
</span>
|
|
78
|
+
</span>
|
|
79
|
+
),
|
|
80
|
+
children: container ? toTreeNodes(child, childPath) : undefined,
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
class JsonAdapter implements FileAdapter {
|
|
86
|
+
async load(source: ResolvedFileSource, context: AdapterLoadContext): Promise<JsonDocument> {
|
|
87
|
+
let text: string;
|
|
88
|
+
try {
|
|
89
|
+
text = await source.text(context.signal);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
throw toViewerError(error, "read-failed", { fileName: source.name });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
return { kind: "json", value: JSON.parse(text) as unknown, text };
|
|
96
|
+
} catch (error) {
|
|
97
|
+
throw new ViewerError(
|
|
98
|
+
"parse-failed",
|
|
99
|
+
error instanceof Error ? error.message : "Invalid JSON.",
|
|
100
|
+
{ fileName: source.name, cause: error },
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function JsonRenderer({ document: doc, className }: AdapterRendererProps) {
|
|
107
|
+
const json = doc as JsonDocument;
|
|
108
|
+
const { t } = useLocale();
|
|
109
|
+
const nodes = toTreeNodes(json.value);
|
|
110
|
+
|
|
111
|
+
// A scalar top level (`42`, `"hello"`, `null`) has no children to expand —
|
|
112
|
+
// a tree of nothing would read as an empty state, which it is not.
|
|
113
|
+
if (nodes.length === 0) {
|
|
114
|
+
return (
|
|
115
|
+
<pre className={cn("text-code font-mono break-words whitespace-pre-wrap", className)}>
|
|
116
|
+
{json.text}
|
|
117
|
+
</pre>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<Tree
|
|
123
|
+
aria-label={t("viewer.json.tree")}
|
|
124
|
+
nodes={nodes}
|
|
125
|
+
// The top level open, everything below collapsed: enough to see the shape
|
|
126
|
+
// without paying to build a DOM for a deeply nested document.
|
|
127
|
+
defaultExpandedIds={nodes.map((node) => node.id)}
|
|
128
|
+
selectionMode="none"
|
|
129
|
+
virtualize
|
|
130
|
+
className={cn("h-full overflow-auto", className)}
|
|
131
|
+
/>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const adapterModule: AdapterModule = {
|
|
136
|
+
manifest: jsonManifest,
|
|
137
|
+
create: () => new JsonAdapter(),
|
|
138
|
+
Renderer: JsonRenderer,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export default adapterModule;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AdapterManifest } from "../../core/types";
|
|
2
|
+
import { PROTOCOL_VERSION } from "../../core/types";
|
|
3
|
+
|
|
4
|
+
/** Eager, data-only (see `image-manifest.ts`). */
|
|
5
|
+
export const jsonManifest: AdapterManifest = {
|
|
6
|
+
id: "json",
|
|
7
|
+
protocol: PROTOCOL_VERSION,
|
|
8
|
+
extensions: ["json"],
|
|
9
|
+
mediaTypes: ["application/json"],
|
|
10
|
+
capabilities: { text: true, search: true },
|
|
11
|
+
};
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { normalizeFileSource } from "@elabs-ai/components-ui";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import type { ResolvedHighlight } from "../../core/highlight";
|
|
6
|
+
import markdownModule, {
|
|
7
|
+
MARKDOWN_CHARACTER_LIMIT,
|
|
8
|
+
type MarkdownDocument,
|
|
9
|
+
} from "./markdown-adapter";
|
|
10
|
+
|
|
11
|
+
const SAMPLE = [
|
|
12
|
+
"# Quarterly review",
|
|
13
|
+
"",
|
|
14
|
+
"Revenue grew **18%** against a flat market.",
|
|
15
|
+
"",
|
|
16
|
+
"- EMEA beat plan",
|
|
17
|
+
"- APAC held flat",
|
|
18
|
+
"",
|
|
19
|
+
"See the [full report](https://example.com/report) and `pnpm build`.",
|
|
20
|
+
"",
|
|
21
|
+
"```ts",
|
|
22
|
+
"const total = 18;",
|
|
23
|
+
"```",
|
|
24
|
+
].join("\n");
|
|
25
|
+
|
|
26
|
+
function source(text: string, name = "README.md") {
|
|
27
|
+
return normalizeFileSource({ kind: "text", text, name, mediaType: "text/markdown" });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const load = (text = SAMPLE) =>
|
|
31
|
+
markdownModule.create().load(source(text), {}) as Promise<MarkdownDocument>;
|
|
32
|
+
|
|
33
|
+
describe("markdown adapter — loading", () => {
|
|
34
|
+
it("keeps the source as-is; the parse belongs to the renderer", async () => {
|
|
35
|
+
const doc = await load();
|
|
36
|
+
expect(doc.kind).toBe("markdown");
|
|
37
|
+
expect(doc.text).toBe(SAMPLE);
|
|
38
|
+
expect(doc.totalCharacters).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("truncates a file too large to parse, and says how much it kept", async () => {
|
|
42
|
+
const doc = await load("a\n".repeat(600_000));
|
|
43
|
+
expect(doc.text).toHaveLength(MARKDOWN_CHARACTER_LIMIT);
|
|
44
|
+
expect(doc.totalCharacters).toBe(1_200_000);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("reports an unreadable source as read-failed, not parse-failed", async () => {
|
|
48
|
+
const failing = {
|
|
49
|
+
...source(""),
|
|
50
|
+
text: () => Promise.reject(new Error("network down")),
|
|
51
|
+
};
|
|
52
|
+
await expect(markdownModule.create().load(failing, {})).rejects.toMatchObject({
|
|
53
|
+
code: "read-failed",
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("markdown adapter — rendering", () => {
|
|
59
|
+
const Renderer = markdownModule.Renderer;
|
|
60
|
+
|
|
61
|
+
const draw = async (text = SAMPLE) => {
|
|
62
|
+
const doc = await load(text);
|
|
63
|
+
return render(<Renderer document={doc} source={source(text)} />);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
it("renders a document, not a wall of source", async () => {
|
|
67
|
+
await draw();
|
|
68
|
+
// A real heading element — this is the ASSET-2 defect in reverse: markdown
|
|
69
|
+
// shown as highlighted source instead of as the document it is.
|
|
70
|
+
expect(screen.getByRole("heading", { name: "Quarterly review" })).toBeInTheDocument();
|
|
71
|
+
expect(screen.getAllByRole("listitem").map((item) => item.textContent)).toEqual([
|
|
72
|
+
"EMEA beat plan",
|
|
73
|
+
"APAC held flat",
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("offsets the document's headings below the host page's own", async () => {
|
|
78
|
+
// A viewed README's `#` is the top of THAT document, not of the page. The
|
|
79
|
+
// default puts it at h2, so a host page keeping its own h1 does not end up
|
|
80
|
+
// with two of them in a screen reader's flat heading list.
|
|
81
|
+
await draw();
|
|
82
|
+
expect(screen.getByRole("heading", { name: "Quarterly review", level: 2 })).toBeInTheDocument();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("honours a host that owns no h1 of its own", async () => {
|
|
86
|
+
const doc = await load();
|
|
87
|
+
render(<Renderer document={doc} source={source(SAMPLE)} baseHeadingLevel={1} />);
|
|
88
|
+
expect(screen.getByRole("heading", { name: "Quarterly review", level: 1 })).toBeInTheDocument();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("never renders past h6, whatever the offset", async () => {
|
|
92
|
+
const doc = await load("###### Deep\n");
|
|
93
|
+
render(<Renderer document={doc} source={source("")} baseHeadingLevel={6} />);
|
|
94
|
+
// 6 + 6 - 1 = 11, clamped: an `h11` is not an element.
|
|
95
|
+
expect(screen.getByRole("heading", { name: "Deep", level: 6 })).toBeInTheDocument();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("renders links as links", async () => {
|
|
99
|
+
await draw();
|
|
100
|
+
expect(screen.getByRole("link", { name: "full report" })).toHaveAttribute(
|
|
101
|
+
"href",
|
|
102
|
+
"https://example.com/report",
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("keeps fenced code as code", async () => {
|
|
107
|
+
const { container } = await draw();
|
|
108
|
+
expect(container.querySelector("pre")).toHaveTextContent("const total = 18;");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("announces truncation as a status, not an error", async () => {
|
|
112
|
+
const doc: MarkdownDocument = {
|
|
113
|
+
kind: "markdown",
|
|
114
|
+
text: "# Partial",
|
|
115
|
+
totalCharacters: 1_200_000,
|
|
116
|
+
};
|
|
117
|
+
render(<Renderer document={doc} source={source("")} />);
|
|
118
|
+
expect(screen.getByRole("status")).toHaveTextContent(/Showing the first/);
|
|
119
|
+
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("shows a whitespace-only file as an empty state, not a blank pane", async () => {
|
|
123
|
+
await draw(" \n\n");
|
|
124
|
+
expect(screen.getByText("This file is empty")).toBeInTheDocument();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("markdown renderer — highlighting", () => {
|
|
129
|
+
const Renderer = markdownModule.Renderer;
|
|
130
|
+
|
|
131
|
+
const cite = (id: string, range: [number, number], active = false): ResolvedHighlight => ({
|
|
132
|
+
id,
|
|
133
|
+
source: "citation",
|
|
134
|
+
status: "resolved",
|
|
135
|
+
address: { kind: "range", start: range[0], end: range[1] },
|
|
136
|
+
active,
|
|
137
|
+
range,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
/** Offsets of a passage in the markdown SOURCE, which is the address space. */
|
|
141
|
+
const rangeOf = (passage: string, text = SAMPLE): [number, number] => {
|
|
142
|
+
const start = text.indexOf(passage);
|
|
143
|
+
expect(start).toBeGreaterThanOrEqual(0);
|
|
144
|
+
return [start, start + passage.length];
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const drawWith = async (
|
|
148
|
+
highlights: ResolvedHighlight[],
|
|
149
|
+
activeHighlightId?: string,
|
|
150
|
+
text = SAMPLE,
|
|
151
|
+
) => {
|
|
152
|
+
const doc = await load(text);
|
|
153
|
+
return render(
|
|
154
|
+
<Renderer
|
|
155
|
+
document={doc}
|
|
156
|
+
source={source(text)}
|
|
157
|
+
highlights={highlights}
|
|
158
|
+
activeHighlightId={activeHighlightId}
|
|
159
|
+
/>,
|
|
160
|
+
);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const plates = (container: HTMLElement) =>
|
|
164
|
+
Array.from(container.querySelectorAll('[data-slot="highlight-block"]'));
|
|
165
|
+
|
|
166
|
+
it("declares the address kinds it can actually honour", () => {
|
|
167
|
+
expect(markdownModule.manifest.capabilities?.highlight).toEqual(["quote", "range"]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("plates the paragraph a passage falls in, and only that one", async () => {
|
|
171
|
+
const { container } = await drawWith([cite("a", rangeOf("against a flat"))]);
|
|
172
|
+
expect(plates(container).map((el) => el.textContent)).toEqual([
|
|
173
|
+
"Revenue grew 18% against a flat market.",
|
|
174
|
+
]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("plates the heading when the passage is the heading", async () => {
|
|
178
|
+
const { container } = await drawWith([cite("a", rangeOf("Quarterly review"))]);
|
|
179
|
+
const [plate] = plates(container);
|
|
180
|
+
expect(plate?.tagName).toBe("H2");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("plates one list item, not the whole list", async () => {
|
|
184
|
+
const { container } = await drawWith([cite("a", rangeOf("APAC held"))]);
|
|
185
|
+
expect(plates(container).map((el) => el.textContent)).toEqual(["APAC held flat"]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("plates a fenced block on its `pre`, so the code keeps its own ground", async () => {
|
|
189
|
+
const { container } = await drawWith([cite("a", rangeOf("const total"))]);
|
|
190
|
+
const [plate] = plates(container);
|
|
191
|
+
expect(plate?.tagName).toBe("PRE");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("plates every block a passage spans", async () => {
|
|
195
|
+
const start = SAMPLE.indexOf("Revenue");
|
|
196
|
+
const end = SAMPLE.indexOf("APAC held flat") + "APAC held flat".length;
|
|
197
|
+
const { container } = await drawWith([cite("a", [start, end])]);
|
|
198
|
+
// The paragraph plus both list items — a citation that runs across a break
|
|
199
|
+
// is still one passage, so every block it touches says so.
|
|
200
|
+
expect(plates(container)).toHaveLength(3);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("does not plate the block after a passage that ends where it starts", async () => {
|
|
204
|
+
// Half-open on both sides: the heading's span ends exactly where the blank
|
|
205
|
+
// line before the paragraph begins, and the paragraph must stay unplated.
|
|
206
|
+
const { container } = await drawWith([cite("a", rangeOf("# Quarterly review"))]);
|
|
207
|
+
expect(plates(container)).toHaveLength(1);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("flags the current passage for assistive tech, not by colour alone", async () => {
|
|
211
|
+
const { container } = await drawWith([cite("a", rangeOf("against a flat"), true)], "a");
|
|
212
|
+
const active = container.querySelector('[data-slot="highlight-block"][data-active]');
|
|
213
|
+
expect(active?.getAttribute("aria-current")).toBe("true");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("plates a loose list item once, not once per nested paragraph", async () => {
|
|
217
|
+
// A loose list renders as `li > p`; both nodes' source spans contain the
|
|
218
|
+
// mark, so without the nesting guard the reader sees two stacked fills.
|
|
219
|
+
const text = "- One\n\n- Two\n";
|
|
220
|
+
const { container } = await drawWith([cite("a", rangeOf("One", text))], undefined, text);
|
|
221
|
+
expect(plates(container)).toHaveLength(1);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("draws nothing when no highlight is passed", async () => {
|
|
225
|
+
const { container } = await drawWith([]);
|
|
226
|
+
expect(plates(container)).toHaveLength(0);
|
|
227
|
+
});
|
|
228
|
+
});
|