@venn-lang/fmt 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vinicius Borges
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # @venn-lang/fmt
2
+
3
+ > The `fmt` namespace: a value in, formatted text out. JSON, tables, YAML, CSV and XML.
4
+
5
+ Formatting is kept separate from printing on purpose. Every verb here returns a `string`, which is a
6
+ value you can print, assert against, send in a request or write to a file, not something that only
7
+ ever reaches a terminal. The plugin is pure: it needs no host capability and touches nothing.
8
+
9
+ ## Install
10
+
11
+ `@venn-lang/fmt` is part of the stdlib the `venn` CLI and the language server load, so there is nothing
12
+ to install. A file that formats says so:
13
+
14
+ ```ruby
15
+ use "venn/fmt"
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ```ruby
21
+ # report.vn, run with `venn run report.vn`
22
+ use "venn/fmt"
23
+
24
+ const people = [
25
+ { name: "Ada", age: 36 },
26
+ { name: "Linus", age: 54 }
27
+ ]
28
+
29
+ print fmt.table(people)
30
+ print fmt.csv(people)
31
+ print fmt.json({ count: people.len }, 0)
32
+ ```
33
+
34
+ ```
35
+ name │ age
36
+ ──────┼────
37
+ Ada │ 36
38
+ Linus │ 54
39
+ name,age
40
+ Ada,36
41
+ Linus,54
42
+ {"count":2}
43
+ ```
44
+
45
+ `print` is in the prelude, so that file imports nothing but `fmt`.
46
+
47
+ ## Verbs
48
+
49
+ | Verb | Signature | What it does |
50
+ | --- | --- | --- |
51
+ | `fmt.json` | `(dynamic, number?) -> string` | JSON text. Indents by 2 by default; `fmt.json(x, 0)` puts it on one line. A value that contains itself degrades instead of throwing. |
52
+ | `fmt.table` | `(list<dynamic>) -> string` | An aligned ASCII table of a list of records. |
53
+ | `fmt.yaml` | `(dynamic) -> string` | YAML for a map, a list or a scalar. |
54
+ | `fmt.csv` | `(list<dynamic>, string?) -> string` | CSV with a header row. `fmt.csv(rows, ";")` changes the separator. |
55
+ | `fmt.xml` | `(dynamic, string?) -> string` | XML text. `fmt.xml(x, "user")` names the root element, which is `root` otherwise. |
56
+
57
+ Every knob is positional, and every verb ends in a `string`. None of them reads an options map, so
58
+ none declares one: an options map in the editor's hover that the verb never looks at would quietly
59
+ strip the keys a caller wrote there.
60
+
61
+ ### What each renderer decides
62
+
63
+ - **`table`** takes the columns from the union of every row's keys, in first-seen order, so a row
64
+ missing a field still lines up. Each column is padded to its widest cell. An empty list renders as
65
+ `(no rows)`.
66
+ - **`yaml`** puts a scalar on its key's line and opens an indented block for a map or a list. A
67
+ string that would not read back as plain YAML is quoted, and so is the empty string. An empty list
68
+ is `[]` and an empty map is `{}`.
69
+ - **`csv`** follows RFC 4180: a field is quoted only when it holds a separator, a quote or a
70
+ newline, and inner quotes are doubled. A list with no records renders as the empty string.
71
+ - **`xml`** turns keys into elements and repeats a tag for each item of a list. Text is escaped
72
+ (`&`, `<`, `>`, `"`), and a map with nothing in it becomes a self-closing element.
73
+
74
+ ## API
75
+
76
+ | Export | What it is |
77
+ | --- | --- |
78
+ | `fmtPlugin` (also the default export) | The `PluginDefinition`: namespace `fmt`, five actions, no required capability, no types of its own. |
79
+ | `fmtActions` | The five `ActionDefinition`s. |
80
+ | `toJson(value, spaces?)` | The renderer behind `fmt.json`. Defaults to 2 spaces. |
81
+ | `toTable(rows)` | The renderer behind `fmt.table`. |
82
+ | `toYaml(value, indent?)` | The renderer behind `fmt.yaml`. |
83
+ | `toCsv(rows, separator?)` | The renderer behind `fmt.csv`. Defaults to a comma. |
84
+ | `toXml(value, tag?, indent?)` | The renderer behind `fmt.xml`. Defaults to the tag `root`. |
85
+
86
+ The renderers are plain functions with no context and no ports, so they are usable on their own:
87
+
88
+ ```ts
89
+ import { toCsv, toYaml } from "@venn-lang/fmt";
90
+
91
+ toCsv([{ text: 'say "hi", now', plain: "ok" }]);
92
+ // 'text,plain\n"say ""hi"", now",ok'
93
+
94
+ toYaml({ name: "Ada", tags: ["a", "b"], nested: { n: 1 } });
95
+ // "name: Ada\ntags:\n - a\n - b\nnested:\n n: 1"
96
+ ```
97
+
98
+ ## See also
99
+
100
+ - [`@venn-lang/io`](../std-io) for writing the resulting text to standard output or standard error.
101
+ - [`@venn-lang/assert`](../std-assert) for asserting on it once it is a string.
102
+ - [`@venn-lang/sdk`](../sdk) for `defineAction` and the typed argument helpers used here.
@@ -0,0 +1,94 @@
1
+ import { ActionDefinition, PluginDefinition } from "@venn-lang/sdk";
2
+ //#region src/actions/fmt-actions.d.ts
3
+ /**
4
+ * The verbs of the `fmt` namespace: a value in, a string out.
5
+ *
6
+ * Formatting is kept apart from printing on purpose. The string is a value you
7
+ * can compare in a test, send in a request or write to a file, not something
8
+ * that only ever reaches a terminal.
9
+ *
10
+ * Every verb is positional and none reads an options map, so none declares a
11
+ * params schema: one would put a `{ … }` in the editor's hover that the verb
12
+ * never looks at, and would quietly strip the keys a caller wrote there.
13
+ */
14
+ declare const fmtActions: ActionDefinition[];
15
+ //#endregion
16
+ //#region src/plugin.d.ts
17
+ /**
18
+ * The `fmt` plugin: a value to text, as JSON, a table, YAML, CSV or XML.
19
+ *
20
+ * Every verb is pure, so the plugin needs no host capability and runs anywhere,
21
+ * the LSP included. What comes back is a string, yours to print, compare or send.
22
+ */
23
+ declare const fmtPlugin: PluginDefinition;
24
+ //#endregion
25
+ //#region src/render/csv.d.ts
26
+ /**
27
+ * Renders a list of records as CSV with a header row.
28
+ *
29
+ * Columns are the union of every record's keys, in first-seen order, so a
30
+ * record missing a field still lines up. A field is quoted only when it holds a
31
+ * separator, a quote or a newline, and inner quotes are doubled (RFC 4180).
32
+ *
33
+ * @param rows The records to render. Anything that is not a record is skipped.
34
+ * @param separator What goes between fields. A comma by default.
35
+ * @returns The CSV text, or an empty string when no record survives the filter.
36
+ */
37
+ declare function toCsv(rows: readonly unknown[], separator?: string): string;
38
+ //#endregion
39
+ //#region src/render/json.d.ts
40
+ /**
41
+ * Renders a value as JSON text.
42
+ *
43
+ * A value JSON cannot express (a cycle, a BigInt) falls back to `String(value)`
44
+ * instead of throwing: formatting is not where a run should die.
45
+ *
46
+ * @param value What to render.
47
+ * @param spaces Spaces per level of nesting. 0 puts it all on one line.
48
+ * @returns The JSON text.
49
+ */
50
+ declare function toJson(value: unknown, spaces?: number): string;
51
+ //#endregion
52
+ //#region src/render/table.d.ts
53
+ /**
54
+ * Renders a list of records as an aligned table, for reading in a terminal.
55
+ *
56
+ * Columns are the union of every row's keys, in first-seen order, so a row
57
+ * missing a field still lines up. Each column is as wide as its widest cell.
58
+ *
59
+ * @param rows The records to render. Anything that is not a record is skipped.
60
+ * @returns The table text, or `(no rows)` when there is nothing to show.
61
+ */
62
+ declare function toTable(rows: readonly unknown[]): string;
63
+ //#endregion
64
+ //#region src/render/xml.d.ts
65
+ /**
66
+ * Renders a value as indented XML.
67
+ *
68
+ * A map's keys become tags; a list repeats the tag it was given, once per item.
69
+ * Text is escaped. Attributes are never produced, so a round trip through this
70
+ * and back is not the identity.
71
+ *
72
+ * @param value What to render.
73
+ * @param tag The name of the outermost element.
74
+ * @param indent Depth to start at, two spaces per level. The recursion sets it.
75
+ * @returns The XML text, with no declaration or prologue.
76
+ */
77
+ declare function toXml(value: unknown, tag?: string, indent?: number): string;
78
+ //#endregion
79
+ //#region src/render/yaml.d.ts
80
+ /**
81
+ * Renders a value as YAML: maps, lists and scalars, two spaces per level.
82
+ *
83
+ * A string is quoted only where plain style would change what it means. Empty
84
+ * maps and lists print as `{}` and `[]`, since a block with nothing under it
85
+ * would read as an absent value.
86
+ *
87
+ * @param value What to render.
88
+ * @param indent Depth to start at. The recursion sets it.
89
+ * @returns The YAML text, with no document marker.
90
+ */
91
+ declare function toYaml(value: unknown, indent?: number): string;
92
+ //#endregion
93
+ export { fmtPlugin as default, fmtPlugin, fmtActions, toCsv, toJson, toTable, toXml, toYaml };
94
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/actions/fmt-actions.ts","../src/plugin.ts","../src/render/csv.ts","../src/render/json.ts","../src/render/table.ts","../src/render/xml.ts","../src/render/yaml.ts"],"mappings":";;;;;;;;;;;;;cA4Ba,YAAY;;;;;;;;;cCnBZ,WAAW;;;;;;;;;;;;;;iBCIR,MAAM,0BAA0B;;;;;;;;;;;;;iBCHhC,OAAO,gBAAgB;;;;;;;;;;;;iBCCvB,QAAQ;;;;;;;;;;;;;;;iBCCR,MAAM,gBAAgB,cAAc;;;;;;;;;;;;;;iBCDpC,OAAO,gBAAgB"}
package/dist/index.js ADDED
@@ -0,0 +1,271 @@
1
+ import { arg, defineAction, definePlugin, optionalArg } from "@venn-lang/sdk";
2
+ import { t } from "@venn-lang/types";
3
+ //#region src/render/csv.ts
4
+ /**
5
+ * Renders a list of records as CSV with a header row.
6
+ *
7
+ * Columns are the union of every record's keys, in first-seen order, so a
8
+ * record missing a field still lines up. A field is quoted only when it holds a
9
+ * separator, a quote or a newline, and inner quotes are doubled (RFC 4180).
10
+ *
11
+ * @param rows The records to render. Anything that is not a record is skipped.
12
+ * @param separator What goes between fields. A comma by default.
13
+ * @returns The CSV text, or an empty string when no record survives the filter.
14
+ */
15
+ function toCsv(rows, separator = ",") {
16
+ const records = rows.filter(isRow$1);
17
+ if (records.length === 0) return "";
18
+ const columns = columnsOf$1(records);
19
+ return [columns.map(quote).join(separator), ...records.map((row) => columns.map((c) => quote(cell$1(row[c]))).join(separator))].join("\n");
20
+ }
21
+ function isRow$1(value) {
22
+ return value !== null && typeof value === "object" && !Array.isArray(value);
23
+ }
24
+ function columnsOf$1(rows) {
25
+ const seen = /* @__PURE__ */ new Set();
26
+ for (const row of rows) for (const key of Object.keys(row)) seen.add(key);
27
+ return [...seen];
28
+ }
29
+ function cell$1(value) {
30
+ if (value === null || value === void 0) return "";
31
+ return typeof value === "object" ? JSON.stringify(value) : String(value);
32
+ }
33
+ /** Quote when the field holds a separator, a quote or a newline; double inner quotes. */
34
+ function quote(field) {
35
+ if (!/[",;\t\n\r]/.test(field)) return field;
36
+ return `"${field.replace(/"/g, "\"\"")}"`;
37
+ }
38
+ //#endregion
39
+ //#region src/render/json.ts
40
+ /**
41
+ * Renders a value as JSON text.
42
+ *
43
+ * A value JSON cannot express (a cycle, a BigInt) falls back to `String(value)`
44
+ * instead of throwing: formatting is not where a run should die.
45
+ *
46
+ * @param value What to render.
47
+ * @param spaces Spaces per level of nesting. 0 puts it all on one line.
48
+ * @returns The JSON text.
49
+ */
50
+ function toJson(value, spaces = 2) {
51
+ try {
52
+ return JSON.stringify(value, null, spaces) ?? String(value);
53
+ } catch {
54
+ return String(value);
55
+ }
56
+ }
57
+ //#endregion
58
+ //#region src/render/table.ts
59
+ /**
60
+ * Renders a list of records as an aligned table, for reading in a terminal.
61
+ *
62
+ * Columns are the union of every row's keys, in first-seen order, so a row
63
+ * missing a field still lines up. Each column is as wide as its widest cell.
64
+ *
65
+ * @param rows The records to render. Anything that is not a record is skipped.
66
+ * @returns The table text, or `(no rows)` when there is nothing to show.
67
+ */
68
+ function toTable(rows) {
69
+ const records = rows.filter(isRow);
70
+ if (records.length === 0) return "(no rows)";
71
+ const columns = columnsOf(records);
72
+ const widths = columns.map((column) => widthOf(column, records));
73
+ return [
74
+ line(columns, widths),
75
+ widths.map((width) => "─".repeat(width)).join("─┼─"),
76
+ ...records.map((row) => line(columns.map((column) => cell(row[column])), widths))
77
+ ].join("\n");
78
+ }
79
+ function isRow(value) {
80
+ return value !== null && typeof value === "object" && !Array.isArray(value);
81
+ }
82
+ function columnsOf(rows) {
83
+ const seen = /* @__PURE__ */ new Set();
84
+ for (const row of rows) for (const key of Object.keys(row)) seen.add(key);
85
+ return [...seen];
86
+ }
87
+ function widthOf(column, rows) {
88
+ return rows.reduce((width, row) => Math.max(width, cell(row[column]).length), column.length);
89
+ }
90
+ function line(values, widths) {
91
+ return values.map((value, index) => value.padEnd(widths[index] ?? 0)).join(" │ ");
92
+ }
93
+ function cell(value) {
94
+ if (value === null || value === void 0) return "";
95
+ if (typeof value === "object") return JSON.stringify(value);
96
+ return String(value);
97
+ }
98
+ //#endregion
99
+ //#region src/render/xml.ts
100
+ /**
101
+ * Renders a value as indented XML.
102
+ *
103
+ * A map's keys become tags; a list repeats the tag it was given, once per item.
104
+ * Text is escaped. Attributes are never produced, so a round trip through this
105
+ * and back is not the identity.
106
+ *
107
+ * @param value What to render.
108
+ * @param tag The name of the outermost element.
109
+ * @param indent Depth to start at, two spaces per level. The recursion sets it.
110
+ * @returns The XML text, with no declaration or prologue.
111
+ */
112
+ function toXml(value, tag = "root", indent = 0) {
113
+ const pad = " ".repeat(indent);
114
+ if (Array.isArray(value)) return value.map((item) => toXml(item, tag, indent)).join("\n");
115
+ if (isMap$1(value)) return element({
116
+ tag,
117
+ body: children(value, indent),
118
+ pad,
119
+ block: true
120
+ });
121
+ return element({
122
+ tag,
123
+ body: escaped(text(value)),
124
+ pad,
125
+ block: false
126
+ });
127
+ }
128
+ function children(map, indent) {
129
+ return Object.entries(map).map(([key, item]) => toXml(item, key, indent + 1)).join("\n");
130
+ }
131
+ function element(args) {
132
+ const { tag, body, pad } = args;
133
+ if (!args.block) return `${pad}<${tag}>${body}</${tag}>`;
134
+ return body === "" ? `${pad}<${tag}/>` : `${pad}<${tag}>\n${body}\n${pad}</${tag}>`;
135
+ }
136
+ function isMap$1(value) {
137
+ return value !== null && typeof value === "object" && !Array.isArray(value);
138
+ }
139
+ function text(value) {
140
+ return value === null || value === void 0 ? "" : String(value);
141
+ }
142
+ function escaped(value) {
143
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
144
+ }
145
+ //#endregion
146
+ //#region src/render/yaml.ts
147
+ /**
148
+ * Renders a value as YAML: maps, lists and scalars, two spaces per level.
149
+ *
150
+ * A string is quoted only where plain style would change what it means. Empty
151
+ * maps and lists print as `{}` and `[]`, since a block with nothing under it
152
+ * would read as an absent value.
153
+ *
154
+ * @param value What to render.
155
+ * @param indent Depth to start at. The recursion sets it.
156
+ * @returns The YAML text, with no document marker.
157
+ */
158
+ function toYaml(value, indent = 0) {
159
+ const pad = " ".repeat(indent);
160
+ if (Array.isArray(value)) return listYaml(value, indent, pad);
161
+ if (isMap(value)) return mapYaml(value, indent, pad);
162
+ return `${pad}${scalar(value)}`;
163
+ }
164
+ function listYaml(items, indent, pad) {
165
+ if (items.length === 0) return `${pad}[]`;
166
+ return items.map((item) => `${pad}- ${nested(item, indent)}`).join("\n");
167
+ }
168
+ function mapYaml(map, indent, pad) {
169
+ const entries = Object.entries(map);
170
+ if (entries.length === 0) return `${pad}{}`;
171
+ return entries.map(([key, item]) => `${pad}${key}:${suffix(item, indent)}`).join("\n");
172
+ }
173
+ /** A scalar sits on the key's line; a map or list opens a block below it. */
174
+ function suffix(value, indent) {
175
+ if (isEmpty(value)) return ` ${Array.isArray(value) ? "[]" : "{}"}`;
176
+ if (Array.isArray(value) || isMap(value)) return `\n${toYaml(value, indent + 1)}`;
177
+ return ` ${scalar(value)}`;
178
+ }
179
+ /** A nested value under `- `, with its first line already positioned. */
180
+ function nested(value, indent) {
181
+ if (isEmpty(value) || !Array.isArray(value) && !isMap(value)) return scalar(value);
182
+ return toYaml(value, indent + 1).trimStart();
183
+ }
184
+ function isEmpty(value) {
185
+ if (Array.isArray(value)) return value.length === 0;
186
+ return isMap(value) && Object.keys(value).length === 0;
187
+ }
188
+ function isMap(value) {
189
+ return value !== null && typeof value === "object" && !Array.isArray(value);
190
+ }
191
+ const PLAIN = /^[A-Za-z_][\w .-]*$/;
192
+ function scalar(value) {
193
+ if (value === null || value === void 0) return "null";
194
+ if (typeof value !== "string") return String(value);
195
+ return value === "" || !PLAIN.test(value) ? JSON.stringify(value) : value;
196
+ }
197
+ //#endregion
198
+ //#region src/actions/fmt-actions.ts
199
+ function list(value) {
200
+ return Array.isArray(value) ? value : [value];
201
+ }
202
+ function number(value, fallback) {
203
+ const parsed = Number(value);
204
+ return value === void 0 || Number.isNaN(parsed) ? fallback : parsed;
205
+ }
206
+ /**
207
+ * The verbs of the `fmt` namespace: a value in, a string out.
208
+ *
209
+ * Formatting is kept apart from printing on purpose. The string is a value you
210
+ * can compare in a test, send in a request or write to a file, not something
211
+ * that only ever reaches a terminal.
212
+ *
213
+ * Every verb is positional and none reads an options map, so none declares a
214
+ * params schema: one would put a `{ … }` in the editor's hover that the verb
215
+ * never looks at, and would quietly strip the keys a caller wrote there.
216
+ */
217
+ const fmtActions = [
218
+ defineAction({
219
+ name: "json",
220
+ doc: "JSON text. `fmt.json(x, 0)` puts it on one line; the default indents by 2.",
221
+ args: [arg("value", t.dynamic, "What to render."), optionalArg("indent", t.number, "Spaces per level. 0 puts it on one line.")],
222
+ result: t.string,
223
+ run: (_ctx, input) => toJson(input.args[0], number(input.args[1], 2))
224
+ }),
225
+ defineAction({
226
+ name: "table",
227
+ doc: "An aligned ASCII table of a list of records.",
228
+ args: [arg("rows", t.list(t.dynamic), "A list of records. Their keys become the columns.")],
229
+ result: t.string,
230
+ run: (_ctx, input) => toTable(list(input.args[0]))
231
+ }),
232
+ defineAction({
233
+ name: "yaml",
234
+ doc: "YAML text, for a map, a list or a scalar.",
235
+ args: [arg("value", t.dynamic, "What to render.")],
236
+ result: t.string,
237
+ run: (_ctx, input) => toYaml(input.args[0])
238
+ }),
239
+ defineAction({
240
+ name: "csv",
241
+ doc: "CSV with a header row. `fmt.csv(rows, ';')` changes the separator.",
242
+ args: [arg("rows", t.list(t.dynamic), "A list of records. Their keys become the header row."), optionalArg("separator", t.string, "What goes between fields. A comma by default.")],
243
+ result: t.string,
244
+ run: (_ctx, input) => toCsv(list(input.args[0]), String(input.args[1] ?? ","))
245
+ }),
246
+ defineAction({
247
+ name: "xml",
248
+ doc: "XML text. `fmt.xml(x, 'user')` names the root element.",
249
+ args: [arg("value", t.dynamic, "What to render."), optionalArg("root", t.string, "What to call the outermost element.")],
250
+ result: t.string,
251
+ run: (_ctx, input) => toXml(input.args[0], String(input.args[1] ?? "root"))
252
+ })
253
+ ];
254
+ //#endregion
255
+ //#region src/plugin.ts
256
+ /**
257
+ * The `fmt` plugin: a value to text, as JSON, a table, YAML, CSV or XML.
258
+ *
259
+ * Every verb is pure, so the plugin needs no host capability and runs anywhere,
260
+ * the LSP included. What comes back is a string, yours to print, compare or send.
261
+ */
262
+ const fmtPlugin = definePlugin({
263
+ name: "venn/fmt",
264
+ version: "0.0.0",
265
+ namespace: "fmt",
266
+ actions: fmtActions
267
+ });
268
+ //#endregion
269
+ export { fmtPlugin as default, fmtPlugin, fmtActions, toCsv, toJson, toTable, toXml, toYaml };
270
+
271
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","names":["isRow","columnsOf","cell","isMap"],"sources":["../src/render/csv.ts","../src/render/json.ts","../src/render/table.ts","../src/render/xml.ts","../src/render/yaml.ts","../src/actions/fmt-actions.ts","../src/plugin.ts"],"sourcesContent":["type Row = Record<string, unknown>;\n\n/**\n * Renders a list of records as CSV with a header row.\n *\n * Columns are the union of every record's keys, in first-seen order, so a\n * record missing a field still lines up. A field is quoted only when it holds a\n * separator, a quote or a newline, and inner quotes are doubled (RFC 4180).\n *\n * @param rows The records to render. Anything that is not a record is skipped.\n * @param separator What goes between fields. A comma by default.\n * @returns The CSV text, or an empty string when no record survives the filter.\n */\nexport function toCsv(rows: readonly unknown[], separator = \",\"): string {\n const records = rows.filter(isRow);\n if (records.length === 0) return \"\";\n const columns = columnsOf(records);\n const header = columns.map(quote).join(separator);\n const body = records.map((row) => columns.map((c) => quote(cell(row[c]))).join(separator));\n return [header, ...body].join(\"\\n\");\n}\n\nfunction isRow(value: unknown): value is Row {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction columnsOf(rows: readonly Row[]): string[] {\n const seen = new Set<string>();\n for (const row of rows) for (const key of Object.keys(row)) seen.add(key);\n return [...seen];\n}\n\nfunction cell(value: unknown): string {\n if (value === null || value === undefined) return \"\";\n return typeof value === \"object\" ? JSON.stringify(value) : String(value);\n}\n\n/** Quote when the field holds a separator, a quote or a newline; double inner quotes. */\nfunction quote(field: string): string {\n if (!/[\",;\\t\\n\\r]/.test(field)) return field;\n return `\"${field.replace(/\"/g, '\"\"')}\"`;\n}\n","/**\n * Renders a value as JSON text.\n *\n * A value JSON cannot express (a cycle, a BigInt) falls back to `String(value)`\n * instead of throwing: formatting is not where a run should die.\n *\n * @param value What to render.\n * @param spaces Spaces per level of nesting. 0 puts it all on one line.\n * @returns The JSON text.\n */\nexport function toJson(value: unknown, spaces = 2): string {\n try {\n return JSON.stringify(value, null, spaces) ?? String(value);\n } catch {\n return String(value);\n }\n}\n","type Row = Record<string, unknown>;\n\n/**\n * Renders a list of records as an aligned table, for reading in a terminal.\n *\n * Columns are the union of every row's keys, in first-seen order, so a row\n * missing a field still lines up. Each column is as wide as its widest cell.\n *\n * @param rows The records to render. Anything that is not a record is skipped.\n * @returns The table text, or `(no rows)` when there is nothing to show.\n */\nexport function toTable(rows: readonly unknown[]): string {\n const records = rows.filter(isRow);\n if (records.length === 0) return \"(no rows)\";\n const columns = columnsOf(records);\n const widths = columns.map((column) => widthOf(column, records));\n return [\n line(columns, widths),\n widths.map((width) => \"─\".repeat(width)).join(\"─┼─\"),\n ...records.map((row) =>\n line(\n columns.map((column) => cell(row[column])),\n widths,\n ),\n ),\n ].join(\"\\n\");\n}\n\nfunction isRow(value: unknown): value is Row {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction columnsOf(rows: readonly Row[]): string[] {\n const seen = new Set<string>();\n for (const row of rows) for (const key of Object.keys(row)) seen.add(key);\n return [...seen];\n}\n\nfunction widthOf(column: string, rows: readonly Row[]): number {\n return rows.reduce((width, row) => Math.max(width, cell(row[column]).length), column.length);\n}\n\nfunction line(values: readonly string[], widths: readonly number[]): string {\n return values.map((value, index) => value.padEnd(widths[index] ?? 0)).join(\" │ \");\n}\n\nfunction cell(value: unknown): string {\n if (value === null || value === undefined) return \"\";\n if (typeof value === \"object\") return JSON.stringify(value);\n return String(value);\n}\n","/**\n * Renders a value as indented XML.\n *\n * A map's keys become tags; a list repeats the tag it was given, once per item.\n * Text is escaped. Attributes are never produced, so a round trip through this\n * and back is not the identity.\n *\n * @param value What to render.\n * @param tag The name of the outermost element.\n * @param indent Depth to start at, two spaces per level. The recursion sets it.\n * @returns The XML text, with no declaration or prologue.\n */\nexport function toXml(value: unknown, tag = \"root\", indent = 0): string {\n const pad = \" \".repeat(indent);\n if (Array.isArray(value)) return value.map((item) => toXml(item, tag, indent)).join(\"\\n\");\n if (isMap(value)) return element({ tag, body: children(value, indent), pad, block: true });\n return element({ tag, body: escaped(text(value)), pad, block: false });\n}\n\nfunction children(map: Record<string, unknown>, indent: number): string {\n return Object.entries(map)\n .map(([key, item]) => toXml(item, key, indent + 1))\n .join(\"\\n\");\n}\n\nfunction element(args: { tag: string; body: string; pad: string; block: boolean }): string {\n const { tag, body, pad } = args;\n if (!args.block) return `${pad}<${tag}>${body}</${tag}>`;\n return body === \"\" ? `${pad}<${tag}/>` : `${pad}<${tag}>\\n${body}\\n${pad}</${tag}>`;\n}\n\nfunction isMap(value: unknown): value is Record<string, unknown> {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nfunction text(value: unknown): string {\n return value === null || value === undefined ? \"\" : String(value);\n}\n\nfunction escaped(value: string): string {\n return value\n .replace(/&/g, \"&amp;\")\n .replace(/</g, \"&lt;\")\n .replace(/>/g, \"&gt;\")\n .replace(/\"/g, \"&quot;\");\n}\n","/**\n * Renders a value as YAML: maps, lists and scalars, two spaces per level.\n *\n * A string is quoted only where plain style would change what it means. Empty\n * maps and lists print as `{}` and `[]`, since a block with nothing under it\n * would read as an absent value.\n *\n * @param value What to render.\n * @param indent Depth to start at. The recursion sets it.\n * @returns The YAML text, with no document marker.\n */\nexport function toYaml(value: unknown, indent = 0): string {\n const pad = \" \".repeat(indent);\n if (Array.isArray(value)) return listYaml(value, indent, pad);\n if (isMap(value)) return mapYaml(value, indent, pad);\n return `${pad}${scalar(value)}`;\n}\n\nfunction listYaml(items: readonly unknown[], indent: number, pad: string): string {\n if (items.length === 0) return `${pad}[]`;\n return items.map((item) => `${pad}- ${nested(item, indent)}`).join(\"\\n\");\n}\n\nfunction mapYaml(map: Record<string, unknown>, indent: number, pad: string): string {\n const entries = Object.entries(map);\n if (entries.length === 0) return `${pad}{}`;\n return entries.map(([key, item]) => `${pad}${key}:${suffix(item, indent)}`).join(\"\\n\");\n}\n\n/** A scalar sits on the key's line; a map or list opens a block below it. */\nfunction suffix(value: unknown, indent: number): string {\n if (isEmpty(value)) return ` ${Array.isArray(value) ? \"[]\" : \"{}\"}`;\n if (Array.isArray(value) || isMap(value)) return `\\n${toYaml(value, indent + 1)}`;\n return ` ${scalar(value)}`;\n}\n\n/** A nested value under `- `, with its first line already positioned. */\nfunction nested(value: unknown, indent: number): string {\n if (isEmpty(value) || (!Array.isArray(value) && !isMap(value))) return scalar(value);\n return toYaml(value, indent + 1).trimStart();\n}\n\nfunction isEmpty(value: unknown): boolean {\n if (Array.isArray(value)) return value.length === 0;\n return isMap(value) && Object.keys(value).length === 0;\n}\n\nfunction isMap(value: unknown): value is Record<string, unknown> {\n return value !== null && typeof value === \"object\" && !Array.isArray(value);\n}\n\nconst PLAIN = /^[A-Za-z_][\\w .-]*$/;\n\nfunction scalar(value: unknown): string {\n if (value === null || value === undefined) return \"null\";\n if (typeof value !== \"string\") return String(value);\n return value === \"\" || !PLAIN.test(value) ? JSON.stringify(value) : value;\n}\n","import { type ActionDefinition, arg, defineAction, optionalArg } from \"@venn-lang/sdk\";\nimport { t } from \"@venn-lang/types\";\nimport { toCsv } from \"../render/csv.js\";\nimport { toJson } from \"../render/json.js\";\nimport { toTable } from \"../render/table.js\";\nimport { toXml } from \"../render/xml.js\";\nimport { toYaml } from \"../render/yaml.js\";\n\nfunction list(value: unknown): unknown[] {\n return Array.isArray(value) ? value : [value];\n}\n\nfunction number(value: unknown, fallback: number): number {\n const parsed = Number(value);\n return value === undefined || Number.isNaN(parsed) ? fallback : parsed;\n}\n\n/**\n * The verbs of the `fmt` namespace: a value in, a string out.\n *\n * Formatting is kept apart from printing on purpose. The string is a value you\n * can compare in a test, send in a request or write to a file, not something\n * that only ever reaches a terminal.\n *\n * Every verb is positional and none reads an options map, so none declares a\n * params schema: one would put a `{ … }` in the editor's hover that the verb\n * never looks at, and would quietly strip the keys a caller wrote there.\n */\nexport const fmtActions: ActionDefinition[] = [\n defineAction({\n name: \"json\",\n doc: \"JSON text. `fmt.json(x, 0)` puts it on one line; the default indents by 2.\",\n // Anything at all can be serialised, so the first parameter promises nothing.\n args: [\n arg(\"value\", t.dynamic, \"What to render.\"),\n optionalArg(\"indent\", t.number, \"Spaces per level. 0 puts it on one line.\"),\n ],\n result: t.string,\n run: (_ctx, input) => toJson(input.args[0], number(input.args[1], 2)),\n }),\n defineAction({\n name: \"table\",\n doc: \"An aligned ASCII table of a list of records.\",\n // The columns are read off the records at runtime, so the element type\n // promises nothing. A lone record is tolerated, but this verb wants a list.\n args: [arg(\"rows\", t.list(t.dynamic), \"A list of records. Their keys become the columns.\")],\n result: t.string,\n run: (_ctx, input) => toTable(list(input.args[0])),\n }),\n defineAction({\n name: \"yaml\",\n doc: \"YAML text, for a map, a list or a scalar.\",\n args: [arg(\"value\", t.dynamic, \"What to render.\")],\n result: t.string,\n run: (_ctx, input) => toYaml(input.args[0]),\n }),\n defineAction({\n name: \"csv\",\n doc: \"CSV with a header row. `fmt.csv(rows, ';')` changes the separator.\",\n args: [\n arg(\"rows\", t.list(t.dynamic), \"A list of records. Their keys become the header row.\"),\n optionalArg(\"separator\", t.string, \"What goes between fields. A comma by default.\"),\n ],\n result: t.string,\n run: (_ctx, input) => toCsv(list(input.args[0]), String(input.args[1] ?? \",\")),\n }),\n defineAction({\n name: \"xml\",\n doc: \"XML text. `fmt.xml(x, 'user')` names the root element.\",\n args: [\n arg(\"value\", t.dynamic, \"What to render.\"),\n optionalArg(\"root\", t.string, \"What to call the outermost element.\"),\n ],\n result: t.string,\n run: (_ctx, input) => toXml(input.args[0], String(input.args[1] ?? \"root\")),\n }),\n];\n","import { definePlugin, type PluginDefinition } from \"@venn-lang/sdk\";\nimport { fmtActions } from \"./actions/fmt-actions.js\";\n\n/**\n * The `fmt` plugin: a value to text, as JSON, a table, YAML, CSV or XML.\n *\n * Every verb is pure, so the plugin needs no host capability and runs anywhere,\n * the LSP included. What comes back is a string, yours to print, compare or send.\n */\nexport const fmtPlugin: PluginDefinition = definePlugin({\n name: \"venn/fmt\",\n version: \"0.0.0\",\n namespace: \"fmt\",\n actions: fmtActions,\n});\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@venn-lang/fmt",
3
+ "version": "0.1.0",
4
+ "description": "The fmt namespace: a value in, formatted text out. JSON, tables, YAML, CSV and XML.",
5
+ "keywords": [
6
+ "venn",
7
+ "testing",
8
+ "e2e",
9
+ "fmt"
10
+ ],
11
+ "homepage": "https://github.com/venn-lang/venn/tree/main/packages/std-fmt#readme",
12
+ "bugs": "https://github.com/venn-lang/venn/issues",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/venn-lang/venn.git",
16
+ "directory": "packages/std-fmt"
17
+ },
18
+ "license": "MIT",
19
+ "author": "Vinicius Borges",
20
+ "type": "module",
21
+ "sideEffects": false,
22
+ "exports": {
23
+ ".": {
24
+ "development": "./src/index.ts",
25
+ "types": "./dist/index.d.ts",
26
+ "import": "./dist/index.js",
27
+ "default": "./dist/index.js"
28
+ }
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "src",
33
+ "!src/**/*.test.ts",
34
+ "!src/**/*.suite.ts"
35
+ ],
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "dependencies": {
40
+ "@venn-lang/sdk": "0.1.0",
41
+ "@venn-lang/types": "0.1.0"
42
+ },
43
+ "devDependencies": {
44
+ "tsdown": "^0.22.14",
45
+ "typescript": "^7.0.2",
46
+ "vitest": "^4.1.10"
47
+ },
48
+ "scripts": {
49
+ "build": "tsdown",
50
+ "test": "vitest run",
51
+ "typecheck": "tsc --noEmit"
52
+ }
53
+ }
@@ -0,0 +1,77 @@
1
+ import { type ActionDefinition, arg, defineAction, optionalArg } from "@venn-lang/sdk";
2
+ import { t } from "@venn-lang/types";
3
+ import { toCsv } from "../render/csv.js";
4
+ import { toJson } from "../render/json.js";
5
+ import { toTable } from "../render/table.js";
6
+ import { toXml } from "../render/xml.js";
7
+ import { toYaml } from "../render/yaml.js";
8
+
9
+ function list(value: unknown): unknown[] {
10
+ return Array.isArray(value) ? value : [value];
11
+ }
12
+
13
+ function number(value: unknown, fallback: number): number {
14
+ const parsed = Number(value);
15
+ return value === undefined || Number.isNaN(parsed) ? fallback : parsed;
16
+ }
17
+
18
+ /**
19
+ * The verbs of the `fmt` namespace: a value in, a string out.
20
+ *
21
+ * Formatting is kept apart from printing on purpose. The string is a value you
22
+ * can compare in a test, send in a request or write to a file, not something
23
+ * that only ever reaches a terminal.
24
+ *
25
+ * Every verb is positional and none reads an options map, so none declares a
26
+ * params schema: one would put a `{ … }` in the editor's hover that the verb
27
+ * never looks at, and would quietly strip the keys a caller wrote there.
28
+ */
29
+ export const fmtActions: ActionDefinition[] = [
30
+ defineAction({
31
+ name: "json",
32
+ doc: "JSON text. `fmt.json(x, 0)` puts it on one line; the default indents by 2.",
33
+ // Anything at all can be serialised, so the first parameter promises nothing.
34
+ args: [
35
+ arg("value", t.dynamic, "What to render."),
36
+ optionalArg("indent", t.number, "Spaces per level. 0 puts it on one line."),
37
+ ],
38
+ result: t.string,
39
+ run: (_ctx, input) => toJson(input.args[0], number(input.args[1], 2)),
40
+ }),
41
+ defineAction({
42
+ name: "table",
43
+ doc: "An aligned ASCII table of a list of records.",
44
+ // The columns are read off the records at runtime, so the element type
45
+ // promises nothing. A lone record is tolerated, but this verb wants a list.
46
+ args: [arg("rows", t.list(t.dynamic), "A list of records. Their keys become the columns.")],
47
+ result: t.string,
48
+ run: (_ctx, input) => toTable(list(input.args[0])),
49
+ }),
50
+ defineAction({
51
+ name: "yaml",
52
+ doc: "YAML text, for a map, a list or a scalar.",
53
+ args: [arg("value", t.dynamic, "What to render.")],
54
+ result: t.string,
55
+ run: (_ctx, input) => toYaml(input.args[0]),
56
+ }),
57
+ defineAction({
58
+ name: "csv",
59
+ doc: "CSV with a header row. `fmt.csv(rows, ';')` changes the separator.",
60
+ args: [
61
+ arg("rows", t.list(t.dynamic), "A list of records. Their keys become the header row."),
62
+ optionalArg("separator", t.string, "What goes between fields. A comma by default."),
63
+ ],
64
+ result: t.string,
65
+ run: (_ctx, input) => toCsv(list(input.args[0]), String(input.args[1] ?? ",")),
66
+ }),
67
+ defineAction({
68
+ name: "xml",
69
+ doc: "XML text. `fmt.xml(x, 'user')` names the root element.",
70
+ args: [
71
+ arg("value", t.dynamic, "What to render."),
72
+ optionalArg("root", t.string, "What to call the outermost element."),
73
+ ],
74
+ result: t.string,
75
+ run: (_ctx, input) => toXml(input.args[0], String(input.args[1] ?? "root")),
76
+ }),
77
+ ];
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ // The `fmt` namespace: a value in, formatted text out.
2
+ export { fmtActions } from "./actions/fmt-actions.js";
3
+ export { fmtPlugin, fmtPlugin as default } from "./plugin.js";
4
+ export { toCsv } from "./render/csv.js";
5
+ export { toJson } from "./render/json.js";
6
+ export { toTable } from "./render/table.js";
7
+ export { toXml } from "./render/xml.js";
8
+ export { toYaml } from "./render/yaml.js";
package/src/plugin.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { definePlugin, type PluginDefinition } from "@venn-lang/sdk";
2
+ import { fmtActions } from "./actions/fmt-actions.js";
3
+
4
+ /**
5
+ * The `fmt` plugin: a value to text, as JSON, a table, YAML, CSV or XML.
6
+ *
7
+ * Every verb is pure, so the plugin needs no host capability and runs anywhere,
8
+ * the LSP included. What comes back is a string, yours to print, compare or send.
9
+ */
10
+ export const fmtPlugin: PluginDefinition = definePlugin({
11
+ name: "venn/fmt",
12
+ version: "0.0.0",
13
+ namespace: "fmt",
14
+ actions: fmtActions,
15
+ });
@@ -0,0 +1,42 @@
1
+ type Row = Record<string, unknown>;
2
+
3
+ /**
4
+ * Renders a list of records as CSV with a header row.
5
+ *
6
+ * Columns are the union of every record's keys, in first-seen order, so a
7
+ * record missing a field still lines up. A field is quoted only when it holds a
8
+ * separator, a quote or a newline, and inner quotes are doubled (RFC 4180).
9
+ *
10
+ * @param rows The records to render. Anything that is not a record is skipped.
11
+ * @param separator What goes between fields. A comma by default.
12
+ * @returns The CSV text, or an empty string when no record survives the filter.
13
+ */
14
+ export function toCsv(rows: readonly unknown[], separator = ","): string {
15
+ const records = rows.filter(isRow);
16
+ if (records.length === 0) return "";
17
+ const columns = columnsOf(records);
18
+ const header = columns.map(quote).join(separator);
19
+ const body = records.map((row) => columns.map((c) => quote(cell(row[c]))).join(separator));
20
+ return [header, ...body].join("\n");
21
+ }
22
+
23
+ function isRow(value: unknown): value is Row {
24
+ return value !== null && typeof value === "object" && !Array.isArray(value);
25
+ }
26
+
27
+ function columnsOf(rows: readonly Row[]): string[] {
28
+ const seen = new Set<string>();
29
+ for (const row of rows) for (const key of Object.keys(row)) seen.add(key);
30
+ return [...seen];
31
+ }
32
+
33
+ function cell(value: unknown): string {
34
+ if (value === null || value === undefined) return "";
35
+ return typeof value === "object" ? JSON.stringify(value) : String(value);
36
+ }
37
+
38
+ /** Quote when the field holds a separator, a quote or a newline; double inner quotes. */
39
+ function quote(field: string): string {
40
+ if (!/[",;\t\n\r]/.test(field)) return field;
41
+ return `"${field.replace(/"/g, '""')}"`;
42
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Renders a value as JSON text.
3
+ *
4
+ * A value JSON cannot express (a cycle, a BigInt) falls back to `String(value)`
5
+ * instead of throwing: formatting is not where a run should die.
6
+ *
7
+ * @param value What to render.
8
+ * @param spaces Spaces per level of nesting. 0 puts it all on one line.
9
+ * @returns The JSON text.
10
+ */
11
+ export function toJson(value: unknown, spaces = 2): string {
12
+ try {
13
+ return JSON.stringify(value, null, spaces) ?? String(value);
14
+ } catch {
15
+ return String(value);
16
+ }
17
+ }
@@ -0,0 +1,51 @@
1
+ type Row = Record<string, unknown>;
2
+
3
+ /**
4
+ * Renders a list of records as an aligned table, for reading in a terminal.
5
+ *
6
+ * Columns are the union of every row's keys, in first-seen order, so a row
7
+ * missing a field still lines up. Each column is as wide as its widest cell.
8
+ *
9
+ * @param rows The records to render. Anything that is not a record is skipped.
10
+ * @returns The table text, or `(no rows)` when there is nothing to show.
11
+ */
12
+ export function toTable(rows: readonly unknown[]): string {
13
+ const records = rows.filter(isRow);
14
+ if (records.length === 0) return "(no rows)";
15
+ const columns = columnsOf(records);
16
+ const widths = columns.map((column) => widthOf(column, records));
17
+ return [
18
+ line(columns, widths),
19
+ widths.map((width) => "─".repeat(width)).join("─┼─"),
20
+ ...records.map((row) =>
21
+ line(
22
+ columns.map((column) => cell(row[column])),
23
+ widths,
24
+ ),
25
+ ),
26
+ ].join("\n");
27
+ }
28
+
29
+ function isRow(value: unknown): value is Row {
30
+ return value !== null && typeof value === "object" && !Array.isArray(value);
31
+ }
32
+
33
+ function columnsOf(rows: readonly Row[]): string[] {
34
+ const seen = new Set<string>();
35
+ for (const row of rows) for (const key of Object.keys(row)) seen.add(key);
36
+ return [...seen];
37
+ }
38
+
39
+ function widthOf(column: string, rows: readonly Row[]): number {
40
+ return rows.reduce((width, row) => Math.max(width, cell(row[column]).length), column.length);
41
+ }
42
+
43
+ function line(values: readonly string[], widths: readonly number[]): string {
44
+ return values.map((value, index) => value.padEnd(widths[index] ?? 0)).join(" │ ");
45
+ }
46
+
47
+ function cell(value: unknown): string {
48
+ if (value === null || value === undefined) return "";
49
+ if (typeof value === "object") return JSON.stringify(value);
50
+ return String(value);
51
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Renders a value as indented XML.
3
+ *
4
+ * A map's keys become tags; a list repeats the tag it was given, once per item.
5
+ * Text is escaped. Attributes are never produced, so a round trip through this
6
+ * and back is not the identity.
7
+ *
8
+ * @param value What to render.
9
+ * @param tag The name of the outermost element.
10
+ * @param indent Depth to start at, two spaces per level. The recursion sets it.
11
+ * @returns The XML text, with no declaration or prologue.
12
+ */
13
+ export function toXml(value: unknown, tag = "root", indent = 0): string {
14
+ const pad = " ".repeat(indent);
15
+ if (Array.isArray(value)) return value.map((item) => toXml(item, tag, indent)).join("\n");
16
+ if (isMap(value)) return element({ tag, body: children(value, indent), pad, block: true });
17
+ return element({ tag, body: escaped(text(value)), pad, block: false });
18
+ }
19
+
20
+ function children(map: Record<string, unknown>, indent: number): string {
21
+ return Object.entries(map)
22
+ .map(([key, item]) => toXml(item, key, indent + 1))
23
+ .join("\n");
24
+ }
25
+
26
+ function element(args: { tag: string; body: string; pad: string; block: boolean }): string {
27
+ const { tag, body, pad } = args;
28
+ if (!args.block) return `${pad}<${tag}>${body}</${tag}>`;
29
+ return body === "" ? `${pad}<${tag}/>` : `${pad}<${tag}>\n${body}\n${pad}</${tag}>`;
30
+ }
31
+
32
+ function isMap(value: unknown): value is Record<string, unknown> {
33
+ return value !== null && typeof value === "object" && !Array.isArray(value);
34
+ }
35
+
36
+ function text(value: unknown): string {
37
+ return value === null || value === undefined ? "" : String(value);
38
+ }
39
+
40
+ function escaped(value: string): string {
41
+ return value
42
+ .replace(/&/g, "&amp;")
43
+ .replace(/</g, "&lt;")
44
+ .replace(/>/g, "&gt;")
45
+ .replace(/"/g, "&quot;");
46
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Renders a value as YAML: maps, lists and scalars, two spaces per level.
3
+ *
4
+ * A string is quoted only where plain style would change what it means. Empty
5
+ * maps and lists print as `{}` and `[]`, since a block with nothing under it
6
+ * would read as an absent value.
7
+ *
8
+ * @param value What to render.
9
+ * @param indent Depth to start at. The recursion sets it.
10
+ * @returns The YAML text, with no document marker.
11
+ */
12
+ export function toYaml(value: unknown, indent = 0): string {
13
+ const pad = " ".repeat(indent);
14
+ if (Array.isArray(value)) return listYaml(value, indent, pad);
15
+ if (isMap(value)) return mapYaml(value, indent, pad);
16
+ return `${pad}${scalar(value)}`;
17
+ }
18
+
19
+ function listYaml(items: readonly unknown[], indent: number, pad: string): string {
20
+ if (items.length === 0) return `${pad}[]`;
21
+ return items.map((item) => `${pad}- ${nested(item, indent)}`).join("\n");
22
+ }
23
+
24
+ function mapYaml(map: Record<string, unknown>, indent: number, pad: string): string {
25
+ const entries = Object.entries(map);
26
+ if (entries.length === 0) return `${pad}{}`;
27
+ return entries.map(([key, item]) => `${pad}${key}:${suffix(item, indent)}`).join("\n");
28
+ }
29
+
30
+ /** A scalar sits on the key's line; a map or list opens a block below it. */
31
+ function suffix(value: unknown, indent: number): string {
32
+ if (isEmpty(value)) return ` ${Array.isArray(value) ? "[]" : "{}"}`;
33
+ if (Array.isArray(value) || isMap(value)) return `\n${toYaml(value, indent + 1)}`;
34
+ return ` ${scalar(value)}`;
35
+ }
36
+
37
+ /** A nested value under `- `, with its first line already positioned. */
38
+ function nested(value: unknown, indent: number): string {
39
+ if (isEmpty(value) || (!Array.isArray(value) && !isMap(value))) return scalar(value);
40
+ return toYaml(value, indent + 1).trimStart();
41
+ }
42
+
43
+ function isEmpty(value: unknown): boolean {
44
+ if (Array.isArray(value)) return value.length === 0;
45
+ return isMap(value) && Object.keys(value).length === 0;
46
+ }
47
+
48
+ function isMap(value: unknown): value is Record<string, unknown> {
49
+ return value !== null && typeof value === "object" && !Array.isArray(value);
50
+ }
51
+
52
+ const PLAIN = /^[A-Za-z_][\w .-]*$/;
53
+
54
+ function scalar(value: unknown): string {
55
+ if (value === null || value === undefined) return "null";
56
+ if (typeof value !== "string") return String(value);
57
+ return value === "" || !PLAIN.test(value) ? JSON.stringify(value) : value;
58
+ }