Skip to content

v0.6.0

Choose a tag to compare

@github-actions github-actions released this 25 Aug 09:16
· 27 commits to main since this release
9d17000

Added

  • llms.txt, llms-full.txt and a markdown twin per post, as Hugo output
    formats. /llms.txt is the index an answer engine can read in one request instead
    of crawling — title, summary, and every post as a linked list with a line of
    context; /llms-full.txt is the content behind those links in one file, each post
    preceded by its canonical URL; and each post publishes an index.md next to its
    HTML. All three are per language: a bilingual site gets /llms.txt and
    /pt/llms.txt, each listing its own posts. llms.txt links to the twins rather
    than the HTML, which is what the spec asks for, and the canonical URL is the first
    line inside each twin so a citation that follows the link still knows where to
    point. Both use .RenderShortcodes — the markdown as written, headings and code
    fences intact, with the shortcodes resolved — rather than .Plain, which is what is
    left after throwing that structure away, or .RawContent, which hands the reader
    unrendered Hugo template syntax where the figure was supposed to be. Part of #34.

    All three honour [params.aeo] disallow and the same build condition robots.txt
    uses.
    A path excluded from crawlers whose full body sits in llms-full.txt is not
    excluded, and answer engines are the audience that key names — the exclusion has to
    reach the files written for them or it is not one. An excluded post is named nowhere,
    its body is nowhere, and its twin says why instead of carrying it. A build that is
    not for indexing publishes the files with the same answer robots.txt gives, which
    keeps the four from contradicting each other the way robots.txt's own comment warns
    about.

    Posts in llms-full.txt are separated by a --- post: <url> --- line rather than a
    bare ---. A thematic break is ordinary markdown that someone writes inside a post
    without thinking about this file at all, and it was indistinguishable from the line
    that separates two posts — so was the setext underline under a heading. The boundary
    now carries the answer to the question the split is being made to answer.

    Every label and heading is English in every language's copy. They are keys, not prose:
    a reader parsing /pt/llms.txt should not have to know the site is Portuguese to find
    the post list, and ## Blog translated is a section a parser written against the
    spec's example cannot find. The values carry the language and - Language: states
    which one. Half of it used to be translated, which was the worst of the two — neither
    parseable by key nor readable as prose — and CI now asserts the two copies use the
    same keys.

    Every value that reaches a line is normalised for it. These are lines in a plain-text
    file with no forgiving renderer behind them: a newline inside a title ends the list
    it is in, and a ]TIL: array[0], Reading [a spec] — closes the markdown link
    early and turns the rest of the item into something else. TrimSpace was covering
    the ends of the first of those.

    The theme defines the three formats; the site declares them, because Hugo's default
    config merge does not bring a theme's [outputs] into the site's. One exception, now
    documented and asserted: a site with _merge = "deep" does inherit them, and one
    that also declares its own [outputs] inherits the kinds it did not restate — which
    means a markdown twin of every page it has. The README says so and CI asserts every
    half: that the files appear when a site declares the block, that a site that never
    does still builds and keeps its RSS feed, and what a deep merge actually gets.

  • scripts/check_aeo.py counts what it was supposed to check rather than only what it
    managed to match. A list item whose link the parser cannot read is a problem now, not
    silence — a title carrying a ] broke the link it sat in, the regex stopped matching
    it, and the item was simply not verified: two of four posts were corrupt in the
    fixture that found this and the file still came back clean, because links in another
    section matched. A line that continues the item above is caught alongside the blank
    line that was already, and a twin that names a different page is compared on the whole
    path rather than its last segment.

  • scripts/check_aeo.py grew the other half of its job: every link in every
    llms.txt resolves to a file the build published, each language's index is rooted
    at the site rather than at its own language directory, and every markdown twin
    names its own page back. The link check found two of its own bugs while it was
    being written — a percent-encoded tag (tags/migração/ is linked as
    tags/migra%C3%A7%C3%A3o/) and the second language's index, whose - Home: is
    /pt/ while its links are rooted at /.

  • The AEO score is printed to the GitHub Pages job summary on every deploy, as
    information and never as a gate — with its two limits printed next to it, because
    the number is a floor rather than a measurement. npx aeo.js check scans
    new URL(target).origin, so for a project site published under a path it reads the
    host root, which belongs to no deploy of this theme; and its HTML checks require
    quoted attributes while hugo --minify emits valid unquoted HTML5, so canonical
    and JSON-LD read as absent whatever is on the page. The authoritative check is
    check_aeo.py, which now also runs on the bytes about to be deployed.

  • scripts/check_aeo.py reads every page that carries a graph, not the home and the
    posts only. The gate had been if "BlogPosting" in html, which left the lists, the
    taxonomies, the term pages and the whole WebPage branch unverified — a
    /blogs/index.html with every one of its blocks corrupted came back clean. Redirect
    stubs are skipped rather than read as pages that lost their JSON-LD, which is what a
    site with defaultContentLanguageInSubdir publishes at its root. --not-indexable
    works against a preview build now: it was checking for a Sitemap: line that the
    template correctly does not emit there, so the script's only preview mode rejected the
    theme's own output. An unknown flag exits 2 instead of being accepted in silence, and
    the usage line names the flags the code actually reads.

  • layouts/robots.txt. Hugo's built-in one is User-agent: * and nothing else — no
    Sitemap: line, and nothing said either way about the crawlers that feed answer
    engines. This one names them, in two groups that are not the same request: answer
    engines fetch a page to answer a question now and cite the source back to the reader,
    and dataset crawlers collect it into a corpus with no citation and no referral.
    [params.aeo] allowAI and allowTraining switch them independently, both defaulting
    to true — which is what the bare User-agent: * already meant, so an upgrade does
    not quietly change what a site publishes. [params.aeo] disallow keeps paths out of
    every group, the AI ones included: robots.txt groups do not inherit, so a path excluded
    only from * would have stayed open to exactly the crawlers a site had just named.
    A build that is not for indexing publishes Disallow: / instead, matching the noindex
    meta head.html already emits — the two files disagreeing is how a deploy preview gets
    crawled. Requires enableRobotsTXT = true in the site's config: it is a root key and
    a theme's config is not merged for it. Part of #34.

  • JSON-LD for what the theme actually renders. Person (or Organization) and
    WebSite on every page, BlogPosting on a post, WebPage on any other single page,
    and BreadcrumbList on everything but the home page. Before this the site emitted a
    Person on the home page and nothing anywhere else — a blog whose posts never said
    they were posts, which is where the 0/20 on Schema Presence came from. The nodes are
    linked rather than repeated: the publisher carries an @id and the post's author and
    publisher point at it. BlogPosting carries headline (capped at the 110 characters
    Google's documentation caps it at, since a longer one drops the field entirely),
    datePublished, dateModified, author, image, keywords from the page's tags,
    wordCount and inLanguage. Breadcrumbs are built from .Ancestors — the real content
    tree, not the URL string — so a crumb cannot point somewhere that is not a page.
    The 404 is the one page that emits none: it is not in the content tree, so a
    breadcrumb there describes a hierarchy that does not contain it, and a WebSite node
    invites a crawler to treat an error as a document. A section index, a tag list
    and a term page carry a CollectionPage — the narrower true statement about a page
    whose content is the set of pages it links to, and the node their BreadcrumbList
    needed: without it those pages published a trail leading to something the graph said
    nothing about.

    Every string that reaches the graph is plain text, and getting there took two passes.
    truncate escapes a plain string and leaves a template.HTML alone, so headline
    the one field the theme transforms rather than copies — came out as
    Vue &amp;amp; Vitest for a title as ordinary as Vue & Vitest: HTML entities inside a
    JSON string, where the consumer reads them literally, contradicting the name built
    from the same title in the same node. name had the opposite problem, carrying
    whatever markup the front matter wrote. Both are plainified now, in the breadcrumb
    trail too, and check_aeo.py asserts the invariant that catches either drifting again:
    headline and name come from one title, so one has to be the start of the other.
    The 110-character cap was also 111 in practice — truncate appends its ellipsis
    after the limit — which the theme's own checker rejected. CI now builds a post whose
    title carries an ampersand, an apostrophe, markup, a quote, an emoji and 118
    characters, because every title in the exampleSite is short, plain ASCII.

  • [params.schema] type = "Organization" with a logo, for the sites that genuinely are
    one. Person stays the default because this is a portfolio theme, and the type is a
    statement about the site rather than a lever: an Organization on a personal site is
    schema that lies. The two carry different fields — jobTitle belongs to a person,
    logo to an organization, and each is dropped for the other type. An unknown value
    warns and falls back to Person, the way an unknown palette falls back to lime. The
    logo resolves through asset-path.html, the same partial as [params.favicon],
    [params.ogImage] and a post's image: it had spelled the trim out inline, which is
    how it came to differ from the other three and rewrite //cdn.example/logo.png — a
    URL naming another host — onto this site.

  • allowIndexing is read through params-bool.html, in one partial both head.html
    and robots.txt call. Read raw it reached an if, and if accepts anything:
    allowIndexing = "false" is a non-empty string, so the spelling that most plainly
    means do not index was switching indexing on — silently, on exactly the preview
    deploy the flag exists for. It now warns and holds, and the two files cannot answer
    the question differently, which is the failure robots.txt's own comment names.

  • scripts/check_aeo.py --training-blocked, for a site that means it. Three of the four
    crawlers it checks are training crawlers, so a site using [params.aeo] allowTraining = false failed the check three times for doing exactly what the switch is for. The
    flag is an assertion rather than a mute: with it, a build that still allows them
    fails.

  • partials/params-bool.html, the fourth of the params-*.html family and the one whose
    failure mode is quietest. The other three reach a range, a field lookup or a cast, so
    a wrong type either stops the build or renders something a consumer can see. A boolean
    reaches an if, and if accepts anything: allowAI = "false" is a non-empty string,
    which is true, so the switch a site wrote to turn something off turns it on instead,
    and nothing warns. Only a real boolean counts and only an explicit false vetoes.

  • JetBrains Mono is self-hosted. The font ships with the theme in static/fonts/,
    declared by an @font-face at the top of terminal.css, and the two preconnect
    hints and the fonts.googleapis.com stylesheet are gone — the theme now makes no
    third-party request at all. Behind Google Fonts the font was a three-hop critical
    path the browser could not start until it had parsed the HTML: document, then
    Google's CSS, then the .woff2 that CSS named. That last hop landed at 1,602 ms in
    a throttled trace, later than terminal.css at 1,261 ms, which made the font the
    critical path and the theme's own stylesheet not. Lighthouse put the saving at
    1.33–1.59 s per page and scored render-blocking-resources,
    render-blocking-insight and network-dependency-tree-insight at zero on every
    page of the site. Measured after: mobile performance 92 → 100, FCP 2.5 s →
    1.1 s
    , Speed Index 4.4 s → 1.1 s, and the longest critical chain 1,602 ms →
    44 ms
    . Part of #34.

    Two subsets are published — latin (31.4 KB) and latin-ext (11.6 KB) — both
    variable fonts covering 400 800, so one file per subset serves every weight the
    theme uses. Asking Google for wght@400;500;600;700;800 had been returning 30
    @font-face blocks and 12.4 KB of CSS to arrive at the same .woff2 that
    wght@400..800 returns in six. latin-ext carries a unicode-range, so a site
    that never writes those codepoints never requests it and shipping it costs nothing;
    only latin is preloaded, since a preload the page does not use is a wasted round
    trip and a console warning. The font is SIL OFL 1.1, redistributed with its licence
    at static/fonts/OFL.txt and named in LICENSE, which is where someone vendoring
    the theme looks.

    The binaries are the ones fonts.gstatic.com serves for v24, committed unmodified,
    and static/fonts/SHA256SUMS is what makes that checkable: CI verifies it, so the
    claim is not a sentence in a comment. It also enforces the premise the immutable
    cache header rests on — the font's version is in the filename because serving it for
    a year is only safe if replacing the bytes means writing a different name, and
    swapping them in place now fails the build instead.

  • [params.fonts] latinExt = true adds the second <link rel=preload>, for sites
    that write mostly in Polish, Czech, Turkish, Romanian or Hungarian. It changes
    nothing about what is published — both subsets always are — only which of them
    starts downloading in the first round trip.

  • scripts/check_contrast.py, run by CI, asserts three properties of all five palettes
    at once: every token the stylesheet reads as a text color clears 4.5:1 against every
    ground the palette declares, and --dim < --dim-2 < --muted-2 < --muted < --prose < --soft < --text by relative luminance. The first is the defect below. The second is
    the half a contrast fix quietly breaks, and it caught exactly that while this change
    was being written. It is the same argument as the existing mount check: a sixth
    palette is meant to be a list of hex values, and this is what stops one from entering
    the theme with a rung that fails. It reads the stylesheet only — no build, no
    browser, no network.

    The third property is the checker checking itself. It reads #rgb and #rrggbb, and
    anything else — rgb(), color-mix(), #rrggbbaa — is now a named failure rather
    than a token it walks past: a --dim written rgb(106,106,106) used to never enter
    the parser at all, so a stylesheet failing AA came back clean, which is the exact rot
    the checker exists to prevent. A ground written that way was worse, because it also
    fell back to lime's value and made the failure line name a hex the palette never
    declared. It also fails if the stylesheet declares a background token that is not
    among the four it compares against, since that list is written by hand while the text
    tokens are discovered.

  • The palette is configuration. [params.theme] palette = "…" picks one of five —
    lime (the default, and what every earlier version rendered), amber, cyberpunk,
    ice and mono — and [params.theme.colors] overrides individual tokens on top of
    whichever won. Until now the README's answer to "change the accent" was to edit
    --accent in assets/css/terminal.css, which is advice a site can only follow if it
    vendored the theme; consumed as a Hugo Module or a submodule the file is not the
    site's to edit, so the theme was effectively one color. Closes #30.

    Palettes live in the stylesheet as :root[data-palette="…"] blocks, stamped onto
    <html> by baseof.htmlterminal.css stays static, so it keeps being minified,
    fingerprinted and cached the way it was, and a site that configures nothing gets the
    bare :root, which is still lime. Overrides are the one thing that has to come from
    the config, and they arrive as a <style> element after the stylesheet. It is written
    :root:root on purpose: a palette selector is 0,2,0 and a plain :root is 0,1,0, so
    the obvious spelling would have worked on lime and silently done nothing on the other
    four — the harder half of that bug to notice.

    An unknown palette name, a [params.theme] written as a scalar, an unknown color key
    and a value that is not a color each warn and are ignored. None of them fails the
    build, which is the rule sections.html set and the params-*.html guards enforce.

Fixed

  • The typewriter no longer runs the HTML parser once per character. wrap() for an
    unfinished segment always produces the same shape — one span with a color — so all
    442 tailEl.innerHTML assignments were rebuilding a node identical to the one they
    had just destroyed. The tail is now one span holding one text node, both built once,
    with .data reassigned. Measured over three Lighthouse runs on a production build,
    mobile: HTML parsing 62 ms → 12 ms, consistently and well outside the run-to-run
    spread. Part of #34.

    The loop moved from setTimeout(…, 15) to requestAnimationFrame, writing once per
    frame instead of once per character — and only the frames in which a character came
    due, since assigning the same string still marks the node dirty and the opening pause
    is some twenty-five frames of exactly that. On a machine fast enough to render every
    16 ms that is about the same number of writes minus the timer churn; on the throttled
    profile the original measurement came from, two to four characters come due within
    one frame and now cost one write between them. It also stops entirely in a background tab, where
    the old timer kept typing to nobody. A frame boundary the reader was away for is
    capped at 100 ms so a backgrounded tab resumes typing rather than dumping the rest of
    the terminal in one paint. The opening pause, the per-character and per-newline
    timings, and prefers-reduced-motion are all unchanged.

    .term__body gets contain: layout style. The min-height above it already
    guarantees the box does not change size while it fills, and this is the browser being
    told so: without it every write invalidated the layout of the whole document, measured
    at 172 invalidations across one run. Not paint or content — paint containment
    clips, and the links inside carry an outline-offset that would be clipped with them.

    Two things the original report projected did not survive being measured, and are
    recorded here rather than quietly dropped. The 1.4 s it attributed to the
    typewriter is the cost of the animation existing — it came from an A/B against
    --force-prefers-reduced-motion, which does not run it at all — not the cost of how
    it is written; the implementation change is worth the 50 ms of parsing above plus the
    work it stops doing in a hidden tab. And forced-reflow-insight cannot be cleared:
    it fires on about half of Lighthouse's runs both before and after, because what is
    forced is the document's first layout, which the hero's height reservation genuinely
    needs. Reusing one persistent probe instead of building one per call was tried and
    reverted — it moved the audit not at all and left the ruler's 0123456789 inside the
    terminal's textContent for the life of the page, read by anything that extracts text
    from the rendered DOM, which on this theme now includes the answer engines llms.txt
    is for. Deferring the first measurement into requestAnimationFrame would clear the
    audit by giving up the reservation, which is the layout shift the reservation exists
    to prevent.

  • [params.favicon], [params.ogImage] and a post's image now resolve inside the
    baseURL
    , not at the host root. relURL and absURL read a path with a leading
    slash as rooted at the host, so on a site published under a sub-path — the demo is
    one — "/img/og.png" dropped the sub-path and resolved to a URL that 404s, while
    "img/og.png" resolved correctly. The theme's own exampleSite documented the first
    spelling. All three call sites now go through one partial, asset-path.html, that
    trims the leading slash before the conversion, so both spellings work — and a path
    that names another host still passes through untouched, whether it carries a scheme
    or is written //cdn.example/og.png. Fixes #37.

    Nothing about it was visible: a favicon that 404s is a blank tab, an og:image that
    404s only fails in someone else's link card, and a post banner that 404s renders as a
    reserved empty block that looks deliberate. The build was green because the templates
    did exactly what they were told. A site that really did mean the host root — an image
    served from outside Hugo — writes the absolute URL, which is the spelling that always
    meant that unambiguously.

    The other half was the gap that let it through: the exampleSite leaves all three
    params empty, so the documented path was never built by CI. A new step builds under a
    sub-path baseURL with the three filled in, in both spellings, and asserts that every
    URL the build emits names a file the build actually published, that the two spellings
    agree, and that both spellings of an off-site URL arrive untouched.

  • --dim failed WCAG AA in all five palettes, against all four grounds. Lighthouse
    reported it as two selectors — .term__title on the home page and .toc__label on
    a post — but the token is read by thirteen rules (.footer, .post-meta, .pager,
    .tl-date, .lang-switch, figcaption, Chroma's line numbers…), all of it at
    12–13.5 px, where the threshold is 4.5:1 rather than the 3:1 large text gets. It
    measured between 3.44:1 and 3.81:1 depending on the palette, worst against
    --surface-2 — the lightest ground, not --bg. Each palette's value is lifted the
    minimum needed to clear 4.5:1 against that worst case, preserving its hue:
    #6b7263 → #798071 (lime), #776a56 → #887b67 (amber), #6d6390 → #8076a3
    (cyberpunk), #62737f → #6e7f8b (ice), #6a6a6a → #7d7d7d (mono). Accessibility
    goes to 100 on every page. Part of #34.

    Two of these were not in the original report and came out of checking the whole ramp
    rather than the one token: mono's --dim-2 was failing the same way at 4.45:1, and
    lifting amber's --dim pushed it past the --dim-2 it is named to sit under —
    a palette that passes AA while contradicting its own token names. Both --dim-2
    values move with it (#7c7c7c → #868686, #8c7a5c → #95866a).

  • The entrance animation no longer holds the largest element on the page invisible.
    .fadeup animated opacity from 0 with animation-fill-mode: both, and Chrome does
    not count an element at opacity: 0 towards FCP or LCP — so for the first 500 ms of
    every page in the site the metric was reporting whatever it could see instead of
    the main content. .fadeup wraps six layouts: the hero's .hero__id, the whole
    <article> in single.html, and the containers in list.html, term.html,
    terms.html and 404.html. Mobile was worse, because .hero__id{order:-1} at 760px
    puts exactly that <h1> at the top of the viewport. Confirmed by A/B on a production
    build: with the rule disabled the LCP element changes (nodeId 18 → 86) to the <h1>
    that renders immediately. The keyframes now animate transform alone — the motion is
    unchanged, and a partial fade would not have worked, since Chrome's heuristic is not
    a threshold on the computed value. prefers-reduced-motion still switches it off.
    CI asserts the keyframes animate transform alone — it is the one change here that
    renders identically when it regresses, so nothing else would have caught it coming
    back.

  • Colors that did not follow the accent because they never went through it. Eight
    rgba(182,255,60,…) washes were spelled out in terminal.css — the solid button's
    glow, the reading-progress bar, the contact box, the tag hover, the blockquote, the
    post banner's dot grid, the 404's text-shadow and the gradient behind the whole body —
    along with the typewriter's five colors in terminal.js, five inline style="color:#…"
    attributes in 404.html, and the favicon. They were correct while there was one
    palette and would have stayed lime green in the other four. The washes are now
    color-mix() against var(--accent); the script and the 404 emit var(--accent-dim)
    and friends rather than hex, which also removes the question of when it is safe to
    read a computed color, since nothing is resolved in JavaScript at all; and each palette
    ships its own favicon. The neutral overlays stay literal — the white scanlines and the
    black drop shadows are not palette colors and read the same on every ground.

    Under lime every one of these resolves to the value it had before, exactly, with two
    exceptions — both derivations of a value that had been picked by hand, and both under
    2/255 of where they were:

    • the border on code blocks, #232a1c, now derived from --border and --border-soft:
      +1 on red and +2 on blue, on a 1px rule;
    • the post banner's gradient, #11160d, now --surface-2 (#11140d): −2 on green.
  • Browser chrome follows the palette too: <meta name="theme-color"> carries the
    palette's ground, or the [params.theme.colors] override of it, so an amber site does
    not get a lime address bar drawn around it — the same argument as the per-palette
    favicon, one layer out.

  • color-mix() has a literal fallback where it reaches something structural. A
    declaration carrying color-mix() parses fine and only becomes invalid at
    computed-value time, which resolves to the property's initial value rather than to
    an earlier declaration — so the usual two-declaration fallback does not work through a
    custom property, and on a browser without the function (pre-2023) the sticky nav and
    the open mobile menu both went transparent over the scrolling page, and the code-block
    borders disappeared. The derived tokens now carry the values the theme shipped before
    palettes existed and are upgraded inside @supports. Decorative mixes — glows, hover
    tints, shadows — are deliberately left to degrade to nothing.

  • [params.theme.colors] keys accept the spelling you copied out of the stylesheet:
    accentDim, accentdim, accent-dim and accent_dim are one key. TOML bare keys
    do permit -, so accent-dim was reaching the theme intact and being reported as an
    unknown key.

  • [params.theme.colors] rejects two values it used to emit. A non-string — accent = true,
    accent = 255 — is never a color, and emitted CSS that was valid to parse and invalid
    to compute, so every var(--accent) on the page fell back to nothing with no warning.
    A value with an unclosed parenthesis — accent = "rgb(" — swallowed every declaration
    after it in the block, so one typo silently deleted the other overrides. Both now
    warn and are ignored.

  • The warning for an unknown palette no longer reports printf's error syntax. palette = 3
    arrives as an integer and %q on an integer is a rune literal, so it announced that the
    requested palette was '\x03'; palette = true reported %!q(bool=true).

  • An untranslated language rendered a blank interface. Hugo's fallback for a missing
    i18n key is the site's defaultContentLanguage, not the theme's i18n/en.toml, so a
    site whose default language the theme ships no strings for — defaultContentLanguage = "es" with no i18n/es.toml — rendered every label as the empty string: // for
    the hero intro, [ ] for the contact button, nav links with no text, the 404 with no
    message. The build was green and said nothing. A language declared alongside en
    was never affected, which is why the bilingual exampleSite never showed it.

    Every UI string now goes through partials/t.html, which asks Hugo first and the
    theme's own English table second, so the floor is a readable page in the wrong
    language rather than an unreadable one in the right language. That table is the
    theme's real i18n/en.toml, not a copy of it, whether the theme is vendored, a
    submodule or a Hugo Module — reached through a second mount the theme's own
    hugo.toml declares at data/terminal_mono_i18n, which is what keeps a site's own
    i18n/en.toml from taking its place: os.ReadFile returns the first match on Hugo's
    union filesystem instead of merging, so overriding three English strings used to leave
    a three-key fallback behind and blank the other 46 labels. Your English is merged over
    the theme's now, key by key, the way the translation lookup itself merges. Hugo parses
    that mount, so no template has to guess a file format either — one comma in a comment
    used to make transform.Unmarshal read the file as CSV and abort the build.

    partials/i18n-check.html warns once per language, naming the file to create when a
    language has no strings at all and listing the missing keys when it has some, with
    other = "" counting as missing. The count comes from the translation files — the
    theme's through that same mount, the site's through the union filesystem, unioned —
    because a resolved value cannot say where it came from: in any language but the
    default one Hugo has already substituted the default language's string, so asking
    i18n for it saw a fully translated site every time. [languages.es] alongside a
    translated en, the multilingual setup the README walks you through, warned about
    nothing at all.

    Every lookup is wrapped in try(). With --printI18nWarnings and --panicOnWarning
    together — what CI runs the exampleSite under — a missing key makes i18n return an
    error rather than an empty string, which would otherwise abort the build inside the
    partial that exists to prevent exactly that. Nav labels go through t.html too, so a
    menu identifier the theme has no string for falls back to the theme's English rather
    than to the identifier.

    Strings that interpolate a value keep working through the fallback: {{ .count }} and
    friends are substituted literally, which is what the one such string in the theme
    (posts_tagged) needs — a $ in the value survives, and a one form added to
    i18n/en.toml is used when the count is 1 instead of being ignored for other.
    Nothing in the rendered output changed for a site that has its translations — the
    exampleSite builds byte-identical to v0.5.0.

  • README: the language section says which half of adding a language is configuration
    (all of it — switcher, hreflang, og:locale:alternate, menu and dates follow from
    [languages.<lang>]) and which half is a file you write. It also records that label
    and locale are the current spellings — languageName and languageCode are
    deprecated as of Hugo 0.158, the theme's own minimum — and that dates localize from
    the language key with or without locale. Finally it states what the theme does not
    do: languageDirection = "rtl" reaches the page as <html dir="rtl">, but the
    stylesheet is written in physical left/right properties and does not mirror, so
    RTL is untested and unclaimed rather than quietly broken.

Changed

  • netlify.toml serves /css/*, /js/* and /fonts/*.woff2 with
    max-age=31536000, immutable. All three are immutable by construction — the CSS and
    the JS carry a SHA-256 of their contents in the filename and the fonts carry the
    font's version — and were going out with Netlify's default max-age=600, which
    Lighthouse scored at an 8% cache hit probability. The README now documents the
    equivalent for Cloudflare Pages, Vercel and nginx, and says plainly that GitHub Pages
    cannot set headers at all, because nothing in the built output tells a consumer that
    the theme fingerprints its assets. The glob is *.woff2 rather than the whole
    directory because OFL.txt and SHA256SUMS live there too, and nothing renames
    those when they change.

Full changelog: v0.5.0...v0.6.0