@weborigami/origami 0.0.49 → 0.0.51

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.
Files changed (61) hide show
  1. package/exports/buildExports.js +2 -2
  2. package/exports/exports.js +28 -15
  3. package/package.json +4 -4
  4. package/src/builtins/@addNextPrevious.js +18 -7
  5. package/src/builtins/{@arrows.js → @arrowsMap.js} +7 -7
  6. package/src/builtins/@changes.js +46 -0
  7. package/src/builtins/@clean.js +19 -0
  8. package/src/builtins/@constructor.js +17 -0
  9. package/src/builtins/@crawl.js +2 -2
  10. package/src/builtins/@deepMap.js +19 -0
  11. package/src/builtins/@deepMapFn.js +25 -0
  12. package/src/builtins/{@mergeDeep.js → @deepMerge.js} +7 -7
  13. package/src/builtins/@deepTake.js +21 -0
  14. package/src/builtins/@deepTakeFn.js +22 -0
  15. package/src/builtins/{@valuesDeep.js → @deepValues.js} +6 -5
  16. package/src/builtins/@files.js +14 -1
  17. package/src/builtins/@group.js +20 -0
  18. package/src/builtins/@groupFn.js +34 -0
  19. package/src/builtins/@if.js +2 -1
  20. package/src/builtins/@image/format.js +10 -31
  21. package/src/builtins/@image/formatFn.js +15 -0
  22. package/src/builtins/@image/resize.js +7 -28
  23. package/src/builtins/@image/resizeFn.js +14 -0
  24. package/src/builtins/@invoke.js +1 -1
  25. package/src/builtins/@json.js +5 -1
  26. package/src/builtins/@jsonParse.js +9 -0
  27. package/src/builtins/{@count.js → @length.js} +2 -5
  28. package/src/builtins/@map.js +7 -178
  29. package/src/builtins/@mapFn.js +145 -0
  30. package/src/builtins/@mdHtml.js +2 -0
  31. package/src/builtins/@mdTree.js +69 -0
  32. package/src/builtins/@merge.js +21 -1
  33. package/src/builtins/@naturalOrder.js +1 -0
  34. package/src/builtins/@paginate.js +18 -0
  35. package/src/builtins/@paginateFn.js +61 -0
  36. package/src/builtins/@redirect.js +10 -1
  37. package/src/builtins/@regexMatch.js +5 -0
  38. package/src/builtins/{@makeParser.js → @regexMatchFn.js} +1 -1
  39. package/src/builtins/@sitemap.js +4 -4
  40. package/src/builtins/@sort.js +10 -7
  41. package/src/builtins/@sortFn.js +58 -0
  42. package/src/builtins/@take.js +3 -17
  43. package/src/builtins/@takeFn.js +21 -0
  44. package/src/builtins/@tree.js +2 -14
  45. package/src/builtins/@yaml.js +4 -0
  46. package/src/builtins/@yamlParse.js +10 -0
  47. package/src/builtins/map.d.ts +1 -1
  48. package/src/common/ShuffleTransform.js +3 -3
  49. package/src/common/{arrowFunctionsMap.js → arrowsMapFn.js} +3 -3
  50. package/src/common/serialize.js +1 -10
  51. package/src/misc/explore.ori +7 -7
  52. package/src/misc/treeDot.js +2 -2
  53. package/src/builtins/@apply.js +0 -6
  54. package/src/builtins/@groupBy.js +0 -37
  55. package/src/builtins/@isAsyncTree.js +0 -17
  56. package/src/builtins/@mapDeep.js +0 -71
  57. package/src/builtins/@new.js +0 -6
  58. package/src/builtins/@parse/json.js +0 -9
  59. package/src/builtins/@parse/yaml.js +0 -10
  60. package/src/builtins/@sortBy.js +0 -37
  61. package/src/builtins/@with.js +0 -22
@@ -0,0 +1,15 @@
1
+ import sharp from "sharp";
2
+ import assertScopeIsDefined from "../../misc/assertScopeIsDefined.js";
3
+
4
+ /**
5
+ * Return a function that transforms an input image to a different format.
6
+ *
7
+ * @this {import("@weborigami/types").AsyncTree|null}
8
+ * @param {keyof import("sharp").FormatEnum|import("sharp").AvailableFormatInfo}
9
+ * format
10
+ * @param {any} options
11
+ */
12
+ export default function imageFormatFn(format, options) {
13
+ assertScopeIsDefined(this, "image/formatFn");
14
+ return (buffer) => sharp(buffer).toFormat(format, options).toBuffer();
15
+ }
@@ -1,35 +1,14 @@
1
- import sharp from "sharp";
1
+ import assertScopeIsDefined from "../../misc/assertScopeIsDefined.js";
2
+ import imageResizeFn from "./resizeFn.js";
2
3
 
3
4
  /**
4
5
  * Resize an image.
5
6
  *
6
7
  * @this {import("@weborigami/types").AsyncTree|null}
7
- *
8
- * @typedef {import("sharp").ResizeOptions} ResizeOptions
9
- *
10
- * @overload
11
- * @param {ResizeOptions} param1
12
- * @returns {(buffer: Buffer) => Promise<Buffer>}
13
- *
14
- * @overload
15
- * @param {Buffer} param1
16
- * @param {ResizeOptions} param2
17
- * @returns {Promise<Buffer>}
8
+ * @param {Buffer} buffer
9
+ * @param {import("sharp").ResizeOptions} options
18
10
  */
19
- export default function resize(param1, param2) {
20
- // Identify which overload was used.
21
- let buffer;
22
- let options;
23
- if (param2 === undefined) {
24
- options = param1;
25
- } else {
26
- buffer = param1;
27
- options = param2;
28
- }
29
-
30
- // Include `rotate()` to auto-rotate according to EXIF data.
31
- const transform = (buffer) =>
32
- sharp(buffer).rotate().resize(options).toBuffer();
33
-
34
- return buffer ? transform(buffer) : transform;
11
+ export default async function resize(buffer, options) {
12
+ assertScopeIsDefined(this, "image/resize");
13
+ return imageResizeFn.call(this, options)(buffer);
35
14
  }
@@ -0,0 +1,14 @@
1
+ import sharp from "sharp";
2
+ import assertScopeIsDefined from "../../misc/assertScopeIsDefined.js";
3
+
4
+ /**
5
+ * Return a function that resizes an image.
6
+ *
7
+ * @this {import("@weborigami/types").AsyncTree|null}
8
+ * @param {import("sharp").ResizeOptions} options
9
+ */
10
+ export default function imageResizeFn(options) {
11
+ assertScopeIsDefined(this, "image/resizeFn");
12
+ // Include `rotate()` to auto-rotate according to EXIF data.
13
+ return (buffer) => sharp(buffer).rotate().resize(options).toBuffer();
14
+ }
@@ -3,7 +3,7 @@ import assertScopeIsDefined from "../misc/assertScopeIsDefined.js";
3
3
  import builtins from "./@builtins.js";
4
4
 
5
5
  /**
6
- * Invoke the given function.
6
+ * Invoke the given text as an Origami function.
7
7
  *
8
8
  * This built-in exists to facilitate executing an Origami file as a script via
9
9
  * a [shebang](https://en.wikipedia.org/wiki/Shebang_(Unix)) directive.
@@ -1,4 +1,5 @@
1
1
  /** @typedef {import("@weborigami/types").AsyncTree} AsyncTree */
2
+ import { isUnpackable } from "@weborigami/async-tree";
2
3
  import * as serialize from "../common/serialize.js";
3
4
  import assertScopeIsDefined from "../misc/assertScopeIsDefined.js";
4
5
 
@@ -20,9 +21,12 @@ export default async function json(obj) {
20
21
  if (obj === undefined) {
21
22
  return undefined;
22
23
  }
24
+ if (isUnpackable(obj)) {
25
+ obj = await obj.unpack();
26
+ }
23
27
  const value = await serialize.toJsonValue(obj);
24
28
  return JSON.stringify(value, null, 2);
25
29
  }
26
30
 
27
31
  json.usage = "@json <obj>\tRender the object as text in JSON format";
28
- json.documentation = "https://weborigami.org/language/@json.html";
32
+ json.documentation = "https://weborigami.org/builtins/@json.html";
@@ -0,0 +1,9 @@
1
+ import { toString } from "../common/utilities.js";
2
+
3
+ export default async function jsonParse(input) {
4
+ const text = toString(input);
5
+ return text ? JSON.parse(text) : undefined;
6
+ }
7
+
8
+ jsonParse.usage = `@jsonParse <text>\tParse text as JSON`;
9
+ jsonParse.documentation = "https://weborigami.org/builtins/@jsonParse.html";
@@ -8,11 +8,8 @@ import getTreeArgument from "../misc/getTreeArgument.js";
8
8
  * @this {AsyncTree|null}
9
9
  * @param {Treelike} [treelike]
10
10
  */
11
- export default async function count(treelike) {
12
- const tree = await getTreeArgument(this, arguments, treelike, "@count");
11
+ export default async function length(treelike) {
12
+ const tree = await getTreeArgument(this, arguments, treelike, "@length");
13
13
  const keys = Array.from(await tree.keys());
14
14
  return keys.length;
15
15
  }
16
-
17
- count.usage = `@count <treelike>\tReturn the number of keys in the tree`;
18
- count.documentation = "https://weborigami.org/cli/@tree.html#count";
@@ -1,190 +1,19 @@
1
- import {
2
- cachedKeyFunctions,
3
- isPlainObject,
4
- keyFunctionsForExtensions,
5
- map,
6
- } from "@weborigami/async-tree";
7
- import { Scope } from "@weborigami/language";
8
- import addValueKeyToScope from "../common/addValueKeyToScope.js";
9
- import { toFunction } from "../common/utilities.js";
10
1
  import assertScopeIsDefined from "../misc/assertScopeIsDefined.js";
2
+ import mapFn from "./@mapFn.js";
11
3
 
12
4
  /**
13
5
  * Map a hierarchical tree of keys and values to a new tree of keys and values.
14
6
  *
15
- * @typedef {import("@weborigami/async-tree").KeyFn} KeyFn
7
+ * @typedef {import("@weborigami/types").AsyncTree} AsyncTree
16
8
  * @typedef {import("@weborigami/async-tree").Treelike} Treelike
17
9
  * @typedef {import("@weborigami/async-tree").ValueKeyFn} ValueKeyFn
18
- * @typedef {import("@weborigami/async-tree").TreeTransform} TreeTransform
19
- * @typedef {import("@weborigami/types").AsyncTree} AsyncTree
20
- *
21
- * @typedef {{ deep?: boolean, description?: string, extension?: string,
22
- * extensions?: string, inverseKey?: KeyFn, key?: ValueKeyFn, keyMap?:
23
- * ValueKeyFn, needsSourceValue?: boolean, value?: ValueKeyFn, valueMap?:
24
- * ValueKeyFn }} MapOptionsDictionary
25
- *
26
- * @typedef {ValueKeyFn|MapOptionsDictionary} OptionsOrValueFn
27
- *
28
- * @overload
29
- * @param {Treelike} source
30
- * @param {OptionsOrValueFn} instructions
31
- * @returns {AsyncTree}
32
- *
33
- * @overload
34
- * @param {OptionsOrValueFn} instructions
35
- * @returns {TreeTransform}
10
+ * @typedef {import("./map.d.ts").TreeMapOptions} TreeMapOptions
36
11
  *
37
12
  * @this {AsyncTree|null}
38
- * @param {Treelike|OptionsOrValueFn} param1
39
- * @param {OptionsOrValueFn} [param2]
13
+ * @param {Treelike} source
14
+ * @param {ValueKeyFn|TreeMapOptions} operation
40
15
  */
41
- export default function treeMap(param1, param2) {
16
+ export default function map(source, operation) {
42
17
  assertScopeIsDefined(this, "map");
43
-
44
- // Identify whether the map instructions are in the first parameter or the
45
- // second. If present, identify the function to apply to values.
46
-
47
- /** @type {Treelike|undefined} */
48
- let source;
49
- /** @type {OptionsOrValueFn} */
50
- let instructions;
51
- /** @type {ValueKeyFn|undefined} */
52
- let valueFn;
53
-
54
- if (arguments.length === 0) {
55
- throw new TypeError(
56
- `@map: You must give @map a function or a dictionary of options.`
57
- );
58
- } else if (!param1) {
59
- throw new TypeError(`@map: The first argument was undefined.`);
60
- } else if (arguments.length === 1) {
61
- // One argument
62
- // @ts-ignore
63
- instructions = param1;
64
- } else if (param2 === undefined) {
65
- throw new TypeError(`@map: The second argument was undefined.`);
66
- } else {
67
- // Two arguments
68
- source = param1;
69
- instructions = param2;
70
- }
71
-
72
- // Identify whether the map instructions take the form of a value function or
73
- // a dictionary of options.
74
- /** @type {MapOptionsDictionary} */
75
- let options;
76
- if (isPlainObject(instructions)) {
77
- // @ts-ignore
78
- options = instructions;
79
- valueFn = options?.value ?? options?.valueMap;
80
- } else if (
81
- typeof instructions === "function" ||
82
- typeof (/** @type {any} */ (instructions)?.unpack) === "function"
83
- ) {
84
- valueFn = instructions;
85
- options = {};
86
- } else {
87
- throw new TypeError(
88
- `@map: You must specify a value function or options dictionary.`
89
- );
90
- }
91
-
92
- let { deep, description, inverseKey, needsSourceValue } = options;
93
- let extension = options.extension ?? options.extensions;
94
- let keyFn = options.keyMap ?? options.key;
95
-
96
- description ??= `@map ${extension ?? ""}`;
97
-
98
- if (extension && (keyFn || inverseKey)) {
99
- throw new TypeError(
100
- `@map: You can't specify extensions and also a key or inverseKey function`
101
- );
102
- }
103
-
104
- const baseScope = Scope.getScope(this);
105
-
106
- // Extend the value function to include the value and key in scope.
107
- let extendedValueFn;
108
- if (valueFn) {
109
- const resolvedValueFn = toFunction(valueFn);
110
- extendedValueFn = function (sourceValue, sourceKey, tree) {
111
- const scope = addValueKeyToScope(baseScope, sourceValue, sourceKey);
112
- return resolvedValueFn.call(scope, sourceValue, sourceKey, tree);
113
- };
114
- }
115
-
116
- // Extend the key function to include the value and key in scope.
117
- let extendedKeyFn;
118
- let extendedInverseKeyFn;
119
- if (extension) {
120
- let { resultExtension, sourceExtension } = parseExtensions(extension);
121
- const keyFns = keyFunctionsForExtensions({
122
- resultExtension,
123
- sourceExtension,
124
- });
125
- extendedKeyFn = keyFns.key;
126
- extendedInverseKeyFn = keyFns.inverseKey;
127
- } else if (keyFn) {
128
- const resolvedKeyFn = toFunction(keyFn);
129
- async function scopedKeyFn(sourceKey, tree) {
130
- const sourceValue = await tree.get(sourceKey);
131
- const scope = addValueKeyToScope(baseScope, sourceValue, sourceKey);
132
- const resultKey = await resolvedKeyFn.call(
133
- scope,
134
- sourceValue,
135
- sourceKey,
136
- tree
137
- );
138
- return resultKey;
139
- }
140
- const keyFns = cachedKeyFunctions(scopedKeyFn);
141
- extendedKeyFn = keyFns.key;
142
- extendedInverseKeyFn = keyFns.inverseKey;
143
- } else {
144
- // Use sidecar keyFn/inverseKey functions if the valueFn defines them.
145
- extendedKeyFn = /** @type {any} */ (valueFn)?.key;
146
- extendedInverseKeyFn = /** @type {any} */ (valueFn)?.inverseKey;
147
- }
148
-
149
- const transform = function mapTreelike(treelike) {
150
- return map({
151
- deep,
152
- description,
153
- inverseKey: extendedInverseKeyFn,
154
- key: extendedKeyFn,
155
- needsSourceValue,
156
- value: extendedValueFn,
157
- })(treelike);
158
- };
159
-
160
- return source ? transform(source) : transform;
161
- }
162
-
163
- /**
164
- * Given a string specifying an extension or a mapping of one extension to another,
165
- * return the source and result extensions.
166
- *
167
- * Syntax:
168
- * foo
169
- * foo→bar Unicode Rightwards Arrow
170
- * foo->bar hyphen and greater-than sign
171
- *
172
- * @param {string} specifier
173
- */
174
- function parseExtensions(specifier) {
175
- const lowercase = specifier?.toLowerCase() ?? "";
176
- const extensionRegex =
177
- /^\.?(?<sourceExtension>\S*)(?:\s*(→|->)\s*)\.?(?<extension>\S+)$/;
178
- let resultExtension;
179
- let sourceExtension;
180
- const match = lowercase.match(extensionRegex);
181
- if (match?.groups) {
182
- // foo→bar
183
- ({ extension: resultExtension, sourceExtension } = match.groups);
184
- } else {
185
- // foo
186
- resultExtension = lowercase;
187
- sourceExtension = lowercase;
188
- }
189
- return { resultExtension, sourceExtension };
18
+ return mapFn.call(this, operation)(source);
190
19
  }
@@ -0,0 +1,145 @@
1
+ import {
2
+ cachedKeyFunctions,
3
+ isPlainObject,
4
+ keyFunctionsForExtensions,
5
+ mapFn,
6
+ } from "@weborigami/async-tree";
7
+ import { Scope } from "@weborigami/language";
8
+ import { toFunction } from "../common/utilities.js";
9
+ import assertScopeIsDefined from "../misc/assertScopeIsDefined.js";
10
+
11
+ /**
12
+ * Return a function that transforms a tree of keys and values to a new tree of
13
+ * keys and values.
14
+ *
15
+ * @typedef {import("@weborigami/types").AsyncTree} AsyncTree
16
+ * @typedef {import("@weborigami/async-tree").Treelike} Treelike
17
+ * @typedef {import("@weborigami/async-tree").ValueKeyFn} ValueKeyFn
18
+ * @typedef {import("./map.d.ts").TreeMapOptions} TreeMapOptions
19
+ *
20
+ * @this {AsyncTree|null}
21
+ * @param {ValueKeyFn|TreeMapOptions} operation
22
+ */
23
+ export default function mapFnBuiltin(operation) {
24
+ assertScopeIsDefined(this, "map");
25
+ const scope = this;
26
+
27
+ // Identify whether the map instructions take the form of a value function or
28
+ // a dictionary of options.
29
+ /** @type {TreeMapOptions} */
30
+ let options;
31
+ /** @type {ValueKeyFn|undefined} */
32
+ let valueFn;
33
+ if (isPlainObject(operation)) {
34
+ // @ts-ignore
35
+ options = operation;
36
+ if (`value` in options && !options.value) {
37
+ throw new TypeError(`@mapFn: The value function is not defined.`);
38
+ }
39
+ valueFn = options?.value;
40
+ } else if (
41
+ typeof operation === "function" ||
42
+ typeof (/** @type {any} */ (operation)?.unpack) === "function"
43
+ ) {
44
+ valueFn = operation;
45
+ options = {};
46
+ } else {
47
+ throw new TypeError(
48
+ `@mapFn: You must specify a value function or options dictionary as the first parameter.`
49
+ );
50
+ }
51
+
52
+ const { deep, extension, needsSourceValue } = options;
53
+ const description = options.description ?? `@mapFn ${extension ?? ""}`;
54
+ const keyFn = options.key;
55
+ const inverseKeyFn = options.inverseKey;
56
+
57
+ if (extension && (keyFn || inverseKeyFn)) {
58
+ throw new TypeError(
59
+ `@mapFn: You can't specify extensions and also a key or inverseKey function`
60
+ );
61
+ }
62
+
63
+ let extendedValueFn;
64
+ if (valueFn) {
65
+ const resolvedValueFn = toFunction(valueFn);
66
+ // Have the value function run in this scope.
67
+ extendedValueFn = resolvedValueFn.bind(scope);
68
+ }
69
+
70
+ // Extend the value function to run in scope.
71
+ let extendedKeyFn;
72
+ let extendedInverseKeyFn;
73
+ if (extension) {
74
+ let { resultExtension, sourceExtension } = parseExtensions(extension);
75
+ const keyFns = keyFunctionsForExtensions({
76
+ resultExtension,
77
+ sourceExtension,
78
+ });
79
+ extendedKeyFn = keyFns.key;
80
+ extendedInverseKeyFn = keyFns.inverseKey;
81
+ } else if (keyFn) {
82
+ const resolvedKeyFn = toFunction(keyFn);
83
+ async function scopedKeyFn(sourceKey, tree) {
84
+ const sourceValue = await tree.get(sourceKey);
85
+ const resultKey = await resolvedKeyFn.call(
86
+ scope,
87
+ sourceValue,
88
+ sourceKey,
89
+ tree
90
+ );
91
+ return resultKey;
92
+ }
93
+ const keyFns = cachedKeyFunctions(scopedKeyFn);
94
+ extendedKeyFn = keyFns.key;
95
+ extendedInverseKeyFn = keyFns.inverseKey;
96
+ } else {
97
+ // Use sidecar keyFn/inverseKey functions if the valueFn defines them.
98
+ extendedKeyFn = /** @type {any} */ (valueFn)?.key;
99
+ extendedInverseKeyFn = /** @type {any} */ (valueFn)?.inverseKey;
100
+ }
101
+
102
+ const fn = mapFn({
103
+ deep,
104
+ description,
105
+ inverseKey: extendedInverseKeyFn,
106
+ key: extendedKeyFn,
107
+ needsSourceValue,
108
+ value: extendedValueFn,
109
+ });
110
+
111
+ return (treelike) => {
112
+ const mapped = fn(treelike);
113
+ const scoped = Scope.treeWithScope(mapped, scope);
114
+ return scoped;
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Given a string specifying an extension or a mapping of one extension to another,
120
+ * return the source and result extensions.
121
+ *
122
+ * Syntax:
123
+ * foo
124
+ * foo→bar Unicode Rightwards Arrow
125
+ * foo->bar hyphen and greater-than sign
126
+ *
127
+ * @param {string} specifier
128
+ */
129
+ function parseExtensions(specifier) {
130
+ const lowercase = specifier?.toLowerCase() ?? "";
131
+ const extensionRegex =
132
+ /^\.?(?<sourceExtension>\S*)(?:\s*(→|->)\s*)\.?(?<extension>\S+)$/;
133
+ let resultExtension;
134
+ let sourceExtension;
135
+ const match = lowercase.match(extensionRegex);
136
+ if (match?.groups) {
137
+ // foo→bar
138
+ ({ extension: resultExtension, sourceExtension } = match.groups);
139
+ } else {
140
+ // foo
141
+ resultExtension = lowercase;
142
+ sourceExtension = lowercase;
143
+ }
144
+ return { resultExtension, sourceExtension };
145
+ }
@@ -26,6 +26,8 @@ marked.use(
26
26
  );
27
27
 
28
28
  /**
29
+ * Transform markdown to HTML.
30
+ *
29
31
  * @typedef {import("@weborigami/async-tree").StringLike} StringLike
30
32
  * @typedef {import("@weborigami/async-tree").Unpackable<StringLike>} UnpackableStringlike
31
33
  *
@@ -0,0 +1,69 @@
1
+ import { toString } from "../common/utilities.js";
2
+
3
+ /**
4
+ * Returns the tree structure of a markdown document.
5
+ *
6
+ * @typedef {import("@weborigami/async-tree").StringLike} StringLike
7
+ * @typedef {import("@weborigami/async-tree").Unpackable<StringLike>}
8
+ * UnpackableStringlike
9
+ *
10
+ * @this {import("@weborigami/types").AsyncTree|null|void}
11
+ * @param {StringLike|UnpackableStringlike} input
12
+ */
13
+ export default function mdStructure(input) {
14
+ const markdown = toString(input);
15
+ if (markdown === null) {
16
+ throw new Error("No markdown text provided.");
17
+ }
18
+
19
+ // The document acts as an entry for heading level zero. All level one
20
+ // headings will end up as its children.
21
+ const document = {};
22
+ const activeHeadings = [document];
23
+
24
+ // Split the text by lines that contain markdown headings.
25
+ const lines = markdown.split("\n");
26
+ lines.forEach((line) => {
27
+ const match = line.match(/^(?<levelMarkers>#{1,6})\s(?<heading>.*)$/);
28
+ if (!match?.groups) {
29
+ return;
30
+ }
31
+ const { levelMarkers, heading } = match.groups;
32
+ const level = levelMarkers.length;
33
+
34
+ // If we've gone up a level (or more), pop completed entries off the stack.
35
+ while (activeHeadings.length > level) {
36
+ activeHeadings.pop();
37
+ }
38
+
39
+ // If we've skipped a level (or more), add intermediate entries using
40
+ // symbols to avoid name collisions.
41
+ while (activeHeadings.length < level) {
42
+ const entry = {};
43
+ const parentEntry = activeHeadings[activeHeadings.length - 1];
44
+ parentEntry[Symbol()] = entry;
45
+ activeHeadings.push(entry);
46
+ }
47
+
48
+ // Add a new entry to the list of children under construction.
49
+ const entry = {};
50
+ const parentEntry = activeHeadings[activeHeadings.length - 1];
51
+ parentEntry[heading] = entry;
52
+ activeHeadings.push(entry);
53
+ });
54
+
55
+ return pruneEmptyObjects(document) ?? {};
56
+ }
57
+
58
+ // Replace empty objects in the tree with nulls.
59
+ function pruneEmptyObjects(tree) {
60
+ const keys = [...Object.keys(tree), ...Object.getOwnPropertySymbols(tree)];
61
+ if (keys.length === 0) {
62
+ return null;
63
+ }
64
+ const result = {};
65
+ for (const key of keys) {
66
+ result[key] = pruneEmptyObjects(tree[key]);
67
+ }
68
+ return result;
69
+ }
@@ -33,7 +33,7 @@ export default async function treeMerge(...trees) {
33
33
 
34
34
  // If all trees are plain objects, return a plain object.
35
35
  if (unpacked.every((tree) => isPlainObject(tree))) {
36
- return Object.assign({}, ...unpacked);
36
+ return mergeObjects(...unpacked);
37
37
  }
38
38
 
39
39
  // If a tree can take a scope, give it one that includes the other trees and
@@ -55,5 +55,25 @@ export default async function treeMerge(...trees) {
55
55
  return result;
56
56
  }
57
57
 
58
+ /**
59
+ * Merge the indicated plain objects. If a key is present in multiple objects,
60
+ * the value from the first object is used.
61
+ *
62
+ * This is similar to calling Object.assign() with the objects in reverse order,
63
+ * but we want to ensure the keys end up in the same order they're encountered
64
+ * in the objects.
65
+ *
66
+ * @param {...any} objects
67
+ */
68
+ function mergeObjects(...objects) {
69
+ const result = {};
70
+ for (const obj of objects) {
71
+ for (const key of Object.keys(obj)) {
72
+ result[key] ??= obj[key];
73
+ }
74
+ }
75
+ return result;
76
+ }
77
+
58
78
  treeMerge.usage = `@merge <...trees>\tMerge the given trees`;
59
79
  treeMerge.documentation = "https://weborigami.org/cli/builtins.html#@merge";
@@ -0,0 +1 @@
1
+ export { naturalOrder as default } from "@weborigami/async-tree";
@@ -0,0 +1,18 @@
1
+ import getTreeArgument from "../misc/getTreeArgument.js";
2
+ import paginateFn from "./@paginateFn.js";
3
+
4
+ /**
5
+ * Return a new grouping of the treelike's values into chunks of the specified
6
+ * size.
7
+ *
8
+ * @typedef {import("@weborigami/types").AsyncTree} AsyncTree
9
+ * @typedef {import("@weborigami/async-tree").Treelike} Treelike
10
+ *
11
+ * @this {AsyncTree|null}
12
+ * @param {Treelike} [treelike]
13
+ * @param {number} [size=10]
14
+ */
15
+ export default async function paginate(treelike, size = 10) {
16
+ const tree = await getTreeArgument(this, arguments, treelike, "@count");
17
+ return paginateFn.call(this, size)(tree);
18
+ }
@@ -0,0 +1,61 @@
1
+ import { Tree } from "@weborigami/async-tree";
2
+ import { Scope } from "@weborigami/language";
3
+
4
+ /**
5
+ * Return a new grouping of the treelike's values into "pages" of the specified
6
+ * size.
7
+ *
8
+ * @typedef {import("@weborigami/types").AsyncTree} AsyncTree
9
+ * @typedef {import("@weborigami/async-tree").Treelike} Treelike
10
+ *
11
+ * @this {AsyncTree|null}
12
+ * @param {number} [size=10]
13
+ */
14
+ export default function paginateFn(size = 10) {
15
+ const scope = this;
16
+ /**
17
+ * @param {Treelike} [treelike]
18
+ */
19
+ return async function (treelike) {
20
+ const tree = Tree.from(treelike);
21
+ const keys = Array.from(await tree.keys());
22
+ const pageCount = Math.ceil(keys.length / size);
23
+
24
+ const result = {
25
+ async get(pageKey) {
26
+ // Note: page numbers are 1-based.
27
+ const pageNumber = Number(pageKey);
28
+ if (Number.isNaN(pageNumber)) {
29
+ return undefined;
30
+ }
31
+ const nextPage = pageNumber + 1 <= pageCount ? pageNumber + 1 : null;
32
+ const previousPage = pageNumber - 1 >= 1 ? pageNumber - 1 : null;
33
+ const items = {};
34
+ for (
35
+ let index = (pageNumber - 1) * size;
36
+ index < Math.min(keys.length, pageNumber * size);
37
+ index++
38
+ ) {
39
+ const key = keys[index];
40
+ items[key] = await tree.get(keys[index]);
41
+ }
42
+
43
+ return {
44
+ items,
45
+ nextPage,
46
+ pageCount,
47
+ pageNumber,
48
+ previousPage,
49
+ };
50
+ },
51
+
52
+ async keys() {
53
+ // Return an array from 1..totalPages
54
+ return Array.from({ length: pageCount }, (_, index) => index + 1);
55
+ },
56
+ };
57
+
58
+ const scoped = Scope.treeWithScope(result, scope);
59
+ return scoped;
60
+ };
61
+ }