yamlover 0.3.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 +214 -0
- package/bin/yamlover.js +202 -0
- package/dist/server.js +4681 -0
- package/index.html +12 -0
- package/package.json +72 -0
- package/src/client/App.tsx +372 -0
- package/src/client/NodeView.tsx +422 -0
- package/src/client/TaskStrip.tsx +34 -0
- package/src/client/Tree.tsx +97 -0
- package/src/client/api.ts +186 -0
- package/src/client/icons.ts +91 -0
- package/src/client/links.tsx +108 -0
- package/src/client/live.ts +42 -0
- package/src/client/main.tsx +10 -0
- package/src/client/paste-html.ts +228 -0
- package/src/client/paste-links.ts +42 -0
- package/src/client/paths.ts +109 -0
- package/src/client/render.tsx +326 -0
- package/src/client/renderers/annotate.tsx +507 -0
- package/src/client/renderers/asciidoc.tsx +35 -0
- package/src/client/renderers/chapter.tsx +138 -0
- package/src/client/renderers/csv.tsx +233 -0
- package/src/client/renderers/decoded.tsx +72 -0
- package/src/client/renderers/djvu.tsx +97 -0
- package/src/client/renderers/doc.tsx +40 -0
- package/src/client/renderers/docx.tsx +49 -0
- package/src/client/renderers/epub.tsx +147 -0
- package/src/client/renderers/explorer.tsx +209 -0
- package/src/client/renderers/fb2.tsx +149 -0
- package/src/client/renderers/headings.ts +69 -0
- package/src/client/renderers/heic.tsx +23 -0
- package/src/client/renderers/imagemap.tsx +157 -0
- package/src/client/renderers/kml.ts +46 -0
- package/src/client/renderers/latex.tsx +36 -0
- package/src/client/renderers/map.tsx +205 -0
- package/src/client/renderers/marklower.tsx +119 -0
- package/src/client/renderers/markup.tsx +64 -0
- package/src/client/renderers/media.tsx +19 -0
- package/src/client/renderers/panzoom.ts +101 -0
- package/src/client/renderers/pdf.tsx +176 -0
- package/src/client/renderers/plaintext.tsx +120 -0
- package/src/client/renderers/plantuml.tsx +82 -0
- package/src/client/renderers/psd.tsx +25 -0
- package/src/client/renderers/registry.tsx +389 -0
- package/src/client/renderers/rtf.tsx +210 -0
- package/src/client/renderers/spreadsheet.tsx +105 -0
- package/src/client/renderers/tag.tsx +113 -0
- package/src/client/renderers/text.tsx +41 -0
- package/src/client/renderers/tiff.tsx +33 -0
- package/src/client/styles.css +1115 -0
- package/src/client/vendor/README.md +30 -0
- package/src/client/vendor/djvu.js +15535 -0
- package/src/client/vite-env.d.ts +31 -0
- package/src/server/api.ts +147 -0
- package/src/server/engine-api.ts +1442 -0
- package/src/server/gitignore.ts +81 -0
- package/src/server/node-kind.ts +48 -0
- package/src/server/tasks.ts +83 -0
- package/src/server/yamlover.ts +1133 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Pasted-LINK handlers: a paste that is EXACTLY one well-known URL means the linked CONTENT is
|
|
2
|
+
// wanted, not the URL text. Each recognizer turns the link into something the normal paste flows
|
|
3
|
+
// accept — a file to upload (arXiv) or a text to chunk (a tweet). A link inside longer text is
|
|
4
|
+
// left alone (the prose is the paste). Used by NodeView's paste listener.
|
|
5
|
+
|
|
6
|
+
/** The arXiv paper behind a pasted link: a lone `arxiv.org/{abs|pdf|html}/<id>` URL — fetch its
|
|
7
|
+
* PDF (arXiv sends `access-control-allow-origin: *`) and run the normal file-paste flow. New
|
|
8
|
+
* (`2605.00615v2`) and old (`math/0211159`) id styles. */
|
|
9
|
+
export function arxivPdf(text: string): { url: string; name: string } | null {
|
|
10
|
+
const m = /^(?:https?:\/\/)?(?:www\.)?arxiv\.org\/(?:abs|pdf|html)\/(.+?)(?:\.pdf)?(?:[?#][^\s]*)?$/i.exec(text.trim());
|
|
11
|
+
if (!m) return null;
|
|
12
|
+
const id = m[1];
|
|
13
|
+
if (!/^(?:\d{4}\.\d{4,5}|[a-z-]+(?:\.[A-Z]{2})?\/\d{7})(?:v\d+)?$/.test(id)) return null;
|
|
14
|
+
return { url: `https://arxiv.org/pdf/${id}`, name: `arxiv-${id.replace(/\//g, "-")}.pdf` };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The tweet behind a pasted `x.com`/`twitter.com` status link, canonicalized; null if the text
|
|
18
|
+
* is not exactly one such link. */
|
|
19
|
+
export function tweetUrl(text: string): string | null {
|
|
20
|
+
const m = /^(?:https?:\/\/)?(?:www\.|mobile\.)?(?:x\.com|twitter\.com)\/(\w{1,15})\/status(?:es)?\/(\d+)(?:[/?#][^\s]*)?$/i.exec(text.trim());
|
|
21
|
+
return m ? `https://twitter.com/${m[1]}/status/${m[2]}` : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Fetch a tweet's FULL content via X's public oEmbed endpoint (`publish.x.com/oembed` — the
|
|
25
|
+
* embedded-tweet API: no auth, CORS-open) and compose it as pasteable text — the whole message,
|
|
26
|
+
* then an author/date attribution line, then the link. */
|
|
27
|
+
export async function fetchTweetText(statusUrl: string): Promise<string> {
|
|
28
|
+
const res = await fetch(`https://publish.x.com/oembed?url=${encodeURIComponent(statusUrl)}&omit_script=true&dnt=true`);
|
|
29
|
+
if (!res.ok) throw new Error(`oEmbed HTTP ${res.status}`);
|
|
30
|
+
const o = (await res.json()) as { html?: string; author_name?: string; author_url?: string; url?: string };
|
|
31
|
+
// the payload's `html` is a <blockquote><p>tweet…</p>— Author (@handle) <a>date</a></blockquote>
|
|
32
|
+
const doc = new DOMParser().parseFromString((o.html ?? "").replace(/<br\s*\/?>/gi, "\n"), "text/html");
|
|
33
|
+
const quote = doc.querySelector("blockquote");
|
|
34
|
+
const body = quote?.querySelector("p")?.textContent?.trim();
|
|
35
|
+
if (!body) throw new Error("no tweet text in the oEmbed payload");
|
|
36
|
+
const handle = (o.author_url ?? "").split("/").filter(Boolean).pop();
|
|
37
|
+
const dateLinks = quote ? Array.from(quote.querySelectorAll(":scope > a")) : [];
|
|
38
|
+
const date = dateLinks[dateLinks.length - 1]?.textContent?.trim();
|
|
39
|
+
const who = [o.author_name, handle && `@${handle}`].filter(Boolean).join(" ");
|
|
40
|
+
const attribution = [who && `— ${who}`, date].filter(Boolean).join(", ");
|
|
41
|
+
return [body, "", [attribution, o.url ?? statusUrl].filter(Boolean).join("\n")].join("\n");
|
|
42
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// JSON-space path helpers. The CANONICAL client path is COLON-form (SEPARATOR.md M4):
|
|
2
|
+
// `:key[0]:sub`, root `:` — what the API speaks and the UI displays. The BROWSER URL
|
|
3
|
+
// stays SLASH-transported (`/key[0]/sub` — ruling: "the URL should be slashed, of
|
|
4
|
+
// course"), converted at this boundary only.
|
|
5
|
+
//
|
|
6
|
+
// A key may itself contain `:`, `/`, `[`, or `]` (e.g. `@vitejs/plugin-react`), so each
|
|
7
|
+
// key is percent-encoded (encodeURIComponent) — the structural separators then
|
|
8
|
+
// unambiguously tokenize, and the URL spelling is address-bar-safe as is.
|
|
9
|
+
|
|
10
|
+
export type Seg = string | number;
|
|
11
|
+
|
|
12
|
+
/** Canonical client path: `:key[0]:sub` (keys percent-encoded), root `:`. */
|
|
13
|
+
export function segsToStr(segs: Seg[]): string {
|
|
14
|
+
return segs.map((s) => (typeof s === "number" ? `[${s}]` : `:${encodeURIComponent(s)}`)).join("") || ":";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const PATH_TOKEN = /\[\d+\]|[^:\[\]]+/g; // canonical (colon) form
|
|
18
|
+
const URL_TOKEN = /\[\d+\]|[^/\[\]]+/g; // URL (slash) transport form
|
|
19
|
+
|
|
20
|
+
export function strToSegs(str: string): Seg[] {
|
|
21
|
+
const out: Seg[] = [];
|
|
22
|
+
for (const tok of str.match(PATH_TOKEN) || []) {
|
|
23
|
+
out.push(/^\[\d+\]$/.test(tok) ? Number(tok.slice(1, -1)) : safeDecode(tok));
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function safeDecode(s: string): string {
|
|
29
|
+
try {
|
|
30
|
+
return decodeURIComponent(s);
|
|
31
|
+
} catch {
|
|
32
|
+
return s;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The current JSON path taken from the browser URL (slash transport), in canonical
|
|
37
|
+
* COLON form. The pathname is already per-key-encoded, so it is tokenized *before*
|
|
38
|
+
* decoding — segments ride through encoded into the canonical string. */
|
|
39
|
+
export function pathFromUrl(): string {
|
|
40
|
+
const segs: Seg[] = [];
|
|
41
|
+
for (const tok of window.location.pathname.match(URL_TOKEN) || []) {
|
|
42
|
+
segs.push(/^\[\d+\]$/.test(tok) ? Number(tok.slice(1, -1)) : safeDecode(tok));
|
|
43
|
+
}
|
|
44
|
+
return segsToStr(segs);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The slash-transport URL spelling of a canonical path (`:a[0]:b` → `/a[0]/b`). */
|
|
48
|
+
export function urlOfPath(path: string): string {
|
|
49
|
+
const segs = strToSegs(path);
|
|
50
|
+
return segs.map((s) => (typeof s === "number" ? `[${s}]` : `/${encodeURIComponent(s)}`)).join("") || "/";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A human-readable form of a canonical path: each key decoded (so a percent-encoded
|
|
54
|
+
* segment like `%D0%9F…` shows as its actual characters), colon-separated, indices as
|
|
55
|
+
* `[i]`. For display only — tooltips, labels — never for URLs or navigation. */
|
|
56
|
+
export function displayPath(path: string): string {
|
|
57
|
+
const segs = strToSegs(path);
|
|
58
|
+
if (!segs.length) return ":";
|
|
59
|
+
return segs.map((s) => (typeof s === "number" ? `[${s}]` : `:${s}`)).join("");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A human-readable form of a path-LIKE key whose structure must survive verbatim —
|
|
63
|
+
* a relations key (`..`, `:eve`, `::a:b`), where {@link displayPath} would mangle the
|
|
64
|
+
* leading `::` or a bare `..`. Each key token is decoded in place; display only. */
|
|
65
|
+
export function displayKey(key: string): string {
|
|
66
|
+
return key.replace(/[^:/\[\]]+/g, (tok) => safeDecode(tok));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Whether canonical path `a` is a (strict) ancestor of `p`. Root `:` is an ancestor
|
|
70
|
+
* of everything; otherwise `p` must continue past `a` at a `:` or `[`. */
|
|
71
|
+
export function isAncestorPath(a: string, p: string): boolean {
|
|
72
|
+
if (a === p) return false;
|
|
73
|
+
if (a === ":") return true;
|
|
74
|
+
return p.startsWith(a + ":") || p.startsWith(a + "[");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The current representation taken from the URL's `?format=` (or `fallback`). */
|
|
78
|
+
export function formatFromUrl(fallback: string): string {
|
|
79
|
+
return new URLSearchParams(window.location.search).get("format") || fallback;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Write the JSON path (canonical colon form, converted to the slash-transport URL)
|
|
83
|
+
* plus `?format=` into the URL. Path navigation pushes a history entry; switching
|
|
84
|
+
* format replaces. Any other query params already present are kept (e.g. a renderer's
|
|
85
|
+
* own options such as the CSV `sep`/`header`), so only `format` is overwritten here. */
|
|
86
|
+
export function writeUrl(path: string, format: string, replace = false): void {
|
|
87
|
+
const params = new URLSearchParams(window.location.search);
|
|
88
|
+
params.set("format", format);
|
|
89
|
+
const url = `${urlOfPath(path || ":")}?${params.toString()}`;
|
|
90
|
+
if (url === window.location.pathname + window.location.search) return;
|
|
91
|
+
if (replace) window.history.replaceState({}, "", url);
|
|
92
|
+
else window.history.pushState({}, "", url);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Breadcrumb crumbs for a path. The head is the CLI ROOT (`rootLabel`); it is
|
|
96
|
+
* omitted when blank, so a default (cwd) root shows just the in-tree segments.
|
|
97
|
+
* e.g. (":a:b", "examples") → [examples, a, b] (each decoded, linking to its path). */
|
|
98
|
+
export function crumbs(p: string, rootLabel: string): { label: string; path: string }[] {
|
|
99
|
+
const segs = strToSegs(p);
|
|
100
|
+
const out: { label: string; path: string }[] = [];
|
|
101
|
+
if (rootLabel) out.push({ label: rootLabel, path: ":" });
|
|
102
|
+
segs.forEach((s, i) => {
|
|
103
|
+
out.push({
|
|
104
|
+
label: typeof s === "number" ? `[${s}]` : s,
|
|
105
|
+
path: segsToStr(segs.slice(0, i + 1)),
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
// Keep in sync with LINK_KEY / BINARY_KEY in src/server/yamlover.ts.
|
|
4
|
+
//
|
|
5
|
+
// A node shown only as a link (a container past the one-level view, or any binary
|
|
6
|
+
// leaf) arrives as `{ [LINK_KEY]: {kind, path, count|size} }` — the same marker in
|
|
7
|
+
// the value and the schema — so every representation renders the same hyperlink.
|
|
8
|
+
//
|
|
9
|
+
// The bytes of a selected binary leaf arrive as `{ [BINARY_KEY]: {format,size,
|
|
10
|
+
// base64} }`, rendered as `!!binary` (YAML) or the metadata object (JSON).
|
|
11
|
+
//
|
|
12
|
+
// An `x-yamlover.rel` pointer arrives as `{ [REF_KEY]: {text, path} }` — the
|
|
13
|
+
// pointer string rendered as a hyperlink that navigates to the resolved `path`
|
|
14
|
+
// (or as plain text when `path` is null, i.e. the pointer does not resolve).
|
|
15
|
+
const LINK_KEY = "$yamloverLink";
|
|
16
|
+
const BINARY_KEY = "$yamloverBinary";
|
|
17
|
+
const REF_KEY = "$yamloverRef";
|
|
18
|
+
// An omni/mix node (a `!!omni` self-value + fields, or a `!!mix` of items + fields) arrives as
|
|
19
|
+
// `{ [MIXED_KEY]: {kind, value?, entries:[{key,value}]} }`, rendered in yamlover as a leading
|
|
20
|
+
// scalar (omni) then each entry positional (`- v`, key=null) or keyed (`k: v`).
|
|
21
|
+
const MIXED_KEY = "$yamloverMixed";
|
|
22
|
+
|
|
23
|
+
export interface Link {
|
|
24
|
+
kind: "object" | "array" | "scalar" | "binary" | "omni" | "mix";
|
|
25
|
+
type?: string; // the target's JSON-Schema type; with `format`, the routing key
|
|
26
|
+
path: string;
|
|
27
|
+
title?: string; // the target's schema title, when set (used as a link label)
|
|
28
|
+
count?: number;
|
|
29
|
+
size?: number;
|
|
30
|
+
format?: string | null;
|
|
31
|
+
value?: unknown; // for a link to a scalar: its value, shown as the label
|
|
32
|
+
color?: string | null; // for a link to a pure color tag: its explicit color (badges)
|
|
33
|
+
concrete?: string | null; // how the target is stored; `dir`/`yamlover` → a folder icon
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface Ref {
|
|
37
|
+
text: string;
|
|
38
|
+
path: string | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface Mixed {
|
|
42
|
+
kind: "omni" | "mix";
|
|
43
|
+
value?: unknown; // omni: the node's own scalar self-value
|
|
44
|
+
entries: { key: string | null; value: unknown }[]; // key=null ⇒ positional item, else keyed field
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface BinaryPayload {
|
|
48
|
+
format: string | null;
|
|
49
|
+
size: number;
|
|
50
|
+
base64: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function asSingle<T>(v: unknown, key: string): T | null {
|
|
54
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
55
|
+
const keys = Object.keys(v as object);
|
|
56
|
+
if (keys.length === 1 && keys[0] === key) return (v as any)[key] as T;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Read a one-level link marker (a nested container or binary shown as a
|
|
62
|
+
* hyperlink), or null when `v` is not one. Exported for custom renderers that
|
|
63
|
+
* need to treat a child's link specially (e.g. the chapter renderer). */
|
|
64
|
+
export const asLink = (v: unknown) => asSingle<Link>(v, LINK_KEY);
|
|
65
|
+
const asBinary = (v: unknown) => asSingle<BinaryPayload>(v, BINARY_KEY);
|
|
66
|
+
const asRef = (v: unknown) => asSingle<Ref>(v, REF_KEY);
|
|
67
|
+
const asMixed = (v: unknown) => asSingle<Mixed>(v, MIXED_KEY);
|
|
68
|
+
|
|
69
|
+
type Syntax = "yaml" | "json";
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Render a one-level value or schema in YAML or JSON syntax, syntax-highlighted,
|
|
73
|
+
* with nested containers as hyperlinks. Used for every RHS representation so they
|
|
74
|
+
* behave identically: scalars shown, nested objects/arrays clicked to descend.
|
|
75
|
+
*/
|
|
76
|
+
export function Render({
|
|
77
|
+
value,
|
|
78
|
+
syntax,
|
|
79
|
+
onNavigate,
|
|
80
|
+
}: {
|
|
81
|
+
value: unknown;
|
|
82
|
+
syntax: Syntax;
|
|
83
|
+
onNavigate: (path: string) => void;
|
|
84
|
+
}) {
|
|
85
|
+
const bin = asBinary(value);
|
|
86
|
+
if (bin && syntax === "yaml") return <BinaryYaml bin={bin} />;
|
|
87
|
+
const v = bin ?? value; // JSON shows the {format,size,base64} metadata object
|
|
88
|
+
|
|
89
|
+
const out: ReactNode[] = [];
|
|
90
|
+
const kc = { n: 0 };
|
|
91
|
+
if (syntax === "yaml") emitYaml(v, 0, out, kc, onNavigate);
|
|
92
|
+
else emitJson(v, 0, out, kc, onNavigate);
|
|
93
|
+
return <>{out}</>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A selected binary leaf as a YAML `!!binary` block (a comment carries the
|
|
97
|
+
* format/size; the base64 is the block scalar's content, canonically wrapped at
|
|
98
|
+
* 76 columns and indented two spaces). */
|
|
99
|
+
function BinaryYaml({ bin }: { bin: BinaryPayload }) {
|
|
100
|
+
const lines: string[] = [];
|
|
101
|
+
for (let i = 0; i < bin.base64.length; i += 76) lines.push(" " + bin.base64.slice(i, i + 76));
|
|
102
|
+
return (
|
|
103
|
+
<>
|
|
104
|
+
<span className="b">!!binary</span> <span className="punct">|</span>{" "}
|
|
105
|
+
<span className="c">{`# ${bin.format ?? "binary"}, ${bin.size} bytes`}</span>
|
|
106
|
+
{"\n"}
|
|
107
|
+
<span className="s">{lines.join("\n")}</span>
|
|
108
|
+
</>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface KC {
|
|
113
|
+
n: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function linkNode(link: Link, syntax: Syntax, kc: KC, onNavigate: (p: string) => void): ReactNode {
|
|
117
|
+
// a scalar child links by its rendered value (`~`/`null`, quoted/bare per syntax);
|
|
118
|
+
// a container by its `{ … }`/`[ … ]` summary
|
|
119
|
+
const label = link.kind === "scalar" ? scalarLabel(link.value, syntax) : linkLabel(link);
|
|
120
|
+
return (
|
|
121
|
+
<a
|
|
122
|
+
key={kc.n++}
|
|
123
|
+
className="descend"
|
|
124
|
+
href={link.path}
|
|
125
|
+
onClick={(e) => {
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
onNavigate(link.path);
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
{label}
|
|
131
|
+
</a>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A scalar value as it would render in `syntax` — used as a scalar link's label. */
|
|
136
|
+
function scalarLabel(v: unknown, syntax: Syntax): string {
|
|
137
|
+
if (v === null || v === undefined) return syntax === "json" ? "null" : "~";
|
|
138
|
+
if (typeof v === "boolean" || typeof v === "number") return String(v);
|
|
139
|
+
return syntax === "json" ? JSON.stringify(v) : String(v);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A `rel` pointer: its text as a hyperlink to the resolved `path`, or — when the
|
|
143
|
+
* pointer does not resolve — plain string text (no link). */
|
|
144
|
+
function refNode(ref: Ref, syntax: Syntax, kc: KC, onNavigate: (p: string) => void): ReactNode {
|
|
145
|
+
const text = syntax === "json" ? JSON.stringify(ref.text) : ref.text;
|
|
146
|
+
if (!ref.path) return <span className="s" key={kc.n++}>{text}</span>;
|
|
147
|
+
const target = ref.path;
|
|
148
|
+
return (
|
|
149
|
+
<a
|
|
150
|
+
key={kc.n++}
|
|
151
|
+
className="descend"
|
|
152
|
+
href={target}
|
|
153
|
+
onClick={(e) => {
|
|
154
|
+
e.preventDefault();
|
|
155
|
+
onNavigate(target);
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
158
|
+
{text}
|
|
159
|
+
</a>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function linkLabel(link: Link): string {
|
|
164
|
+
const n = link.count ?? 0;
|
|
165
|
+
if (link.kind === "array") return `[ array with ${n} ${n === 1 ? "item" : "items"} ]`;
|
|
166
|
+
if (link.kind === "binary") return `< binary of ${link.size ?? 0} bytes >`;
|
|
167
|
+
if (link.kind === "mix") return `{ mixed with ${n} ${n === 1 ? "entry" : "entries"} }`;
|
|
168
|
+
if (link.kind === "omni") return `{ variant ${scalarLabel(link.value, "yaml")} + ${n} ${n === 1 ? "field" : "fields"} }`;
|
|
169
|
+
return `{ object with ${n} ${n === 1 ? "property" : "properties"} }`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function scalarNode(v: unknown, syntax: Syntax, kc: KC): ReactNode {
|
|
173
|
+
if (v === null) return <span className="null" key={kc.n++}>{syntax === "json" ? "null" : "~"}</span>;
|
|
174
|
+
if (typeof v === "boolean") return <span className="b" key={kc.n++}>{String(v)}</span>;
|
|
175
|
+
if (typeof v === "number") return <span className="n" key={kc.n++}>{String(v)}</span>;
|
|
176
|
+
const text = syntax === "json" ? JSON.stringify(v) : String(v);
|
|
177
|
+
return <span className="s" key={kc.n++}>{text}</span>;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const isObj = (v: unknown): v is Record<string, unknown> =>
|
|
181
|
+
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
182
|
+
|
|
183
|
+
// --------------------------------------------------------------------------- //
|
|
184
|
+
// YAML
|
|
185
|
+
// --------------------------------------------------------------------------- //
|
|
186
|
+
function emitYaml(value: unknown, indent: number, out: ReactNode[], kc: KC, nav: (p: string) => void): void {
|
|
187
|
+
const link = asLink(value);
|
|
188
|
+
if (link) {
|
|
189
|
+
out.push(linkNode(link, "yaml", kc, nav), "\n");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const ref = asRef(value);
|
|
193
|
+
if (ref) {
|
|
194
|
+
out.push(refNode(ref, "yaml", kc, nav), "\n");
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const mixed = asMixed(value);
|
|
198
|
+
if (mixed) {
|
|
199
|
+
const pad = " ".repeat(indent);
|
|
200
|
+
// omni: the node's own scalar value on its own line first (`!!omni 5` → `5`)
|
|
201
|
+
if (mixed.kind === "omni") out.push(pad, scalarNode(mixed.value, "yaml", kc), "\n");
|
|
202
|
+
for (const e of mixed.entries) {
|
|
203
|
+
if (e.key === null) {
|
|
204
|
+
out.push(pad, <span className="punct" key={kc.n++}>{"- "}</span>);
|
|
205
|
+
emitYamlChild(e.value, indent, out, kc, nav, true);
|
|
206
|
+
} else {
|
|
207
|
+
out.push(pad, <span className="k" key={kc.n++}>{e.key}</span>, <span className="punct" key={kc.n++}>:</span>);
|
|
208
|
+
emitYamlChild(e.value, indent, out, kc, nav);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (isObj(value)) {
|
|
214
|
+
const entries = Object.entries(value);
|
|
215
|
+
if (!entries.length) {
|
|
216
|
+
out.push(<span className="punct" key={kc.n++}>{"{}"}</span>, "\n");
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const pad = " ".repeat(indent);
|
|
220
|
+
for (const [k, v] of entries) {
|
|
221
|
+
out.push(pad, <span className="k" key={kc.n++}>{k}</span>, <span className="punct" key={kc.n++}>:</span>);
|
|
222
|
+
emitYamlChild(v, indent, out, kc, nav);
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (Array.isArray(value)) {
|
|
227
|
+
if (!value.length) {
|
|
228
|
+
out.push(<span className="punct" key={kc.n++}>{"[]"}</span>, "\n");
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const pad = " ".repeat(indent);
|
|
232
|
+
for (const item of value) {
|
|
233
|
+
out.push(pad, <span className="punct" key={kc.n++}>{"- "}</span>);
|
|
234
|
+
emitYamlChild(item, indent, out, kc, nav, true);
|
|
235
|
+
}
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
out.push(scalarNode(value, "yaml", kc), "\n");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Render a value that follows a `key:` or `- ` — inline for scalars/links/empties,
|
|
242
|
+
// or a newline then a nested block.
|
|
243
|
+
function emitYamlChild(
|
|
244
|
+
v: unknown,
|
|
245
|
+
indent: number,
|
|
246
|
+
out: ReactNode[],
|
|
247
|
+
kc: KC,
|
|
248
|
+
nav: (p: string) => void,
|
|
249
|
+
inArray = false,
|
|
250
|
+
): void {
|
|
251
|
+
const link = asLink(v);
|
|
252
|
+
const ref = asRef(v);
|
|
253
|
+
if (link) {
|
|
254
|
+
out.push(" ", linkNode(link, "yaml", kc, nav), "\n");
|
|
255
|
+
} else if (ref) {
|
|
256
|
+
out.push(" ", refNode(ref, "yaml", kc, nav), "\n");
|
|
257
|
+
} else if (isObj(v) && Object.keys(v).length === 0) {
|
|
258
|
+
out.push(" ", <span className="punct" key={kc.n++}>{"{}"}</span>, "\n");
|
|
259
|
+
} else if (Array.isArray(v) && v.length === 0) {
|
|
260
|
+
out.push(" ", <span className="punct" key={kc.n++}>{"[]"}</span>, "\n");
|
|
261
|
+
} else if (isObj(v) || Array.isArray(v)) {
|
|
262
|
+
out.push("\n");
|
|
263
|
+
emitYaml(v, indent + 2, out, kc, nav);
|
|
264
|
+
} else {
|
|
265
|
+
out.push(" ", scalarNode(v, "yaml", kc), "\n");
|
|
266
|
+
}
|
|
267
|
+
void inArray;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// --------------------------------------------------------------------------- //
|
|
271
|
+
// JSON (nested containers become links, so the view is not strictly valid JSON —
|
|
272
|
+
// it is the same one-level representation, in JSON syntax)
|
|
273
|
+
// --------------------------------------------------------------------------- //
|
|
274
|
+
function emitJson(value: unknown, indent: number, out: ReactNode[], kc: KC, nav: (p: string) => void): void {
|
|
275
|
+
const link = asLink(value);
|
|
276
|
+
if (link) {
|
|
277
|
+
out.push(linkNode(link, "json", kc, nav));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const ref = asRef(value);
|
|
281
|
+
if (ref) {
|
|
282
|
+
out.push(refNode(ref, "json", kc, nav));
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const mixed = asMixed(value);
|
|
286
|
+
const objEntries: [string, unknown][] | null = mixed
|
|
287
|
+
? [
|
|
288
|
+
...(mixed.kind === "omni" ? ([["$value", mixed.value]] as [string, unknown][]) : []),
|
|
289
|
+
...mixed.entries.map((e, i): [string, unknown] => [e.key ?? String(i), e.value]),
|
|
290
|
+
]
|
|
291
|
+
: isObj(value)
|
|
292
|
+
? Object.entries(value)
|
|
293
|
+
: null;
|
|
294
|
+
if (objEntries) {
|
|
295
|
+
const entries = objEntries;
|
|
296
|
+
if (!entries.length) {
|
|
297
|
+
out.push(<span className="punct" key={kc.n++}>{"{}"}</span>);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const pad = " ".repeat(indent + 2);
|
|
301
|
+
out.push(<span className="punct" key={kc.n++}>{"{"}</span>, "\n");
|
|
302
|
+
entries.forEach(([k, v], i) => {
|
|
303
|
+
out.push(pad, <span className="k" key={kc.n++}>{`"${k}"`}</span>, <span className="punct" key={kc.n++}>{": "}</span>);
|
|
304
|
+
emitJson(v, indent + 2, out, kc, nav);
|
|
305
|
+
out.push(i < entries.length - 1 ? "," : "", "\n");
|
|
306
|
+
});
|
|
307
|
+
out.push(" ".repeat(indent), <span className="punct" key={kc.n++}>{"}"}</span>);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (Array.isArray(value)) {
|
|
311
|
+
if (!value.length) {
|
|
312
|
+
out.push(<span className="punct" key={kc.n++}>{"[]"}</span>);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const pad = " ".repeat(indent + 2);
|
|
316
|
+
out.push(<span className="punct" key={kc.n++}>{"["}</span>, "\n");
|
|
317
|
+
value.forEach((item, i) => {
|
|
318
|
+
out.push(pad);
|
|
319
|
+
emitJson(item, indent + 2, out, kc, nav);
|
|
320
|
+
out.push(i < value.length - 1 ? "," : "", "\n");
|
|
321
|
+
});
|
|
322
|
+
out.push(" ".repeat(indent), <span className="punct" key={kc.n++}>{"]"}</span>);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
out.push(scalarNode(value, "json", kc));
|
|
326
|
+
}
|