Init with initial deployment

This commit is contained in:
Colby Russell
2026-09-09 23:30:56 -05:00
commit d754334f47
7 changed files with 791 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
<!doctype html>
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8"/>
<meta http-equiv="302 Moved Temporarily"/>
<meta http-equiv="Location" content="/"/>
<link rel="canonical" href="https://atrender.com/1/"/>
<meta http-equiv="refresh" content="0; url=/" type="text/html"/>
</head>
</html>
+400
View File
@@ -0,0 +1,400 @@
function main(event, page = event.target.body) {
;;; check_for_json_file_name: ;;;
let name = page.ownerDocument.location.pathname;
if (!name.endsWith(".json") && !name.endsWith(".json.html")) {
logDiagnostic("not treated as JSON: " + name);
return null;
}
;;; json_extraction_and_parsing: ;;;
// assert(page.ownerDocument.compatMode == "BackCompat")
let json = getJSONEncoding(page); // showing on old page
page = fixQuirks(page.ownerDocument); // empty, new page
// assert(page.ownerDocument.compatMode == "CSS1Compat")
page.innerHTML = (`<${'pre'}>`);
page.querySelector("pre").textContent = json;
let error = null;
let processor = new WatchlistProcessor(page);
try {
var data = JSON.parse(json, $revive);
} catch (ex) {
logDiagnostic("file contains invalid JSON: " + name);
error = ex;
}
function $revive(key, value, ...rest) {
try {
processor.observe(key, value, ...rest);
} catch (ex) {
logDiagnostic("process failed during observation: " + name);
error = error || ex; // keep the first error (if we saw one)
}
return value;
}
;;; finished_parsing: ;;;
if (error) throw error;
;;; parse_succeeded: ;;;
processor.render(json, data, page.querySelector("pre").firstChild);
}
// <script>
class WatchlistProcessor {
constructor(page) {
this.controls = new WatchlistDataControls(page);
this.currentSourceGroup = [];
this.sourceItems = [];
this.renderValue = null;
}
observe(key, value, context) {
// NB: This JSON processor is NOT written to handle pathological cases
// (e.g. adversarial inputs); the "watchlib.js" in this demo is not a
// general-purpose library fit for production use. It will work on the
// input in this demo (and will work on some other inputs, too), but this
// remains just a demo and will fall apart at the slightest perturbation.
//
// (To give one example: we assume that all keys are unescaped. And our
// helpers don't fare well on object definitions with duplicate keys.)
if ("source" in context) {
let item = new WatchlistProcessor.SourceItem(context.source, key);
this.sourceItems.push(item);
if (key == "id" || key == "title") {
this.currentSourceGroup.push(item);
}
} else if (!Array.isArray(value)) {
if ("id" in value && "title" in value) {
let count = this.currentSourceGroup.length;
let items = this.currentSourceGroup.splice(0, count);
for (let i = 0; i < count; ++i) {
items[i].object = value;
}
}
}
}
render(unparsed, parsed, node) {
// assert(node.nodeValue == unparsed)
let doc = node.ownerDocument;
let caret = 0;
let newNodes = doc.createDocumentFragment();
for (let i = 0, n = this.sourceItems.length; i < n; ++i) {
let item = this.sourceItems[i];
// assert(item.source.length > 0)
item.offset = unparsed.indexOf(item.source, caret);
// assert(item.offset >= 0 && item.offset < unparsed.length)
let span = doc.createElement("span");
span.className = "source-item";
span.append(doc.createTextNode(item.source));
newNodes.append(
doc.createTextNode(unparsed.substring(caret, item.offset)),
span
);
item.node = span;
if (item.key == "title") {
span.classList.add("movie-title");
let title = item.object["title"];
if (item.source != JSON.stringify(title)) {
span.setAttribute("title", title);
span.classList.add("escaped-value");
}
} else if (item.key == "id") {
span.classList.add("foreign-key");
let id = item.object["id"];
if (id.startsWith("[") && id.endsWith("]")) {
let pair = id.substring(1, id.length - 1);
let split = pair.split(":");
if (split.length == 2) {
switch (split[0]) {
case "wikidata":
this.controls.linkPropertyToURL(
"title", item.object,
"https://www.wikidata.org/wiki/Special:GoToLinkedPage?" +
"site=enwiki&itemid=" + split[1]
);
this.controls.linkPropertyToURL(
"id", item.object,
"https://www.wikidata.org/wiki/Special:EntityPage/" +
split[1],
pair
);
break;
case "tmdb":
this.controls.linkPropertyToURL(
"title", item.object,
"https://themoviedb.org/" +
split[1]
);
this.controls.linkPropertyToURL(
"id", item.object,
"https://themoviedb.org/" +
split[1],
pair
);
break;
}
}
}
} else if (item.key == "@render") {
span.classList.add("loader-string");
// Fun fact: If you do it right, you can apply a similar trick to JS
// files; the browser doesn't care about the content type (or the file
// extension) of a script loaded with the src attribute, so you could
// make polyglot JS+HTML files, too (and even use it as the loader...)
}
caret = item.offset + item.source.length;
}
newNodes.append(doc.createTextNode(unparsed.substring(caret)));
node.parentElement.className = "json-source";
WatchlistDataControls.applySkin(doc.head);
this.controls.insertLinks(this.sourceItems);
node.parentNode.replaceChild(newNodes, node);
}
}
// A "source" refers to the source code of a (primitive) value from a
// key/value pair. We go ahead and keep track of the name of the key it's
// defined for and the object that the key is defined on because this is
// something we already have access to, and it's useful information.
//
// (These are NOT for complex/composite values--only primitives.)
WatchlistProcessor.SourceItem = class {
constructor(source, kind, object = null) {
this.node = null;
this.offset = -1;
this.source = source;
this.key = kind;
this.object = object;
}
}
// <script>
class WatchlistDataControls {
constructor(page) {
this.page = page;
this.links = [];
}
static applySkin(parent) {
let styleElement = parent.ownerDocument.createElement("style");
parent.appendChild(styleElement);
let $ = styleElement.sheet.insertRule.bind(styleElement.sheet);
$("body {" +
"background-color: #FDFDFD;" +
"}");
$(".json-source {" +
"color: #222222;" +
"}");
$(".source-item:not(.loader-string) {" +
"color: #000000;" +
"}");
$(".movie-title {" +
"font-weight: bold;" +
"}");
$(".movie-title a {" +
"color: #090909;" +
"}");
$(".foreign-key a {" +
"color: #000000;" +
"}");
$(".source-item a:not(:hover) {" +
"text-decoration: none;" +
"}");
$(".escaped-value {" +
"text-decoration: dashed underline;" +
"}");
}
// Expects span to have exactly one text node as a child.
linkContentsToURL(span, start, end, url) {
span.firstChild.splitText(end);
let content = span.firstChild.splitText(start);
if (!span.firstChild.textContent.length) {
span.removeChild(span.firstChild);
}
if (!span.lastChild.textContent.length) {
span.removeChild(span.lastChild);
}
let link = span.ownerDocument.createElement("a");
link.target = "_top";
link.append(span.replaceChild(link, content));
link.href = url;
}
linkPropertyToURL(key, owner, url, text = null) {
const { LinkTransform } = WatchlistDataControls;
let transform = new LinkTransform(key, owner, url, text);
this.links.push(transform);
}
insertLinks(sourceCollection, linksCollection = this.links) {
// We need to go from a given key & object definition to the source span
// for the appropriate value in the key/value pair.
//
// Basic strategy: figure out which key/value pairs belong together (i.e.
// they're properties defined on the the same object), and then when we
// have a given transform, we go from transform to containing object to
// property value to its corresponding span in the DOM.
let propertyGroups = new Map();
for (let i = 0, n = sourceCollection.length; i < n; ++i) {
let obj = sourceCollection[i].object;
if (!obj) continue;
if (!propertyGroups.has(obj)) {
propertyGroups.set(obj, new Set());
}
let group = propertyGroups.get(obj);
group.add(sourceCollection[i]);
}
for (let i = 0, n = linksCollection.length; i < n; ++i) {
let transform = linksCollection[i];
if (transform.finished) continue;
let group = propertyGroups.get(transform.owner);
if (!group) {
logDiagnostic("property definitions unexpectedly unavailable");
continue;
}
let found = ([ ...group ]).filter((x) => (
x.key == transform.key
));
if (found.length != 1) {
logDiagnostic(
"couldn't find key to link property for url: " + transform.url
);
continue;
}
let span = found[0].node;
let start = 0;
let end = span.textContent.length;
if (transform.text) {
start = span.textContent.indexOf(transform.text);
if (start < 0) {
start = 0;
} else {
end = start + transform.text.length;
}
} else if (span.textContent.startsWith(`"`) &&
span.textContent.endsWith(`"`)) {
++start;
--end;
}
this.linkContentsToURL(span, start, end, transform.url); // XXX
transform.finished = true;
}
}
}
WatchlistDataControls.LinkTransform = class {
constructor(key, owner, url, text) {
this.finished = false;
this.key = key;
this.owner = owner;
this.url = url;
this.text = text;
}
}
// <script>
function getJSONEncoding(page) {
let elements = [ ...page.querySelectorAll("*") ];
if (elements.length != 1) {
return fail();
}
let [ script ] = elements.filter((x) => (
x.nodeName.toLowerCase() == "script" &&
x.previousSibling.nodeType == page.TEXT_NODE &&
x.previousSibling.nodeValue.trim().replace((/ /g), "").endsWith(
`"@render":"`
)
));
if (typeof(script) == "undefined") {
return fail();
}
let allKids = ([ ...page.childNodes ]);
let holding = [];
holding.push(...allKids.map((x) => {
if (x.nodeType == page.TEXT_NODE) {
return x.nodeValue.replace((/</g), "\\u003c");
} else if (x == script) {
return script.outerHTML.replace((/"/g), "'");
}
return fail();
}));
return holding.join("");
function fail() {
throw Error("payload is malformed; expecting loader script element");
}
}
// <script>
function fixQuirks(doc, title = "") {
doc.open();
doc.write(
"<!DOCTYPE html><html><head><meta charset='utf-8'><title>" + title
);
doc.close();
return doc.body;
}
// <script>
function logDiagnostic(...args) {
if (typeof(__WATCHLIB_LOGGING__) != "undefined" &&
typeof(console) != "undefined") {
console.debug(...args);
}
}
// <script>
void function _start() {
if (typeof(document) == "undefined") throw Error("panic!");
if (typeof(window) == "undefined") throw Error("panic!");
if (document.readyState == "loading") {
return void(window.onload = main);
}
return void(main(null, document.body));
} ()
// </script>
+50
View File
@@ -0,0 +1,50 @@
{
"@render": "<script src='./watchlib.js'></script>",
"name": "Alice's watchlist",
"url": "https://alice.example.net/movies",
"already_watched": [
{
"title": "The Iron Giant", "id": "[wikidata:Q867283]",
"score": ":thumbsup:"
},
{
"title": "Under the Skin", "id": "[wikidata:Q4366287]",
"score": ":thumbsdown:"
},
{
"title": "The Long Kiss Goodnight", "id": "[wikidata:Q1168399]",
"score": ":thumbsup:"
}
],
"up_next": [
{"title": "In the Heat of the Night", "id":"[wikidata:Q622240]"},
{"title": "Thelma \u0026 Louise", "id":"[wikidata:Q658041]"},
{"title": "Patriot Games", "id":"[wikidata:Q855222]"},
{"title": "Crash", "id":"[wikidata:Q188000]"},
{"title": "The Pope of Greenwich Village", "id":"[wikidata:Q583221]"},
{"title": "The Lost Bus", "id":"[wikidata:Q125153299]"},
{"title": "Quisling: The Final Days", "id":"[wikidata:Q125417255]"},
{"title": "The Railway Man", "id":"[wikidata:Q4178880]"},
{"title": "Speed", "id":"[wikidata:Q108006]"},
{"title": "Blue Velvet", "id":"[wikidata:Q660950]"},
{"title": "Eyes Wide Shut", "id":"[wikidata:Q209481]"},
{"title": "Casablanca", "id":"[wikidata:Q132689]"},
{"title": "It Happened One Night", "id":"[wikidata:Q208632]"},
{"title": "Incendies", "id":"[wikidata:Q1212650]"},
{"title": "Roofman", "id":"[wikidata:Q130742503]"},
{"title": "Point Break", "id":"[wikidata:Q1146552]"},
{"title": "Dead End", "id":"[wikidata:Q676341]"},
{"title": "Sophie's Choice", "id":"[wikidata:Q165627]"},
{"title": "A Quiet Place Part II", "id":"[wikidata:Q53911403]"},
{"title": "The Silence of the Lambs", "id":"[wikidata:Q133654]"},
{"title": "Maria Bamford: Local Act", "id":"[tmdb:movie/1215158]"},
{"title": "Mr. Jones", "id":"[wikidata:Q30963297]"},
{"title": "First Cow", "id":"[wikidata:Q65082453]"},
{"title": "Fried Green Tomatoes", "id":"[wikidata:Q118375]"},
{"title": "Glass Onion: A Knives Out Mystery", "id":"[wikidata:Q84712797]"},
{"title": "Wake Up Dead Man", "id":"[wikidata:Q115931717]"},
{"title": "Dead Poets Society", "id":"[wikidata:Q106316]"},
{"title": "The English Patient", "id":"[wikidata:Q63026]"},
{"title": "Atonement", "id":"[wikidata:Q1626186]"}
]
}
+190
View File
@@ -0,0 +1,190 @@
<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8">
<title>About @render for JSON</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
body {
max-width: 99ch;
margin: auto;
padding: 2em;
line-height: 1.5em;
font-family: Helvetica, Arial, sans-serif;
}
h1 {
margin: 2em 0 1em;
}
h2 {
margin-top: 2em;
}
pre {
padding: 1em;
overflow-x: auto;
line-height: 1.45;
background: #F4F4F4;
}
iframe {
width: 100%;
height: 24em;
margin: 2em 0 1em;
border: 4px solid #676767;
}
footer {
margin-top: 4em;
border-top: 1px solid #AAAAAA;
}
</style>
<link rel="canonical" href="https://atrender.com/1/"/>
<meta name="generator" content="me">
</head>
<body>
<header>
<h1>About @render for JSON</h1>
<p>
You can use @render in your JSON to provide rich controls to help with
viewing, filtering, and editing what would otherwise be inert data.
</p>
<p>
The @render trick depends on well-defined behavior implemented by all
browsers.
</p>
<p>
Read on to understand @render and how it works.
</p>
</header>
<article>
<h2 id="1-what-does-it-look-like">1. What does @render look
like?</h2>
<p>In a text editor, it looks like an @render attribute annotating
your JSON. This references your desired loader to bootstrap the UI builder
(or "hydration" logic) for working with the JSON data. For example:
<pre>
{
"@render": "&lt;script src='https://cdn.example/watchlib.js'></script>",
"name": "Alice's watchlist",
"url": "https://alice.example.net/movies",
"already_watched": [
{
"title": "The Iron Giant", "id": "[wikidata:Q867283]",
"score": ":thumbsup:"
},
{
"title": "Under the Skin", "id": "[wikidata:Q4366287]",
"score": ":thumbsdown:"
},
/* ... */
</pre>
<p>In a browser, it looks however you want it to look—subject to what your
@render loader chooses to build and put on the screen.
<p>Here's a live example of Alice's watchlist data, showing how the
post-render data looks in the browser:</p>
<iframe src="./demo/watchlist.json.html"></iframe>
<p>(You can also follow a <a href="./demo/watchlist.json.html">direct link to
the JSON payload</a> to see it outside the iframe shown here.)</p>
<h2 id="2-how-does-it-work">2. How does it work?</h2>
<p>To a JSON parser, @render is just an ordinary property whose value is a string
(albeit one that we know happens to look like HTML).
<p>To an HTML parser, your JSON just looks like a bunch of text surrounding a
bit of markup describing a lone script element near the beginning of the file
(in the body rather than the head, but that's no big deal).
<p>The trick is to convince browsers to treat the JSON as HTML, whether by
sending an HTML media type from the server, or by using an HTML file extension
(instead of .json) for local files opened from your computer.
<p>In an HTML parsing context, the browser sees the markup for your loader
script and then loads and executes the script source. The script, if
self-aware enough, can read out the JSON data and then attach purpose-built,
in-browser controls for viewing, editing, and otherwise working with the
specific type of data contained within the JSON payload (or other data that it
references, or anything else that you want to put on the screen).
<h2 id="3-why-would-you-want-this">3. Why would you want this?</h2>
<p>JSON is an acceptable format for authoring and data exchange, but sometimes
big blobs of JSON can get unwieldy. Not all user agents have perfect (or your
preferred) JSON viewing tools, and even browsers that have tools for
dealing with JSON can't have application-specific affordances for all the
different shapes of JSON they might be absked to show.
<p>The @render trick lets you put your data on the screen in the way that you
prefer to show it while remaining valid JSON.
<p>And of course, the latitude that this gives you means you can use @render
to make the actual editing experience for JSON-based formats nicer, too.
<h2 id="4-the-app-could-just-do-that-though">4. Shouldn't the apps we're
building already provide those editing affordances?</h2>
<p>Maybe. Using @render can be useful for providing a lower-level interface to
the data encoded in the file that ordinary users might not be interested in
(or ever even see).
<h2 id="5-what-else">5. What else?</h2>
<p>There are some caveats.</p>
<p>In addition to getting it to load in a parsing context that's expecting
markup, you'll need to make sure to properly escape your JSON content for
HTML. Doing this successfully isn't super-complicated. It's enough to escape
all occurrences of <small>U+0026 AMPERSAND</small> and <small>U+003C LESS-THAN
SIGN</small> using the Unicode escape sequence notation for string literals in
JSON, i.e., <code>\u0026</code> and \<code>u003c,</code> respectively.</p>
<p>There are additional <a title="Authoring notes (using @render with JSON)"
href="/notes/">authoring notes and tips relevant to @render</a> collected on
a separate page.</p>
</article>
<footer class="colophon">
<dl>
<dt>Document identifier</dt>
<dd><a href="https://atrender.com/1/">https://atrender.com/1/</a></dd>
<dt>Publication date</dt>
<dd>2026 September 09</dd>
<dt>Latest revision</dt>
<dd><a href="https://atrender.com/latest/">https://atrender.com/latest/</a></dd>
</dl>
</footer>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
<!doctype html>
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8"/>
<meta http-equiv="302 Moved Temporarily"/>
<meta http-equiv="Location" content="/"/>
<meta http-equiv="refresh" content="0; url=/" type="text/html"/>
</head>
</html>
+10
View File
@@ -0,0 +1,10 @@
<!doctype html>
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8"/>
<meta http-equiv="302 Moved Temporarily"/>
<meta http-equiv="Location" content="/notes/"/>
<link rel="canonical" href="https://atrender.com/notes/1/"/>
<meta http-equiv="refresh" content="0; url=/notes/" type="text/html"/>
</head>
</html>
+122
View File
@@ -0,0 +1,122 @@
<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8">
<title>Authoring notes (using @render with JSON)</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
body {
max-width: 99ch;
margin: auto;
padding: 2em;
line-height: 1.5em;
font-family: Helvetica, Arial, sans-serif;
}
h1 {
margin: 2em 0 1em;
}
h2 {
margin-top: 2em;
}
pre {
padding: 1em;
overflow-x: auto;
line-height: 1.45;
background: #F4F4F4;
}
footer {
margin-top: 4em;
border-top: 1px solid #AAAAAA;
}
.name {
font-weight: bold;
}
</style>
<link rel="canonical" href="https://atrender.com/notes/1/"/>
<meta name="generator" content="me">
</head>
<body>
<article>
<h1>Authoring notes</h1>
<p>Is there anything else to be mindful of with the use of
<span class="name">@render</span>? Yes. There
are a few things—</p>
<p>To re-iterate the most important: forcing JSON into an HTML parsing context
means that any text that's not escaped for HTML poses a risk to whether and
how the loader does its job. In the worst cases, <strong>it could lead to
cross-site scripting if you act without care. It's important to escape the
data to prevent this</strong>. A good rule of thumb is to treat any open
angle bracket in your payload (after the @render line, that is) as an
oversight that needs immediate correction, regardless of the provenance of the
data.</p>
<p>While consistently escaping <small>U+003C LESS-THAN-SIGN</small> (as
<code>\u003c</code>) in all other parts of the JSON payload is enough to
eliminate the cross-site scripting threat, for good measure, you may wish to
always escape <small>U+0026 AMPERSAND</small> (as <code>\u0026</code>), too,
since if the input goes through multiple rounds of parsing, then the sequence
<code>&amp;lt;script</code> can become <code>&lt;script</code> if you are
inconsistent.</p>
<p>Secondly, if you don't have enough influence over the server configuration
to control the Content-Type header (example: you have a static site), then
you'll probably want to save your data with a .json.html file extension. This
is also/already more or less required for any files that you intend to share
that are "unhosted" and expected to be opened straight from the file
system—it's what will get the correct double-click behavior in most system
file managers and hint to the browser how you want it to be parsed.</p>
<p>Additionally, when your loader runs, be aware that browsers will by default
put any UI elements that you add to the page into a document in quirks mode
rather than standards mode. This can affect layout and cause
hard-to-track-down issues. It's not impossible to address, but your
application logic does have to be aware of it if you hope to be able get out
of quirks mode.</p>
<p>Lastly, be aware that, although @render heretofore has had no particular
significance in JSON, we do want to play well with the JSON-LD ecosystem.
It's anticipated that it will be useful to define semantics for the fragment
in the script element src attribute. (It may be beneficial for the fragment
to serve as shorthand for a @type annotation, for example.) For that reason,
use of the fragment identifier in the script element src attribute should be
avoided for now until there has been an opportunity work out the exact
semantics.</p>
<p>Remember that wherever feasible, you should probably try to use existing
standards like JSON Schema and JSON-LD attributes to describe the data, but
this is not necessary for @render to work.</p>
</article>
<footer class="colophon">
<dl>
<dt>Document identifier</dt>
<dd><a href="https://atrender.com/notes/1/">https://atrender.com/notes/1/</a></dd>
<dt>Publication date</dt>
<dd>2026 September 09</dd>
<dt>Latest revision</dt>
<dd><a href="https://atrender.com/notes/">https://atrender.com/notes/</a></dd>
</dl>
</footer>
</body>
</html>