Background
I run a small static site, a Japanese dictionary of words and phrases that are trending on X. Every page is generated daily by a dependency-free build.js: a Node.js script that only uses the built-in fs module to write HTML files.
Each page carries JSON-LD (JSON for Linked Data, the structured-data format where you describe what a page is about as JSON and put it in a <script type="application/ld+json"> tag for search engines). There is no template engine involved. The JSON-LD goes straight into the HTML through a JavaScript template literal.
This post walks through that setup, and through the single line of escaping that I consider mandatory whenever you embed JSON this way.
How it works
The structure is simple. Each page builds an array of plain objects it wants to publish as JSON-LD and hands that array to a shared head() function.
data/*.json(語のデータ)
│
▼
renderWordPage(w) ── DefinedTerm / Article / BreadcrumbList / FAQPage のオブジェクトを作る
│ jsonld: [...]
▼
head(opts) ── 配列を <script type="application/ld+json"> に1個ずつ変換して <head> に入れる
│
▼
public/word/<slug>.html
(The labels in the diagram are the original Japanese notes: word data goes in, renderWordPage builds the DefinedTerm / Article / BreadcrumbList / FAQPage objects, and head turns each one into its own script tag inside <head>.)
Because the objects are ordinary JavaScript objects, adding or dropping properties conditionally is easy to read. The only place that builds an HTML string by hand is the very last step.
Implementation
1. head() turns the array into script tags
Here is the JSON-LD part of the real head() in build.js:
function head(opts) {
const {
title,
description,
canonicalPath,
ogType = "website",
ogImage = "/og-image.png",
jsonld = [],
noindex = false,
} = opts;
const url = CANON + canonicalPath;
const jsonldStr = jsonld
.map(
(o) => `<script type="application/ld+json">${JSON.stringify(o).replace(/</g, "\\u003c")}</script>`,
)
.join("\n");
jsonld is an array of objects, and each object becomes one script tag. The important part is that every < in the JSON.stringify output is replaced with \u003c. The Gotchas section explains why.
2. Pages just build objects and pass them in
On a word page, the word itself is a DefinedTerm (the schema.org type for a defined term) and the explanation is an Article. This is an excerpt from the actual code:
const altNames = [w.reading, ...w.aliases].filter(Boolean);
const definedTerm = {
"@context": "https://schema.org",
"@type": "DefinedTerm",
name: w.word,
alternateName: altNames.length > 1 ? altNames : w.reading,
description: w.meaning,
inDefinedTermSet: {
"@type": "DefinedTermSet",
name: site.siteName,
url: CANON + "/words",
},
url: CANON + path_,
};
If a word only has a reading, alternateName stays a plain string. It becomes an array only when the word also has alternative spellings. The Article object follows the same pattern: it skips keywords when there are no tags (...(w.tags.length ? { keywords: ... } : {})) and only sets datePublished when the word has a date. All of those decisions happen while building the object, not while building HTML.
Breadcrumbs appear on every page, so the JSON-LD version is generated by a function from the same array that renders the visible breadcrumb navigation:
function breadcrumbJsonLd(items) {
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: items.map((it, i) => ({
"@type": "ListItem",
position: i + 1,
name: it.label,
item: CANON + it.href,
})),
};
}
Since both the on-page breadcrumb and the structured-data breadcrumb come from the same crumbs array, they cannot drift apart when one of them is edited.
This is the actual output on a generated word page (/word/50-50). The full page has more blocks, so only the BreadcrumbList is shown:
<script type="application/ld+json">{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"トップ","item":"https://kotoba.autoarticles.net/"},{"@type":"ListItem","position":2,"name":"辞書","item":"https://kotoba.autoarticles.net/words"},{"@type":"ListItem","position":3,"name":"50-50","item":"https://kotoba.autoarticles.net/word/50-50"}]}</script>
The three input crumbs come out as ListItem entries with position 1 to 3, on a single line. (The name values are the Japanese labels for "Top" and "Dictionary".)
Gotchas
JSON.stringify alone lets the script tag close early
JSON.stringify gives you valid JSON. It does not give you something that is safe to drop into HTML. The HTML parser does not read the script body as JSON; it closes the tag as soon as it sees </script. If a word's description or a product name ever contains the string </script>, the JSON-LD gets cut off right there and the rest is parsed as HTML.
Here is a minimal script you can run yourself:
const o = { "@type": "DefinedTerm", name: "</script><script>alert(1)</script>" };
const raw = `<script type="application/ld+json">${JSON.stringify(o)}</script>`;
const safe = `<script type="application/ld+json">${JSON.stringify(o).replace(/</g, "\\u003c")}</script>`;
console.log(raw);
console.log(safe);
const body = safe.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/)[1];
console.log(JSON.parse(body).name);
Running it with node prints:
<script type="application/ld+json">{"@type":"DefinedTerm","name":"</script><script>alert(1)</script>"}</script>
<script type="application/ld+json">{"@type":"DefinedTerm","name":"\u003c/script>\u003cscript>alert(1)\u003c/script>"}</script>
</script><script>alert(1)</script>
Line 1 (before the replace) has a literal </script> in the middle of the value, which is where the HTML parser closes the tag. In line 2 (after the replace) every < is \u003c, so </script never appears in the HTML. Line 3 shows that \u003c is a JSON string escape, so JSON.parse gives you the original value back. Search engines see exactly the same data; only the HTML structure is protected.
> and & are left alone. What ends a script body is the appearance of </script, so removing < is enough.
Add a test that fails if the replace disappears
To anyone who did not write it, this line looks like a pointless replace, which makes it an easy casualty of a refactor. So test/jsonld-escape.test.js checks two things (run with npm test, which is node --test test/):
- It extracts every JSON-LD block from all generated HTML under
public/and asserts that no raw<remains and that each block passesJSON.parse. - It reads the
build.jssource and asserts that the expression embedding JSON-LD has.replace(/</g, "\\u003c")attached.
Check 1 on its own passes silently whenever the current data happens to contain no <. Check 2 catches the fix being removed regardless of the data. Both also fail when they find no JSON-LD at all, so the test cannot go green by checking nothing.
The result
This is the site that ships JSON-LD this way. Open the source of any word page and you will see the script tags described above: https://kotoba.autoarticles.net
The same one-line replace is also in the build.js of my other sites that are built the same way.
Wrap-up
- For JSON-LD on a static site, build plain objects and let
head()turn them into script tags. Conditional logic stays on the object side, and the code stays readable. - When embedding with a template literal, always run
JSON.stringify(o).replace(/</g, "\\u003c"). Valid JSON and safe-to-embed-in-HTML are two different problems. - Defensive code whose purpose is not obvious should come with a test that fails the moment it is removed.
This article is about my own side project. It was written with AI assistance.
Top comments (0)