smol is the reference generator for the Attested HTML Static No-JS Profile.
It builds minimal, self-contained XHTML5 pages that are ready to be signed
byte-for-byte with attest / attested-html. It can also emit flat Gemini
capsules from Gemtext or Markdown source.
- Creates small static sites with pages, posts, partials, assets, and theme config.
- Emits XHTML5 as the default and only HTML format, with inline CSS.
- Rejects malformed XML, incompatible HTML structure, and unsupported static markup.
- Emits flat Gemini capsules from
gemtext-v1andmarkdown-xhtml-v1pages. - Embeds supported local images as
data:URLs through{{image}}and Markdown images. - Renders constrained Markdown/XHTML and Gemtext source bodies for HTML output.
- Checks already-built HTML/XHTML/Gemini output against the static no-JS profile.
- Provides a
SMOL ATTESTED MANIFEST V1comment to both starter themes inside the signed payload, with resource provenance and region hashes for drift checks. Custom themes may omit it; authorship verification needs no manifest. - Automatically calls
attest/attested-html signas the final build step when a signing key is configured and a signer is discoverable.
- No JavaScript.
- No broad CommonMark/GFM in v0; only constrained
markdown-xhtml-v1andgemtext-v1source formats. - No plugins, arbitrary hooks, or client-side routing. The HTML structure check
uses
golang.org/x/net/html; CSS tokenization usesgithub.com/tdewolff/parse/v2/css. Signing remains in the separateattesttool. - No external stylesheets, scripts, fonts, iframes, forms, embeds, or remote image fetching.
- No Publii theme rendering and no Handlebars support.
- No cryptographic signing implementation inside
smol. - Targets XHTML5, with well-formed XML and HTML-compatible syntax. Historical XHTML 1.1 DTDs and external entity declarations are unsupported.
Build from source:
go build -o smol .Run tests:
go test ./...smol init mysite
cd mysite
smol new post hello-world "Hello World"
smol build
smol check public
smol publish --dry-run
smol publish --sign
smol build --sign
smol build --sign-key FINGERPRINTBuild options must appear before positional arguments:
smol build --out public .For the quickest Markdown/XHTML publishing demo, start with the classic XHTML starter:
smol init --starter classic-xhtml mysite
cd mysite
smol buildThe classic-xhtml starter creates a flat XHTML site with .md bodies, a
classic typographic stylesheet, and a sample page that exercises the supported
Markdown elements.
smol.json
content/
pages/
index.html
about.md
sample/
index.md
assets/
posts/
hello-world.md
themes/
default/
theme.json
templates/
partials/
assets/
public/
.gitattributes
{
"format": "smol-site-v1",
"title": "Example Site",
"base_url": "https://example.org"
}Optional fields include description, language, publisher, theme,
output_mode, routing, sign_key, nav, and publish. If --sign-key is supplied,
it overrides sign_key in smol.json. Use --sign to choose a local GPG
secret key interactively for a single build or publish run. Use --unsigned to
suppress signing even when a key is configured or selected. The signing key
value is a fingerprint or key ID, not a secret, but private signing keys should
not be placed in cloud workspaces.
output_mode defaults to "xhtml5", the only HTML output format. routing
selects "directory" (default: /about/index.html) or "flat" (/about.html);
posts use /posts/<slug>/index.html or /posts/<slug>.html. Both choices use
identical XHTML5 and static-resource validation. Gemini is a separate format.
An index post keeps its own post route; only the index page is the homepage.
For an older flat site, replace "output_mode": "flat-xhtml-v1" with
"output_mode": "xhtml5", "routing": "flat".
Every generated page must be well-formed UTF-8 XML with <!DOCTYPE html>, an
html root in http://www.w3.org/1999/xhtml, one head with a title, and one
body. XML and HTML parsing must agree on structure and text; static content
models are checked as well. Exactly one UTF-8 encoding declaration, normally
<meta charset="utf-8" />, must use literal metadata (no character references)
and fit entirely within the first 1024 bytes.
Ordinary elements need explicit closing tags;
void elements use <img … />, <br />, etc. Attributes must be quoted, and
boolean attributes need values, such as open="open". Use Unicode, numeric
references, or XML's predefined entities (&, <, >, ",
'), rather than HTML-only named entities such as .
The inline stylesheet is wrapped in CSS-commented CDATA boundaries, preserving
characters such as & and < under either XML or HTML parsing. XML declarations,
processing instructions, external DTDs, and entity declarations are rejected.
The final signature is an XML-safe comment after the root element. Both the
unsigned document and the signer's final output are validated in staging;
validation or signing failure preserves existing output.
CSS resource checks use tokenization after CSS input preprocessing and identifier
escape decoding. Quoted comment delimiters cannot hide live declarations.
The same policy applies to stylesheets, decoded style attributes, and SVG
attribute values; comments and harmless resource words inside strings are inert.
Pages and posts are source files under content/pages/ and content/posts/.
The filename is the slug, and the extension selects the source format:
content/pages/index.html
content/pages/about.md
content/posts/hello-world.md
Extensions are matched case-insensitively, so about.MD is accepted. Slugs
still use the filename before the extension and must match smol's lowercase slug
rule, so About.MD is rejected as slug About.
Local images require bundle form so assets have a page-local directory:
content/pages/sample/
index.md
assets/hero.png
A bundle may contain exactly one index.html, index.md, or index.gmi
source file, matched case-insensitively. Other root files with source extensions
inside the bundle are rejected. Non-source files and subdirectories are treated
as local assets; nested child/index.md files are not discovered as pages.
Source files may begin with small front matter:
---
title: Hello World
summary: A first signed page.
published: 2026-06-07T00:00:00Z
updated: 2026-06-07T00:00:00Z
tags: [attestation]
draft: false
links:
up: essays
previous: kore
next: lament
related: [parable-of-old-stone]
---
Write your page here.Front matter is intentionally small and is not YAML. After the closing fence, the body is read byte-for-byte; no blank line is stripped or added. The accepted grammar is:
| Construct | Accepted syntax |
|---|---|
| Leading BOM | One leading UTF-8 BOM is stripped before parsing or rendering. |
| Opening fence | First non-BOM bytes must be --- with optional trailing spaces or tabs, followed by LF, CRLF, or EOF. EOF form is treated as unclosed front matter. |
| Closing fence | Line is --- or ... with optional trailing spaces or tabs, followed by LF, CRLF, or EOF. |
| Leading blank before fence | Not front matter; the entire file is body content. |
| Line endings | LF and CRLF are accepted. CR-only front matter is rejected. |
| Quoted keys | Surrounding matched '...' or "..." is stripped before key recognition; keys may not contain whitespace. |
| Unknown top-level keys | Ignored, including inline values, indented child blocks, key-indent list items, and nested maps. |
| Recognized nested under unknown | Top-level smol keys nested under unknown metadata are rejected so safety fields such as draft are not silently ignored. |
| Scalars | Bare strings, double-quoted JSON strings, and single-quoted strings. |
Bare scalars starting with ' |
Parsed as single-quoted and must close; use double quotes or doubled apostrophes for leading apostrophe text. |
| Single quotes | '' becomes '; backslashes are literal; trailing text after the closing quote is rejected. |
| Lists | Inline [a, "b, c", 'd'] or block lists at/deeper than the key indentation. Quotes delimit inline items only when they start an item, so apostrophes inside bare items are literal. Empty inline list [] is valid; bare tags: with no items is rejected. Top-level block lists are flat. The first non-list line ends a block list. |
| Links | links is a strict block map with up, previous, next, and related; those subkeys are not top-level keys and are not guarded inside unknown foreign blocks. |
| Navigation label | Optional nav_label scalar names the page in other pages' generated navigation (Up/Previous/Next/Related links); when absent, the page title is used. The page's own <h1> and <title> always use title. |
| Markdown extensions | Optional markdown_extensions: [annotated-text-v1] enables the closed annotated-text vocabulary below, for Markdown/XHTML output only. Empty [] keeps the default grammar. Unknown or duplicate names are errors. |
| Dates | RFC3339 timestamps are accepted unchanged. Bare YYYY-MM-DD dates are normalized to midnight UTC. Empty date fields are treated as omitted. Other date formats are rejected. |
| Table of contents | toc: true generates a table of contents from the page's level-2 Markdown headings, ATX or setext. Anchors are auto-derived (lowercase letters and digits, other runs become single hyphens; non-ASCII characters are dropped); an explicit {#id} on an ATX or setext heading overrides. Link and image markup in a collected heading contributes only its label to the entry text and anchor. With toc: true every heading anchor on the page must be unique and must not collide with footnote checkbox ids. Duplicate or underivable anchors are errors, as is toc: true with no level-2 headings, a non-Markdown body, or flat-gemini-v1 output. |
| Comments | Unsupported; # is literal text. |
| Booleans | true and false are accepted case-insensitively. |
Recognized keys must be top-level. Duplicate recognized keys and malformed
recognized values are errors. published/updated are aliases for the internal
published_utc/updated_utc fields; do not specify both forms for the same
field.
If title is absent, index becomes Home and other slugs are humanized, for
example hello-world becomes Hello World. The first Markdown # Heading is
not inspected. Use explicit title front matter when exact casing matters, such
as acronyms or proper nouns.
Link values are page slugs only. Post slugs, draft pages, unknown slugs,
self-links, duplicate related targets, and external URLs are rejected. Themes
can render the resolved links with .PageNav; the starter themes render them as
generated navigation chrome outside the authored content body. Navigation
labels use the target page's nav_label when set, otherwise its title. An
up: index link is validated but rendered only as the Home link — Up
appears in the chrome only when the parent is a page other than home. This is
structural isolation only: signed HTML still covers the full generated page.
Manifest region hashes are provenance and drift-check aids; tamper resistance
comes from the whole-page attested signature.
See docs/smol-static-nojs-v1.md for the output profile contract and
docs/smol-attested-manifest-v1.md for manifest fields.
A generated table of contents is opt-in per page. With toc: true, smol
collects the page's level-2 Markdown headings (ATX or setext) in document
order, injects the derived (or explicit {#id}) anchors into the rendered
headings, and exposes the entries to themes as .Page.TOC; the classic XHTML
starter renders them as chrome above the authored content:
---
title: Sample
toc: true
---
## First Section
## Custom Anchor {#custom}HTML source files use .html and contain an HTML fragment. They may use only
the fixed body functions:
<p>Hello.</p>
{{image "assets/hero.png" "Alt text"}}
<p>{{date .Page.PublishedUTC "2006-01-02"}}</p>Supported image files are PNG, JPG, JPEG, GIF, WebP, and AVIF. SVG image files are
not embedded; constrained inline SVG may be authored in .html fragments.
Images are read only from the bundle directory and embedded as
data URLs. Flat files do not have a local asset directory, so {{image}} and
Markdown images require bundle form.
Markdown source files use .md and render as markdown-xhtml-v1. It is a
constrained Markdown/XHTML dialect, not full CommonMark or GFM. Normal text is
HTML-escaped. Supported Markdown includes paragraphs, ATX headings, Setext
headings, inline links, local embedded images, emphasis, strong text, inline
code, ordered lists, unordered lists, static task-list items, blockquotes,
simple pipe tables, fenced code blocks, horizontal rules, and literal numeric
inline notes.
Markdown bodies must be pure Markdown with optional small front matter — no embedded HTML in any form. Two kinds of HTML are rejected at build time, each with a file and a 1-based line number:
- HTML elements. Block and inline tags, closing tags, and comments
(
<div>,</p>,<!-- … -->) are rejected; use the.htmlbody format for full-HTML authoring, or a code span for literal tag text. Autolink syntax (<https://example.org>) is likewise rejected; write[label](https://example.org)instead. A<is flagged only when it reads as HTML:<or</followed by a letter, or<!beginning a comment or declaration. Anything else —a < b,1<2,</3,<!?, a trailing<— is ordinary text, buta<bis rejected because<breads as a tag. - HTML character entities. Named, decimal, and hexadecimal references
(
,&, , ) are rejected; type the character directly — a real non-breaking space,&,—, and so on. A bare&that is not a complete&entity;reference (e.g.Tom & Jerry, a?a=1&b=2query string) is ordinary text.
Both checks ignore content inside fenced code blocks and inline code spans, so
literal tags and entities can still be displayed as code. Standard CommonMark
backslash escapes (\*, 1\.) are Markdown rather than HTML and are permitted.
The annotated-text-v1 extension is intended for source texts whose printed
annotations must remain distinct. Enable it explicitly per Markdown page:
---
title: Annotated text
markdown_extensions: [annotated-text-v1]
---
{anchor|work.4.31}{chapter|4} {verse|31} {initial|A} supplied passage, with
{u-dotted|another visibly distinct annotation}.{br}A new line.[^129]
[^129]: First paragraph with *emphasis* and [a source](bibliography.html#entry-1).
A soft continuation of the first paragraph.
A second paragraph of the same note.Without the opt-in, these brace forms remain ordinary text and existing Markdown rendering is unchanged. The extension adds exactly nine forms:
| Form | XHTML result |
|---|---|
| `{u-solid | text}` |
| `{u-dotted | text}` |
| `{sup | text}` |
| `{anchor | ID}` |
{br} |
A fixed br element. |
| `{verse | 31}` |
| `{chapter | 4}` |
| `{initial | A}` |
| `{gap | . . .}` |
The vocabulary does not accept source-supplied styles, arbitrary classes,
attributes, elements, includes, or executable templates. The existing raw HTML
and character-entity bans still apply inside annotations and note definitions.
Annotation syntax inside inline or fenced code remains literal. Existing
backslash escapes include braces and pipe: \{u-solid|literal\} displays the
brace form, while \} and \| preserve those characters inside an annotation.
Pipe-table headers and cells may contain annotations, including nested forms.
Pipes inside an annotation or inline code do not separate table cells; use
\| for a literal pipe elsewhere in a cell.
Annotations are balanced and bounded to 32 nested inline constructs. Their text
may include existing emphasis and strong syntax. Solid and dotted spans can nest
to preserve both printed layers; nesting the same underline kind twice is an
error. Crossing source intervals must be split into properly nested runs. Empty
zero-byte annotation bodies are errors, but whitespace-only underlining is valid
and its whitespace is retained. Such underline spans additionally receive the
fixed smol-marked-space class; the theme can preserve those spaces explicitly
without imposing pre-wrap on ordinary text runs or their note descendants.
The theme must supply fixed CSS for the underline classes,
including visible offsets when both kinds are nested; the names describe printed
appearance and do not assign an editorial interpretation.
Superscript is literal source text with emphasis or code, not an automatically
inferred verse or citation. It cannot contain another superscript, line breaks,
anchors, links, images, or footnote controls. Numeric notes retain [^N] syntax
and their literal numbering. Underlined text may contain a numeric note marker.
The four source roles are explicit and do not infer or insert source characters. Verse labels remain baseline text rather than using the note-marker superscript style. A verse label begins with a digit and may contain ASCII letters, digits, dot, colon, hyphen and spaces; chapter labels contain decimal digits. An initial is exactly one Unicode letter, with no nested markup. A gap contains only printed periods, ellipsis characters and whitespace, including at least one period or ellipsis. Verse, chapter and gap bodies may include emphasis and underlining, so partial number/ellipsis underlines remain inside one grouped role. They cannot contain another source role, superscript, anchor, link, image, line break or note control. A source role can itself sit within an underline span. The theme owns the fixed role styling, including chapter/initial sizing and gap no-wrap behavior; the author cannot supply CSS values or arbitrary attributes.
Anchor IDs contain only ASCII letters, digits, underscore, dot and hyphen, and
must begin with a letter or digit. cbN (decimal digits), top, content,
toc_container and toc_title are reserved. Inline anchors and explicit/derived
heading anchors share duplicate detection, including when toc is disabled.
Explicit heading {#id} syntax also accepts dots in this extension. Generated
TOC entries use visible heading text rather than annotation syntax or anchor IDs.
Final document validation additionally catches collisions with custom theme IDs.
Numeric footnote definitions begin at column zero with [^N]: and nonempty
inline prose. A following line with four leading spaces continues the note.
Blank lines followed by another four-space continuation separate its paragraphs;
unindented content ends the definition. Note bodies accept inline prose, links,
and the fixed annotation forms, but not images, nested notes, headings, lists,
tables, quoted blocks, fenced blocks, or further block indentation. Definitions
inside fenced code are never extracted. Missing, duplicate, unused, or repeated
note references are errors; use an ordinary link to an explicit note-body anchor
when a note must be mentioned again.
The existing checkbox/label/note wrapper is retained. In annotated mode its note
content span has the fixed smol-note-body class, so the theme can reset inherited
heading/emphasis styles while preserving explicit child emphasis. Legacy notes
without the extension remain byte-identical. For multiple paragraphs, that
inner note span contains one fixed
smol-note-paragraph span per paragraph, separated by literal blank lines. A
theme can display these child spans as blocks with paragraph spacing. They
remain valid phrasing content inside the inline note; the renderer never inserts
p or div elements there. No extra attributes are added to checkbox inputs.
annotated-text-v1 is XHTML-only and is explicitly rejected for
flat-gemini-v1, rather than silently dropping scholarly distinctions or note
paragraphs. Existing Markdown-to-Gemini behavior without the extension is
unchanged. The static output profile, allowlists, validators, resource rules,
and signing model are unchanged.
Task-list items render as static list text with checkbox glyphs, not form
controls or JavaScript. Markdown links may use relative URLs, fragments,
http://, https://, gemini://, and mailto:. javascript:, data:, and
unsupported URL schemes are rejected.
Markdown images use this form:
Markdown images must be local files under the page or post content directory,
matching the {{image}} content lookup policy. Use bundle form when a Markdown
file references a local image. Remote image URLs, protocol
relative URLs, absolute paths, path traversal, missing files, unsupported
extensions, and SVG are rejected. Supported image types are PNG, JPG, JPEG, GIF,
WebP, and AVIF. Images are embedded as data: URLs in the generated XHTML. In
HTML output modes that emit a SMOL ATTESTED MANIFEST V1 comment, Markdown
image resources are included in that manifest.
The classic-xhtml starter stylesheet covers links, images, emphasis, strong
text, inline code, code blocks, ordered lists, unordered lists, task lists,
blockquotes, tables, horizontal rules, and headings with small static CSS rules.
Gemtext source files use .gmi and render as gemtext-v1. In HTML output modes it
renders Gemtext line types to constrained XHTML. In flat-gemini-v1 output mode
it emits Gemini capsule pages directly. Supported Gemtext includes text lines,
blank lines, #/##/### headings, => links, * list items, > quotes,
and fenced preformatted blocks.
| Source file | Source format | HTML/XHTML output | flat-gemini-v1 output |
|---|---|---|---|
.html |
html |
Rendered as constrained HTML through the selected theme. | Not supported. |
.md |
markdown-xhtml-v1 |
Rendered as the supported constrained Markdown/XHTML subset. Markdown images embed as local data: URLs. |
Rendered lossily to Gemtext. Formatting is reduced to readable text, links become separate => URL label lines, tables become preformatted pipe tables, and Markdown images are rejected. |
.gmi |
gemtext-v1 |
Rendered as constrained XHTML from supported Gemtext line types. | Near-native pass-through with a generated page title heading. |
Markdown-to-Gemini conversion preserves document shape where Gemtext has a
matching concept: headings, paragraphs, links, lists, task-list text,
blockquotes, tables as preformatted blocks, fenced code blocks, and horizontal
rules.
Emphasis, strong text, inline code, and footnote markup are stripped to readable
plain text. Markdown images are rejected for flat-gemini-v1; binary image
asset copying into capsules is intentionally not implemented.
published_utc and updated_utc are publisher claims, not trusted timestamps.
Trusted timestamping belongs in the external attestation evidence, not in page
metadata.
Set output_mode to flat-gemini-v1 to generate a flat Gemini capsule:
{
"format": "smol-site-v1",
"title": "Example Capsule",
"base_url": "gemini://example.org",
"output_mode": "flat-gemini-v1",
"sign_key": ""
}Capsule output supports pages only. Pages may use .gmi or .md sources.
.html pages and posts are rejected. smol writes the page title as the first
# heading, then appends the rendered Gemtext body. The generated capsule
writes index.gmi for the index page and <slug>.gmi for other pages.
Themes, templates, CSS, HTML bodies, posts, Markdown images, and signing are not
used by flat-gemini-v1.
smol check validates already-built output files or directories against the
static no-JS profile:
smol check public
smol check --mode xhtml5 public
smol check --mode flat-gemini-v1 publicThe default xhtml5 mode checks .html, .htm, and .xhtml files using the
same XML, HTML structure, and static-resource policy for both route layouts.
Use --mode flat-gemini-v1 for Gemini. .gmi files are validated as Gemtext.
smol check also validates manifest syntax and declared region hashes when a
manifest is present. It does not verify cryptographic signatures.
smol publish builds the site, then publishes the output directory over SSH
using scp and remote ssh commands. Configure the target in smol.json:
{
"format": "smol-site-v1",
"title": "Example Site",
"base_url": "https://example.org",
"publish": {
"method": "scp",
"host": "example.org",
"user": "deploy",
"port": 22,
"path": "/var/www/example"
}
}Without explicit SSH settings, publishing uses your normal SSH configuration,
keys, agent, and known_hosts. Optional publish.identity_file,
publish.known_hosts_file, and publish.host_key_fingerprint fields select an
identity and a pinned server key. The known-host file and SHA256 fingerprint
must be supplied together; smol checks their agreement before building or
connecting, freezes the validated public key for the transfer, and uses strict
host-key checking. Explicit SSH settings require batch public-key authentication.
Local paths are relative to the site directory; ~/ expands to the user's home.
The corresponding CLI overrides are --identity-file, --known-hosts-file,
and --host-key-fingerprint. Private key contents do not belong in smol.json.
smol publish
smol publish --out public .
smol publish --no-build
smol publish --sign
smol publish --sign-key FINGERPRINT
smol publish --dry-run
smol publish --check-ssh
smol publish --mode in-place
smol publish --host example.org --user deploy --port 2222 --path /var/www/exampleHTML publication requires a full expected signing-key fingerprint in
smol.json.sign_key or --sign-key. --sign chooses a local key for the build.
Unsigned HTML remains available for local previews; --unsigned cannot publish
HTML. --no-build verifies existing pages against the configured fingerprint;
it cannot run signing or accept --sign / --sign-key.
Forced builds (including publication's build step) reject output that contains the site or overlaps its content, themes or Git data. These checks resolve existing directory aliases, including ancestors of an output not yet created.
The default publish builds finalized pages, makes a private output snapshot,
and validates every page's XML, static-resource policy, and embedded OpenPGP
signature with attest verify --trusted FINGERPRINT. Each gzip sibling must
decompress to its matching signed page. Symlinks and special files are rejected.
Only that verified snapshot is uploaded. The remote host requires exactly the
expected files and directories, including empty directories, rejects symlinks and
special files, and checks SHA-256 hashes before activating the output, using sha256sum,
OpenBSD sha256, or shasum. Extra files or directories fail the check.
A failed check leaves the live site in place.
The default publish.mode is "swap": the destination directory is replaced,
which requires write access to its parent. Each publication has a unique staging
and backup directory. A target-specific .NAME.smol-lock directory serializes
activation and rollback; an overlapping activation fails without changing the
live target, and can be retried after the active publication finishes. A failed
activation restores the previous target where possible and retains its uniquely
named backup if restoration fails. Transfer/check failures clean only their own
staging. A lost setup connection may leave an orphan stage; a killed remote
process or host crash may leave a lock or backup. Inspect those paths and confirm
no publisher is active before recovering them; locks are never stolen based on
their age.
Use "mode": "in-place" or
--mode in-place when only the existing web root is writable. That mode stages
inside the web root, applies the same exact inventory/hash gate to the upload,
then replaces files individually by rename. It never renames or chmods the web
root or writes to its parent, and retains existing remote files absent from the
verified snapshot. Replacements are individually atomic; the entire update is
not, so readers can briefly see old and new pages together. Failed transfers or
verification leave existing pages in place; an installation failure can leave a
partially updated site. Unique staging cleanup is attempted on failure.
IPv6 hosts can be supplied as 2001:db8::1 or [2001:db8::1]; set the port with
publish.port or --port. SSH uses the bare address and SCP brackets it. A pinned
known-hosts entry uses the bare address for port 22 or [2001:db8::1]:2222 for a
nondefault port, matching OpenSSH.
--check-ssh checks existing output paths, SSH access, and destination permissions
without building, uploading, or creating staging directories. It does not certify
signatures or perform a deployment, and cannot be combined with --dry-run or
active signing flags.
--attest PATH selects the signer/verifier. Gemini publication retains its
separate unsigned behavior.
--dry-run previews commands without building, verifying signatures, or
connecting to the host; it does not certify an output directory as publishable.
Unsigned builds do not require attest, attested-html, gpg, or a private
key. If no signing key is configured, supplied with --sign-key, or selected
with --sign, smol builds normal unsigned HTML even when a signer and local
GPG secret keys are available. The local keyring is inspected only when
interactive --sign is requested.
Signed builds require:
- A discoverable
attestorattested-htmlsigner. - A local
gpgexecutable. - A private key in the local GPG secret keyring.
--sign-key and smol.json.sign_key name a key ID or fingerprint, not a
secret. Private signing keys should not be placed in this repository or in cloud
workspaces. For platform-specific setup, see the attest guides for
macOS,
Linux,
OpenBSD,
and Windows.
Unsigned builds write finalized HTML to public/ when no signing key is
configured, supplied, or selected with --sign, or when --unsigned is
passed. --unsigned wins over smol.json.sign_key, --sign-key, and
interactive signing, which is useful for CI or local preview builds where
signing should be skipped (these builds cannot be published as HTML):
smol build
smol build --unsignedSigned builds call attest / attested-html for each generated page when
--sign-key or smol.json.sign_key is non-empty, or when --sign is used and
a key is selected. If a signing key is active but no signer can be resolved, the
build fails before rendering pages:
Gemini capsule output does not support signing yet. If flat-gemini-v1 is
configured with a signing key, the build fails unless --unsigned is passed.
smol build --sign
smol build --sign-key FINGERPRINT
smol build --sign-key FINGERPRINT --attest /path/to/attested-htmlEvery generated HTML page is also written as a deterministic
best-compression gzip sibling (index.html.gz) of its final bytes — for
signed builds, the bytes after the attestation trailer is appended — so
static servers configured for precompressed content (OpenBSD httpd
gzip-static, nginx gzip_static on;) can serve the smaller file while
clients decompress to exactly the attested page. smol check ignores the
.gz siblings; smol publish verifies that they match the signed pages, and flat-gemini-v1 capsules do not emit them.
Explicit --attest or --attested-html paths win. If no explicit signer path
is supplied, smol searches PATH for attest, then attested-html. It then
checks sibling directories beside the resolved smol executable: ../attest
and ../attested-html. If a sibling is a dependency-free Go project with
go.mod and main.go, smol logs building signer from ... and builds a
temporary signer for the current build. Sibling projects with require
dependencies in go.mod are rejected to avoid unexpected dependency fetches.
smol uses this command shape:
SIGNER sign -k SIGNING_KEY -o OUTPUT.html --force INPUT.html
It validates the final signed document without rewriting its bytes. It does
not duplicate signing logic or modify output after the signer writes it. The final filenames stay as normal .html paths; smol
does not emit separate .attested.html files.
Signed HTML is byte-sensitive. Git line-ending normalization can break byte-for-byte signatures, so generated sites include:
public/**/*.html -text
build/**/*.html -text
*.attested.html -text