@weborigami/origami 0.0.38 → 0.0.40

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.
@@ -3,6 +3,13 @@ import { Tree, isPlainObject, isStringLike } from "@weborigami/async-tree";
3
3
  const textDecoder = new TextDecoder();
4
4
  const TypedArray = Object.getPrototypeOf(Uint8Array);
5
5
 
6
+ // Return true if the text appears to contain non-printable binary characters;
7
+ // used to infer whether a file is binary or text.
8
+ export function hasNonPrintableCharacters(text) {
9
+ // https://stackoverflow.com/a/1677660/76472
10
+ return /[\x00-\x08\x0E-\x1F]/.test(text);
11
+ }
12
+
6
13
  export function isTransformApplied(Transform, obj) {
7
14
  let transformName = Transform.name;
8
15
  if (!transformName) {
@@ -25,6 +32,22 @@ export const keySymbol = Symbol("key");
25
32
 
26
33
  export const parentSymbol = Symbol("parent");
27
34
 
35
+ /**
36
+ * If the given key ends in the source extension (which will generally include a
37
+ * period), replace that extension with the result extension (which again should
38
+ * generally include a period). Otherwise, return the key as is.
39
+ *
40
+ * @param {string} key
41
+ * @param {string} sourceExtension
42
+ * @param {string} resultExtension
43
+ */
44
+ export function replaceExtension(key, sourceExtension, resultExtension) {
45
+ if (!key.endsWith(sourceExtension)) {
46
+ return key;
47
+ }
48
+ return key.slice(0, -sourceExtension.length) + resultExtension;
49
+ }
50
+
28
51
  /**
29
52
  * Convert the given object to a function.
30
53
  *
@@ -80,7 +103,9 @@ export function toString(object) {
80
103
  return object["@text"];
81
104
  } else if (object instanceof ArrayBuffer || object instanceof TypedArray) {
82
105
  // Serialize data as UTF-8.
83
- return textDecoder.decode(object);
106
+ const decoded = textDecoder.decode(object);
107
+ // If the result has non-printable characters, it's probably not a string.
108
+ return hasNonPrintableCharacters(decoded) ? null : decoded;
84
109
  } else if (isStringLike(object)) {
85
110
  return String(object);
86
111
  } else {
@@ -33,7 +33,7 @@ export default function OriCommandTransform(Base) {
33
33
  ambientsTree[keySymbol] = "ori command";
34
34
  const extendedScope = new Scope(ambientsTree, Scope.getScope(this));
35
35
  const source = key.slice(1).trim();
36
- value = await ori.call(extendedScope, source);
36
+ value = await ori.call(extendedScope, source, { formatResult: false });
37
37
 
38
38
  // Ensure this transform is applied to any subtree.
39
39
  if (Tree.isAsyncTree(value)) {
@@ -0,0 +1,88 @@
1
+ * {
2
+ box-sizing: border-box;
3
+ }
4
+
5
+ html {
6
+ height: 100%;
7
+ }
8
+
9
+ body {
10
+ background: #333;
11
+ color: #eee;
12
+ display: grid;
13
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
14
+ font-size: 13px;
15
+ grid-template-columns: 200px 1fr;
16
+ grid-template-rows: minMax(0, 1fr);
17
+ height: 100%;
18
+ margin: 0;
19
+ overflow: hidden;
20
+ }
21
+
22
+ nav {
23
+ display: grid;
24
+ gap: 1em;
25
+ grid-auto-rows: min-content;
26
+ grid-template-columns: minmax(0, 1fr);
27
+ overflow: auto;
28
+ padding: 1em 0.5em;
29
+ }
30
+
31
+ #label {
32
+ font-weight: bold;
33
+ }
34
+
35
+ #scopeToolbar {
36
+ display: grid;
37
+ grid-template-columns: repeat(4, auto);
38
+ }
39
+
40
+ button {
41
+ background: transparent;
42
+ border: solid 1px #555;
43
+ color: inherit;
44
+ font-size: smaller;
45
+ font-family: inherit;
46
+ font-weight: inherit;
47
+ padding: 0.25em;
48
+ }
49
+ button:hover {
50
+ border-color: #999;
51
+ }
52
+ button:active {
53
+ border-color: #eee;
54
+ }
55
+ button[aria-pressed="true"] {
56
+ background: #555;
57
+ }
58
+
59
+ ul {
60
+ list-style: none;
61
+ margin: 0;
62
+ padding: 0;
63
+ }
64
+
65
+ h2 {
66
+ color: #999;
67
+ font-size: inherit;
68
+ margin: 0.25em 0;
69
+ padding-left: 0.25em;
70
+ }
71
+
72
+ li {
73
+ padding: 0.25em;
74
+ padding-left: 1em;
75
+ text-indent: -0.75em;
76
+ }
77
+
78
+ a {
79
+ color: inherit;
80
+ text-decoration: none;
81
+ }
82
+
83
+ iframe {
84
+ background: white;
85
+ border: none;
86
+ height: 100%;
87
+ width: 100%;
88
+ }
@@ -0,0 +1,119 @@
1
+ let defaultPath;
2
+ let frame;
3
+
4
+ const modes = {
5
+ Content: "",
6
+ Index: "!@index",
7
+ YAML: "!@yaml",
8
+ SVG: "!@svg",
9
+ };
10
+
11
+ // Extract the path from the URL hash.
12
+ function getPathFromHash() {
13
+ return window.location.hash.slice(1); // Remove `#`
14
+ }
15
+
16
+ function getModeFromLocation() {
17
+ const href = document.location.href;
18
+ const match = /[\/](?<command>\!(?:@index|@yaml|@svg))$/.exec(href);
19
+ const command = match?.groups?.command ?? "";
20
+ const mode =
21
+ Object.keys(modes).find((key) => modes[key] === command) ?? "Content";
22
+ return mode;
23
+ }
24
+
25
+ function removeDocumentPath(path) {
26
+ const documentPath = document.location.pathname;
27
+ if (path.startsWith(documentPath)) {
28
+ // Remove the document path prefix.
29
+ path = path.slice(documentPath.length);
30
+ }
31
+ if (path.startsWith("/")) {
32
+ // Remove the leading slash.
33
+ path = path.slice(1);
34
+ }
35
+ return path;
36
+ }
37
+
38
+ function selectMode(newMode) {
39
+ const currentMode = getModeFromLocation();
40
+ if (newMode !== currentMode) {
41
+ let newPath = removeDocumentPath(frame.contentDocument.location.pathname);
42
+ const currentExtension = modes[currentMode];
43
+ if (currentExtension && newPath.endsWith(currentExtension)) {
44
+ // Remove the current extension.
45
+ newPath = newPath.slice(0, -currentExtension.length);
46
+ }
47
+ const newExtension = modes[newMode];
48
+ const separator = newPath.endsWith("/") ? "" : "/";
49
+ const newFullPath = `${newPath}${separator}${newExtension}`;
50
+ setPath(newFullPath);
51
+ }
52
+ }
53
+
54
+ function setPath(path) {
55
+ // Show the indicated page in the frame.
56
+ const abbreviatedPath = `/${path}`;
57
+ const fullPath = `${document.location.pathname}/${path}`;
58
+ const framePathname = frame.contentDocument.location.pathname;
59
+ if (framePathname !== abbreviatedPath && framePathname !== fullPath) {
60
+ // Use `replace` to avoid affecting browser history.
61
+ frame.contentWindow.location.replace(fullPath);
62
+ }
63
+
64
+ // If the path ends with a file name corresponding to a mode, select
65
+ // the corresponding mode button.
66
+ const mode = getModeFromLocation();
67
+ const selectedButtonId = `button${mode}`;
68
+ scopeToolbar.querySelectorAll("button").forEach((button) => {
69
+ const pressed = button.id === selectedButtonId ? "true" : "false";
70
+ button.setAttribute("aria-pressed", pressed);
71
+ });
72
+ }
73
+
74
+ // When hash changes, load the indicated page.
75
+ window.addEventListener("hashchange", () => {
76
+ const hashPath = getPathFromHash();
77
+ const newPath = hashPath !== undefined ? hashPath : defaultPath;
78
+ if (newPath) {
79
+ setPath(newPath);
80
+ }
81
+ });
82
+
83
+ // Initialize
84
+ window.addEventListener("load", () => {
85
+ // Refresh title on page load.
86
+ frame = document.getElementById("frame");
87
+ frame.addEventListener("load", () => {
88
+ if (frame.contentDocument.location.href !== "about:blank") {
89
+ document.title = frame.contentDocument.title;
90
+ const newPath = removeDocumentPath(
91
+ frame.contentDocument.location.pathname
92
+ );
93
+ const hash = `#${newPath}`;
94
+ if (window.location.hash !== hash) {
95
+ // Use `replace` to avoid affecting browser history.
96
+ window.location.replace(hash);
97
+ }
98
+ }
99
+ });
100
+
101
+ buttonContent.addEventListener("click", () => {
102
+ selectMode("Content");
103
+ });
104
+ buttonIndex.addEventListener("click", () => {
105
+ selectMode("Index");
106
+ });
107
+ buttonYAML.addEventListener("click", () => {
108
+ selectMode("YAML");
109
+ });
110
+ buttonSVG.addEventListener("click", () => {
111
+ selectMode("SVG");
112
+ });
113
+
114
+ // Navigate to any path already in the hash.
115
+ defaultPath = getPathFromHash();
116
+ if (defaultPath) {
117
+ setPath(defaultPath);
118
+ }
119
+ });
@@ -0,0 +1,33 @@
1
+ =`<!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
6
+ <title>Web Origami Explorer</title>
7
+ <style>{{ explore.css }}</style>
8
+ <script>{{ explore.js.inline }}</script>
9
+ </head>
10
+ <body>
11
+ <nav>
12
+ <div id="label">Web Origami Explorer</div>
13
+ <div id="scopeToolbar">
14
+ <button id="buttonContent">Content</button>
15
+ <button id="buttonIndex">Index</button>
16
+ <button id="buttonSVG">SVG</button>
17
+ <button id="buttonYAML">YAML</button>
18
+ </div>
19
+ {{ @map(=`
20
+ <ul>
21
+ <h2>{{ _/name }}</h2>
22
+ {{ @map(=`
23
+ <li>
24
+ <a href="./!@explore/{{ _ }}" target="frame">{{ _ }}</a>
25
+ </li>
26
+ `)(_/keys) }}
27
+ </ul>
28
+ `)(_) }}
29
+ </nav>
30
+ <iframe id="frame" name="frame"></iframe>
31
+ </body>
32
+ </html>
33
+ `
@@ -7,6 +7,7 @@ import {
7
7
  } from "@weborigami/async-tree";
8
8
  import { Scope, extname } from "@weborigami/language";
9
9
  import * as serialize from "../common/serialize.js";
10
+ import { toString } from "../common/utilities.js";
10
11
  import { mediaTypeForExtension, mediaTypeIsText } from "./mediaTypes.js";
11
12
 
12
13
  const TypedArray = Object.getPrototypeOf(Uint8Array);
@@ -52,18 +53,7 @@ export function treeRouter(tree) {
52
53
  export async function handleRequest(request, response, tree) {
53
54
  // For parsing purposes, we assume HTTPS -- it doesn't affect parsing.
54
55
  const url = new URL(request.url, `https://${request.headers.host}`);
55
-
56
- // We allow the use of %2F in paths as a way to insert a slash into a key, so
57
- // we parse the path into keys first, then decode them.
58
- const keys = keysFromPath(url.pathname).map((key) =>
59
- typeof key === "string" ? decodeURIComponent(key) : key
60
- );
61
-
62
- // If the path ends with a trailing slash, the final key will be an empty
63
- // string. Change that to "index.html".
64
- if (keys[keys.length - 1] === "") {
65
- keys[keys.length - 1] = "index.html";
66
- }
56
+ const keys = keysFromUrl(url);
67
57
 
68
58
  const extendedTree =
69
59
  url.searchParams && "parent" in tree
@@ -131,7 +121,7 @@ export async function handleRequest(request, response, tree) {
131
121
 
132
122
  let data;
133
123
  if (mediaType) {
134
- data = mediaTypeIsText[mediaType] ? String(resource) : resource;
124
+ data = mediaTypeIsText[mediaType] ? toString(resource) : resource;
135
125
  } else {
136
126
  data = textOrObject(resource);
137
127
  }
@@ -152,7 +142,7 @@ export async function handleRequest(request, response, tree) {
152
142
  const validResponse = typeof data === "string" || data instanceof TypedArray;
153
143
 
154
144
  if (!validResponse) {
155
- const typeName = data.constructor?.name ?? typeof data;
145
+ const typeName = data?.constructor?.name ?? typeof data;
156
146
  console.error(
157
147
  `A served tree must return a string or a TypedArray (such as a Buffer) but returned an instance of ${typeName}.`
158
148
  );
@@ -172,6 +162,28 @@ export async function handleRequest(request, response, tree) {
172
162
  return true;
173
163
  }
174
164
 
165
+ function keysFromUrl(url) {
166
+ // Split on occurrences of `/!`, which represent Origami debug commands.
167
+ // Command arguments can contain slashes; don't treat those as path keys.
168
+ const parts = url.pathname.split(/\/!/);
169
+
170
+ // Split everything before the first command by slashes and decode those.
171
+ const path = parts.shift();
172
+ const pathKeys = keysFromPath(path).map((key) => decodeURIComponent(key));
173
+
174
+ // If there are no commands, and the path ends with a trailing slash, the
175
+ // final key will be an empty string. Change that to "index.html".
176
+ if (parts.length === 0 && pathKeys[pathKeys.length - 1] === "") {
177
+ pathKeys[pathKeys.length - 1] = "index.html";
178
+ }
179
+
180
+ // Add back the `!` to commands.
181
+ const commandKeys = parts.map((command) => `!${command}`);
182
+
183
+ const keys = [...pathKeys, ...commandKeys];
184
+ return keys;
185
+ }
186
+
175
187
  /**
176
188
  * A request listener for use with the node http.createServer and
177
189
  * https.createServer calls, letting you serve an async tree as a set of pages.
@@ -233,26 +245,12 @@ ${message}
233
245
  * Convert to a string if we can, but leave objects that convert to something
234
246
  * like "[object Object]" alone.
235
247
  *
236
- * @param {any} obj
248
+ * @param {any} object
237
249
  */
238
- function textOrObject(obj) {
239
- if (typeof obj === "string") {
240
- // Return string as is.
241
- return obj;
242
- }
243
-
244
- // See if we can convert the object to a string.
245
- const text = String(obj);
246
-
247
- // See if we ended up with a default string.
248
- const constructor = obj.constructor;
249
- const name = constructor.name || "Object";
250
- if (text === `[object Object]` || text === `[object ${name}]`) {
251
- // Got a default string, so probably not what we wanted.
252
- // Return original object.
253
- return obj;
254
- } else {
255
- // We appear to have cast the object to a string; return that.
256
- return text;
250
+ function textOrObject(object) {
251
+ // Return buffers and typed arrays as is.
252
+ if (object instanceof ArrayBuffer || object instanceof TypedArray) {
253
+ return object;
257
254
  }
255
+ return toString(object);
258
256
  }
@@ -1,48 +0,0 @@
1
- import { Scope } from "@weborigami/language";
2
- import * as compile from "../../../../language/src/compiler/compile.js";
3
- import processUnpackedContent from "../../common/processUnpackedContent.js";
4
- import * as utilities from "../../common/utilities.js";
5
- import builtins from "../@builtins.js";
6
- import unpackText from "./txt.js";
7
-
8
- /**
9
- * Load and evaluate an Origami template from a file.
10
- *
11
- * @typedef {import("@weborigami/types").AsyncTree} AsyncTree
12
- * @type {import("@weborigami/language").FileUnpackFunction}
13
- */
14
- export default async function unpackOrigamiTemplate(input, options = {}) {
15
- const parent =
16
- options.parent ??
17
- /** @type {any} */ (input).parent ??
18
- /** @type {any} */ (input)[utilities.parentSymbol];
19
-
20
- // Get the input text and any attached front matter.
21
- let inputDocument;
22
- if (input["@text"]) {
23
- inputDocument = input;
24
- } else {
25
- // Unpack the input as a text document with possible front matter.
26
- inputDocument = await unpackText(input, options);
27
- }
28
- const text = utilities.toString(inputDocument);
29
-
30
- // Compile the body text as an Origami expression and evaluate it.
31
- const expression = compile.templateDocument(text);
32
- const parentScope = parent ? Scope.getScope(parent) : builtins;
33
- const lambda = await expression.call(parentScope);
34
-
35
- // Wrap the lambda with a function that will attach the input data to the
36
- // result.
37
- /** @this {AsyncTree|null} */
38
- const fn = async function createTemplateResult(templateInput) {
39
- const text = await lambda.call(this, templateInput);
40
- /** @type {any} */
41
- const result = new String(text);
42
- result.unpack = () => templateInput;
43
- return result;
44
- };
45
- fn.code = lambda.code;
46
-
47
- return processUnpackedContent(fn, parent, inputDocument);
48
- }
@@ -1,241 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width,initial-scale=1" />
6
- <title>Web Origami Explorer</title>
7
- <style>
8
- * {
9
- box-sizing: border-box;
10
- }
11
-
12
- html {
13
- height: 100%;
14
- }
15
-
16
- body {
17
- background: #333;
18
- color: #eee;
19
- display: grid;
20
- font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
21
- font-size: 13px;
22
- grid-template-columns: 200px 1fr;
23
- grid-template-rows: minMax(0, 1fr);
24
- height: 100%;
25
- margin: 0;
26
- overflow: hidden;
27
- }
28
-
29
- nav {
30
- display: grid;
31
- gap: 1em;
32
- grid-auto-rows: min-content;
33
- grid-template-columns: minmax(0, 1fr);
34
- overflow: auto;
35
- padding: 1em 0.5em;
36
- }
37
-
38
- #label {
39
- font-weight: bold;
40
- }
41
-
42
- #scopeToolbar {
43
- display: grid;
44
- grid-template-columns: repeat(4, auto);
45
- }
46
-
47
- button {
48
- background: transparent;
49
- border: solid 1px #555;
50
- color: inherit;
51
- font-size: smaller;
52
- font-family: inherit;
53
- font-weight: inherit;
54
- padding: 0.25em;
55
- }
56
- button:hover {
57
- border-color: #999;
58
- }
59
- button:active {
60
- border-color: #eee;
61
- }
62
- button[aria-pressed="true"] {
63
- background: #555;
64
- }
65
-
66
- ul {
67
- list-style: none;
68
- margin: 0;
69
- padding: 0;
70
- }
71
-
72
- h2 {
73
- color: #999;
74
- font-size: inherit;
75
- margin: 0.25em 0;
76
- padding-left: 0.25em;
77
- }
78
-
79
- li {
80
- padding: 0.25em;
81
- padding-left: 1em;
82
- text-indent: -0.75em;
83
- }
84
-
85
- a {
86
- color: inherit;
87
- text-decoration: none;
88
- }
89
-
90
- iframe {
91
- background: white;
92
- border: none;
93
- height: 100%;
94
- width: 100%;
95
- }
96
- </style>
97
- <script>
98
- let defaultPath;
99
- let path;
100
- let frame;
101
-
102
- const modes = {
103
- Content: "",
104
- Index: "!@index",
105
- YAML: "!@yaml",
106
- SVG: "!@svg",
107
- };
108
-
109
- // Extract the path from the URL hash.
110
- function getPathFromHash() {
111
- return window.location.hash.slice(1); // Remove `#`
112
- }
113
-
114
- function getModeFromLocation() {
115
- const href = document.location.href;
116
- const match = /[\/](?<command>\!(?:@index|@yaml|@svg))$/.exec(href);
117
- const command = match?.groups.command ?? "";
118
- const mode = Object.keys(modes).find(key => modes[key] === command) ?? "Content";
119
- return mode;
120
- }
121
-
122
- function removeDocumentPath(path) {
123
- const documentPath = document.location.pathname;
124
- if (path.startsWith(documentPath)) {
125
- // Remove the document path prefix.
126
- path = path.slice(documentPath.length);
127
- }
128
- if (path.startsWith("/")) {
129
- // Remove the leading slash.
130
- path = path.slice(1);
131
- }
132
- return path;
133
- }
134
-
135
- function selectMode(newMode) {
136
- const currentMode = getModeFromLocation();
137
- if (newMode !== currentMode) {
138
- let newPath = removeDocumentPath(frame.contentDocument.location.pathname);
139
- const currentExtension = modes[currentMode];
140
- if (currentExtension && newPath.endsWith(currentExtension)) {
141
- // Remove the current extension.
142
- newPath = newPath.slice(0, -currentExtension.length);
143
- }
144
- const newExtension = modes[newMode];
145
- const separator = newPath.endsWith("/") ? "" : "/";
146
- const newFullPath = `${newPath}${separator}${newExtension}`;
147
- setPath(newFullPath);
148
- }
149
- }
150
-
151
- function setPath(path) {
152
- currentPath = path;
153
-
154
- // Show the indicated page in the frame.
155
- const abbreviatedPath = `/${path}`;
156
- const fullPath = `${document.location.pathname}/${path}`;
157
- const framePathname = frame.contentDocument.location.pathname;
158
- if (framePathname !== abbreviatedPath && framePathname !== fullPath) {
159
- // Use `replace` to avoid affecting browser history.
160
- frame.contentWindow.location.replace(fullPath);
161
- }
162
-
163
- // If the path ends with a file name corresponding to a mode, select
164
- // the corresponding mode button.
165
- const mode = getModeFromLocation();
166
- const selectedButtonId = `button${mode}`;
167
- scopeToolbar.querySelectorAll("button").forEach(button => {
168
- const pressed = button.id === selectedButtonId ? "true" : "false";
169
- button.setAttribute("aria-pressed", pressed);
170
- });
171
- }
172
-
173
- // When hash changes, load the indicated page.
174
- window.addEventListener("hashchange", () => {
175
- const hashPath = getPathFromHash();
176
- const newPath = hashPath !== undefined ? hashPath : defaultPath;
177
- if (newPath) {
178
- setPath(newPath);
179
- }
180
- });
181
-
182
- // Initialize
183
- window.addEventListener("load", () => {
184
- // Refresh title on page load.
185
- frame = document.getElementById("frame");
186
- frame.addEventListener("load", () => {
187
- if (frame.contentDocument.location.href !== "about:blank") {
188
- document.title = frame.contentDocument.title;
189
- const newPath = removeDocumentPath(frame.contentDocument.location.pathname);
190
- const hash = `#${newPath}`;
191
- if (window.location.hash !== hash) {
192
- // Use `replace` to avoid affecting browser history.
193
- window.location.replace(hash);
194
- }
195
- }
196
- });
197
-
198
- buttonContent.addEventListener("click", () => {
199
- selectMode("Content");
200
- });
201
- buttonIndex.addEventListener("click", () => {
202
- selectMode("Index");
203
- });
204
- buttonYAML.addEventListener("click", () => {
205
- selectMode("YAML");
206
- });
207
- buttonSVG.addEventListener("click", () => {
208
- selectMode("SVG");
209
- });
210
-
211
- // Navigate to any path already in the hash.
212
- defaultPath = getPathFromHash();
213
- if (defaultPath) {
214
- setPath(defaultPath);
215
- }
216
- })
217
- </script>
218
- </head>
219
- <body>
220
- <nav>
221
- <div id="label">Web Origami Explorer</div>
222
- <div id="scopeToolbar">
223
- <button id="buttonContent">Content</button>
224
- <button id="buttonIndex">Index</button>
225
- <button id="buttonSVG">SVG</button>
226
- <button id="buttonYAML">YAML</button>
227
- </div>
228
- {{ @map(=`
229
- <ul>
230
- <h2>{{ _/name }}</h2>
231
- {{ @map(=`
232
- <li>
233
- <a href="./!@explore/{{ _ }}" target="frame">{{ _ }}</a>
234
- </li>
235
- `)(_/keys) }}
236
- </ul>
237
- `)(_) }}
238
- </nav>
239
- <iframe id="frame" name="frame"></iframe>
240
- </body>
241
- </html>