On this page
Sitez
How Sitez is built: the site layer, with its folders, the frame, links, redirects, bundling and the dev server. What it promises is in design.md, and everything here can change underneath without changing those promises. The element is elementz.md's.
Sitez 0.2 was a CLI with +layout.js, .live files and a reset. Sitez 0.3 made it a
Vite plugin, and moved it to code/index.html and explicit metadata. Since then an element is one
@name.html file, and its code is src/elementz/.
Where Sitez sits
Sitez is the top of four layers, and each adds one thing to the one below it. The split is a direction, not packages yet. Sitez has one site, and a layer earns its own package when a second one uses it.
| Layer | Adds | Knows nothing of |
|---|---|---|
| markz | Markdown's syntax, as an AST with positions. It never evaluates. | elements, pages |
| elementz | the element, one @name.html file, rendered and run |
Markdown, files |
| pagez | the page, one Markz file and its elements in, one HTML file out | other pages, folders |
| sitez | the site: folders, pages that link, Vite, dev and deploy |
- elementz owns
html, the element file, rendering nested elements inside-out, element CSS and the browser runtime (elementz.md). Its first form issrc/elementz/in Sitez, which imports nothing from the rest of Sitez. That boundary is the layer, kept honest without a package to publish. - pagez owns Markz to HTML, the head from metadata and the frame (its design). It gets built when a page needs it, and amitkaps.com/stories, a page with its own look, may be that page (Open questions, "A second frame").
- sitez keeps what only a site has, such as discovery, links, redirects,
data/, bundling and the dev server.
A host gives elementz its context. pagez gives page, and sitez adds pages and site. So an
element that reads only page works in both. Splitting into packages was ruled out for now. It
would mean three repos to release in step for one person, and an API designed for a consumer
that doesn't exist yet.
Toolchain
Sitez is a Vite plugin, run with Vite+. A site adds it in vite.config.js and runs vp's commands from its
package.json (docs/design.md#commands). The plugin takes no options. It reads the site, renders
every page, and has Vite build the CSS and the script.
site
├── vite-plus dev server, preview, Rolldown, oxfmt, oxlint
└── @amitkaps/sitez the plugin, and html for build code
└── @amitkaps/markz parser
vite-plus is a peer dependency, which the site installs and pins beside Sitez, and Sitez imports
Vite's API from it. So a site needs no vite of its own. A site that adds a plugin wanting vite
points vite at Vite+'s build with an override, as every repository at
ship does. Imports resolve as Node's do, from the site's own
node_modules, at build time and in element scripts alike.
Vite strips TypeScript's types with no setup, so code/ takes .ts as well as .js.
Sitez began as a CLI that ran Vite itself, with the config nobody writes, and then as a plugin was
ruled out (Install). It became one once behavior could import npm packages.
Those imports need a bundler that resolves node_modules, so Vite stays. That ruled out a spike
without Vite, which the plan had held. A plugin then costs the site one small file,
and saves Sitez its CLI, its preview server, check, deploy and finding the site's root.
How build renders. Vite's buildApp hook runs the plugin's build in place of Vite's own,
since a site has no index.html for Vite to build from yet. The plugin starts a Vite server
with no listener or watcher, made from the site's own config file, and renders every page through
its module runner, as build did before the plugin. Then two more Vite builds, from the same
config file, bundle the CSS and the script. So a plugin the site adds applies to rendering, to its
CSS and to its script alike. The plugin marks Vite's environments built, so Vite doesn't build
them again. vp preview serves dist/, and the plugin adds the two things a static host does
that Vite doesn't, a redirect from a folder to its slash and 404.html with a 404 status.
The site must name Sitez. The check that the site's package.json named Sitez went with the
CLI. A site's vite.config.js imports @amitkaps/sitez/vite, and Vite fails to load a config
whose import isn't installed. So the import is the check, and it says which package is missing.
Ruled out:
- Rendering in Vite's environments. A build environment bundles modules and a dev server runs
them where they are. Rendering from a bundle would move every module, and
import.meta.urlwith it. The Markz site's Quality page reads its test cases relative to its own file. The plugin would also have to name every element as an entry before it had read the site, when the site's files say which of them a page needs. The server costs a second config load. - Bundling the CSS and script in the one client build. It would be fewer builds, but both come
from what the pages rendered, and the stylesheet has no code splitting where the script has
chunks. Two builds keep
dist/'s files and hashes as they were. - Plain
vite. A site'snode_modulesis about 31 MB on it, against 158 MB on Vite+, which bundles oxlint, oxfmt and a test runner. Vite+ won anyway, since every repository runs one toolchain, so a site formats, lints and builds the way Sitez itself does. Sitez 0.3 ran on plainvite. - Options for the plugin. Every option is configuration, which design.md leaves out. When a feature seems to need one, the convention that makes it unnecessary comes first.
Names in code/
code/index.html is the one name Sitez reads in code/. An element's file is @name.html. Any
other name is the site's own module or CSS (docs/design.md#folders).
@is Markz's own mark.{@call-out}in text is@call-out.htmlin code, so the file for an element is found by reading the element.- Where a part sits says when it runs, inside the file (elementz.md). So the name needs no suffix for the time.
index.htmlis Vite's own name for the HTML a build starts from, and it links what the page needs, as Vite's does.text/index.mdis the home page, and nothing else incode/is HTML a page starts from, so the two don't meet.- Elements are global. Subfolders in
code/only organize. One script holds every element's behavior, so a tag can only have one. A tag that meant different markup in different folders would mean different things to the same CSS. So two files for one element fail, wherever they sit.
Sitez began with other names, and they went because each was a rule with nothing behind it.
- JS pages (
code/blog/index.js→/blog/). A page that lists posts is a text page holding an element, so a second kind of page bought nothing. - Capitalized layouts and components (
Layout.js,CallOut.js). They had to be translated from the element's name, and case is easy to get wrong on a case-insensitive disk. _for modules. Once nothing incode/is a page, a module needs no mark.+for files Sitez reads (+layout.js,+style.css). Onceindex.htmllinks its own CSS, Sitez reads one file by name. A mark for a set of one is a rule to learn with nothing behind it..live, then.browser.js, for behavior, and@name.js, then.html.js, for build markup. Each named the time in the file's suffix. One file, where a part's place names the time, replaced them. A leftover.html.js,.browser.jsor bare@name.jsfails, naming the@name.htmlthat replaces it.- Elements found up the tree. A
code/blog/@post-list.jsapplied under/blog/only. It was a second rule for where a name means something, and one script had already made behavior global.
Other marks were weighed and ruled out.
.server.jsand.client.js. They're familiar from Nuxt and Remix, but a static site has no server. Nothing runs when a page is served, so the names would mislead about what code can do.- A sigil for the browser half (
$tag-filter.js,~tag-filter.js). A symbol has to be taught where a word reads, and$,~,!and#also mean something to shells. - Browser code as a second export of a build module (
renderandenhance). One module would run in Node and in the browser, and its imports would cross the boundary unseen. An element file keeps them apart by position, and Sitez cuts each script into its own module. fromorsrcfor data.datasays what the element gets, andsrcalready means a URL in HTML.
The frame
Every page is code/index.html with the page in its <slot> (rule 2). The file is complete HTML,
so it opens in a browser as it is and shows the site. Everything a page shows besides its text is
written there, and the head is split by who knows each tag.
index.htmlwrites what the site chooses. That'slang,charset,viewport, icons, font preloads, the stylesheets, the header and the footer. Its<title>and description are the site's own, kept by any page that sets neither.- Sitez writes what follows from metadata. It puts a page's
titleanddescriptionin place ofindex.html's. It writes the canonical link,og:title,og:description,og:url,og:type(website),twitter:card(summary),twitter:titleandtwitter:description. Those seven repeat two values with no choice in them, so writing them by hand would only invite a typo. - Sitez adds what only the build knows. That's each stylesheet's hashed name, the script on pages that use behavior, and a redirect's refresh.
Sitez's tags go at the end of the head, after what index.html wrote, so a site's own tags come
first. A frame with no </head> gets them after its description. The 404 page is served at URLs
that aren't its own, so it has no canonical link and no og:url. A page that sets neither title
nor description keeps the frame's, and its social tags say the same. A stylesheet link with a
relative path is a file in code/, and one that starts / or names a host is the site's own to
get right (Bundling).
index.html fails the build for what would make every page wrong. A missing lang, charset,
viewport, <title> or description fails. So does a slot count other than one, and a canonical,
og: or twitter: tag, which would be on the page twice. Each message shows the line to add or
remove. Sitez isn't an HTML validator, so it checks nothing else.
charset, viewport and lang are the site's to write, and Sitez fails without them rather
than adding them. Without them a page's accented text garbles, a phone zooms out, and a screen
reader guesses the language. All three are the same on every page, so writing them once costs
three lines.
Ruled out:
- Template syntax (
{{ site.title }}, as Jekyll writes). The file would be broken until something ran it, and its syntax a language to learn. Sitez fills the two tags whose values change by page, which needs none. +layout.jswith aheadexport. It was JavaScript around markup that is mostly fixed. amitkaps.github.io's was 58 lines, most of them a nav loop and font preloads that HTML writes as plainly. It also needed rules for a layout that dropped or repeated its children.- Frames that nest, found up the tree as
+layout.jswas.index.htmlis a whole page, so one can't go inside another, and a section varies through elements that readpage. A second frame that replaces the first is a different question, open below. <main>as the place for the page. A frame often puts more around the page inside<main>, such as an<article>.<slot>is HTML's own word for where content goes.- The title as content. Sitez 0.2 took
titlefrom the first heading andsummaryfrom the first paragraph, and the layout showed the title and a tagline. So a page's heading came from its metadata. Now metadata only reaches the head and the code, and a page writes its heading. - A title suffix (
About | Amit Kapoor). A page that wants it writes it. - A namespace for head keys (
meta.title). Only two keys reach the head, so a namespace buys nothing, andmetabeside Markz'smetadatawould confuse both. nameandlanginsite.md.langis<html lang>, where the frame writes it.namewas read only by the feed.- Writing the Open Graph tags in
index.html, filled by Sitez as<title>is. It's more visible, and seven tags that hold the same two values.
Dev server
vp dev serves the site through Sitez's plugin. It renders a page when it's asked for, with the code
build uses, so the two can't disagree. Drafts are pages like any other. The site is read again
for every page, so a new or deleted file is a new or missing URL without a restart.
A page in dev is index.html with the page in its slot, served through Vite's HTML pipeline. Its
stylesheet links resolve to the files in code/, so Vite hot-replaces a change to CSS. A second
script imports the page's element scripts and elementz's runtime.
Anything else reloads the page.
- A change to code can change any page's HTML, even whether a link on another page is broken. So the server's modules are all invalidated.
- A custom element can't be defined twice, so an element's script can't be swapped in place.
- Pages rendered at build time have no client state to keep, so a reload is the whole of HMR for them.
A failure renders as a page holding build's message, with Vite's client on it. So fixing the
file reloads it, CSS included. A url() in the site's CSS that points at nothing is one such
failure. Vite hands it to the browser unchecked in dev, so dev checks the stylesheets itself as
a page is served, as build would fail on them.
dev is Vite's own server, so its dependency optimizer pre-bundles the packages an element's
script imports, in the site's node_modules/.vite. The server build renders with turns it off.
Build
Pages render through Vite's module runner, in build as in dev, not from a bundle. The site's
modules run from where they are, so import.meta.url still points at them. And dev and build
can't render a page differently. The build wraps each render and names the page file, since an
error's stack names the data module rather than the page.
A page is Markz's HTML put in index.html's slot, with each element that has a file rendered by
elementz (elementz.md). html() renders a view of the
document in which each such element is a marker, as each raw =html block is. So the only tags in
its output are Markz's own, and replacing one is exact. index.html's own elements and the ones
an element writes render the same way. The frame renders for each page, since its elements read
page, and the page's HTML goes in last.
Elements in HTML are found by a small tag scanner (src/markup.ts), not by a parser. It reads
what the HTML tokenizer would call a tag, so a < in a comment, a script or a value is text. An
element's content is given to it as a marker, and put back once its output has rendered. So an
element's output is read for the elements it wrote and never for the ones it was handed, which
would render twice.
Sitez gives each element page, pages and site as well as its attrs. A data attribute is
read first. Its path resolves in data/, and the parsed JSON replaces the string. One that isn't
there fails, naming the line. page is a page's metadata block and its url, and nothing else.
pages is every page's, read before any page renders. site is site.md's block. Sitez adds no
other key, so whatever code reads is written in a file.
Sitez reads from Markz how each element is used, and fails an inline element whose output would
end its paragraph (elementz.md). A raw block is written as it is. build
prints Markz's warnings, and they never fail it.
Links
Links are rewritten from the AST, not the HTML, so examples in code blocks are left alone. The
view html() renders has each link's destination swapped for its URL, as it has markers for
elements and raw blocks.
- A relative link to a
.mdintext/becomes that page's URL, and one to a file inpublic/becomes its path. - A link to any other file in the repo becomes
{repo}/blob/main/{path}(treefor a folder). The path is from the git root, which may be above the site's. - A site link (
/about) is looked up among the pages, then inpublic/. - A link to a draft, to the 404 page, or to anything that isn't there fails with its line.
The check needs every page's draft status before any text renders, which every page's metadata block gives.
Then each rendered page's href and src attributes are checked against the same pages,
public/ and Sitez's own files. That catches what index.html, elements and raw blocks write. A
site link there has to be exact (/blog/), since the HTML is finished and nothing rewrites it.
Only start tags are read. So HTML shown as text, whose < is escaped, and code inside <script>
aren't taken for links. A link an element's setup writes exists only in the browser and isn't
checked. That's one more reason behavior enhances rather than generates.
Redirects
A page that moved lists its old URLs under redirects, and build writes a page at each. It is
index.html rendered for the page it reaches, with that page's page, so the frame's elements
never see a page with no metadata. Its slot holds a link to the new URL, and its head refreshes
there at once, names it canonical, and asks not to be indexed. Search engines read an instant
refresh as a permanent redirect. It works on every static host and in preview, and dev
answers an old URL with a 301.
- An old URL is a page's URL (
/old/, or/oldread as one) or an old.htmlfile. Any other extension is a file, which a page can't stand in for. - It fails when a page has it, drafts included, or when another page lists it. It also fails when
a file in
public/is already there. - An old
/x.htmlfails while a page has/x/. Hosts servex.htmlat/xas well, so the redirect page would stand in front of that page, and/xwould reach it through a refresh. Cloudflare sends/x.htmlto/x/by itself, so a page that only lost its.htmlneeds no redirect. GitHub Pages doesn't, and there that old URL is lost. A clean/xmattered more. - A link to an old URL fails, naming the page it reaches. Redirects are for links from elsewhere, so nothing on the site goes through one.
- A draft's redirects aren't written, since the page they reach isn't.
Ruled out:
- A URL in a page's metadata (Jekyll's
permalink, an Astro entry'sslug). The path would stop being the URL (rule 2), and every link check would need a second source of URLs. - A host's redirect file (
_redirectsinpublic/). Only some hosts read it, GitHub Pages andpreviewamong those that don't, and Sitez can't check it. A name starting_is skipped inpublic/as in every folder, so a site can't ship one. That's open, in Open questions. - A stub file in
text/for each old URL. A site that moved its pages into folders would get one file per old URL, and the stub would be a page with no content.
Real 301s, written into a host's redirect file beside the stub pages, could come later without changing the key.
Outputs
build writes a page for each text file and each redirect, the 404 page as 404.html, the CSS
and the script, and public/ as it is. The 404 page is left out of pages, since nothing lists or
links to it.
Ruled out:
sitemap.xml. Every site got one, with no way out. Search engines find a small site's pages by its links, and a sitemap page for humans is the site's own, written frompages.feed.xml. Any page with adatejoined it, so one key both ordered pages and published them. amitkaps.com dated its essays only to order them. A feed needs its own decisions, such as which pages and how much of each, and those are a site's.
Bundling
Once every page has rendered, Rolldown builds what the browser downloads besides the HTML.
Nothing it builds reaches dist/ until every page has.
Each stylesheet index.html links becomes one file, [name].[hash].css, with what it
@imports, and the link is rewritten to it. A link is a file in code/ when its path is
relative, and it fails when the file isn't there. A path that starts / or names a host is left
as written. Each stylesheet is a build of its own, named for its file. Most sites link one. In
dev the link points at the file, which Vite serves and replaces as it changes. CSS is small and
needed before anything paints, so one file, cached by the first page and reused by every other,
costs less than a split that saves a few bytes a page. A url() in it is bundled as assets/[name].[hash][ext]. Vite would ship one
it can't resolve as written, with only a warning, so Sitez fails the build instead.
Sitez's reset went. It was a @layer reset of Sitez's look before the site's CSS, and
amitkaps.github.io's first rules undid it. A look a site didn't write is the kind of value Sitez
no longer fills in (promise 4).
Element CSS is the one thing Sitez adds. The <style> of every element a built page uses goes in
@layer elements, in the first stylesheet index.html links, so it is cached with the site's
(elementz.md). Sitez adds no other CSS, so @layer and !important mean what
they always mean.
A site has at most one script, script.[hash].js, for the same reason as its CSS. It holds
elementz's runtime and the setup of every element a built page uses, each defined under the tag
its file's name gives. Only a page
with such an element loads it, so a page with none loads no JavaScript. Defining a tag the page
doesn't have registers a class that never runs, which costs nothing worth counting. The report
shows the script once, on the common row, and each page's notes name its elements with
behavior.
One script means a large library loads inside its element. A package imported at the top goes into
the script, which every page with behavior loads. That's right for a small one, and the report's
common row shows what it costs. await import("d3") inside the element becomes a hashed chunk
of its own, fetched only where the element connects. A URL imported at the top fails, since the
browser would fetch it before the script runs on every page with behavior. One imported inside is
left as it is, and the report names its host.
Two other ways were ruled out.
- A script for each page, with what pages share split into
common.js. It saved each page a few hundred bytes, and cost per-page entries, chunk splitting,modulepreloadlinks and a report column. It paid off while signals and htl were the shared part, and both are gone. - Inlining the script in each page. It saves a request, and repeats the bytes on every page. A site with a Content-Security-Policy would also have to allow inline scripts.
File names carry a content hash (script.3f9a1c.js), so a new deploy is never served from a stale
cache. dev still loads each page's own element scripts, since it serves modules as they are
and has no file to cache. The build sets production mode itself.
Check, preview and deploy
Sitez has no commands of its own. vp preview serves dist/, and Sitez's plugin adds what a
static host does that Vite doesn't, such as serving 404.html with a 404. Formatting and linting
are the site's own scripts, with oxfmt and oxlint as its own dependencies. oxfmt formats the HTML
inside html templates, and its Markdown output is Markz's canonical form.
Ruled out:
sitez check. It ran oxfmt and oxlint as Sitez's dependencies, and reported Markz's warnings as problems. That put two linters in every site's install, and a wrapper between the author and tools they can run directly.buildprints Markz's warnings.sitez preview. It was a static server of Sitez's own, beside Vite's.
Install
Sitez is installed in each site, never globally. package.json names Sitez and Vite+, and
npm install puts those versions in node_modules. So the author's machine and the host build
with the same Sitez by construction. The report's last line names the version that ran.
vite.config.js sits at the site's root, beside site.md, and Vite's root is the site's. So
several sites in one repo are several folders, each with its own config. A site kept in site/
inside its repo leaves the repo's own files at the top, which is a habit, not a rule.
Ruled out:
- A global install. It was the first design, with no
package.json, and nothing in the repo said which version built the site. - A single binary. It was for installing without Node, and a site with a
package.jsonneeds Node anyway. - A CLI that runs Vite itself. It was Sitez 0.2, and it kept Vite out of sight so the
toolchain was Sitez's to swap. Once element scripts import npm packages, Vite isn't
swappable, and the CLI was a second name for each of Vite's commands. It also needed its own
check that the site's
package.jsonnamed Sitez, which the plugin's import invite.config.jsmakes (Toolchain). The cost of the plugin is that a site sees Vite, and can add other plugins to its config. - A version in
site.md. It holds metadata, never settings, and npm already pins packages.
Deploy
A site deploys from Cloudflare, which builds the repo itself, as prose's site does. Its build
command is npm run build, and its deploy command npx wrangler deploy runs Wrangler on
Cloudflare's machine. So neither Sitez nor a site depends on Wrangler, and a site needs no Sitez
command or setting to deploy.
Ruled out:
- A deploy command. Each host has its own one-time setup, such as a domain, a 404 page or a config file, and a command covers one step of it. Steps for one host are simpler than a command for several. GitHub Pages was the other host, and Sitez's sites are moving off it.
- A deploy target in
site.md(deploy.github,deploy.cloudflare). Markz reads dotted keys, but a target is a setting, andsite.mdholds metadata.
Release
Sitez is published to npm as @amitkaps/sitez, the name a site's code and vite.config.js
import. It releases the way every package does, as ship's standard
says: pnpm release sitez X.Y.Z, run from ship, opens a pull request that bumps version, and
its description is the release's summary. Merging it runs the release workflow, which stages
the version on npm for a maintainer to approve, then tags the commit and publishes a GitHub
Release with notes from the pull requests' labels.
Markz is a dev dependency, bundled into dist/, so a site never installs a second Markz, and
Sitez releases without waiting on one. A new Markz reaches sites in Sitez's next release.
Ruled out:
- The plain name
sitez. npm refuses it as too similar tovite, which also keeps anyone else from taking it. Publishing the scoped package for sites to install as"sitez": "npm:@amitkaps/sitez"was ruled out too. Every site would need that line, and the error naming it would have to explain it. - Installing from GitHub (
github:amitkaps/sitez#v0.2.0).dist/isn't committed, so each install would build Sitez, and pnpm runs no dependency's build scripts unless the site allows them.
Why not Svelte
Sitez 0.1 (v0.1.0) wrote its pages, layouts
and components in Svelte. It rendered them on the server and hydrated each island in the browser.
It worked, and most of it existed for one feature, rendering an island on the server and then
hydrating it. That feature needed several pieces.
- Reading browser behavior from Svelte's AST.
- Wrapping every component in the server render.
- Serializing props with
devalue. - Passing children back through
createRawSnippet. - Compiling text as Svelte, so both shared one renderer.
Six of its seven dependencies and about a third of src/ served that.
Sitez's real site didn't need it. amitkaps.github.io has one kind of interactivity, card grids filtered by category, and the grid is already complete HTML. A Svelte island for it shipped about 11 KB of runtime. It sent the grid's data twice, as HTML and again as props, so the browser could draw what the build already had. What leaving gives up is Svelte's editor support inside templates. The spike that decided it is in the repo's history, at spike/.
Why not SvelteKit, Astro or Ogygia
- SvelteKit has server rendering, build-time data (
loadwithprerender) and a Vite dev server. It also has what Sitez leaves out. That's a client router,+page,+layoutand+serverfiles, endpoints, form actions, hooks, adapters and a server runtime, each with its own conventions. It hydrates a whole page or none of it. It has no convention for text, so amitkaps.github.io writes its own loaders to read its Markdown and YAML. Its/teaching/page loads 206 KB of JavaScript. - Astro is the nearest tool, with content pages, islands, no JavaScript by default and View
Transitions. Its pages are
.astro. Its content is Markdown or MDX configured through content collections. Its islands are framework components hydrated with their props. - Ogygia adds islands to SvelteKit, and Sitez 0.1's islands
followed it. They were rendered on the server, then hydrated, with props through
devalue. Ogygia is also what SvelteKit plus islands becomes without a limit. It has server islands, remote functions, content collections with schemas, a docs shell, versions and locales, and an SPA router. Adopting it takes a Vite config, server hooks and anORIGINvariable.
Open questions
- Types. A site has had no type checker since svelte-check went with Svelte. The likely one is
tscwithcheckJsovercode/, strict but asking for no annotations. It comes once a site's mistakes call for it. - Misspelled elements. An element with no files is a plain HTML element, which is legitimate. So a misspelled name silently does nothing. A warning could catch it, for an element with no files and no selector in the site's CSS. That's worth it if it isn't too fragile.
- Raw HTML. A
```=htmlblock ships as written,<script>included. It is the author's escape hatch, outside the element scripts and the JavaScript they account for. The build report notes a page with a raw<script>. Whether to sanitize raw HTML waits until sites use it enough to say. - Host files in
public/. Cloudflare reads_headersand_redirectsfrom the folder it serves, and a name starting_is skipped in every folder (docs/design.md#folders). So a site can't ship either. The rule could leavepublic/out, since it copies files as they are and_there means nothing to Sitez. That's the human's call, since the idea says "every folder". - A second frame. amitkaps.com/stories/ is one page with its own header, footer, stylesheet
and font, sharing nothing with the site's frame. Through the one frame it would take the site's
look, and its CSS and font would reach every page. The narrow rule would be that
code/stories/index.htmlframes the pages intext/stories/, with no nesting. The heads wouldn't drift, since they're meant to differ. The cost is that one folder incode/means something, and the stylesheet is one per frame instead of one per site. Until the page is merged, its built HTML goes inpublic/stories/, which Sitez copies as it is. The rule waits for that merge, and for the decision to keep the page's own look. - A social image.
og:imageis left out until Sitez can make one. Each page's card would be drawn at build time by a function givenpageandsite, or by an SVG template. Social sites don't take SVG, so the card has to be rasterized to PNG. That means a renderer such as resvg in the toolchain. Twitter's card becomessummary_large_imageonce there is one. A simpler first step is animagekey naming a picture the page already has. amitkaps.com/stories/ is the known case, a page shared with a large card of its book cover.