@weborigami/origami 0.0.38 → 0.0.39

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2021–2023 Jan Miksovsky and other contributors
3
+ Copyright (c) 2024 Jan Miksovsky and other contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,6 +1,6 @@
1
1
  /** @typedef {import("@weborigami/types").AsyncTree} AsyncTree */
2
2
  import { keyMapsForExtensions, map } from "@weborigami/async-tree";
3
- import unpackOrigamiTemplate from "../src/builtins/@loaders/orit.js";
3
+ import unpackOrigamiTemplate from "../src/builtins/@loaders/ori.js";
4
4
  import { transformObject } from "../src/common/utilities.js";
5
5
  import PathTransform from "./PathTransform.js";
6
6
 
@@ -14,8 +14,8 @@ const specialBuiltinNames = {
14
14
  };
15
15
 
16
16
  // Top-level template for the export file
17
- const templateText = `// This file is generated by running buildExports.js -- do not edit by hand.
18
- {{ _ }}`;
17
+ const templateText = `=\`// This file is generated by running buildExports.js -- do not edit by hand.
18
+ {{ _ }}\``;
19
19
 
20
20
  // Generate a top-level export file for the entire project. For each .js file in
21
21
  // the given source tree, generate an appropriate statement that includes that
@@ -1,6 +1,7 @@
1
1
  // This file is generated by running buildExports.js -- do not edit by hand.
2
2
  export { default as apply } from "../src/builtins/@apply.js";
3
3
  export { default as arrows } from "../src/builtins/@arrows.js";
4
+ export { default as basename } from "../src/builtins/@basename.js";
4
5
  export { default as builtins } from "../src/builtins/@builtins.js";
5
6
  export { default as cache } from "../src/builtins/@cache.js";
6
7
  export { default as config } from "../src/builtins/@config.js";
@@ -10,6 +11,7 @@ export { default as debug } from "../src/builtins/@debug.js";
10
11
  export { default as document } from "../src/builtins/@document.js";
11
12
  export { default as equals } from "../src/builtins/@equals.js";
12
13
  export { default as explore } from "../src/builtins/@explore.js";
14
+ export { default as fetch } from "../src/builtins/@fetch.js";
13
15
  export { default as files } from "../src/builtins/@files.js";
14
16
  export { default as filter } from "../src/builtins/@filter.js";
15
17
  export { default as globs } from "../src/builtins/@globs.js";
@@ -33,19 +35,21 @@ export { default as loadersJson } from "../src/builtins/@loaders/json.js";
33
35
  export { default as loadersMd } from "../src/builtins/@loaders/md.js";
34
36
  export { default as loadersMjs } from "../src/builtins/@loaders/mjs.js";
35
37
  export { default as loadersOri } from "../src/builtins/@loaders/ori.js";
36
- export { default as loadersOrit } from "../src/builtins/@loaders/orit.js";
37
38
  export { default as loadersTxt } from "../src/builtins/@loaders/txt.js";
38
39
  export { default as loadersXhtml } from "../src/builtins/@loaders/xhtml.js";
39
40
  export { default as loadersYaml } from "../src/builtins/@loaders/yaml.js";
40
41
  export { default as loadersYml } from "../src/builtins/@loaders/yml.js";
41
42
  export { default as map } from "../src/builtins/@map.js";
43
+ export { default as mapDeep } from "../src/builtins/@mapDeep.js";
42
44
  export { default as match } from "../src/builtins/@match.js";
43
45
  export { default as mdHtml } from "../src/builtins/@mdHtml.js";
44
46
  export { default as node } from "../src/builtins/@node.js";
45
47
  export { default as not } from "../src/builtins/@not.js";
48
+ export { default as once } from "../src/builtins/@once.js";
46
49
  export { default as or } from "../src/builtins/@or.js";
47
50
  export { default as ori } from "../src/builtins/@ori.js";
48
51
  export { default as pack } from "../src/builtins/@pack.js";
52
+ export { default as package } from "../src/builtins/@package.js";
49
53
  export { default as parseJson } from "../src/builtins/@parse/json.js";
50
54
  export { default as parseYaml } from "../src/builtins/@parse/yaml.js";
51
55
  export { default as project } from "../src/builtins/@project.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weborigami/origami",
3
- "version": "0.0.38",
3
+ "version": "0.0.39",
4
4
  "description": "Web Origami language, CLI, framework, and server",
5
5
  "type": "module",
6
6
  "repository": {
@@ -13,23 +13,23 @@
13
13
  "main": "./exports/exports.js",
14
14
  "types": "./index.ts",
15
15
  "devDependencies": {
16
- "@types/chai": "4.3.9",
17
- "@types/mocha": "10.0.3",
18
- "@types/node": "20.8.10",
19
- "typescript": "5.2.2"
16
+ "@types/chai": "4.3.11",
17
+ "@types/mocha": "10.0.6",
18
+ "@types/node": "20.11.3",
19
+ "typescript": "5.3.3"
20
20
  },
21
21
  "dependencies": {
22
- "@weborigami/async-tree": "*",
23
- "@weborigami/language": "*",
24
- "@weborigami/types": "0.0.38",
22
+ "@weborigami/async-tree": "0.0.39",
23
+ "@weborigami/language": "0.0.39",
24
+ "@weborigami/types": "0.0.39",
25
25
  "graphviz-wasm": "3.0.1",
26
26
  "highlight.js": "11.9.0",
27
- "marked": "9.1.5",
28
- "marked-gfm-heading-id": "3.1.0",
29
- "marked-highlight": "2.0.6",
30
- "marked-smartypants": "1.1.3",
31
- "sharp": "0.32.6",
32
- "yaml": "2.3.3"
27
+ "marked": "11.1.1",
28
+ "marked-gfm-heading-id": "3.1.2",
29
+ "marked-highlight": "2.1.0",
30
+ "marked-smartypants": "1.1.5",
31
+ "sharp": "0.33.2",
32
+ "yaml": "2.3.4"
33
33
  },
34
34
  "scripts": {
35
35
  "build": "ori exports/buildExports.js src > exports/exports.js",
@@ -0,0 +1,6 @@
1
+ import { extname } from "@weborigami/language";
2
+
3
+ export default function basename(key) {
4
+ const ext = extname(key);
5
+ return ext ? key.slice(0, -ext.length) : key;
6
+ }
@@ -16,7 +16,7 @@ const miscFiles = Scope.treeWithScope(new OrigamiFiles(miscDir), builtins);
16
16
  */
17
17
  export default async function explore() {
18
18
  const scope = Scope.getScope(this);
19
- const templateFile = await miscFiles.get("explore.orit");
19
+ const templateFile = await miscFiles.get("explore.ori");
20
20
  const template = await templateFile.unpack();
21
21
 
22
22
  const data = await getScopeData(scope);
@@ -0,0 +1,7 @@
1
+ export default async function fetchBuiltin(href) {
2
+ const response = await fetch(href);
3
+ return response.ok ? await response.arrayBuffer() : undefined;
4
+ }
5
+
6
+ fetchBuiltin.usage = `@fetch href\tReturns the contents of the given URL as an ArrayBuffer`;
7
+ fetchBuiltin.documentation = "https://weborigami.org/languages/@fetch.html";
@@ -1,5 +1,7 @@
1
+ import unpackText from "../builtins/@loaders/txt.js";
2
+ import * as utilities from "../common/utilities.js";
1
3
  import assertScopeIsDefined from "../misc/assertScopeIsDefined.js";
2
- import unpackOrigamiTemplate from "./@loaders/orit.js";
4
+ import unpackOrigamiExpression from "./@loaders/ori.js";
3
5
 
4
6
  /**
5
7
  * Inline any Origami expressions found inside {{...}} placeholders in the input
@@ -13,16 +15,30 @@ import unpackOrigamiTemplate from "./@loaders/orit.js";
13
15
  */
14
16
  export default async function inline(input) {
15
17
  assertScopeIsDefined(this);
16
- if (/** @type {any} */ (input).unpack) {
17
- input = await /** @type {any} */ (input).unpack();
18
+
19
+ // Get the input text and any attached front matter.
20
+ let inputDocument;
21
+ if (input["@text"]) {
22
+ inputDocument = input;
23
+ } else if (/** @type {any} */ (input).unpack) {
24
+ // Have the input unpack itself.
25
+ inputDocument = await /** @type {any} */ (input).unpack();
26
+ } else {
27
+ // Unpack the input as a text document with possible front matter.
28
+ inputDocument = await unpackText(input);
18
29
  }
19
- const inputDocument = input["@text"] ? input : null;
20
- const templateInput = inputDocument ?? input;
21
- const templateFn = await unpackOrigamiTemplate(templateInput);
22
- const text = await templateFn(inputDocument);
30
+
31
+ // Treat the input text as the body of an Origami template literal.
32
+ const inputText = utilities.toString(inputDocument);
33
+ const templateDocument = Object.assign({}, inputDocument, {
34
+ "@text": `=\`${inputText}\``,
35
+ });
36
+
37
+ const templateFn = await unpackOrigamiExpression(templateDocument);
38
+ const templateResult = await templateFn(inputDocument);
23
39
  return inputDocument
24
- ? Object.assign({}, inputDocument, { "@text": String(text) })
25
- : text;
40
+ ? Object.assign({}, inputDocument, { "@text": String(templateResult) })
41
+ : templateResult;
26
42
  }
27
43
 
28
44
  inline.usage = `@inline <text>\tInline Origami expressions found in the text`;
@@ -1,6 +1,7 @@
1
1
  import { Scope } from "@weborigami/language";
2
2
  import * as compile from "../../../../language/src/compiler/compile.js";
3
3
  import processUnpackedContent from "../../common/processUnpackedContent.js";
4
+ import * as utilities from "../../common/utilities.js";
4
5
  import builtins from "../@builtins.js";
5
6
 
6
7
  /**
@@ -8,14 +9,20 @@ import builtins from "../@builtins.js";
8
9
  *
9
10
  * @type {import("@weborigami/language").FileUnpackFunction}
10
11
  */
11
- export default async function unpackOrigamiExpression(input, options = {}) {
12
- const parent = options.parent ?? null;
12
+ export default async function unpackOrigamiExpression(
13
+ inputDocument,
14
+ options = {}
15
+ ) {
16
+ const parent =
17
+ options.parent ??
18
+ /** @type {any} */ (inputDocument).parent ??
19
+ /** @type {any} */ (inputDocument)[utilities.parentSymbol];
13
20
 
14
21
  // Compile the body text as an Origami expression and evaluate it.
15
- const text = String(input);
16
- const fn = compile.expression(text);
22
+ const inputText = utilities.toString(inputDocument);
23
+ const fn = compile.expression(inputText);
17
24
  const parentScope = parent ? Scope.getScope(parent) : builtins;
18
25
  let content = await fn.call(parentScope);
19
26
 
20
- return processUnpackedContent(content, parent);
27
+ return processUnpackedContent(content, parent, inputDocument);
21
28
  }
@@ -74,6 +74,7 @@ export default function treeMap(param1, param2) {
74
74
  inverseKeyMap,
75
75
  keyMap,
76
76
  keyName,
77
+ needsSourceValue,
77
78
  valueName,
78
79
  } = options;
79
80
 
@@ -105,7 +106,7 @@ export default function treeMap(param1, param2) {
105
106
 
106
107
  // Extend the key function to include the value and key in scope.
107
108
  let extendedKeyMap;
108
- let extendedInnerKeyMap;
109
+ let extendedInverseKeyMap;
109
110
  if (extensions) {
110
111
  let { resultExtension, sourceExtension } = parseExtensions(extensions);
111
112
  const keyFns = keyMapsForExtensions({
@@ -113,7 +114,7 @@ export default function treeMap(param1, param2) {
113
114
  sourceExtension,
114
115
  });
115
116
  extendedKeyMap = keyFns.keyMap;
116
- extendedInnerKeyMap = keyFns.inverseKeyMap;
117
+ extendedInverseKeyMap = keyFns.inverseKeyMap;
117
118
  } else if (keyMap) {
118
119
  const resolvedKeyFn = toFunction(keyMap);
119
120
  async function scopedKeyFn(sourceKey, tree) {
@@ -135,7 +136,11 @@ export default function treeMap(param1, param2) {
135
136
  }
136
137
  const keyFns = cachedKeyMaps(scopedKeyFn);
137
138
  extendedKeyMap = keyFns.keyMap;
138
- extendedInnerKeyMap = keyFns.inverseKeyMap;
139
+ extendedInverseKeyMap = keyFns.inverseKeyMap;
140
+ } else {
141
+ // Use sidecar keyMap/inverseKeyMap functions if the valueMap defines them.
142
+ extendedKeyMap = valueMap?.keyMap;
143
+ extendedInverseKeyMap = valueMap?.inverseKeyMap;
139
144
  }
140
145
 
141
146
  const transform = function mapTreelike(treelike) {
@@ -143,8 +148,9 @@ export default function treeMap(param1, param2) {
143
148
  return map({
144
149
  deep,
145
150
  description,
146
- inverseKeyMap: extendedInnerKeyMap,
151
+ inverseKeyMap: extendedInverseKeyMap,
147
152
  keyMap: extendedKeyMap,
153
+ needsSourceValue,
148
154
  valueMap: extendedValueFn,
149
155
  })(tree);
150
156
  };
@@ -0,0 +1,22 @@
1
+ import { isPlainObject } from "@weborigami/async-tree";
2
+ import treeMap from "./@map.js";
3
+
4
+ export default function mapDeep(param1, param2) {
5
+ // Identify whether the valueMap/options are the first parameter
6
+ // or the second.
7
+ let source;
8
+ let options;
9
+ if (param2 === undefined) {
10
+ options = param1;
11
+ } else {
12
+ source = param1;
13
+ if (isPlainObject(param2)) {
14
+ options = param2;
15
+ } else {
16
+ options = { valueMap: param2 };
17
+ }
18
+ }
19
+
20
+ options.deep = true;
21
+ return treeMap(source, options);
22
+ }
@@ -3,6 +3,7 @@ import { marked } from "marked";
3
3
  import { gfmHeadingId as markedGfmHeadingId } from "marked-gfm-heading-id";
4
4
  import { markedHighlight } from "marked-highlight";
5
5
  import { markedSmartypants } from "marked-smartypants";
6
+ import { replaceExtension } from "../common/utilities.js";
6
7
 
7
8
  marked.use(
8
9
  markedGfmHeadingId(),
@@ -41,5 +42,9 @@ export default async function mdHtml(input) {
41
42
  : html;
42
43
  }
43
44
 
45
+ mdHtml.keyMap = (sourceKey) => replaceExtension(sourceKey, ".md", ".html");
46
+ mdHtml.inverseKeyMap = (resultKey) =>
47
+ replaceExtension(resultKey, ".html", ".md");
48
+
44
49
  mdHtml.usage = `@mdHtml <markdown>\tRender the markdown text as HTML`;
45
50
  mdHtml.documentation = "https://weborigami.org/language/@mdHtml.html";
@@ -0,0 +1,18 @@
1
+ import assertScopeIsDefined from "../misc/assertScopeIsDefined.js";
2
+
3
+ const fnPromiseMap = new WeakMap();
4
+
5
+ /**
6
+ * Evaluate the given function only once and cache the result.
7
+ *
8
+ * @typedef {import("@weborigami/types").AsyncTree} AsyncTree
9
+ * @this {AsyncTree|null}
10
+ * @param {Function} fn
11
+ */
12
+ export default async function once(fn) {
13
+ assertScopeIsDefined(this);
14
+ if (!fnPromiseMap.has(fn)) {
15
+ fnPromiseMap.set(fn, fn.call(this));
16
+ }
17
+ return fnPromiseMap.get(fn);
18
+ }
@@ -39,7 +39,11 @@ export default async function ori(expression) {
39
39
  }
40
40
 
41
41
  async function formatResult(result) {
42
- if (typeof result === "string" || result instanceof TypedArray) {
42
+ if (
43
+ typeof result === "string" ||
44
+ result instanceof ArrayBuffer ||
45
+ result instanceof TypedArray
46
+ ) {
43
47
  // Use as is
44
48
  return result;
45
49
  }
@@ -0,0 +1,28 @@
1
+ import { Tree, keysFromPath } from "@weborigami/async-tree";
2
+ import { Scope } from "@weborigami/language";
3
+ import project from "./@project.js";
4
+
5
+ /**
6
+ * @this {import("@weborigami/types").AsyncTree|null}
7
+ * @param {string[]} packageKeys
8
+ */
9
+ export default async function packageBuiltin(...packageKeys) {
10
+ let scope = this;
11
+ if (!scope) {
12
+ const projectRoot = await project.call(null);
13
+ scope = Scope.getScope(projectRoot);
14
+ }
15
+ const packageRoot = await Tree.traverse(
16
+ // @ts-ignore
17
+ scope,
18
+ "node_modules",
19
+ ...packageKeys
20
+ );
21
+ const mainPath = await Tree.traverse(packageRoot, "package.json", "main");
22
+ const mainKeys = keysFromPath(mainPath);
23
+ const mainContainerKeys = mainKeys.slice(0, -1);
24
+ const mainFileName = mainKeys[mainKeys.length - 1];
25
+ const mainContainer = await Tree.traverse(packageRoot, ...mainContainerKeys);
26
+ const packageExports = await mainContainer.import(mainFileName);
27
+ return packageExports;
28
+ }
@@ -1,7 +1,10 @@
1
1
  import { Tree, isPlainObject, isStringLike } from "@weborigami/async-tree";
2
2
  import { extname } from "@weborigami/language";
3
3
  import * as serialize from "../../common/serialize.js";
4
- import { keySymbol } from "../../common/utilities.js";
4
+ import {
5
+ hasNonPrintableCharacters,
6
+ keySymbol,
7
+ } from "../../common/utilities.js";
5
8
  import assertScopeIsDefined from "../../misc/assertScopeIsDefined.js";
6
9
 
7
10
  /**
@@ -36,12 +39,6 @@ ${treeArcs.join("\n")}
36
39
  }`;
37
40
  }
38
41
 
39
- // Return true if the text appears to contain non-printable binary characters.
40
- function probablyBinary(text) {
41
- // https://stackoverflow.com/a/1677660/76472
42
- return /[\x00-\x08\x0E-\x1F]/.test(text);
43
- }
44
-
45
42
  async function statements(tree, nodePath, nodeLabel, options) {
46
43
  let result = [];
47
44
  const createLinks = options.createLinks ?? true;
@@ -110,7 +107,7 @@ async function statements(tree, nodePath, nodeLabel, options) {
110
107
  let i = 0;
111
108
  for (const key of Object.keys(nodes)) {
112
109
  let label = String(nodes[key].label);
113
- if (probablyBinary(label)) {
110
+ if (hasNonPrintableCharacters(label)) {
114
111
  nodes[key].label = "[binary data]";
115
112
  } else if (label) {
116
113
  let clippedStart = false;
@@ -1,10 +1,10 @@
1
1
  import { Tree } from "@weborigami/async-tree";
2
2
  import assertScopeIsDefined from "../../misc/assertScopeIsDefined.js";
3
3
  import builtins from "../@builtins.js";
4
- import unpackOrigamiTemplate from "../@loaders/orit.js";
4
+ import unpackOrigamiExpression from "../@loaders/ori.js";
5
5
  import paths from "./paths.js";
6
6
 
7
- const templateText = `<?xml version="1.0" encoding="UTF-8"?>
7
+ const templateText = `=\`<?xml version="1.0" encoding="UTF-8"?>
8
8
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
9
9
  {{ @map(=\`
10
10
  <url>
@@ -12,6 +12,7 @@ const templateText = `<?xml version="1.0" encoding="UTF-8"?>
12
12
  </url>
13
13
  \`)(_) }}
14
14
  </urlset>
15
+ \`
15
16
  `;
16
17
 
17
18
  /**
@@ -50,7 +51,7 @@ export default async function sitemap(treelike, baseHref = "") {
50
51
  .filter((path) => path.endsWith(".html"))
51
52
  .map((path) => (path.endsWith("index.html") ? path.slice(0, -10) : path));
52
53
 
53
- const templateFn = await unpackOrigamiTemplate(templateText);
54
+ const templateFn = await unpackOrigamiExpression(templateText);
54
55
  const templateResult = await templateFn.call(builtins, htmlPaths);
55
56
  return String(templateResult);
56
57
  }
package/src/cli/cli.js CHANGED
@@ -9,6 +9,8 @@ import project from "../builtins/@project.js";
9
9
  import { keySymbol } from "../common/utilities.js";
10
10
  import showUsage from "./showUsage.js";
11
11
 
12
+ const TypedArray = Object.getPrototypeOf(Uint8Array);
13
+
12
14
  async function main(...args) {
13
15
  const expression = args.join(" ");
14
16
 
@@ -42,7 +44,12 @@ async function main(...args) {
42
44
  const scope = Scope.getScope(tree);
43
45
  const result = await ori.call(scope, expression);
44
46
  if (result !== undefined) {
45
- const output = result instanceof Buffer ? result : String(result);
47
+ const output =
48
+ result instanceof ArrayBuffer
49
+ ? new Uint8Array(result)
50
+ : typeof result === "string" || result instanceof TypedArray
51
+ ? result
52
+ : String(result);
46
53
  await stdout.write(output);
47
54
 
48
55
  // If stdout points to the console, and the result didn't end in a newline,
@@ -10,17 +10,19 @@ import builtins from "../builtins/@builtins.js";
10
10
  *
11
11
  * @param {any} content
12
12
  * @param {AsyncTree|null} parent
13
- * @param {any} [attachedData]
13
+ * @param {any} [inputDocument]
14
14
  * @returns
15
15
  */
16
- export default function processUnpackedContent(content, parent, attachedData) {
16
+ export default function processUnpackedContent(content, parent, inputDocument) {
17
17
  if (typeof content === "function") {
18
18
  // Wrap the function such to add ambients to the scope.
19
19
  const fn = content;
20
20
 
21
21
  // Use the parent's scope, adding any attached data.
22
22
  const parentScope = parent ? Scope.getScope(parent) : builtins;
23
- const extendedScope = new Scope(attachedData, parentScope);
23
+ const extendedScope = Tree.isTreelike(inputDocument)
24
+ ? new Scope(inputDocument, parentScope)
25
+ : parentScope;
24
26
 
25
27
  /** @this {AsyncTree|null} */
26
28
  async function extendScope(input, ...rest) {
@@ -1,7 +1,9 @@
1
1
 
2
2
  export const keySymbol: unique symbol;
3
3
  export const parentSymbol: unique symbol;
4
+ export function hasNonPrintableCharacters(text: string): boolean;
4
5
  export function isTransformApplied(Transform: Function, object: any): boolean;
6
+ export function replaceExtension(key: string, sourceExtension: string, resultExtension: string): string;
5
7
  export function toFunction(object: any): Function;
6
8
  export function toString(object: any): string|null;
7
9
  export function transformObject(Transform: Function, object: any): any;
@@ -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 {
@@ -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);
@@ -53,11 +54,14 @@ 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
 
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
- );
57
+ // Split on occurrences of `/!`, which represent Origami debug commands.
58
+ // Command arguments can contain slashes; don't treat those as path keys.
59
+ const parts = url.pathname.split(/\/!/);
60
+ const keys = parts.flatMap((part, index) => {
61
+ const decoded = decodeURIComponent(part);
62
+ // Split keys that aren't commands; add back the `!` to commands.
63
+ return index % 2 === 0 ? keysFromPath(decoded) : `!${decoded}`;
64
+ });
61
65
 
62
66
  // If the path ends with a trailing slash, the final key will be an empty
63
67
  // string. Change that to "index.html".
@@ -131,7 +135,7 @@ export async function handleRequest(request, response, tree) {
131
135
 
132
136
  let data;
133
137
  if (mediaType) {
134
- data = mediaTypeIsText[mediaType] ? String(resource) : resource;
138
+ data = mediaTypeIsText[mediaType] ? toString(resource) : resource;
135
139
  } else {
136
140
  data = textOrObject(resource);
137
141
  }
@@ -152,7 +156,7 @@ export async function handleRequest(request, response, tree) {
152
156
  const validResponse = typeof data === "string" || data instanceof TypedArray;
153
157
 
154
158
  if (!validResponse) {
155
- const typeName = data.constructor?.name ?? typeof data;
159
+ const typeName = data?.constructor?.name ?? typeof data;
156
160
  console.error(
157
161
  `A served tree must return a string or a TypedArray (such as a Buffer) but returned an instance of ${typeName}.`
158
162
  );
@@ -233,26 +237,12 @@ ${message}
233
237
  * Convert to a string if we can, but leave objects that convert to something
234
238
  * like "[object Object]" alone.
235
239
  *
236
- * @param {any} obj
240
+ * @param {any} object
237
241
  */
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;
242
+ function textOrObject(object) {
243
+ // Return buffers and typed arrays as is.
244
+ if (object instanceof ArrayBuffer || object instanceof TypedArray) {
245
+ return object;
257
246
  }
247
+ return toString(object);
258
248
  }
@@ -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>