@weborigami/origami 0.2.10 → 0.2.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weborigami/origami",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "description": "Web Origami language, CLI, framework, and server",
5
5
  "type": "module",
6
6
  "repository": {
@@ -17,9 +17,10 @@
17
17
  "typescript": "5.8.2"
18
18
  },
19
19
  "dependencies": {
20
- "@weborigami/async-tree": "0.2.10",
21
- "@weborigami/language": "0.2.10",
22
- "@weborigami/types": "0.2.10",
20
+ "@weborigami/async-tree": "0.2.12",
21
+ "@weborigami/language": "0.2.12",
22
+ "@weborigami/json-feed-to-rss": "1.0.0",
23
+ "@weborigami/types": "0.2.12",
23
24
  "exif-parser": "0.1.12",
24
25
  "graphviz-wasm": "3.0.2",
25
26
  "highlight.js": "11.11.1",
@@ -0,0 +1,126 @@
1
+ import { symbols, toString } from "@weborigami/async-tree";
2
+
3
+ export default {
4
+ mediaType: "text/csv",
5
+
6
+ unpack(packed, options = {}) {
7
+ const parent = options.parent ?? null;
8
+ const text = toString(packed);
9
+ const data = csvParse(text);
10
+ // Define `parent` as non-enumerable property
11
+ Object.defineProperty(data, symbols.parent, {
12
+ configurable: true,
13
+ enumerable: false,
14
+ value: parent,
15
+ writable: true,
16
+ });
17
+ return data;
18
+ },
19
+ };
20
+
21
+ /**
22
+ * Parse text as CSV following RFC 4180
23
+ *
24
+ * This assumes the presence of a header row, and accepts both CRLF and LF line
25
+ * endings.
26
+ *
27
+ * @param {string} text
28
+ * @returns {any[]}
29
+ */
30
+ function csvParse(text) {
31
+ const rows = [];
32
+ let currentRow = [];
33
+ let currentField = "";
34
+
35
+ const pushField = () => {
36
+ // Push the completed field and reset for the next field.
37
+ currentRow.push(currentField);
38
+ currentField = "";
39
+ };
40
+
41
+ const pushRow = () => {
42
+ // Push the row if there is at least one field (accounts for potential trailing newline)
43
+ rows.push(currentRow);
44
+ currentRow = [];
45
+ };
46
+
47
+ // Main state machine
48
+ let i = 0;
49
+ let inQuotes = false;
50
+ while (i < text.length) {
51
+ const char = text[i];
52
+
53
+ if (inQuotes) {
54
+ // In a quoted field
55
+ if (char === '"') {
56
+ // Check if next character is also a quote
57
+ if (i + 1 < text.length && text[i + 1] === '"') {
58
+ // Append a literal double quote and skip the next character
59
+ currentField += '"';
60
+ i += 2;
61
+ continue;
62
+ } else {
63
+ // End of the quoted field
64
+ inQuotes = false;
65
+ i++;
66
+ continue;
67
+ }
68
+ } else {
69
+ // All other characters within quotes are taken literally.
70
+ currentField += char;
71
+ i++;
72
+ continue;
73
+ }
74
+ } else if (char === '"') {
75
+ // Start of a quoted field
76
+ inQuotes = true;
77
+ i++;
78
+ continue;
79
+ } else if (char === ",") {
80
+ // End of field
81
+ pushField();
82
+ i++;
83
+ continue;
84
+ } else if (char === "\n" || (char === "\r" && text[i + 1] === "\n")) {
85
+ // End of row: push the last field, then row.
86
+ pushField();
87
+ pushRow();
88
+ if (char === "\r" && text[i + 1] === "\n") {
89
+ i++; // Handle CRLF line endings
90
+ }
91
+ i++;
92
+ continue;
93
+ } else {
94
+ // Regular character
95
+ currentField += char;
96
+ i++;
97
+ continue;
98
+ }
99
+ }
100
+
101
+ // Handle any remaining data after the loop.
102
+ // This will capture the last field/row if the text did not end with a newline.
103
+ if (inQuotes) {
104
+ // Mismatched quotes: you might choose to throw an error or handle it gracefully.
105
+ throw new Error("CSV parsing error: unmatched quote in the input.");
106
+ }
107
+ if (currentField !== "" || text.at(-1) === ",") {
108
+ pushField();
109
+ }
110
+ if (currentRow.length > 0) {
111
+ pushRow();
112
+ }
113
+
114
+ // The first row is assumed to be the header.
115
+ if (rows.length === 0) {
116
+ return [];
117
+ }
118
+
119
+ const header = rows.shift();
120
+
121
+ const data = rows.map((row) =>
122
+ Object.fromEntries(row.map((value, index) => [header[index], value]))
123
+ );
124
+
125
+ return data;
126
+ }
@@ -6,6 +6,7 @@ import {
6
6
  yamlHandler,
7
7
  } from "../internal.js";
8
8
  import cssHandler from "./css.handler.js";
9
+ import csvHandler from "./csv.handler.js";
9
10
  import htmHandler from "./htm.handler.js";
10
11
  import htmlHandler from "./html.handler.js";
11
12
  import jpegHandler from "./jpeg.handler.js";
@@ -19,6 +20,7 @@ import ymlHandler from "./yml.handler.js";
19
20
 
20
21
  export default {
21
22
  "css.handler": cssHandler,
23
+ "csv.handler": csvHandler,
22
24
  "htm.handler": htmHandler,
23
25
  "html.handler": htmlHandler,
24
26
  "jpeg.handler": jpegHandler,
@@ -1,15 +1,8 @@
1
- import {
2
- extension,
3
- ObjectTree,
4
- symbols,
5
- trailingSlash,
6
- } from "@weborigami/async-tree";
1
+ import { extension, trailingSlash } from "@weborigami/async-tree";
7
2
  import { compile } from "@weborigami/language";
8
- import { parseYaml } from "../common/serialize.js";
9
3
  import { toString } from "../common/utilities.js";
10
4
  import { processUnpackedContent } from "../internal.js";
11
5
  import getParent from "./getParent.js";
12
- import parseFrontMatter from "./parseFrontMatter.js";
13
6
 
14
7
  /**
15
8
  * An Origami template document: a plain text file that contains Origami
@@ -23,7 +16,7 @@ export default {
23
16
  const parent = getParent(packed, options);
24
17
 
25
18
  // Unpack as a text document
26
- const unpacked = toString(packed);
19
+ const text = toString(packed);
27
20
 
28
21
  // See if we can construct a URL to use in error messages
29
22
  const key = options.key;
@@ -36,79 +29,16 @@ export default {
36
29
  url = new URL(key, parentHref);
37
30
  }
38
31
 
39
- // Determine the data (if present) and text content
40
- let text;
41
- let frontData = null;
42
- let frontSource = null;
43
- let offset = 0;
44
- let extendedParent = parent;
45
- const parsed = parseFrontMatter(unpacked);
46
- if (!parsed) {
47
- text = unpacked;
48
- } else {
49
- const { body, frontText, isOrigami } = parsed;
50
- if (isOrigami) {
51
- // Origami front matter
52
- frontSource = { name: key, text: frontText, url };
53
- } else {
54
- // YAML front matter
55
- frontData = parseYaml(frontText);
56
- if (typeof frontData !== "object") {
57
- throw new TypeError(`YAML or JSON front matter must be an object`);
58
- }
59
- extendedParent = new ObjectTree(frontData);
60
- extendedParent.parent = parent;
61
- }
62
-
63
- // Determine how many lines the source code is offset by (if any) to
64
- // account for front matter, plus 2 lines for `---` separators
65
- offset = (frontText.match(/\r?\n/g) ?? []).length + 2;
66
-
67
- text = body;
68
- }
69
-
70
- // Construct an object to represent the source code
71
- const bodySource = {
32
+ // Compile the text as an Origami template document
33
+ const source = {
72
34
  name: key,
73
- offset,
74
35
  text,
75
36
  url,
76
37
  };
38
+ const defineFn = compile.templateDocument(source);
77
39
 
78
- // Compile the source as an Origami template document
79
- const scopeCaching = frontSource ? false : true;
80
- const defineTemplateFn = compile.templateDocument(bodySource, {
81
- scopeCaching,
82
- });
83
-
84
- // Determine the result of the template
85
- let result;
86
- if (frontSource) {
87
- // Result is the evaluated front source
88
- const frontFn = compile.expression(frontSource, {
89
- macros: {
90
- "@template": defineTemplateFn.code,
91
- },
92
- });
93
- result = await frontFn.call(parent);
94
- } else {
95
- const templateFn = await defineTemplateFn.call(extendedParent);
96
- if (frontData) {
97
- // Result is a function that adds the front data to the template result
98
- result = async (input) => {
99
- const text = await templateFn.call(extendedParent, input);
100
- const object = {
101
- ...frontData,
102
- "@text": text,
103
- };
104
- object[symbols.parent] = extendedParent;
105
- return object;
106
- };
107
- } else {
108
- // Result is a function that calls the body template
109
- result = templateFn;
110
- }
111
- }
40
+ // Invoke the definition to get back the template function
41
+ const result = await defineFn.call(parent);
112
42
 
113
43
  const resultExtension = key ? extension.extname(key) : null;
114
44
  if (resultExtension && Object.isExtensible(result)) {
@@ -1,3 +1,5 @@
1
+ import { isOrigamiFrontMatter } from "@weborigami/language";
2
+
1
3
  export default function parseFrontMatter(text) {
2
4
  const regex =
3
5
  /^(---\r?\n(?<frontText>[\s\S]*?\r?\n?)---\r?\n)(?<body>[\s\S]*$)/;
@@ -5,17 +7,10 @@ export default function parseFrontMatter(text) {
5
7
  if (!match?.groups) {
6
8
  return null;
7
9
  }
8
- const isOrigami = detectOrigami(match.groups.frontText);
10
+ const isOrigami = isOrigamiFrontMatter(match.groups.frontText);
9
11
  return {
10
12
  body: match.groups.body,
11
13
  frontText: match.groups.frontText,
12
14
  isOrigami,
13
15
  };
14
16
  }
15
-
16
- function detectOrigami(text) {
17
- // Find first character that's not whitespace, alphanumeric, or underscore
18
- const first = text.match(/[^A-Za-z0-9_ \t\n\r]/)?.[0];
19
- const origamiMarkers = ["(", ".", "/", "{"];
20
- return origamiMarkers.includes(first);
21
- }
@@ -0,0 +1,58 @@
1
+ import { isUnpackable, toPlainValue } from "@weborigami/async-tree";
2
+ import assertTreeIsDefined from "../common/assertTreeIsDefined.js";
3
+
4
+ /**
5
+ * Render the object as text in CSV format.
6
+ *
7
+ * The object should a treelike object such as an array. The output will include
8
+ * a header row with field names taken from the first item in the tree/array.
9
+ *
10
+ * @this {import("@weborigami/types").AsyncTree|null}
11
+ * @param {any} [object]
12
+ */
13
+ export default async function csv(object) {
14
+ assertTreeIsDefined(this, "origami:csv");
15
+ if (isUnpackable(object)) {
16
+ object = await object.unpack();
17
+ }
18
+ const value = await toPlainValue(object);
19
+ const text = formatCsv(value);
20
+ return text;
21
+ }
22
+
23
+ function formatCsv(array) {
24
+ if (!array || array.length === 0) {
25
+ return "";
26
+ }
27
+
28
+ // Helper to quote field if necessary
29
+ const formatField = (value) => {
30
+ // Convert value to string.
31
+ let field = String(value);
32
+
33
+ // RFC 4180: Quote field if it contains a comma, a CR/LF, or a double quote
34
+ if (field.search(/("|,|\n|\r)/) !== -1) {
35
+ // Escape existing double quotes by replacing " with ""
36
+ field = field.replace(/"/g, '""');
37
+ // Surround the field with quotes
38
+ field = `"${field}"`;
39
+ }
40
+ return field;
41
+ };
42
+
43
+ // Extract header fields from the first object.
44
+ const headerFields = Object.keys(array[0]);
45
+
46
+ // Generate the header row by formatting each header field.
47
+ const headerRow = headerFields.map(formatField).join(",");
48
+
49
+ // Map through each object and generate a CSV row.
50
+ const dataRows = array.map((row) => {
51
+ return headerFields
52
+ .map((field) => formatField(row[field] !== undefined ? row[field] : ""))
53
+ .join(",");
54
+ });
55
+
56
+ // Concatenate header and data rows, joining and ending with CRLF.
57
+ return [headerRow, ...dataRows].join("\r\n") + "\r\n";
58
+ }
@@ -9,6 +9,7 @@ export { toFunction } from "../common/utilities.js";
9
9
  export { default as help } from "../help/help.js"; // Alias
10
10
  export { default as basename } from "./basename.js";
11
11
  export { default as config } from "./config.js";
12
+ export { default as csv } from "./csv.js";
12
13
  export { default as json } from "./json.js";
13
14
  export { default as jsonParse } from "./jsonParse.js";
14
15
  export { default as naturalOrder } from "./naturalOrder.js";
package/src/site/rss.js CHANGED
@@ -1,22 +1,7 @@
1
1
  import { Tree } from "@weborigami/async-tree";
2
+ import jsonFeedToRss from "@weborigami/json-feed-to-rss";
2
3
  import assertTreeIsDefined from "../common/assertTreeIsDefined.js";
3
4
 
4
- const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
5
- const months = [
6
- "Jan",
7
- "Feb",
8
- "Mar",
9
- "Apr",
10
- "May",
11
- "Jun",
12
- "Jul",
13
- "Aug",
14
- "Sep",
15
- "Oct",
16
- "Nov",
17
- "Dec",
18
- ];
19
-
20
5
  /**
21
6
  * @typedef {import("@weborigami/types").AsyncTree} AsyncTree
22
7
  * @typedef {import("@weborigami/async-tree").Treelike} Treelike
@@ -27,89 +12,5 @@ const months = [
27
12
  export default async function rss(jsonFeedTree, options = {}) {
28
13
  assertTreeIsDefined(this, "site:rss");
29
14
  const jsonFeed = await Tree.plain(jsonFeedTree);
30
- const { description, home_page_url, items, title } = jsonFeed;
31
-
32
- let { feed_url, language } = options;
33
- if (!feed_url && jsonFeed.feed_url) {
34
- // Presume that the RSS feed lives in same location as feed_url
35
- // but with a .xml extension.
36
- feed_url = jsonFeed.feed_url;
37
- if (feed_url.endsWith(".json")) {
38
- feed_url = feed_url.replace(".json", ".xml");
39
- }
40
- }
41
-
42
- const itemsRss = items?.map((story) => itemRss(story)).join("") ?? [];
43
-
44
- const titleElement = title ? ` <title>${escapeXml(title)}</title>\n` : "";
45
- const descriptionElement = description
46
- ? ` <description>${escapeXml(description)}</description>\n`
47
- : "";
48
- const linkElement = home_page_url
49
- ? ` <link>${home_page_url}</link>\n`
50
- : "";
51
- const languageElement = language
52
- ? ` <language>${language}</language>\n`
53
- : "";
54
- const feedLinkElement = ` <atom:link href="${feed_url}" rel="self" type="application/rss+xml"/>\n`;
55
-
56
- return `<?xml version="1.0" ?>
57
- <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
58
- <channel>
59
- ${titleElement}${descriptionElement}${linkElement}${languageElement}${feedLinkElement}${itemsRss} </channel>
60
- </rss>`;
61
- }
62
-
63
- function itemRss(jsonFeedItem) {
64
- const { content_html, id, summary, title, url } = jsonFeedItem;
65
- let { date_published } = jsonFeedItem;
66
- if (typeof date_published === "string") {
67
- // Parse as ISO 8601 date.
68
- date_published = new Date(date_published);
69
- }
70
- const date =
71
- date_published instanceof Date
72
- ? toRFC822Date(date_published)
73
- : date_published;
74
-
75
- const dateElement = date ? ` <pubDate>${date}</pubDate>\n` : "";
76
- const isPermaLink =
77
- id !== undefined && !URL.canParse(id) ? ` isPermaLink="false"` : "";
78
- const guidElement = id ? ` <guid${isPermaLink}>${id}</guid>\n` : "";
79
- const descriptionElement = summary
80
- ? ` <description>${escapeXml(summary)}</description>\n`
81
- : "";
82
- const contentElement = content_html
83
- ? ` <content:encoded><![CDATA[${content_html}]]></content:encoded>\n`
84
- : "";
85
- const titleElement = title
86
- ? ` <title>${escapeXml(title)}</title>\n`
87
- : "";
88
- const linkElement = url ? ` <link>${url}</link>\n` : "";
89
-
90
- return ` <item>
91
- ${dateElement}${titleElement}${linkElement}${guidElement}${descriptionElement}${contentElement} </item>
92
- `;
93
- }
94
-
95
- // Escape XML entities for in the text.
96
- function escapeXml(text) {
97
- return text
98
- .replace(/&/g, "&amp;")
99
- .replace(/</g, "&lt;")
100
- .replace(/>/g, "&gt;")
101
- .replace(/"/g, "&quot;")
102
- .replace(/'/g, "&apos;");
103
- }
104
-
105
- // RSS wants dates in RFC-822.
106
- function toRFC822Date(date) {
107
- const day = days[date.getUTCDay()];
108
- const dayOfMonth = date.getUTCDate().toString().padStart(2, "0");
109
- const month = months[date.getUTCMonth()];
110
- const year = date.getUTCFullYear();
111
- const hours = date.getUTCHours().toString().padStart(2, "0");
112
- const minutes = date.getUTCMinutes().toString().padStart(2, "0");
113
- const seconds = date.getUTCSeconds().toString().padStart(2, "0");
114
- return `${day}, ${dayOfMonth} ${month} ${year} ${hours}:${minutes}:${seconds} GMT`;
15
+ return jsonFeedToRss(jsonFeed, options);
115
16
  }
@@ -43,14 +43,18 @@ export default async function inline(input) {
43
43
  }
44
44
 
45
45
  // @ts-ignore
46
- const templateFn = await oridocumentHandler.unpack(input, {
46
+ let result = await oridocumentHandler.unpack(input, {
47
47
  parent: extendedParent,
48
48
  });
49
49
 
50
- const inputData = inputIsDocument ? input : null;
51
- const templateResult = await templateFn(inputData);
50
+ if (result instanceof Function) {
51
+ const text = await result();
52
+ if (inputIsDocument) {
53
+ return documentObject(text, input);
54
+ } else {
55
+ return text;
56
+ }
57
+ }
52
58
 
53
- return inputIsDocument
54
- ? documentObject(templateResult, inputData)
55
- : templateResult;
59
+ return result;
56
60
  }
@@ -1,4 +1,4 @@
1
- import { Tree } from "@weborigami/async-tree";
1
+ import { addNextPrevious, symbols } from "@weborigami/async-tree";
2
2
  import getTreeArgument from "../common/getTreeArgument.js";
3
3
 
4
4
  /**
@@ -9,55 +9,14 @@ import getTreeArgument from "../common/getTreeArgument.js";
9
9
  * @this {AsyncTree|null}
10
10
  * @param {import("@weborigami/async-tree").Treelike} treelike
11
11
  */
12
- export default async function addNextPrevious(treelike) {
12
+ export default async function addNextPreviousBuiltin(treelike) {
13
13
  const tree = await getTreeArgument(
14
14
  this,
15
15
  arguments,
16
16
  treelike,
17
17
  "tree:addNextPrevious"
18
18
  );
19
- let keys;
20
- return Object.create(tree, {
21
- get: {
22
- value: async (key) => {
23
- let value = await tree.get(key);
24
-
25
- if (value === undefined) {
26
- return undefined;
27
- } else if (Tree.isTreelike(value)) {
28
- value = await Tree.plain(value);
29
- } else if (typeof value === "object") {
30
- // Clone value to avoid modifying the original object.
31
- value = { ...value };
32
- } else if (typeof value === "string") {
33
- // Upgrade text nodes to objects.
34
- value = { "@text": value };
35
- } else {
36
- // Upgrade other scalar types to objects.
37
- value = { "@data": value };
38
- }
39
-
40
- if (keys === undefined) {
41
- keys = Array.from(await tree.keys());
42
- }
43
- const index = keys.indexOf(key);
44
- if (index === -1) {
45
- // Key is supported but not published in `keys`
46
- return value;
47
- }
48
-
49
- // Extend value with nextKey/previousKey properties.
50
- const nextKey = keys[index + 1];
51
- if (nextKey) {
52
- value.nextKey = nextKey;
53
- }
54
- const previousKey = keys[index - 1];
55
- if (previousKey) {
56
- value.previousKey = previousKey;
57
- }
58
- return value;
59
- },
60
- writable: true,
61
- },
62
- });
19
+ const result = await addNextPrevious(tree);
20
+ result[symbols.parent] = this;
21
+ return result;
63
22
  }
package/src/tree/map.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  cachedKeyFunctions,
3
+ extensionKeyFunctions,
3
4
  isPlainObject,
4
5
  isUnpackable,
5
- keyFunctionsForExtensions,
6
6
  map as mapTransform,
7
7
  } from "@weborigami/async-tree";
8
8
  import getTreeArgument from "../common/getTreeArgument.js";
@@ -102,10 +102,7 @@ function extendedOptions(context, operation) {
102
102
  if (extension) {
103
103
  // Generate key/inverseKey functions from the extension
104
104
  let { resultExtension, sourceExtension } = parseExtensions(extension);
105
- const keyFns = keyFunctionsForExtensions({
106
- resultExtension,
107
- sourceExtension,
108
- });
105
+ const keyFns = extensionKeyFunctions(sourceExtension, resultExtension);
109
106
  keyFn = keyFns.key;
110
107
  inverseKeyFn = keyFns.inverseKey;
111
108
  } else if (keyFn) {
@@ -1,3 +1,4 @@
1
+ import { paginate } from "@weborigami/async-tree";
1
2
  import getTreeArgument from "../common/getTreeArgument.js";
2
3
 
3
4
  /**
@@ -11,51 +12,14 @@ import getTreeArgument from "../common/getTreeArgument.js";
11
12
  * @param {Treelike} [treelike]
12
13
  * @param {number} [size=10]
13
14
  */
14
- export default async function paginate(treelike, size = 10) {
15
+ export default async function paginateBuiltin(treelike, size = 10) {
15
16
  const tree = await getTreeArgument(
16
17
  this,
17
18
  arguments,
18
19
  treelike,
19
20
  "tree:paginate"
20
21
  );
21
-
22
- const keys = Array.from(await tree.keys());
23
- const pageCount = Math.ceil(keys.length / size);
24
-
25
- const paginated = {
26
- async get(pageKey) {
27
- // Note: page numbers are 1-based.
28
- const pageNumber = Number(pageKey);
29
- if (Number.isNaN(pageNumber)) {
30
- return undefined;
31
- }
32
- const nextPage = pageNumber + 1 <= pageCount ? pageNumber + 1 : null;
33
- const previousPage = pageNumber - 1 >= 1 ? pageNumber - 1 : null;
34
- const items = {};
35
- for (
36
- let index = (pageNumber - 1) * size;
37
- index < Math.min(keys.length, pageNumber * size);
38
- index++
39
- ) {
40
- const key = keys[index];
41
- items[key] = await tree.get(keys[index]);
42
- }
43
-
44
- return {
45
- items,
46
- nextPage,
47
- pageCount,
48
- pageNumber,
49
- previousPage,
50
- };
51
- },
52
-
53
- async keys() {
54
- // Return an array from 1..totalPages
55
- return Array.from({ length: pageCount }, (_, index) => index + 1);
56
- },
57
- };
58
-
22
+ const paginated = await paginate(tree, size);
59
23
  paginated.parent = this;
60
24
  return paginated;
61
25
  }