@weborigami/origami 0.3.1 → 0.3.3-jse.1

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.
@@ -0,0 +1,161 @@
1
+ import { JSDOM, VirtualConsole } from "jsdom";
2
+ import pathsInCss from "./pathsInCss.js";
3
+ import pathsInJs from "./pathsInJs.js";
4
+ import { addHref } from "./utilities.js";
5
+
6
+ export default function pathsInHtml(html) {
7
+ const paths = {
8
+ crawlablePaths: [],
9
+ resourcePaths: [],
10
+ };
11
+
12
+ // Create a virtual console to avoid logging errors to the console
13
+ const virtualConsole = new VirtualConsole();
14
+ const document = new JSDOM(html, { virtualConsole }).window.document;
15
+
16
+ // Find `href` attributes in anchor, area, link, SVG tags.
17
+ //
18
+ // NOTE: As of April 2024, jsdom querySelectorAll does not appear to find
19
+ // elements with mixed-case tag names.
20
+ const hrefTags = document.querySelectorAll(
21
+ "a[href], area[href], image[href], feImage[href], filter[href], linearGradient[href], link[href], mpath[href], pattern[href], radialGradient[href], textPath[href], use[href]"
22
+ );
23
+ for (const hrefTag of hrefTags) {
24
+ const crawlable = ["A", "AREA"].includes(hrefTag.tagName)
25
+ ? true
26
+ : undefined;
27
+ addHref(paths, hrefTag.getAttribute("href"), crawlable);
28
+ }
29
+
30
+ // Find `src` attributes in input, frame, media, and script tags.
31
+ const srcTags = document.querySelectorAll(
32
+ "audio[src], embed[src], frame[src], iframe[src], img[src], input[src], script[src], source[src], track[src], video[src]"
33
+ );
34
+ for (const srcTag of srcTags) {
35
+ const crawlable = ["FRAME", "IFRAME"].includes(srcTag.tagName)
36
+ ? true
37
+ : srcTag.tagName === "SCRIPT"
38
+ ? srcTag.type === "module" // Only crawl modules
39
+ : undefined;
40
+ addHref(paths, srcTag.getAttribute("src"), crawlable);
41
+ }
42
+
43
+ // Find `srcset` attributes in image and source tags.
44
+ const srcsetTags = document.querySelectorAll("img[srcset], source[srcset]");
45
+ for (const srcsetTag of srcsetTags) {
46
+ const srcset = srcsetTag.getAttribute("srcset");
47
+ const srcRegex = /(?<url>[^\s,]+)(?=\s+\d+(?:\.\d+)?[wxh])/g;
48
+ let match;
49
+ while ((match = srcRegex.exec(srcset))) {
50
+ if (match.groups?.url) {
51
+ addHref(paths, match.groups.url, false);
52
+ }
53
+ }
54
+ }
55
+
56
+ // Find `poster` attributes in <video> tags.
57
+ const posterTags = document.querySelectorAll("video[poster]");
58
+ for (const posterTag of posterTags) {
59
+ addHref(paths, posterTag.getAttribute("poster"), false);
60
+ }
61
+
62
+ // Find `data` attributes in <object> tags.
63
+ const objectTags = document.querySelectorAll("object[data]");
64
+ for (const objectTag of objectTags) {
65
+ addHref(paths, objectTag.getAttribute("data"), false);
66
+ }
67
+
68
+ // Find deprecated `background` attribute on body and table tags.
69
+ const backgroundTags = document.querySelectorAll(
70
+ "body[background], table[background], td[background], th[background]"
71
+ );
72
+ for (const backgroundTag of backgroundTags) {
73
+ addHref(paths, backgroundTag.getAttribute("background"), false);
74
+ }
75
+
76
+ // Find deprecated `longdesc` attributes on <img> tags.
77
+ const longdescTags = document.querySelectorAll("img[longdesc]");
78
+ for (const longdescTag of longdescTags) {
79
+ addHref(paths, longdescTag.getAttribute("longdesc"), false);
80
+ }
81
+
82
+ // Find paths in <meta> image tags.
83
+ const imageMetaTags = document.querySelectorAll('meta[property$=":image"]');
84
+ for (const imageMetaTag of imageMetaTags) {
85
+ const content = imageMetaTag.getAttribute("content");
86
+ if (content) {
87
+ addHref(paths, content, false);
88
+ }
89
+ }
90
+
91
+ // Find paths in CSS in <style> tags.
92
+ const styleTags = document.querySelectorAll("style");
93
+ for (const styleAttribute of styleTags) {
94
+ const cssPaths = pathsInCss(styleAttribute.textContent);
95
+ paths.crawlablePaths.push(...cssPaths.crawlablePaths);
96
+ paths.resourcePaths.push(...cssPaths.resourcePaths);
97
+ }
98
+
99
+ // Find URLs in CSS in `style` attributes.
100
+ const styleAttributeTags = document.querySelectorAll("[style]");
101
+ for (const tag of styleAttributeTags) {
102
+ const style = tag.getAttribute("style");
103
+ const stylePaths = pathsInCss(style, "declarationList");
104
+ stylePaths.resourcePaths.forEach((href) => {
105
+ addHref(paths, href, false);
106
+ });
107
+ }
108
+
109
+ // Find URLs in SVG attributes.
110
+ const svgAttributeNames = [
111
+ "clip-path",
112
+ "fill",
113
+ "filter",
114
+ "marker-end",
115
+ "marker-start",
116
+ "mask",
117
+ "stroke",
118
+ ];
119
+ const svgTags = document.querySelectorAll(
120
+ svgAttributeNames.map((name) => `[${name}]`).join(", ")
121
+ );
122
+ for (const svgTag of svgTags) {
123
+ for (const name of svgAttributeNames) {
124
+ const attributeValue = svgTag.getAttribute(name);
125
+ if (!attributeValue) {
126
+ continue;
127
+ }
128
+ const urlRegex = /url\((['"]?)(?<href>.*?)\1\)/g;
129
+ const attributeValueMatch = urlRegex.exec(attributeValue);
130
+ if (attributeValueMatch) {
131
+ const href = attributeValueMatch.groups?.href;
132
+ if (href) {
133
+ addHref(paths, href, false);
134
+ }
135
+ }
136
+ }
137
+ }
138
+
139
+ // Also look for JS `import` statements that might be in <script type="module"> tags.
140
+ const scriptTags = document.querySelectorAll("script[type='module']");
141
+ for (const scriptTag of scriptTags) {
142
+ const jsPaths = pathsInJs(scriptTag.textContent);
143
+ paths.crawlablePaths.push(...jsPaths.crawlablePaths);
144
+ }
145
+
146
+ // Special handling for <noframes> in framesets. We need to use a regex for
147
+ // this because the jsdom parser supports frames, so it will treat a
148
+ // <noframes> tag as a text node.
149
+ const noframesRegex = /<noframes>(?<html>[\s\S]*?)<\/noframes>/g;
150
+ let match;
151
+ while ((match = noframesRegex.exec(html))) {
152
+ const noframesHtml = match.groups?.html;
153
+ if (noframesHtml) {
154
+ const noframesPaths = pathsInHtml(noframesHtml);
155
+ paths.crawlablePaths.push(...noframesPaths.crawlablePaths);
156
+ paths.resourcePaths.push(...noframesPaths.resourcePaths);
157
+ }
158
+ }
159
+
160
+ return paths;
161
+ }
@@ -0,0 +1,25 @@
1
+ import { normalizeHref } from "./utilities.js";
2
+
3
+ // These are ancient server-side image maps. They're so old that it's hard to
4
+ // find documentation on them, but they're used on the reference Space Jam
5
+ // website we use for testing the crawler.
6
+ //
7
+ // Example: https://www.spacejam.com/1996/bin/bball.map
8
+ export default function pathsInImageMap(imageMap) {
9
+ const resourcePaths = [];
10
+ let match;
11
+
12
+ // Find hrefs as the second column in each line.
13
+ const hrefRegex = /^\w+ (?<href>\S+)(\s*$| [\d, ]+$)/gm;
14
+ while ((match = hrefRegex.exec(imageMap))) {
15
+ const href = normalizeHref(match.groups?.href);
16
+ if (href) {
17
+ resourcePaths.push(href);
18
+ }
19
+ }
20
+
21
+ return {
22
+ crawlablePaths: [],
23
+ resourcePaths,
24
+ };
25
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Find static module references in JavaScript code.
3
+ *
4
+ * Matches:
5
+ *
6
+ * * `import … from "x"`
7
+ * * `import "x"`
8
+ * * `export … from "x"`
9
+ * * `export { … } from "x"`
10
+ *
11
+ * This does simple lexical analysis to avoid matching paths inside comments or
12
+ * string literals.
13
+ *
14
+ * @param {string} js
15
+ */
16
+ export default function pathsInJs(js) {
17
+ return {
18
+ crawlablePaths: modulePaths(js),
19
+ resourcePaths: [],
20
+ };
21
+ }
22
+
23
+ function modulePaths(src) {
24
+ const tokens = Array.from(tokenize(src));
25
+ const paths = new Set();
26
+
27
+ for (let i = 0; i < tokens.length; i++) {
28
+ const t = tokens[i];
29
+
30
+ // static import
31
+ if (t.type === "Identifier" && t.value === "import") {
32
+ // look ahead for either:
33
+ // import "mod"
34
+ // import … from "mod"
35
+ let j = i + 1;
36
+ // skip any punctuation or identifiers until we hit 'from' or a StringLiteral
37
+ while (
38
+ j < tokens.length &&
39
+ tokens[j].type !== "StringLiteral" &&
40
+ !(tokens[j].type === "Identifier" && tokens[j].value === "from")
41
+ ) {
42
+ j++;
43
+ }
44
+ // import "mod"
45
+ if (tokens[j]?.type === "StringLiteral") {
46
+ paths.add(tokens[j].value);
47
+ } else if (
48
+ // import … from "mod"
49
+ tokens[j]?.value === "from" &&
50
+ tokens[j + 1]?.type === "StringLiteral"
51
+ ) {
52
+ paths.add(tokens[j + 1].value);
53
+ }
54
+ } else if (t.type === "Identifier" && t.value === "export") {
55
+ // re-export or export‐from
56
+
57
+ // find a 'from' token on the same statement
58
+ let j = i + 1;
59
+ while (
60
+ j < tokens.length &&
61
+ !(tokens[j].type === "Identifier" && tokens[j].value === "from")
62
+ ) {
63
+ // stop at semicolon so we don't run past the statement
64
+ if (tokens[j].type === "Punctuator" && tokens[j].value === ";") {
65
+ break;
66
+ }
67
+ j++;
68
+ }
69
+
70
+ if (
71
+ tokens[j]?.value === "from" &&
72
+ tokens[j + 1]?.type === "StringLiteral"
73
+ ) {
74
+ paths.add(tokens[j + 1].value);
75
+ }
76
+ }
77
+ }
78
+
79
+ return [...paths];
80
+ }
81
+
82
+ // Lexer emits Identifiers, StringLiterals, and Punctuators
83
+ function* tokenize(src) {
84
+ let i = 0;
85
+ while (i < src.length) {
86
+ const c = src[i];
87
+
88
+ // Skip single‐line comments
89
+ if (c === "/" && src[i + 1] === "/") {
90
+ i += 2;
91
+ while (i < src.length && src[i] !== "\n") {
92
+ i++;
93
+ }
94
+ } else if (c === "/" && src[i + 1] === "*") {
95
+ // Skip multi‐line comments
96
+ i += 2;
97
+ while (i < src.length && !(src[i] === "*" && src[i + 1] === "/")) {
98
+ i++;
99
+ }
100
+ i += 2;
101
+ continue;
102
+ } else if (c === '"' || c === "'" || c === "`") {
103
+ // Skip string literals (but capture them)
104
+ const quote = c;
105
+ let start = i + 1;
106
+ i++;
107
+ while (i < src.length) {
108
+ if (src[i] === "\\") {
109
+ i += 2;
110
+ continue;
111
+ }
112
+ if (src[i] === quote) {
113
+ break;
114
+ }
115
+ i++;
116
+ }
117
+ const str = src.slice(start, i);
118
+ i++;
119
+ yield { type: "StringLiteral", value: str };
120
+ continue;
121
+ } else if (/[A-Za-z_$]/.test(c)) {
122
+ // Identifier
123
+ let start = i;
124
+ i++;
125
+ while (i < src.length && /[\w$]/.test(src[i])) {
126
+ i++;
127
+ }
128
+ yield { type: "Identifier", value: src.slice(start, i) };
129
+ continue;
130
+ } else if (/[{}();,]/.test(c)) {
131
+ // Punctuator (we still keep braces/semis for possible future use)
132
+ yield { type: "Punctuator", value: c };
133
+ i++;
134
+ continue;
135
+ } else {
136
+ // Skip everything else (whitespace, operators, etc.)
137
+ i++;
138
+ }
139
+ }
140
+ }
@@ -0,0 +1,20 @@
1
+ import { normalizeHref } from "./utilities.js";
2
+
3
+ export default function pathsInRobotsTxt(txt) {
4
+ const crawlablePaths = [];
5
+ let match;
6
+
7
+ // Find `Sitemap` directives.
8
+ const sitemapRegex = /Sitemap:\s*(?<href>[^\s]*)/g;
9
+ while ((match = sitemapRegex.exec(txt))) {
10
+ const href = normalizeHref(match.groups?.href);
11
+ if (href) {
12
+ crawlablePaths.push(href);
13
+ }
14
+ }
15
+
16
+ return {
17
+ crawlablePaths,
18
+ resourcePaths: [],
19
+ };
20
+ }
@@ -0,0 +1,20 @@
1
+ import { normalizeHref } from "./utilities.js";
2
+
3
+ export default function pathsInSitemap(xml) {
4
+ const crawlablePaths = [];
5
+ let match;
6
+
7
+ // Find `loc` elements.
8
+ const locRegex = /<loc>(?<href>[^<]*)<\/loc>/g;
9
+ while ((match = locRegex.exec(xml))) {
10
+ const href = normalizeHref(match.groups?.href);
11
+ if (href) {
12
+ crawlablePaths.push(href);
13
+ }
14
+ }
15
+
16
+ return {
17
+ crawlablePaths,
18
+ resourcePaths: [],
19
+ };
20
+ }
@@ -0,0 +1,125 @@
1
+ import {
2
+ extension,
3
+ isPlainObject,
4
+ trailingSlash,
5
+ } from "@weborigami/async-tree";
6
+
7
+ // A fake base URL used to handle cases where an href is relative and must be
8
+ // treated relative to some base URL.
9
+ const fakeBaseUrl = new URL("fake:/");
10
+
11
+ /**
12
+ * Destructively add a path to the paths object
13
+ */
14
+ export function addHref(paths, href, isCrawlable) {
15
+ href = normalizeHref(href);
16
+ if (href === null) {
17
+ // Normalized href is null, was just an anchor or search; skip
18
+ return;
19
+ }
20
+ isCrawlable ??= isCrawlableHref(href);
21
+ if (isCrawlable) {
22
+ paths.crawlablePaths.push(href);
23
+ } else {
24
+ paths.resourcePaths.push(href);
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Add the value to the object at the path given by the keys
30
+ *
31
+ * @param {any} object
32
+ * @param {string[]} keys
33
+ * @param {any} value
34
+ */
35
+ export function addValueToObject(object, keys, value) {
36
+ for (let i = 0, current = object; i < keys.length; i++) {
37
+ const key = trailingSlash.remove(keys[i]);
38
+ if (i === keys.length - 1) {
39
+ // Write out value
40
+ if (isPlainObject(current[key])) {
41
+ // Route with existing values; treat the new value as an index.html
42
+ current[key]["index.html"] = value;
43
+ } else {
44
+ current[key] = value;
45
+ }
46
+ } else {
47
+ // Traverse further
48
+ if (!current[key]) {
49
+ current[key] = {};
50
+ } else if (!isPlainObject(current[key])) {
51
+ // Already have a value at this point. The site has a page at a route
52
+ // like /foo, and the site also has resources within that at routes like
53
+ // /foo/bar.jpg. We move the current value to "index.html".
54
+ current[key] = { "index.html": current[key] };
55
+ }
56
+ current = current[key];
57
+ }
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Determine a URL we can use to determine whether a link is local within the
63
+ * tree or not.
64
+ *
65
+ * If a baseHref is supplied, convert that to a URL. If it's a relative path,
66
+ * use a fake base URL. If no baseHref is supplied, see if the `object`
67
+ * parameter defines an `href` property and use that to construct a URL.
68
+ *
69
+ * @param {string|undefined} baseHref
70
+ * @param {any} object
71
+ */
72
+ export function getBaseUrl(baseHref, object) {
73
+ let url;
74
+ if (baseHref !== undefined) {
75
+ // See if the href is valid
76
+ try {
77
+ url = new URL(baseHref);
78
+ } catch (e) {
79
+ // Invalid, probably a path; use a fake protocol
80
+ url = new URL(baseHref, fakeBaseUrl);
81
+ }
82
+ } else if (object.href) {
83
+ // Use href property on object
84
+ let href = object.href;
85
+ if (!href?.endsWith("/")) {
86
+ href += "/";
87
+ }
88
+ url = new URL(href);
89
+ } else {
90
+ url = fakeBaseUrl;
91
+ }
92
+ return url;
93
+ }
94
+
95
+ export function isCrawlableHref(href) {
96
+ // Use a fake base URL to cover the case where the href is relative.
97
+ const url = new URL(href, fakeBaseUrl);
98
+ const pathname = url.pathname;
99
+ const lastKey = pathname.split("/").pop() ?? "";
100
+ if (lastKey === "robots.txt" || lastKey === "sitemap.xml") {
101
+ return true;
102
+ }
103
+ const ext = extension.extname(lastKey);
104
+ // We assume an empty extension is HTML.
105
+ const crawlableExtensions = [".html", ".css", ".js", ".map", ".xhtml", ""];
106
+ return crawlableExtensions.includes(ext);
107
+ }
108
+
109
+ // Remove any search parameters or hash from the href. Preserve absolute or
110
+ // relative nature of URL. If the URL only has a search or hash, return null.
111
+ export function normalizeHref(href) {
112
+ // Remove everything after a `#` or `?` character.
113
+ const normalized = href.split(/[?#]/)[0];
114
+ return normalized === "" ? null : normalized;
115
+ }
116
+
117
+ // For indexing and storage purposes, treat a path that ends in a trailing slash
118
+ // as if it ends in index.html.
119
+ export function normalizeKeys(keys) {
120
+ const normalized = keys.slice();
121
+ if (normalized.length === 0 || trailingSlash.has(normalized.at(-1))) {
122
+ normalized.push("index.html");
123
+ }
124
+ return normalized;
125
+ }
package/src/dev/dev.js CHANGED
@@ -1,6 +1,8 @@
1
1
  export { default as breakpoint } from "./breakpoint.js";
2
2
  export { default as changes } from "./changes.js";
3
3
  export { default as code } from "./code.js";
4
+ export { default as audit } from "./crawler/audit.js";
5
+ export { default as crawl } from "./crawler/crawl.js";
4
6
  export { default as debug } from "./debug.js";
5
7
  export { default as explore } from "./explore.js";
6
8
  export { default as log } from "./log.js";
@@ -11,9 +11,12 @@ import htmHandler from "./htm.handler.js";
11
11
  import htmlHandler from "./html.handler.js";
12
12
  import jpegHandler from "./jpeg.handler.js";
13
13
  import jpgHandler from "./jpg.handler.js";
14
+ import jseHandler from "./jse.handler.js";
15
+ import jseDocumentHandler from "./jsedocument.handler.js";
14
16
  import jsonHandler from "./json.handler.js";
15
17
  import mdHandler from "./md.handler.js";
16
18
  import mjsHandler from "./mjs.handler.js";
19
+ import tsHandler from "./ts.handler.js";
17
20
  import txtHandler from "./txt.handler.js";
18
21
  import xhtmlHandler from "./xhtml.handler.js";
19
22
  import ymlHandler from "./yml.handler.js";
@@ -26,11 +29,15 @@ export default {
26
29
  "jpeg.handler": jpegHandler,
27
30
  "jpg.handler": jpgHandler,
28
31
  "js.handler": jsHandler,
32
+ "jse.handler": jseHandler,
33
+ "jsep.handler": jseHandler,
34
+ "jsedocument.handler": jseDocumentHandler,
29
35
  "json.handler": jsonHandler,
30
36
  "md.handler": mdHandler,
31
37
  "mjs.handler": mjsHandler,
32
38
  "ori.handler": oriHandler,
33
39
  "oridocument.handler": oridocumentHandler,
40
+ "ts.handler": tsHandler,
34
41
  "txt.handler": txtHandler,
35
42
  "wasm.handler": wasmHandler,
36
43
  "xhtml.handler": xhtmlHandler,
@@ -0,0 +1,16 @@
1
+ import { oriHandler } from "../internal.js";
2
+ import getParent from "./getParent.js";
3
+ import jseModeParent from "./jseModeParent.js";
4
+
5
+ export default {
6
+ ...oriHandler,
7
+
8
+ async unpack(packed, options = {}) {
9
+ const parent = getParent(packed, options);
10
+ return oriHandler.unpack(packed, {
11
+ ...options,
12
+ mode: "jse",
13
+ parent: await jseModeParent(parent),
14
+ });
15
+ },
16
+ };
@@ -0,0 +1,30 @@
1
+ import { FileTree, ObjectTree } from "@weborigami/async-tree";
2
+
3
+ let builtinsNew;
4
+
5
+ // Adapt the existing parent chain to use the new builtins
6
+ export default async function jseModeParent(parent) {
7
+ builtinsNew ??= (await import("../builtinsNew.js")).default;
8
+ return cloneParent(parent);
9
+ }
10
+
11
+ function cloneParent(parent) {
12
+ let clone;
13
+ // We expect the parent to be a FileTree (or a subclass), ObjectTree (or a
14
+ // subclass), or builtins.
15
+ if (!parent) {
16
+ return null;
17
+ } else if (parent instanceof FileTree) {
18
+ clone = Reflect.construct(parent.constructor, [parent.path]);
19
+ } else if (parent instanceof ObjectTree) {
20
+ clone = Reflect.construct(parent.constructor, [parent.object]);
21
+ } else if (!parent.parent) {
22
+ // Builtins
23
+ clone = builtinsNew;
24
+ } else {
25
+ // Maybe a map? Skip it and hope for the best.
26
+ return cloneParent(parent.parent);
27
+ }
28
+ clone.parent = cloneParent(parent.parent);
29
+ return clone;
30
+ }
@@ -0,0 +1,16 @@
1
+ import { oridocumentHandler } from "../internal.js";
2
+ import getParent from "./getParent.js";
3
+ import jseModeParent from "./jseModeParent.js";
4
+
5
+ export default {
6
+ ...oridocumentHandler,
7
+
8
+ async unpack(packed, options = {}) {
9
+ const parent = getParent(packed, options);
10
+ return oridocumentHandler.unpack(packed, {
11
+ ...options,
12
+ mode: "jse",
13
+ parent: await jseModeParent(parent),
14
+ });
15
+ },
16
+ };
@@ -34,7 +34,8 @@ export default {
34
34
 
35
35
  // Compile the source code as an Origami program and evaluate it.
36
36
  const compiler = options.compiler ?? compile.program;
37
- const fn = compiler(source);
37
+ const mode = options.mode ?? "shell";
38
+ const fn = compiler(source, { mode });
38
39
  const target = parent ?? builtinsTree;
39
40
  let content = await fn.call(target);
40
41
 
@@ -35,7 +35,8 @@ export default {
35
35
  text,
36
36
  url,
37
37
  };
38
- const defineFn = compile.templateDocument(source);
38
+ const mode = options.mode ?? "shell";
39
+ const defineFn = compile.templateDocument(source, { mode });
39
40
 
40
41
  // Invoke the definition to get back the template function
41
42
  const result = await defineFn.call(parent);
@@ -0,0 +1 @@
1
+ export { default as default } from "./js.handler.js";
@@ -39,7 +39,8 @@ export default {
39
39
  throw new TypeError("The input to pack must be a JavaScript object.");
40
40
  }
41
41
 
42
- const text = object["@text"] ?? "";
42
+ // TODO: Deprecate @text
43
+ const text = object._body ?? object["@text"] ?? "";
43
44
 
44
45
  /** @type {any} */
45
46
  const dataWithoutText = Object.assign({}, object);
@@ -72,7 +73,14 @@ export default {
72
73
  } else {
73
74
  frontData = parseYaml(frontText);
74
75
  }
76
+ // TODO: Deprecate @text
75
77
  unpacked = Object.assign({}, frontData, { "@text": body });
78
+ Object.defineProperty(unpacked, "_body", {
79
+ configurable: true,
80
+ value: text,
81
+ enumerable: false, // TODO: Make enumerable
82
+ writable: true,
83
+ });
76
84
  } else {
77
85
  // Plain text
78
86
  unpacked = new String(text);
@@ -1,12 +1,18 @@
1
1
  dev:
2
2
  description: Develop and debug Origami projects
3
3
  commands:
4
+ audit:
5
+ args: (tree)
6
+ description: Identify broken internal links and references
4
7
  breakpoint:
5
8
  args: (a)
6
9
  description: Break into the JavaScript debugger, then return a
7
10
  changes:
8
11
  args: (old, new)
9
12
  description: Return a tree of changes
13
+ crawl:
14
+ args: (tree, base)
15
+ description: A tree of a site's discoverable resources
10
16
  debug:
11
17
  args: (tree)
12
18
  description: Add debug features to the tree
@@ -213,12 +219,6 @@ scope:
213
219
  site:
214
220
  description: Add common website features
215
221
  commands:
216
- audit:
217
- args: (tree)
218
- description: Identify broken internal links and references
219
- crawl:
220
- args: (tree, base)
221
- description: A tree of a site's discoverable resources
222
222
  index:
223
223
  args: (tree)
224
224
  description: A default index.html page for the tree