Marketing site for Hypernova Oy — cost-effective Free and Open Source software services: Koha library system cloud hosting, Lainuri self-service checkout machines and Toveri access control devices.
- Astro 6 server-rendered (
output: 'server') with the@astrojs/nodestandalone adapter - Tailwind CSS 4 via
@tailwindcss/viteplus@tailwindcss/typographyfor prose content - English / Finnish UI, resolved per request (query parameter → cookie → English)
- Playwright end-to-end regression tests
- Node.js >= 22.12.0
- A Redmine instance and API key for the quote and contact forms (optional for local UI work)
npm install
cp .env.example .env # fill in the Redmine values, set FORM_TOKEN_SECRET
npm run dev # http://localhost:4321On a fresh Debian or Ubuntu server, scripts/deploy/install.sh does both of the above plus
the service account, the systemd unit, Apache and TLS, and checks the result (see Deployment).
npm run dev # Start the local dev server
npm run build # Build the production site into dist/
npm run check # Astro + TypeScript type check
npm run preview # Preview the production build locally
npm start # Run the built server (dist/server/entry.mjs)
npm run test # Unit tests + Playwright regression tests
npm run test:unit # Node's built-in test runner (form protection, fonts, caching, palette)
npm run test:e2e # Playwright regression tests
npm run test:e2e:preview # The same suite against the build in this checkout (see below)
npm run bench:scroll # Chromium scroll-smoothness benchmark (needs a running server)
npm run index:refresh # Build, restart the site and verify the search index (see Deployment)
npm run og:image # Regenerate the social preview card (public/og-image.png)
npm run font:subset # Re-cut the brand font subset from the whole face (see Performance notes)Playwright needs browsers installed once:
npx playwright install chromiumThe Playwright config starts a dev server on 127.0.0.1:4321 unless something already answers
there, and on a server that runs the site the deployed build answers on that same port. The
suite then tests the deployment instead of the checkout, which hides every change under test.
npm run test:e2e:preview is that suite against the working tree: it builds the checkout,
starts it with npm start on 127.0.0.1:4399, and refuses to reuse a server already on that
port, so it cannot quietly test something else. The other way is to stop the service first
(sudo systemctl stop hypernova) and let Playwright start the dev server from the working copy.
Site-wide settings live in src/config.ts:
export const SITE_TITLE = 'Hypernova';
export const SITE_DESCRIPTION = 'Cost-effective Open Source Services';
export const SITE_URL = 'https://www.hypernova.fi';
export const SOCIAL_LINKS = {
twitter: 'https://twitter.com/farrosfr_',
github: 'https://github.com/farrosfr',
linkedin: 'https://linkedin.com/in/yourhandle',
};
export const NAV_LINKS = [ /* main navigation */ ];
export const FOOTER_LINKS = [ /* footer columns */ ];astro.config.mjs holds the site URL, trailing-slash policy, legacy redirects, the
Node adapter and the locally hosted OCR-A brand font.
The quote form on /koha/ and the contact form on /contact/ create Redmine issues
through the REST API. Copy .env.example to .env and set:
| Variable | Purpose |
|---|---|
REDMINE_NEW_QUOTE_ENDPOINT |
Issues endpoint, e.g. https://redmine.hypernova.fi/issues.json |
REDMINE_NEW_QUOTE_API_KEY |
API key used for issue creation |
REDMINE_NEW_QUOTE_PROJECT_ID |
Project for Koha quote requests |
REDMINE_NEW_QUOTE_STATUS_ID |
Initial status for quote requests |
REDMINE_NEW_CONTACT_PROJECT_ID |
Project for general contact requests |
.env is git-ignored and must never be committed.
| Route | Rendering | Notes |
|---|---|---|
/ |
SSR | Hero, "Why FOSS" features, closing CTA |
/services/ |
SSR | Service cards |
/koha/ |
SSR | Koha overview, hosting service section + quote form (POST) |
/toveri-access-control-device/ |
SSR | Toveri access control device |
/lainuri-checkout-machine/ |
SSR | Lainuri self-service checkout machine |
/contact/ |
SSR | Company and billing details + contact form (POST) |
/privacy/, /terms/ |
SSR | Legal pages |
/sitemap.xml, /robots.txt |
SSR | SEO data |
/search-index.json |
SSR | Data for the command palette (see below) |
| any unknown URL | SSR | src/pages/404.astro, answered with status 404 and translated |
Legacy URLs are redirected in astro.config.mjs. The old standalone pages point at the section
of /koha/ that replaced them (/koha-hosting-quote/ → /koha/#request-a-quote,
/fi/koha-yllapitopalvelu/ → /koha/?l=fi#cloud-hosting-service) or at the page that took
their place (/privacy-policy/ → /privacy/, /lainuri-self-checkout-machine/ →
/lainuri-checkout-machine/), and the Finnish URLs of the old site become the same page with
the ?l=fi language parameter (/fi/lainuri-lainausautomaatti/ →
/lainuri-checkout-machine/?l=fi). /koha-hosting/ is in that list too: it was the address of
the hosting page for as long as the service had one, and it redirects into the section that
describes the service (/koha/#cloud-hosting-service). /blog/ and /changelog/ are in that
list for a third reason: both were routes of the theme this site was built from, both were live
here as empty listings while every entry in them was a draft, and both lead to the home page now
that they are retired.
The Koha hosting service is described on /koha/ and nowhere else. The section with the
cloud-hosting-service id - what the service includes, who runs it and for how long - is
rendered by KohaService.astro, and the navbar link, the button under the screenshot and the
/koha-hosting/ and /fi/koha-yllapitopalvelu/ URLs above all land on it. The service used to
have a page of its own as well, which put one description of it at two addresses a crawler could
reach; that page is gone, its address is a permanent redirect into the section, and its words are
keywords of the /koha/ entry in /search-index.json, so a query about hosting still finds the
page that answers it.
The Koha logo on /koha/ is a wide file (768x220) that carries no
width of its own, so it is capped below md (w-56) and left to its grid cell above it.
Without the cap it filled whatever row it sat in, which drew it wider on a phone than the
desktop layout ever does.
The company and billing details on /contact/ are one panel with two columns from md up:
the VAT ID and the postal address on the left, the four billing codes on the right, and the
billing column divided from the other by a hairline. Each detail is a label above its value
in a <dl>, which is what lets a code be found by its name instead of by reading the
sentence around it, and keeps both columns the same shape. The codes (IBAN, BIC, EDI number,
operator) are copied off the page into an invoicing system, so they stay verbatim and are set
in font-mono rather than spaced out or wrapped for looks. The grid itself is w-full and
not mx-auto like the panels around it: the panel is a column flex container, so an auto
inline margin there sizes the grid to its content and centres it, which left the right half
of the panel empty above 768px.
The navbar search button and Ctrl/Cmd + K open the shared palette component
(src/components/CommandPalette.astro). It fetches /search-index.json once per
language and matches every word of the query against the title, the description, the
hidden keywords and the content of an entry.
The closed button is a chip with a fill of its own, not bare text like the links beside it, and
the greys it wore first - zinc-500 on zinc-100 in light mode, zinc-400 on night-raise in
dark - read 4.39:1 and 5.35:1 there, close enough to their own fill that a closed search box looked
disabled. A step further apart (zinc-700 on zinc-200, 8.2:1, and zinc-300 on the night-line
surface, 8.0:1) leaves the label plainly the foreground and the fill plainly a surface above the
bar. The chip does not write the accelerator on itself either: a Ctrl K badge that a script
rewrote to ⌘K on Apple hardware repeated what the palette's own empty state says one click later,
and was a second place to keep in step with what actually opens the palette.
tests/unit/search-trigger.test.ts holds the badge's absence and both ratios, so a restyle back
into the old pair fails there rather than on a page.
The chip is content-sized until xl and keeps a floor of 11rem from there, so Search and the
shorter Haku give a box of the same width at the size most desktops are, and the layouts without
room for it (1024px is down to 91px of spare width, 768px has none and already wraps) are left as
they were.
The component ships as a bundled script (<script>, not is:inline): the base path and the
localized message it needs are data- attributes on the palette element, so one file - cached
by its hash, see Performance notes - serves both languages and is parsed once rather than
written into every document. A client-side navigation replaces that element without re-running
the file, so it binds the new one and republishes window.toggleCommandPalette (which the two
navbar buttons call) on astro:page-load.
src/pages/search-index.json.ts builds the index:
- the static pages (home, services, Koha, Koha hosting, Lainuri, Toveri, contact, privacy, terms) with localized titles/descriptions, so results follow the visitor's language;
keywords, which always holds both languages plus extra terms (for examplekirjasto,hosting,kulunvalvonta), so a search matches in either language;content, the text of the page itself in the visitor's language, so a page is found by anything written on it (maailman ensimmäinen,since 1999) and not only by its title, description or keywords.
Every entry is one of the static pages: the two index pages of the theme the site was built
from (/blog/, /changelog/) had nothing published behind them and are retired (see
"Routes").
content is not written twice anywhere: the server renders the page and
src/utils/search-content.ts reads the <main> element of that answer back, in the
same request that asks for the index. The head, the navbar and the footer are left out,
because they are identical on every page and would make every page a match for
"contact" or "language". A page that cannot be read (a render error, a timeout) keeps
its title, description and keywords and is reported in the log, so a failure never
takes the palette down with it. Every page is read once per process in a production
build, and on every request in npm run dev so that copy changes show up immediately.
The palette highlights every word of the query where it is visible: in the title, in the
description, and - when the words are not part of those two - in a snippet of the page text,
so it is clear why an entry matched. The snippet window around a match is chosen to hold as
many of the query words as possible, which keeps a phrase (say maailman ensimmäinen) in one
piece, and both it and the title/description highlight the words in the casing of the entry.
A word that only keywords carries (kirjasto on an English page, for example) is still
matched but cannot be highlighted, because it is not part of the visible copy.
The contact page carries two words that are printed nowhere: Tilinumero beside the IBAN and
Pankki beside the BIC, each one sr-only inside the row it names and only on the Finnish
page. They are the Finnish names of those two codes, and since the index reads the text of the
page, a search for either word points at /contact/ with the code the word stands for in the
snippet (see the billing block above). They are markup rather than keywords so that the
result points at the row that actually carries the number, which keywords - one flat string
per page - cannot do.
Because the payload depends on the language cookie, it is served with
Cache-Control: private and Vary: Cookie and must not be cached by a shared proxy.
Keep those headers when adding entries, and keep the entry shape (title,
description, keywords, content, slug, type) - the palette displays title,
description and type, matches on keywords and content, and links to slug.
The current language is resolved once per request in src/middleware.ts
(?l= parameter → language cookie → English) and stored in Astro.locals.lang.
The codes it resolves to are en and fi (languages in src/i18n/ui.ts), and each is also
the lang attribute of the document it renders: both are real language tags, which is what that
attribute has to be. The name the previous site gave English, gb, is read as en in the links
and the cookies that still carry it (LEGACY_LANG in src/i18n/ui.ts) and never written: the
cookie stores the code that was resolved, so an old one is rewritten on the next visit.
Pages and components declare their own dictionary and translate through
useTranslations:
const i18n = {
fi: {
'Our Services': 'Palvelumme',
}
};
const t = useTranslations(i18n, Astro);English keys are used as-is; missing Finnish entries fall back to English. Add new
copy to the component's fi dictionary next to where it is rendered.
t() returns text, so it cannot hold the markup of a sentence that carries a link:
a sentence like that is broken into pieces around its links and each piece is
translated where it sits. The credits line at the bottom of the footer is four
entries for that reason.
The trust badge under the tagline at the top of the footer is the one entry in these
dictionaries whose Finnish side is not a translation of its English key: the Finnish pages
carry the claim of a reader here (Kotimaista laatutyötä) and the English pages the claim of
a reader abroad (Nordic quality. Privacy first.), and both wear the same flag - Finland's,
the company's country of origin, written into the markup as flag="fi" in Footer.astro -
which Badge.astro draws from src/images/flags.
The language switcher is src/components/LanguagePicker.astro. It links to the same page
with ?l=, so changing language never leaves the page the visitor is on. The desktop nav
bar holds it as a pill of its own (variant="inline", the default): the bar around it is
bare text, so the picker carries the fill, the inset, the rounded shape and the size of a
control, its label matching the labels it follows instead of the 16px a bare link would
inherit beside them. The fill is a tint of the bar in each theme - primary-100 under
primary-800, the pairing a Badge gives a label - rather than the solid-control colour
primary-600, which as a near-black pill was the heaviest thing in a bar whose other
controls stay quiet until they are hovered, and read as a competitor to the page's own
calls to action. Dark mode keeps night-raise, which sits above the bar it is drawn on.
Because that bar is hidden on a small screen, the mobile menu renders the same component
as full-width rows (variant="menu"), next to the navigation links it would otherwise be
missing from.
The picker draws the language it can offer, not the one in use: the control is the way out
of the page a visitor is reading, and drawing both would make the bar's one pill two. The
label is written in the language it names, and the link says so (lang on it), so Suomi
is not read with the voice of the page it stands in; the list itself is named for what it
offers (Language, Kieli) in the bar, where the pill carries no visible caption, while
the mobile menu draws a caption of its own above it. The language in use carries no
aria-current: its anchor is hidden and therefore outside the accessibility tree, and the
document already says which language it is written in, with the lang attribute it carries
(BaseLayout.astro).
A page exists in two languages at one address, and the head says which is which. The
canonical - and og:url with it - is the version being served, so /services/ and
/services/?l=fi each declare themselves rather than the Finnish page declaring itself a
copy of the English one; every language is announced as an alternate (hreflang="en",
hreflang="fi") with x-default on the English page for a visitor who reads neither; and
og:locale names the language and the territory a share is matched against (en_GB,
fi_FI, the flag the picker draws beside the label) with the other language beside it as
og:locale:alternate. The whole set is built from languages in src/i18n/ui.ts, so a
third language is announced by being added there and nowhere else.
The title of a page is part of the language it is written in - the one line read away from
the page, in a tab, a search result or a shared link - so it goes through the page's own
dictionary like the rest of its copy: / is Open source library systems, hosted and supported / Avoimen lähdekoodin kirjastojärjestelmät pilvipalveluna ja tuettuna, and
/toveri-access-control-device/ is Toveri Access Control Device /
Toveri-kulunvalvontalaite. Every page of the site is translated: the blog and the changelog,
whose English-only bodies were the two exceptions, are retired (see "Routes").
The mobile menu is a panel of the sticky header, so it covers the top of the page it is
open over and puts itself away unless the tap asks to stay: a tap beside the menu, or on
one of its links, closes it, while a tap on the menu's own caption or padding leaves it
open. The dismissal lives in setupMobileMenu() in src/components/Navbar.astro as one
document listener that looks the menu up again on each tap, because every navigation
renders a new header.
The bar is tight: from 768px up it shares the row with the logo and the controls, and the
Finnish labels are the longest, so a hairline (span[data-nav-separator]) carries the
separation between the items and the gaps around it are kept small (gap-3 lg:gap-2)
until the row has room to breathe again (xl:gap-5). The hairlines start at lg, where
the row stops wrapping its labels; the mobile menu draws its links as rows and has none.
The picker's pill costs the row about 15px more than the bare link did, which the tightest
row (lg, Finnish labels, English in the picker) still has to spare - measured at 768,
1024 and 1280px, in both languages.
The bar is a surface of its own, and a solid one: bg-white dark:bg-night-card, the fills
the mobile menu panel below it already used, so the two stay one surface when the menu opens.
The page scrolls underneath it, and the translucency it used to carry (.glass-panel, 80%)
let the copy behind it read through the links - the cards in the page keep that fill, since
nothing scrolls under them.
Two things in the bar are for assistive technology rather than for the eye. A link that names
the page the visitor is on carries aria-current="page", and one that names a section of the
page it is already on - /#whyfoss, /koha/#cloud-hosting-service - carries
aria-current="location": the bar marks nothing visually, every link the same grey until it
is hovered, so a screen reader otherwise reads five destinations that all sound alike. The
comparison drops the fragment, which is what marks an anchor on the page it points at from
that page, and the marker is written by currentMarker() in src/components/Navbar.astro on
the bar and on the mobile menu alike. The other is the first stop in the tab order of every
page: a skip link (BaseLayout.astro, .skip-link in src/styles/global.css) that puts the
visitor past the bar, which is sticky and otherwise walked again on every page. It is
position: fixed so it can be drawn over the bar it crosses, it is moved by transform and
without a transition so it costs no layout and no time, and the main it opens carries
tabindex="-1" so the browser puts the focus itself there and the next Tab starts inside the
page rather than back in the bar.
/koha/ and /contact/ POST back to themselves. Validation errors are rendered
inline, successful submissions create a Redmine issue and show a thank-you message.
Koha hosting is quoted through /koha/#request-a-quote, so /contact/ says so under its
heading and links there, instead of letting a visitor fill in the wrong form.
All of it lives in src/utils/form-protection.ts and src/utils/proof-of-work.ts, and is
deliberately built from Node's own crypto module and the browser's - no captcha and no
third-party service, so nothing about a visitor leaves our server:
- Rotating trap - one extra field sits off-screen (
aria-hidden,tabindex="-1",autocomplete="off"), invisible to people and screen readers but filled by naive scripts. Which name it has is decided by the token and changes with every page load -website,homepage,url,fax,company_website- so "leave the field called website alone", the first line of most form spam, only ensures that the script fills the field this page load actually rendered. The hiddenform_usefield reports which fields were focused: a person cannot focus an off-screen field, so a script that walks the form and focuses every input it finds is caught even when it read the names off the page. - Signed token - every rendered form embeds a HMAC-signed timestamp
(
FORM_TOKEN_SECRET). Submissions with a missing, forged, foreign, too fast or stale (> 2 h) token are dropped. The payload carries a random nonce and a hash of the client it was issued to - the browser's user agent plus the/24or/64its address sits in - so a token is refused from any other client (client-mismatch) and spent by the submission it carried (replayed-token): one fetched page is worth exactly one post. The address is hashed and only its network is used, so a phone that moves between addresses of one operator is not punished; a visitor whose browser and network both change mid-form has to reload (the user agent alone would have been too easy to share, and the address alone too easy to lose). A form that is re-rendered after a validation error keeps the visitor's token, so fixing a typo is never punished, and the token is only spent once Redmine has accepted the issue it carried - a submission that fails there can still be sent again. - Per-token filling time - a token is too young until its own floor has passed: three
seconds plus up to two more, derived from its signature (
minimumFillTimeMs). The floor is stable for one token, different for the next page load, and unreadable for a script - it has the signature but not the key that produced it - so a wait that was measured once is not enough for the page load after it. - Proof of work - the page has to name a number whose SHA-256 digest over the token,
sha256(token + ':' + solution), starts with four zero hex digits (src/utils/proof-of-work.ts): sixteen bits, so 65 536 hashes on average. The submission carries the answer inform_pow; the server checks it with a single hash, and a submission without one (no-proof) or with a wrong one (bad-proof) never reaches Redmine. The difficulty is read out of the signed token by the browser and verified by the server against its own constant, so a payload can never ask for less work than it was given. The search is handed torequestIdleCallbackand costs the visitor's device about 0.7 s of hashing (measured in Chromium) while they read the page - and a submission that still beats it is held back, finished and sent again, never dropped. - Rate limit - 5 judged submissions per client address and 20 per network (
/24or/64) per 15 minutes, counted in memory (the standalone Node server is a single process). Every judged submission is charged, including the ones the checks have just condemned, so a script that guesses at the proof instead of solving it pays for the guesses out of the same budget as a visitor's messages - free wrong answers would turn a sixteen-bit challenge into a matter of patience. A submission that is not judged, because field validation failed and the visitor has a typo to fix, is not charged. Two counters, because they answer two questions: the address counter is one host hammering us, and the network counter is the same script moving to the next address inside the range its provider handed it - a residential IPv6 line gets a whole/64, so with only the address counted the quota resets with every attempt. The client address is the lastX-Forwarded-Forhop our Apache proxy appended, or the socket address. That last hop is used on purpose: behind a local proxy the socket address is always127.0.0.1, so the proxy has to append the real client address (mod_proxy does this by default). On a host behind--behind-proxyit is the edge proxy in front of Apache that saw the visitor, so that mode turns mod_proxy's own hop off: otherwise its hop would be the edge's address for every visitor and a single bucket would fill up for the whole internet. - Cross-site and content checks -
Sec-Fetch-Site/Originmust belong to us, and free text may not contain control characters, HTML/BBCode markup or more than 4 links. Astro's own origin check (security.checkOrigin) is switched off inastro.config.mjs, because it compares the browser'shttps://origin with the URL the Node adapter derived from the plain HTTP socket behind the proxy, which turns every real submission into403 Cross-site POST form submissions are forbidden. The check inform-protection.tscompares host names instead, so it keeps working behind a proxy that ends TLS. - Bounded fields - every field is length-capped (inputs and textareas carry matching
maxlength), stripped of control characters and single-line-normalised before it is stored in the form state or sent to Redmine.
Bot signals are answered with the same thank-you view as a real submission, so scripts cannot learn that they were detected, and Redmine is never contacted. Only a rate-limited visitor sees an explanation, because a real person should not lose their message silently - and that view is not a detection either: it reports a count of the visitor's own submissions, which a person could keep anyway. Log lines contain the rejection reason only - never visitor data.
One deliberate exception to "nothing here needs JavaScript": the two forms do now, because a
challenge cannot be answered without it. A submission that arrives without an answer is treated
as a bot signal, so both forms carry a <noscript> note where the button is - read by exactly
the visitor who would otherwise lose a message to a thank-you view - and a browser that is here
but fails halfway through the search is told as well. Everything else on the site still works
without JavaScript (src/components/FormValidation.astro and the rest of the markup stay
progressive enhancement).
Because the token encodes when the page was rendered, /contact/ and /koha/ are
served Cache-Control: private, no-store (see src/middleware.ts) so no shared cache
can hand out a stale page whose submissions would be dropped as replays. For the same reason a
page that is still open when a new build starts is dropped: its token predates the challenge,
so it arrives as no-proof. That window is what the 2 h token lifetime and the page's own
note about reloading are for; it is the same trade-off as an open form across a restart, and
the reason FORM_TOKEN_SECRET in .env is worth setting.
The site does not publish e-mail addresses, not even in the legal pages: they point to the contact form and the postal address instead. That keeps address harvesters from having anything to collect. If you ever need to print an address, add it as an image or through the contact form - do not put it back into the markup.
Chromium composites some CSS far more expensively than Firefox, which showed up as janky scrolling and laggy scroll-reveals. Keep these constraints in mind:
- No
backdrop-filteron.glass-panelcards or on anything else that sits over scrolling content. A permanent backdrop blur over scrolling content re-blurs it every frame (removing it took the homepage from ~27ms to ~17ms median frame time). - No
mix-blend-modeon large decorative layers and no infinite animations on blurred layers - they force full-page re-compositing while scrolling. .reveal-on-scrollmust stay compositor-driven:translate3d()pluswill-change(released by.reveal-doneonce the fade ends). A plaintranslateY()transition repaints the element every frame (~1459 raster tasks per scroll pass vs ~173 with promotion)..reveal-on-scrollreveals on any overlap - keepthreshold: 0insrc/layouts/BaseLayout.astro. A threshold that is a share of the element scales with its height: on a phone the one-column card stack on/koha/is 3711px, so 10% of it never fits on the screen and the section stayed atopacity: 0- hiding the cards inside it, already revealed - until the visitor scrolled. Past ten screens tall the share is unreachable and the section would never appear. The8%rootMarginis what starts the fade just before the element scrolls in.- Do not import a stylesheet that covers the world for two elements. The picker in
src/components/LanguagePicker.astrodraws one of two flags, andflag-iconsships a rule per country - about 250 of them, most carrying a base64 copy of the flag. Imported globally it resolved to 421 KB of CSS, which every page then carried:/weighed 563 KB, 421 KB of it flags for countries the site cannot be read in. The two files it needs live insrc/images/flagsand the rules are in the component: the class is built from the language code the markup holds (fi-enbesidefi-fi) while the file keeps the codeflag-iconsnames the flag by, which for English is a country's - there is no English flag, andgb.svgis what the picker has always drawn beside its English label. The footer's trust badge draws the Finnish file too, beside a claim written in either language, fromBadge.astro, and its rule lives there - namedbadge-flag-firather than thefi-*shape a language code gives, because a rule that reads as a country flag to the case below is a rule it counts. The e2e case the language picker draws its own flag fails if a page carries a flag rule for a language the picker does not link to. - The stylesheet is a file, not a copy in every page.
build.inlineStylesheets: 'auto'inastro.config.mjskeeps Astro's default: small sheets are inlined, big ones are written to/_astro/. Tailwind's output is about 100 KB, and inlined it was re-sent with every page and re-parsed on every client-side navigation; as a file it is fetched once (16 KB gzipped) and served from cache withmax-age=31536000, immutable. With the flags gone and the sheet out of the markup,/is 37 KB of HTML (from 47 KB before the command palette became a file, and 563 KB before the flags went), and one 100 KB stylesheet on a first visit - the HTML alone afterwards. - The markup of a page is 30-50 KB, so a page that is much larger than that is carrying
something that is not its content - the flags above were 421 KB of it. Requesting
/from a running build is the quickest way to see where a page stands. - Links are fetched on hover, before they are clicked (
prefetchinastro.config.mjs). Every page is rendered per request, so without it a click waits for a render and a round trip before the document starts to arrive.prefetchAllwith thehoverstrategy fetches the page behind the link the pointer rests on; a touch pointer is left to the normal navigation. A prefetch is not a visit, sosrc/middleware.tsskips the language cookie for one - the e2e case a hovered link is fetched before it is clicked, and changes nothing holds both ends of that. - Apache sends the pages Brotli-compressed where the browser can read it. The deploy's
virtual host adds the filter and enables
mod_brotliwhen the host has the module, andSetEnvIfExprtakes mod_deflate out of a request whose client acceptsbr- that filter is registered first and would otherwise answer with gzip./is 9368 bytes over the wire against gzip's 9900, and/search-index.json, which the stock gzip configuration does not cover at all, is 9340 against 29467. A filter matches a response by its media type, so the list has to name the one that is sent: it carriedapplication/javascriptand not thetext/javascript; charset=utf-8the Node adapter answers a bundled script with, so the scripts of every page went out uncompressed to every browser - 21 KB of script source on/, 12 KiB of it under Enable text compression in a PageSpeed run - while a client that offers only gzip still got a compressed file, becauseno-gziptakes the gzip filter out of exactly the requests that offerbr. The virtual host is not the only carrier: the deploy also writes the block to/etc/apache2/conf-available/hypernova-compression.confand enables it witha2enconf, so the:443virtual host certbot wrote on the first deploy reads the list of the day even though the deploy never writes that host again while a certificate exists. That copy sits inside<Location />because a block at server scope is not enough there: mod_filter merges the filter list of a virtual host with the server-scope one by the name of the filter they each register, so the older list in that host wins for the types it names. Measured on a host configured as a frozen:443copy - the old list in the vhost, the new one at server scope - a script still went out as 488 bytes against Brotli's 269, exactly as if that second block were not there; the same block inside<Location />merged as the filter config of a directory, which is added after the host's own list, and sent 269. Two copies still compress once, not twice: every response decoded to exactly the identity bytes of the file (36108, 96463, 488, 29467), which is what the merge by filter name buys.tests/unit/apache-compression.test.tsfails if a type a page is made of, themod_brotliguard or theno-gzipline goes missing, if the two copies drift apart, or if the server-scope one loses its<Location />or is left enabled after Apache rejects it. - HTTP/2 is what keeps the requests of a page off a queue. HTTP/1.1 opens six connections
per origin and
/makes fourteen requests on one origin (the document, the stylesheet, three fonts, eight scripts, one image), so the ones over the limit wait for a connection before they can even be sent - the 985 ms chain document → page.js → index.js a PageSpeed run drew was made of that.configure_apacheenablesmod_http2when the host has it (apache2carries it on Debian and Ubuntu), and theProtocols h2 h2c http/1.1that selects the protocol is written by the deploy in both places the directive can stand. At server scope:conf-available/hypernova-protocols.conf, guarded by<IfModule mod_http2.c>and taken back if Apache rejects it, so a host whose package file names no protocol any more still serves h2. Measured on this platform, the first file that names the directive at that scope is the one that counts -mods-enabledis included beforeconf-enabled, and the package's list won over the deploy's - so the copy is read only where the files before it name nothing, and it never narrows a list one of them set. Inside the:443virtual host certbot wrote on the first deploy: aProtocolsline there replaces the server-scope list instead of adding to it, so a list that host carries of its own is what every browser gets, and a host carryingProtocols h2coffers noh2at all. That was the state of the production server when this was measured -:80answered an h2c upgrade while:443negotiated no ALPN, HTTP/1.1 for every browser, with Brotli already live on that same host and the rest of the response coming from the same virtual host.repair_tls_vhost_protocolsreads certbot's file and writes it only when it carries aProtocolsline or the deploy's marker, replacing either in place with the marked blocktls_vhost_protocols_blockprints; every other line of that file comes out byte for byte, and a file that already carries the block produces no write at all (write_configcompares the text first), so a second deploy of the same host rewrites nothing. Before this the run reported 15 requests not served via HTTP/2 and 460 ms under Use HTTP/2 for faster loading. Measured on one machine, the same build behind three Apache virtual hosts that differ only in these directives, three mobile runs each: as the deploy wrote it before, 98 with first contentful paint at 1.6 s; the compression fix on its own, still over HTTP/1.1, 99 and 1.4 s; both, 100 and 0.9 s, with the eight scripts of/at 9424 bytes over the wire against 27210. Both changes are needed, and the second one is worth its 2.7 KB of headers alone: HPACK compresses them, where HTTP/1.1 sends about 350 bytes of them with every response.tests/unit/apache-http2.test.tsholds the enabling, the server-scope copy with its guard and its rollback, the replacement of a list the:443host carries of its own, and that running the repair again writes nothing. - Images are resized and re-encoded at the size they are drawn.
astro.config.mjsuses the default image service (sharp, already a dependency) instead of thepassthroughImageService()it had, which answered every request with the original file: the navbar logo drew at 32px from its 1024x1024 source (39 KB), the phone screenshot was the whole 1320x2868 capture (253 KB) on every viewport, and a 365x500 product photo was the 1159x1536 capture (391 KB,content-type: image/undefined). The markup now carries the widths each frame can use and the service answers with WebP: 696 B for the logo, 28-43 KB for the phone screenshot, 14-43 KB for the product photos. WebP rather than AVIF because these pages are rendered per request, so the service has no cache between visitors: the 990px capture encodes to WebP in 224ms and 43 KB, and to AVIF in 3.7s and 26 KB - a first visitor would wait seconds for the last 17 KB. AVIF is worth adding once those files are generated at build time instead. The cases the screenshot is served at the size it is drawn and the logo is served at the size it is drawn fail if a full-size file comes back. - Fonts: the latin subset only, and preloaded (
fontsinastro.config.mjs). The @fontsource packages declared a face per subset - cyrillic, cyrillic-ext, greek, greek-ext, vietnamese, latin-ext - and every one of those declarations was part of every page. The text of this site needs the latin range and nothing else (scanningsrc/for non-ASCII characters finds ä, ö and the punctuation of U+2000-206F). Both variable fonts live insrc/assets/fonts, copied from those packages so a build needs no network, and Astro emits the @font-face rules - with an Arial-based metric-adjusted fallback, which is what keeps the text from reflowing when the real file lands - together with the--font-interand--font-space-groteskvariablessrc/styles/global.csssets its families from. The two text faces are preloaded whole (Inter 48 KB, Space Grotesk 22 KB) bysrc/layouts/BaseLayout.astro, and the wordmark's own face is preloaded by the navbar that draws it - as the subset below, not as the 24 KB face it is cut from. - The brand face is a subset of the wordmark's characters (
npm run font:subset,scripts/subset-brand-font.sh). The face is only ever used for the.brand-fontspans, which set the nine letters of the brand name, so the file that ships -src/assets/fonts/ocra.woff2- is cut down to exactlyhypernovaHYPERNOVA: 24 KB becomes 1.7 KB, and the whole face (ocr-a-full.woff2) is in no build. Re-run the command whenever the wordmark copy changes, and extend the script'scharactersif the face is ever used for something else:tests/unit/brand-font.test.tsfails if the subset stops coveringBRAND_NAME, if it stops being smaller than the face it is cut from, or if the face itself loses its latin range. - The command palette is a file, not a script in every page.
CommandPalette.astrousedis:inlinewithdefine:vars, which wrote its 11.7 KB of code into every document that has a palette - parsed again on every load and re-sent with every page - because an inline script cannot be bundled or cached. It is a bundled script now: the base path and the localized "no results" message come fromdata-attributes on the palette element (one file serves both languages), the file is fetched once with the rest of the client JavaScript (5.4 KB, against 11.7 KB in every document before) and cached by its hash, andtests/unit/command-palette-bundle.test.tsfails if it becomes inline code again. A bundled script is loaded once per document, so it binds the palette element again onastro:page-loadand republisheswindow.toggleCommandPalettefor the navbar buttons - the e2e case the palette is a file of its own and survives a client-side navigation walks that path. - Only the picture a page paints first is fetched up front. Every
<img>states itsloading(an image without it is fetched the moment the parser reaches it, which is how the LCP file ended up behind the whole document), and one file per page carriesfetchpriority="high": the Koha logo on/koha/and the first frame of the product gallery on/lainuri-checkout-machine/. Both are what a phone paints first on those pages./and the other pages prioritise nothing, because the element they paint first is text; that is also why the staff-interface screenshot on/koha/islazy- it used to beeagerwithfetchpriority="high", which put a screenshot that sits under a heading ahead of the heading. The e2e case only the picture the first paint waits for is fetched up front records the count of eager images per page and which one leads. - The static files get their response headers from the virtual host
(
caching_directives()inscripts/deploy/install.sh). The Node adapter answers everything it serves itself withCache-Control: public, max-age=0, which is what the HTML wants - a page is rendered per request and must be revalidated - and wrong for the two groups of files that do not change: the content-hashed files under/_astro/(stylesheet, client JavaScript, fonts, optimized images) never change under one name, and the files inpublic/(favicons,og-image.png) are asked for on every document load. Apache addsmax-age=31536000, immutablefor the first group andmax-age=3600for the second, replacing the adapter's header rather than sending a secondCache-Control(Header unsetfirst: a response with two of them is one a cache may ignore)./_image/needs nothing, because it already answers with a year and an ETag.tests/unit/apache-caching.test.tsrenders both virtual hosts from the script - it only prints them - and fails if the policy, the order of the directives or themod_headersguard goes missing. - Nothing is fetched from another origin. The testimonial avatars were three
i.pravatar.ccURLs - the only third party the site talked to, announcing the visitor to a service that picked a face at random - and they are initials drawn in the markup now; a real photo belongs insrc/images/and goes through<Image>like every other picture.tests/unit/no-third-party-images.test.tsreads every.astrofile for an avatar host and the image config fordomains/remotePatterns, and the e2e case no page fetches a file from another origin checks thesrc/srcset/hrefof every picture, script and stylesheet on the five heaviest pages, plus everyurl()inside the stylesheets. - Check changes with
npm run bench:scrollagainst a running server.
Every response carries a Content Security Policy (the header, not a <meta> element: each route
is rendered per request), configured under security.csp in astro.config.mjs:
- No
'unsafe-inline'. The scripts and styles a page needs are listed by their SHA-256 hash, so an injected<script>or astyleattribute has nothing to match and does not run. The one inline script written by hand - the theme, which has to run before the body is painted - lives insrc/scripts/theme-init.js;astro.config.mjshashes that file andBaseLayout.astroinlines the same file with?raw, so the code and the hash it is allowed by cannot drift apart. - No inline style attributes. The reveal delays are the
reveal-delay-*classes ofsrc/styles/global.css, the forms hide their panels with thehiddenclass, and the Koha screenshot's reserved box takes its ratios from a<style>element whose hash the component hands to the policy (Astro.csp.insertStyleHash). Shiki is out for the same reason - it writes its colours asstyleattributes - andmarkdown.syntaxHighlightis Prism, which writes classes; a Prism theme stylesheet insrc/styles/global.cssgives the tokens colours whenever a post needs them. - No inline event handlers either.
onclick="..."is JavaScript, and what allows a handler is'unsafe-inline'or'unsafe-hashes'- the policy carries neither, and'unsafe-hashes'would allow the attribute value on any element, not on the one that was hashed. A control therefore carries adata-attribute and a script file binds it: the bar's two search buttons carrydata-search-triggerand the palette listens for the click (src/components/CommandPalette.astro), next to Ctrl+K.tests/unit/no-inline-event-handlers.test.tsreads every.astrofile for a handler, because the dev server sends no policy at all and a browser refuses one silently - the search button that did nothing being what the test was written after. - Trusted Types (
require-trusted-types-for 'script') are on, with two policies: the palette (hypernova-palette, the one place markup is built from data, with everything in it escaped) anddefault, which Astro's view transition router needs because it re-creates the scripts of the page it swapped in by assigningscript.innerHTML. Both are pass-throughs, so an injected string still has to match a hash inscript-srcbefore it runs. - No inline module scripts. Vite's
assetsInlineLimitis0, so every bundled script is a file under/_astro/: with an inline module in the document, the router appends<script src="data:application/javascript,">to the body on every client-side navigation - a script the policy refuses.
HSTS, COOP, X-Frame-Options, nosniff and Referrer-Policy are the virtual host's rather than the
app's (security_directives() in scripts/deploy/install.sh); frame-ancestors 'none' in the
policy covers the browsers that ignore X-Frame-Options.
tests/unit/apache-security-headers.test.ts renders both virtual hosts the script can write and
fails if any of the five goes missing.
Check a running deployment with:
node scripts/verify-security-headers.mjs # every page of the sitemap
node scripts/verify-security-headers.mjs http://127.0.0.1:4399 # a local `npm start`
node scripts/verify-security-headers.mjs --require-headers URL # also require HSTS/COOP/...
It hashes every inline script and style of every page against the policy that page was served
with, flags style attributes and data: or cross-origin scripts, and reports the transport
headers the virtual host adds.
The privacy policy and the terms of service are structured, bilingual content in
src/legal/, rendered by src/components/LegalDocument.astro:
src/legal/privacy-policy.ts- GDPR (2016/679) and the Finnish Data Protection Act (1050/2018)src/legal/terms-of-service.ts- website terms of use only. Terms for individual services (Koha cloud hosting, Toveri, Lainuri, consulting) are maintained in the separate agreement, order confirmation and service description for each service.
Both language versions live in the same file. When you change wording, update the English
(en) and Finnish (fi) text together, bump version and updated, and have the change
reviewed before publishing.
The site is server-rendered and runs as a systemd service behind Apache on the Hypernova
servers. scripts/deploy/install.sh sets that up on a fresh Debian or Ubuntu host in one
command: it installs the packages, creates the service account, copies the code, writes
.env, builds, installs the unit and the virtual host, requests a Let's Encrypt
certificate and verifies the running site.
sudo bash scripts/deploy/install.sh # www.hypernova.fi, 127.0.0.1:4321
sudo bash scripts/deploy/install.sh --domain staging.example.com --no-tls
scripts/deploy/install.sh --dry-run # print the changes, make noneEvery step is idempotent, so the same command deploys the next version: it syncs the code
into --dir, reinstalls the dependencies, rebuilds, restarts hypernova.service and runs
the checks again. The default source is the checkout this script lives in, because the site
lives on the hypernova.fi branch of this repository while origin/master is still the
upstream Zenix theme - run the script from an up-to-date checkout. To let the server pull
its own updates, point it at a git remote whose branch holds the site instead:
sudo bash scripts/deploy/install.sh \
--repo https://github.com/Hypernova-Oy/www.hypernova.fi.git --branch hypernova.fiThe branch must exist on that remote (a run stops with a clear message when it does not) and
the service account needs read access to it - a public URL, or a deploy key in its home
directory. origin carries master only so far, so pushing the site branch there once is
what turns the flow on. Later runs fetch and hard-reset --dir to that branch, so that
command is also the update command. Add --auto-update to have the server run that command
by itself (see below).
Copying the checkout leaves its .git, node_modules, dist and .env behind: the server
keeps the .env it was given, and the build it produces, across deploys.
| Option | Default | Effect |
|---|---|---|
-d, --domain |
www.hypernova.fi |
Public hostname: the canonical one (ServerName, certificate, canonical links). The same name with www, or without it, redirects to it |
-p, --port, --host |
4321, 127.0.0.1 |
Where the Node process listens |
-s, --service |
hypernova |
systemd unit, /etc/apache2/sites-available/<name>.conf and /etc/apache2/conf-available/<name>-compression.conf |
-u, --user, --dir |
hypernova, /opt/hypernova/app |
Service account and the checkout it owns |
-r, --repo, -b, --branch |
this checkout | Git remote and branch to deploy |
--source |
this checkout | Tree to copy into --dir |
-e, --env-file, -m, --email |
generated, none | Seed .env from a file; Let's Encrypt account for expiry mail |
--no-packages, --no-apache, --no-tls, --dry-run |
- | Skip or rehearse parts of the run |
--behind-proxy |
- | A proxy that ends TLS sits in front: serve plain HTTP, no certificate, no listener on 443, keep that proxy's X-Forwarded-* chain |
--auto-update, --auto-update-every |
off, 5 minutes |
A cron job on the server repeats the same command and deploys the commits it finds on --branch |
Every option also has a HYPERNOVA_* environment variable (see --help).
The service account owns --dir with owner-only permissions, because .env is read while
npm run build runs: the Redmine endpoint and project ids are baked into the server chunks,
so the built bundle must not be world-readable. Keep FORM_TOKEN_SECRET stable across
restarts and deploys so forms that are already open stay valid, and re-run the script after
editing .env - the running service keeps the values it was built with.
--domain reaches the bundle as HYPERNOVA_SITE_URL, which becomes the build's site: it is
the address a deployment declares, and the one the redirect of its second name names. It is not
where the canonical links, robots.txt and the sitemap come from - those are built from SITE_URL
in src/config.ts, a literal that names the production address, so a deployment under a name of
its own carries production's canonical links. The script also sets HYPERNOVA_ALLOWED_HOSTS to
the domain and its www twin.
That list is what the dev and preview servers accept as a Host (Vite's host check,
server.allowedHosts in astro.config.mjs; without it a development machine reached through
a container or LAN name gets 403 Blocked request). The built server that the deployment runs
refuses nothing by name - Apache decides which name reaches it - and it answers the one name
that is not the address of the site with the same redirect the virtual host sends
(src/utils/canonical-host.ts). The two halves matter because a request does not always arrive
through the virtual host this script writes: the :443 host certbot created once keeps its
own copy of the directives, and the deploy does not rewrite that file while the certificate is
there - the one line it does replace in it is a Protocols list that host carries of its own,
which would otherwise leave every browser on HTTP/1.1 (the HTTP/2 bullet above). Both are
checked at the end of a run.
The two names of a deployment are not two sites. The certificate covers both, and every request
that arrives under the other one is answered with a permanent redirect to --domain:
hypernova.fi/koha/?page=2 reaches https://www.hypernova.fi/koha/?page=2,
path and query kept. The address the redirect names is the one the deployment declares
(HYPERNOVA_SITE_URL, above): https:// wherever a browser reaches this host over TLS - the
certificate, or the proxy in front of --behind-proxy - and http:// on a host that serves
plain HTTP with nothing in front (--no-tls without --behind-proxy), where a redirect to
https:// would name a port nothing listens on. Only --domain is the name of the site, so a
crawler that follows a link to the other one lands on the page it meant and not on another host's
copy of it. A certificate renewal is the exception: /.well-known/acme-challenge/ is answered on
either name, without a redirect in front of it. The deploy checks both halves of the redirect on
the host it has just written.
Apache gets ProxyPreserveHost On (the forms compare the request host) and retry=0 on the
proxy, so a stopped service fails fast instead of hanging. The :80 redirect, mod_ssl and
the certificate come from certbot --apache, which the script re-runs on every deploy.
Without --email no expiry warnings are sent, and while the names do not resolve yet the
site stays on plain HTTP with a warning instead of failing. In the mode below, none of that
happens: this host gets no certificate and does not listen on 443.
Check the result at any time:
sudo systemctl status hypernova
sudo journalctl -u hypernova -n 50
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:4321/The deploy ends by checking that the unit - not a leftover dev server - is what answers on
--port, that the homepage renders in both languages, that a legacy URL redirects with 301,
that /search-index.json carries page text for every entry, that Apache proxies the domain to
the process and moves the other name of the domain to it, and that a form submission posted the
way a browser posts it (with Origin: https://<domain>) is rendered instead of refused. A port
that something else holds fails the run, instead of reporting a green deploy while another
process serves its own pages.
Run the script with --behind-proxy when another machine terminates TLS for this one:
sudo bash scripts/deploy/install.sh --behind-proxyThis host then serves plain, unencrypted HTTP and owns no certificate. The script skips
certbot, does not enable mod_ssl, disables it if an earlier deploy had enabled it, and
leaves the virtual host on port 80 with no redirect to HTTPS. The module matters because
Debian's ports.conf puts its Listen 443 inside <IfModule ssl_module>: with mod_ssl
gone, Apache has no listener on 443 at all. Point the proxy at 127.0.0.1:80 - or at
--host:--port with --no-apache.
Two things the proxy has to do:
- pass the visitor's address on in
X-Forwarded-For, with its own address last, because the rate limit counts the last hop; - keep that hop private: anyone who can reach the target directly can send an
X-Forwarded-Forof their own and step around the rate limit.
The mode also tells Apache to keep the X-Forwarded-For chain the proxy sent
(ProxyAddHeaders Off). On its own, mod_proxy appends a hop of its own, which behind an edge
is that edge's address for every visitor: one bucket for the whole internet, and the sixth
submission from anyone would be refused. The checks at the end of a proxy-mode run send the
headers such a proxy adds, so what they verify is the path visitors take.
A certificate or a certbot that an earlier run left on this host is no longer served. Nothing
renews it while the site is in this mode, so stop the timer too unless another site on the
host uses it: sudo systemctl disable --now certbot.timer.
--auto-update leaves the server to deploy the branch by itself:
sudo bash scripts/deploy/install.sh --behind-proxy --auto-update \
--repo https://github.com/Hypernova-Oy/www.hypernova.fi.git --branch hypernova.fiA cron job in /etc/cron.d/<unit>-auto-update then runs the same installer every five minutes
(--auto-update-every MINUTES, 1-59; the package cron is installed and started if it is
missing). The job carries the flags of the run that installed it, so an automatic deploy is
the same deploy as a manual one - same --dir, same unit, same --behind-proxy - and it adds
--if-changed and --no-packages, so an unattended run never stops to install packages.
Each run first asks the remote for the branch. While that still points at the revision in
--dir, the run stops right there and changes nothing; otherwise it updates the code,
reinstalls the dependencies, rebuilds, restarts the unit and runs the checks, exactly as a
manual run does. /etc/cron.d/<unit>-auto-update is written again by every later run, so
--auto-update-every, --branch and the like are changed by re-running the installer.
An unattended run must not leave a revision that does not work answering the domain. When the build fails, or when any of the checks fails, the run puts back the revision that was live before the pull, builds it and restarts the unit, and says so in the log. That run ends as a failure rather than as a success, so the journal shows the difference between a deploy that happened and one that was undone. The next run of the job tries again, so a fixed commit is deployed without anyone touching the server; a remote that cannot be reached is not read as "nothing to deploy" either - such a run stops with a warning and leaves the checkout alone.
What the job did is in the journal, tagged per unit, and it stops in two ways:
sudo journalctl -t hypernova-auto-update -n 50
sudo bash scripts/deploy/install.sh ... --no-auto-update # also removes the job
sudo rm /etc/cron.d/hypernova-auto-update # the same, without a re-runTwo things to keep in mind:
- the fetch runs as the service account, so a private remote needs a deploy key in that
account's home directory (
--user,--home); a public URL needs nothing; - only what is committed and pushed to the branch is deployed - the pull overwrites local
edits in
--dir, so make changes in a checkout and push them.
This needs the git flow, because the job has to know what the remote holds. A --dir that was
filled by copying a checkout (the default flow) is turned into a clone of the branch by the
first run that uses --repo/--branch: git refuses to clone into a directory that is not
empty, so such a directory is initialized, fetched and hard-reset instead. .env,
node_modules and the built bundle are ignored by git and survive that, and the service is
rebuilt and restarted from the branch in the same run.
For another platform, or to see what the script does, build and run the site as a long-lived Node process:
npm ci
npm run build # emits dist/client and dist/server
HOST=127.0.0.1 PORT=4321 npm startThe Node entry respects the HOST and PORT environment variables, and Apache proxies the
domain to that process (mod_proxy + mod_ssl), including the legacy redirects that Astro
answers itself:
<VirtualHost *:443>
ServerName www.hypernova.fi
ServerAlias hypernova.fi
# the name that is not the canonical one moves to it, path and query kept
RewriteEngine On
RewriteCond %{HTTP_HOST} ^hypernova\.fi(:[0-9]+)?$ [NC]
RewriteRule ^ https://www.hypernova.fi%{REQUEST_URI} [R=301,L]
ProxyPreserveHost On
ProxyPass / http://127.0.0.1:4321/ retry=0
ProxyPassReverse / http://127.0.0.1:4321/
</VirtualHost>Pass HOST/PORT to the process (the generated unit does): /search-index.json renders each
page over http://127.0.0.1:$PORT to fill the searchable text.
/search-index.json (the data behind the command palette) is generated per request from the
running bundle, so nothing is cached and nothing has to be purged: new content shows up once
the service runs the new build. scripts/refresh-search-index.sh builds, restarts and verifies
in one step, and is meant to run on the server that serves the site (it is also available as
npm run index:refresh -- <options>):
scripts/refresh-search-index.sh # build, restart, verify
scripts/refresh-search-index.sh --url https://www.hypernova.fi --service hypernova
scripts/refresh-search-index.sh --restart "sudo systemctl restart hypernova"
scripts/refresh-search-index.sh --no-build --no-restart # only compare, change nothingIt starts the bundle just built on a spare port (--probe-port, default 4399) and compares
/search-index.json for English and Finnish against the live URL. Every entry must carry
slug, title, description, keywords, content and type, slugs must be unique, and
the live payload must be identical to the fresh build for both languages. A service that was
not restarted - or that still runs an older bundle - fails the run with the differing entry
named, instead of silently serving a stale index. Cache-Control: private, no-cache and
Vary: Cookie are reported as notes when the live headers drift from what the README
describes. Exit status: 0 the live index matches the new build, 1 it does not, 2 the
arguments or the environment are wrong.
Both the live server and the probe server read the pages of the site themselves to fill
content (once per process and language), so a run that reports a missing content means
the pages could not be rendered - check the service log for the lines
Search index: no text for <path>.
We welcome contributions and collaboration! Whether you want to fix a bug, add a new feature, or improve documentation, your help is greatly appreciated.
To contribute:
- Fork the repository.
- Create a new branch (
git checkout -b feature/amazing-feature). - Commit your changes (
git commit -m 'feat: add amazing feature'). - Push to the branch (
git push origin feature/amazing-feature). - Open a Pull Request.
If you have an idea for a major change or a new feature, please open an issue first to discuss it with the maintainers. We are also open to collaborations on expanding Zenix for different use cases! Let's build something awesome together.
MIT. The site started from the Zenix Astro theme by farrosfr.