Technical documentation for the vojtamaur-web project

1. Project overview

vojtamaur-web is a static website built with Astro. Content is managed as files in the repository and converted into static output during the build process. The project does not use a CMS or a database. The source of truth is the repository containing the source code, content, and static assets.

The project is divided into the following main content sections:

The architecture is based on the following components:

This model makes it easier to version content, archive build outputs, and potentially migrate the project to another environment without relying on a database runtime.


2. Project structure

2.1 Directory structure

The current project structure is divided into two main parts:

Provided structure:

public/
  demos/
  files/
  images/
  keys/

src/
  components/
  content/
  content.config.ts
  env.d.ts
  layouts/
  lib/
  pages/
  styles/

Generated output directories are kept outside the source tree:

dist/           # normal web or portable build output
dist-arweave/   # derived Arweave / Permaweb deployment output
dist-gemini/    # generated Gemini capsule

These directories are build artifacts and should not be edited as source content.

2.2 Meaning of the main directories

public/

Contains static files that are simply copied into the output during the build:

src/content/

Project content files. In the current configuration, the following are mainly used:

src/components/

Reusable components for working with content and listings:

src/layouts/

Layouts for individual page types:

src/pages/

Application routes. Includes the homepage, category pages, and dynamic article routing.

src/styles/

Global and optionally other style files.

src/lib/

Helper utilities and shared logic used across the project.

source-bundle/

Templates used by scripts/generate-source-bundle.mjs when creating the reconstructable source package. This directory contains the Python asset downloader and the reconstruction README that are copied into the generated source ZIP.


3. Key configuration files

astro.config.mjs

The project uses two Astro build modes. The configuration switches behavior according to the BUILD_TARGET variable. In the standard web build it uses trailingSlash: "always" and build.format: "directory". In the portable file-based build it uses trailingSlash: "never" and build.format: "file".

This produces two primary Astro output types:

A third deployment artifact is derived from the standard web build:

The Arweave build is not a separate Astro BUILD_TARGET. It is a postprocessed version of the normal web build.

A fourth publication artifact is also derived from the finished standard web build:

The Gemini/Gopher text edition is not an Astro BUILD_TARGET and is not placed inside dist/. It reads the finished HTML in dist/ so that the English postprocess is already reflected in the exported content, while article frontmatter remains available for section membership, dates, slugs, and ordering.

package.json

Basic project workflow:

npm run dev
npm run build:web
npm run build:web:signed
npm run build:web:translate
npm run build:web:translate:signed
npm run build:translate:signed
npm run build:web:strict
npm run build:web:strict:signed
npm run build:web:prune:dry
npm run build:web:prune
npm run preview
npm run build:usb
npm run build:usb:signed
npm run build:usb:translate
npm run build:usb:strict
npm run build:usb:strict:signed
npm run build:usb:prune:dry
npm run build:usb:prune
npm run build:arweave
npm run build:arweave:signed
npm run generate:all-posts
npm run generate:gemini
npm run build:gemini
npm run generate:source-bundle
npm run generate:integrity
npm run generate:integrity:arweave
npm run sign:build
npm run sign:build:arweave
npm run export:epub
npm run export:epub:metaweb
npm run export:pdf
npm run export:pdf:metaweb
npm run export:sstv

Meaning of the main scripts:

content.config.ts

Defines content collections and metadata schemas using Zod validation. The project uses at least the following collections:


4. Content Collections

4.1 posts collection

Articles are stored as .mdx files and validated against the schema in content.config.ts.

Required metadata:

Optional metadata:

Section-specific metadata:

For section: "vystavy"

For section: "cestovani"

4.2 videos collection

Used for the Propagační videa section. Contains metadata for external YouTube videos. Typically:


5. Layout logic

5.1 PostLayout.astro

PostLayout.astro wraps article content in the main layout and creates a shared wrapper for article pages.

5.2 Dynamic routing through [slug].astro

The [slug].astro file is the central route for content from the posts collection. It handles:

5.3 Conditional rendering by section

Different sections have different meta blocks:

5.4 Date formatting

In the Volná tvorba section, the date is displayed as month and year, for example:

duben 2026

Internally, the standard date field is still used for sorting.

5.5 Sorting

Articles are sorted by date. This also applies in cases where the UI does not display the exact day, but only the month and year.

5.5.1 Legacy date migration note

This project was created as a replacement for an older website with fragile infrastructure (outdated PHP, WordPress, unmaintained plugins, and dependence on third-party systems).

That legacy site displayed only the month and year for many articles in the Volná tvorba section (for example duben 2020). During migration, the exact original day was often no longer recoverable. In such cases, the date field was normalized to the first day of the given month (for example 2020-04-01) in order to preserve sorting behavior.

This means that for part of the legacy content, the stored day may be approximate and should be understood as a technical migration value rather than an exact historical publication date.

Articles added after April 2026 use the real day in the date field whenever that information is available.


6. Adding and managing content

6.1 Adding a new article

A new article is added by creating a new .mdx file in:

src/content/posts/

The file must contain valid frontmatter according to the posts schema.

6.2 Required and optional metadata

Shared required metadata

Shared optional metadata

Metadata for Výstavy

Metadata for Cestování

6.3 Thumbnails

Each article uses:

Thumbnails are used in article listings, on the homepage, and in individual sections.

6.3.1 Public asset metadata and privacy

Files stored in public/ are copied into the build output unchanged during the Astro phase. This includes images in public/images/ and downloadable or embeddable files in public/files/. The later source-bundle step intentionally replaces only dist/images/kurt-godel-rat.jpg with a JPEG/ZIP carrier, while retaining the complete original public JPEG as its byte-for-byte prefix. Its embedded metadata therefore remains unchanged. Embedded metadata in all public files must be treated as public data once the files are committed and published.

This applies especially to:

The project uses a separate metadata audit script for checking public assets:

python scripts/audit-public-metadata.py --exiftool "D:\Program Files\exiftool\exiftool.exe"

ExifTool path may need to be adjusted depending on the local installation.

The script checks:

It reports privacy-relevant metadata such as camera model, GPS data, author fields, software history, PDF metadata, document IDs, and embedded comments.

Default rule:

For normal publishing, the recommended workflow is:

  1. keep the original file in a private archive, if needed
  2. export or copy a public version of the file
  3. run the metadata audit
  4. strip unintended metadata from the public version
  5. run the metadata audit again
  6. commit only the cleaned public version into public/

The audit and stripping process is not part of the Astro build. It is a separate maintenance step. This is intentional: metadata should be removed before commit, not only from the generated dist/ output, because the source repository, mirrors, releases, and archival snapshots may preserve the original files.

6.4 draft

The draft: true field excludes the article from the public listing and generated paths. It is used for content that is in progress or temporarily hidden.

6.5 Components used in content

Besides standard Markdown, article content can also use the following components:

These components must be explicitly imported in the MDX file.

6.5.1 EntryPoints.astro

The EntryPoints.astro component is used in the meta article to render the main archive entry points.

It reads the public/ARCHIVE.txt file, extracts the section between the markers === ENTRY POINTS START === and === ENTRY POINTS END ===, and displays it inside a <pre> block.

This ensures that the list of primary locations and snapshots is maintained in a single source of truth and does not need to be manually duplicated in the article content.

6.6 Example frontmatter

Volná tvorba

---
title: "Název článku"
slug: "nazev-clanku"
section: "volna-tvorba"
date: 2026-04-19
thumbnail: "/images/nazev-clanku-nahled.jpg"
thumbnailAlt: "Náhled článku"
excerpt: ""
draft: false
---

Výstavy

---
title: "Recamánova struktura"
slug: "vystavy-recamanova-struktura"
section: "vystavy"
date: 2024-01-01
thumbnail: "/images/vystavy-recamanova-struktura-nahled.jpg"
thumbnailAlt: "Recamánova struktura"
dateFrom: "1. 1. 2024"
dateTo: "31. 1. 2024"
city: "Jindřichův Hradec"
venue: "Muzeum fotografie a moderních obrazových médií"
exhibition: "Obrazy nad čísly"
draft: false
---

Cestování

---
title: "Itálie - Benátky 2019"
slug: "cestovani-italie-benatky-2019"
section: "cestovani"
date: 2019-01-01
thumbnail: "/images/cestovani-italie-benatky-2019-nahled.jpg"
thumbnailAlt: "Itálie - Benátky 2019"
year: "2019"
media: "Fotografie"
draft: false
---

6.7 Representative example of content

The following file was provided as a representative example of an MDX article:

recamanova-posloupnost-zelvi-grafice.mdx

This file is suitable as a reference example of content structure, frontmatter, and component usage.


7. Media components

7.1 ImageFigure.astro

A component for standalone images with support for the following parameters:

Supported width variants:

Supported alignment options:

Depending on the configuration, the component can also open the image when clicked.

7.2 MediaRow.astro

A component for arranging multiple items in a row. Supports the following types:

Use cases:

The component also supports a bordered variant.

Image items support alt text. The value is rendered directly into the image alt attribute. If alt is omitted, the component renders an empty alt attribute.

Example:

<MediaRow
  bordered
  items={[
    {
      type: "image",
      src: "/images/example-1.jpg",
      alt: "Popis prvního obrázku"
    },
    {
      type: "image",
      src: "/images/example-2.jpg",
      alt: "Popis druhého obrázku"
    },
    {
      type: "pdf",
      src: "/files/example.pdf"
    },
    {
      type: "text",
      content: "Textový blok v řádku médií."
    }
  ]}
/>

The alt value applies only to type: "image" items. It is not automatically translated by the English postprocess.

Use type: "empty" when a row should keep an intentionally blank cell. Do not create placeholder image items with an empty src, because that produces broken media in the rendered HTML and can also leak into preservation exports such as ALL_POSTS.txt.

Example with one image and two empty cells:

<MediaRow
  items={[
    {
      type: "image",
      src: "/images/nahodna-cisla-zvolena-clovekem-obr-1.jpg",
      alt: "Graf náhodných čísel zvolených počítačem"
    },
    { type: "empty" },
    { type: "empty" }
  ]}
/>

Empty items render only an empty .media-row__item cell marked with aria-hidden="true". They do not render an image, link, iframe, or text block.

7.3 Embed.astro

A generic wrapper for embedded iframe content. It is used, for example, for:

Supported parameters:

For YouTube embeds, prefer the privacy-enhanced youtube-nocookie.com domain instead of the standard youtube.com embed URL:

<Embed
  src="https://www.youtube-nocookie.com/embed/6wDN62Xq3pA"
  kind="youtube"
/>

This reduces unnecessary YouTube cookie use, although the iframe still loads content from a third-party service.

7.4 Edge cases

PDF in MediaRow

When the grid collapses, a PDF iframe may require special adjustment of height or aspect ratio. This was handled using CSS for .media-row__pdf.

Responsive grid collapse

Listings and media layouts have multiple states depending on screen width. Some elements, such as the “show all” button or PDF embeds, required separate behavior adjustments for 3, 2, and 1 column layouts.


8. Homepage architecture

The homepage combines content from multiple parts of the website.

8.1 Dynamic content loading

Each main section on the homepage displays the latest 9 items:

8.2 “Show all” button

If a given section contains more items than the number displayed on the homepage, a “show all” tile is added.

8.3 Propagační videa

The Propagační videa section uses a visual model similar to article listings, but the items link to external YouTube URLs. The listing is based on the videos collection.

8.4 Clickable section headings

Section headings on the homepage are clickable and serve as quick navigation to the relevant categories or an external playlist.

8.5 O mně and Kontakt

The homepage also contains specialized content blocks outside the standard article system:

8.6 OpenPGP contact block

Both homepage variants use the shared component:

<OpenPgpContact lang={lang} />

The component is rendered from both src/pages/index.astro and src/pages/en/index.astro. It provides short Czech or English labels while keeping the cryptographic material shared between both language versions.

The component reads these files during the Astro build:

public/keys/vojta-maur-openpgp.asc
public/keys/vojta-maur-openpgp-fingerprint.txt

OpenPgpContact.astro therefore does not duplicate the armored public key or fingerprint inside either homepage file. The public key remains directly visible in the rendered HTML, while the fingerprint is normalized to a single space-separated line. Both values are marked with translate="no" so that the translation postprocess cannot modify them.

8.7 Gemini homepage parity

The generated Gemini homepage intentionally follows the content logic of the HTML homepage rather than reducing the capsule to a single archive dump.

For Volná tvorba / Personal Work, Výstavy / Exhibitions, and Cestování / Travel, the capsule shows the latest nine entries in the same order as the built homepage. If the HTML homepage contains its final “show all” tile, the Gemini homepage appends an uppercase ZOBRAZIT VŠE or SHOW ALL link after those entries.

The remaining sections are handled differently:

No separate Gemini section pages are created for Promotional Videos, About Me, or Contact. English is the capsule root language at /index.gmi; Czech is available under /cs/index.gmi.


9. Development and normal workflow

9.1 Local development

npm install
npm run dev

Astro normally starts the local server at http://localhost:4321/ and reacts continuously to changes in the project.

9.2 Practical note about the dev server

In this specific project, it sometimes happens that after adding a new .mdx file, the new article does not appear correctly in dev mode, or temporarily replaces another article in the listing. Restarting the development server usually fixes it immediately.

Recommended troubleshooting step:

Ctrl + C
npm run dev

9.2.1 Public asset metadata audit

Before publishing new or changed public assets, run the metadata audit:

python scripts/audit-public-metadata.py --exiftool "D:\Program Files\exiftool\exiftool.exe"

The script scans public/images/ and public/files/ and reports files containing privacy-relevant embedded metadata.

To preview metadata stripping without modifying files:

python scripts/audit-public-metadata.py --exiftool "D:\Program Files\exiftool\exiftool.exe" --strip --dry-run

To strip unintended metadata from supported image files:

python scripts/audit-public-metadata.py --exiftool "D:\Program Files\exiftool\exiftool.exe" --strip

PDF files are treated more conservatively and are not stripped by default. If PDF metadata needs to be removed, create a checked public copy and verify the output afterwards.

After stripping metadata, run the audit again and check the changed files before committing.

Files with intentional embedded metadata can be kept through the script allowlist. These exceptions should remain explicit, because otherwise hidden metadata becomes indistinguishable from accidental leakage.

9.3 Web build

npm run build:web

Creates the standard build intended for normal deployment to web hosting. This command uses existing English translation cache entries, but it does not create new DeepL translations.

The web build also creates the reconstructable source package at dist/source/vojtamaur-web-source.zip. The link to this package is root-relative in the source content (/source/vojtamaur-web-source.zip), which is correct for normal web hosting.

To create missing English translations, use:

npm run build:web:translate

To verify that no English translation cache entry is missing, use:

npm run build:web:strict

To inspect unused English translation cache files without deleting them, use:

npm run build:web:prune:dry

To delete unused, unprotected English translation cache files after the dry run looks correct, use:

npm run build:web:prune

9.4 Build preview

npm run preview

Used for local verification of the production build.

9.5 Portable file-based build

npm run build:usb

This build is suitable, for example, for offline use, archiving, or transfer as a set of files.

The USB build also includes the reconstructable source package. In USB mode, scripts/usb-rewrite.mjs must run after the source package has been generated so that /source/vojtamaur-web-source.zip is rewritten to a relative file://-safe link.

To create missing English translations in the portable build, use:

npm run build:usb:translate

For a strict USB check, use:

npm run build:usb:strict

To inspect unused English translation cache files while producing the portable build, use:

npm run build:usb:prune:dry

To delete unused, unprotected English translation cache files after the dry run looks correct, use:

npm run build:usb:prune

9.6 Plain-text export of all posts

During the build process, the project also generates a plain-text export of all article content:

/ALL_POSTS.txt

The export is produced by:

scripts/generate-all-posts.mjs

The script reads the finished static HTML from dist/, extracts the main article content, converts it into plain text, writes the result to dist/ALL_POSTS.txt, and embeds the same generated text into the finished dist/404.html recovery page.

This is intentionally generated from the built output rather than directly from the source .mdx files. The English version is created as a post-build static artifact by scripts/en-postprocess.mjs, so reading from dist/ allows the export to include both Czech and English article versions.

The file is intended as a minimal preservation layer for:

The export includes metadata for each article, such as title, slug, canonical URL, language, section, date, source file, and built HTML path.

Non-textual and embedded content is represented by explicit placeholders instead of being silently removed. For example:

[MEDIA: image]
[VIDEO EMBED]
[INTERACTIVE EMBED]
[PDF EMBED]
[SVG CONTENT OMITTED]

HTML tables are converted into readable plain-text table blocks marked with [TABLE] and [/TABLE]. Table rows and cells are preserved in a Markdown-like form so that tabular data remains legible in the linear text export.

Code blocks and generated output blocks are preserved as [CODE BLOCK] sections. Very large code or generated output blocks are truncated when they exceed the configured size limits. The export keeps the beginning of the block up to the configured line or character limit, then adds an explicit truncation note with the original size and the amount of omitted content.

This prevents one unusually large generated block from making the entire text export difficult to read or process while still preserving a readable sample of the original block. The full version remains available in the rendered website, source repository, or static snapshots.

The output file is written as UTF-8 with BOM to improve encoding detection in text editors and archival systems.

Recovery 404 page

The source template src/pages/404.astro turns the normal error page into a human-readable recovery interface. The explanatory text and recovery links appear first, followed by the complete visible content of ARCHIVE.txt and then the complete visible content of ALL_POSTS.txt. Nothing is hidden behind an expansion control, encoded as Base64, or fetched by client-side JavaScript.

During the Astro phase, the page reads public/ARCHIVE.txt and renders its URL lines as real HTML links. After the article pages and English postprocess are complete, scripts/generate-all-posts.mjs generates dist/ALL_POSTS.txt and replaces the marked <pre data-all-posts-embed> content in dist/404.html with an HTML-escaped copy of the same text. This order ensures that the standalone file and the copy carried by the 404 page always come from the same build.

The published page must still be served with the real HTTP status 404; 404.html is a custom response body, not a normal indexable success page. The separate /ARCHIVE.txt, /ALL_POSTS.txt, and /metawebovy-clanek/ resources remain normal recovery entry points. The large text blocks are intentionally rendered in full, with ordinary HTTP compression left to the hosting server or CDN.

Manual filtered and compact exports

The generated dist/ALL_POSTS.txt can be filtered manually with:

scripts/filter-all-posts.py

This Python script is a separate post-processing tool. It is not called by npm run build:web, npm run build:usb, npm run build:arweave, npm run generate:all-posts, or any other normal build command. It reads an already generated dist/ALL_POSTS.txt and does not modify the source export.

The script has no external Python dependencies. When it is stored in scripts/, the default input is resolved automatically as dist/ALL_POSTS.txt. Available languages, sections, metadata fields, and current article counts can be inspected with:

python scripts/filter-all-posts.py --list-values

Article selection can be restricted by language, section, or an inclusive date range. Multiple languages or sections can be supplied by repeating an option or by separating values with commas. For example:

python scripts/filter-all-posts.py --language cs --section volna-tvorba,vystavy --format structured --metadata full
python scripts/filter-all-posts.py --language en --section volna-tvorba --format compact
python scripts/filter-all-posts.py --from-date 2020-01-01 --to-date 2026-12-31 --format structured

Two output formats are available:

Compact output and structured output with --metadata none are intentionally not intended as inputs for another filtering pass because the metadata required for reliable filtering has been removed. Use structured output with sufficient metadata when another filtering step may be needed later.

The default output directory is:

exports/

Output filenames describe the selected language, section, date range, format, and metadata profile. A different path can be supplied with --output. The exports/ directory is generated and excluded through .gitignore, while scripts/filter-all-posts.py remains a versioned source file.

Filtered exports are written outside dist/ by default so that they do not silently change the contents described by the existing build integrity manifests. If an explicit --output path is placed inside dist/, the script prints a warning because the existing SHA256SUMS.txt, BUILD_SHA256.txt, and integrity.json do not cover the newly created file.

A selection can be fully parsed and validated without writing a file:

python scripts/filter-all-posts.py --language cs --section volna-tvorba --format structured --metadata full --dry-run

The script refuses to overwrite its input, rejects unknown language or section values, and rejects an empty selection unless --allow-empty is supplied explicitly. It reports the selected article count, output path, and SHA-256 hash after processing. Like the source export, written output uses UTF-8 with BOM.

9.6.1 Build integrity and OpenPGP signatures

During the final post-build phase, the project generates integrity metadata for the completed static output:

/SHA256SUMS.txt
/BUILD_SHA256.txt
/integrity.json
/SIGNING_STATUS.txt

These files are produced by:

scripts/generate-integrity.mjs

The script walks through the selected output directory, calculates a SHA-256 checksum for each included file, writes a sorted per-file manifest to SHA256SUMS.txt, and then calculates a global build hash from that manifest.

The global build hash is stored in:

BUILD_SHA256.txt

Machine-readable informational metadata is stored in:

integrity.json

Every ordinary integrity pass starts in an unsigned state. It removes any stale SHA256SUMS.txt.asc, writes SIGNING_STATUS.txt as unsigned, and records openPgp.present: false in integrity.json. This prevents a detached signature from an older build from surviving after the manifest has changed.

The following files are intentionally excluded from the checksum manifest:

SHA256SUMS.txt
SHA256SUMS
BUILD_SHA256.txt
integrity.json
SHA256SUMS.txt.asc
SIGNING_STATUS.txt
.DS_Store
Thumbs.db

The checksum and signing metadata files cannot describe themselves without creating a recursive dependency. In particular, integrity.json and SIGNING_STATUS.txt are informational and are not cryptographic proof that a build is signed.

The manifest uses the .txt extension intentionally. A bare file named SHA256SUMS can be interpreted badly by local preview or static hosting setups that use directory-style routes and trailingSlash: "always". SHA256SUMS.txt behaves as a normal downloadable text file.

The integrity files are generated from the final build output, not from the source files. They are intended to verify the published static artifact after Astro build, English postprocessing, generated text exports, path rewriting for portable builds, and any other post-build changes that happen before scripts/generate-integrity.mjs runs.

For the normal web build and USB build, the integrity files describe the final contents of dist/.

For the Arweave / Permaweb build, integrity is generated again after dist-arweave/ is created, so the integrity files inside dist-arweave/ describe the final Arweave deployment artifact rather than the original dist/ directory.

The public OpenPGP identity used for signed build artifacts is published separately in the static build:

/keys/vojta-maur-openpgp.asc
/keys/vojta-maur-openpgp-fingerprint.txt

SHA-256 verifies whether the files match a particular manifest. The detached OpenPGP signature authenticates that exact manifest as one signed by the holder of the corresponding private key. Publishing the public key alone does not authenticate a deployment.

The explicit signing step is implemented by:

scripts/sign-build.mjs

It reads the expected primary-key fingerprint from public/keys/vojta-maur-openpgp-fingerprint.txt, locates the private key through GNUPGHOME, creates the armored detached signature:

/SHA256SUMS.txt.asc

and immediately verifies the new signature against SHA256SUMS.txt. Only after successful verification does it write a signed SIGNING_STATUS.txt and update the informational openPgp object in integrity.json.

If signing fails, the script removes SHA256SUMS.txt.asc, writes a failed unsigned status where possible, records openPgp.present: false, and exits with a non-zero status. A build is therefore signed only when the detached signature is present and verifies successfully.

When GNUPGHOME is already set, the script validates that directory and confirms that it contains the requested secret key. When it is not set and an interactive terminal is available, the script asks for the GnuPG home directory. In a non-interactive environment, GNUPGHOME must be set explicitly.

A Windows CMD example is:

set "GNUPGHOME=C:\path\to\gnupg"
npm run build:web:signed

The private signing key is never stored in the repository, copied into public/, included in the build, or provided to third-party CI systems.

The available signed build commands are:

npm run build:web:signed
npm run build:translate:signed
npm run build:web:strict:signed
npm run build:usb:signed
npm run build:usb:strict:signed
npm run build:arweave:signed

The signing step can also be applied to an already generated output:

npm run sign:build
npm run sign:build:arweave

Verifying a signed build

Download the public key, checksum manifest, and detached signature into the same directory:

vojta-maur-openpgp.asc
SHA256SUMS.txt
SHA256SUMS.txt.asc

Import the public key:

gpg --import vojta-maur-openpgp.asc

Display its fingerprint:

gpg --fingerprint C5F5B3905220BE59

The primary-key fingerprint must match exactly:

57E9 D455 FB10 A228 F66E 18AE C5F5 B390 5220 BE59

The fingerprint should also be compared with a copy obtained from an independent trusted source or archive. Downloading the public key, manifest, and signature from the same compromised mirror would not by itself establish the author’s identity.

Verify the detached signature:

gpg --verify SHA256SUMS.txt.asc SHA256SUMS.txt

A valid result includes a message similar to:

Good signature from "Vojta Maur vojtamaur.cz"

A trust label such as [unknown] concerns the local GnuPG trust assigned to the identity. It does not mean that the mathematical signature verification failed. The important results are a good signature and an independently confirmed primary-key fingerprint.

Verifying the OpenPGP signature authenticates SHA256SUMS.txt. To check the complete downloaded build against that authenticated manifest, run this command from the build root:

sha256sum -c SHA256SUMS.txt

The last command requires sha256sum. It is normally available on Linux, through GNU coreutils on macOS, and on Windows through environments such as Git Bash or WSL.

9.6.2 Reconstructable source package

During the web and USB builds, the project generates a reconstructable source package:

/source/vojtamaur-web-source.zip

In the local build output this file is written to:

dist/source/vojtamaur-web-source.zip

The package is produced by:

scripts/generate-source-bundle.mjs

This source package is not a full copy of the generated website and it is not intended to duplicate all media files inside the ZIP. It is a compact reconstruction layer: it contains the project source, content, build scripts, package.json, package-lock.json when available, Astro configuration, selected small files from public/, and the files required to reconstruct omitted media.

Large or externally useful media assets from public/images/ and public/files/ are omitted from the ZIP and described in MEDIA_MANIFEST.json and MEDIA_SHA256SUMS.txt. The generated download-assets.py script can then restore them from the configured mirrors and verify them using SHA-256. The clean base file public/images/kurt-godel-rat.jpg is the intentional exception: it is bundled directly and listed in the manifest’s bundledPublicFiles field.

The public/demos/ directory is bundled directly into the source ZIP rather than restored through the asset downloader. The demo files are small enough to include, and their HTML can differ between source, web output, USB output, and static mirrors after path rewriting or deployment-specific postprocessing. Treating them as downloadable checksum assets would create false hash mismatches.

The generated package contains the downloader and reconstruction notes both at the ZIP root and inside source-bundle/. This allows a reconstructed copy of the project to build the website and then generate a new source package again. In other words, the source package is recursively reconstructable: a source ZIP can produce a rebuilt website, and that rebuilt website can produce another source ZIP.

The default reconstruction flow is:

python download-assets.py
npm install
npm run build:web:strict

For a portable file-based reconstruction:

python download-assets.py
npm install
npm run build:usb

The downloader first checks local candidate paths when possible, then uses the configured public mirrors. Files are accepted only when their SHA-256 hash matches the manifest. Hash mismatches are reported and rejected by default rather than silently written into public/.

After writing the standalone source package, the same generator also creates a JPEG/ZIP archival carrier at:

dist/images/kurt-godel-rat.jpg

The generator always reads the clean carrier base from public/images/kurt-godel-rat.jpg. Its complete JPEG byte stream, including all six redundant copies of the archival text in JPEG Comment, EXIF, Windows XP and XMP metadata fields, is written unchanged as the output prefix. The source-package ZIP is then written after the JPEG EOI marker with offsets that remain valid for strict ZIP readers. The image is never decoded, recompressed or passed through a metadata editor.

The clean carrier base is included directly in the source package and excluded from the external asset list. This breaks the otherwise unavoidable hash cycle in which a source ZIP would need to contain the final hash of the JPEG that contains that same ZIP. It also makes generation idempotent: every run starts from the clean public/ JPEG instead of appending another archive to the previous dist/ result.

ZIP-aware tools such as 7-Zip can open the published .jpg directly. Tools that require a .zip suffix can operate on a copied or renamed kurt-godel-rat.zip. Ordinary image software continues to read the same JPEG image and ignores the archive data after EOI.

The source package and JPEG/ZIP carrier are generated before the final integrity pass. Therefore SHA256SUMS.txt includes both artifacts as part of the final published or portable build.

9.6.3 Manual PDF export

The project includes a separate manual PDF export tool:

scripts/export-site-pdf.py

The exporter is intentionally not run by any normal web, usb, or Arweave build. PDF generation is comparatively slow and can create large output files, so it is started only when an archival or reading copy is needed.

The script reads the finished static HTML from dist/, not the source .mdx files. This is necessary because English article pages are finalized by scripts/en-postprocess.mjs after the Astro build. The PDF export therefore reflects the same rendered Czech and English pages that exist in the finished build.

Before running the PDF exporter, create a standard web build (npm run build:web or another web-build variant), not a portable USB build. USB-rewritten paths in dist/ may produce invalid hyperlinks in the exported PDF.

Before the first use, install the Python dependencies and the Playwright Chromium browser:

python -m pip install -r requirements-pdf-export.txt
python -m playwright install chromium

The standard command is:

npm run export:pdf

Equivalently, the script can be called directly:

python scripts/export-site-pdf.py

The default export:

The generated files are written to:

exports/

This is the same generated output directory used by scripts/filter-all-posts.py, so manually created text and PDF exports are kept together outside dist/. The exports/ directory is excluded through .gitignore. The exporter script and requirements-pdf-export.txt remain versioned source files.

Useful selection options include:

python scripts/export-site-pdf.py --lang cs
python scripts/export-site-pdf.py --lang en
python scripts/export-site-pdf.py --lang both
python scripts/export-site-pdf.py --section cestovani
python scripts/export-site-pdf.py --section volna-tvorba,vystavy
python scripts/export-site-pdf.py --separate

--section can be repeated or supplied as a comma-separated list. --separate creates one PDF for each article/language page instead of one combined file.

The script normally expects an existing dist/ directory. A build can be requested explicitly for a particular run:

python scripts/export-site-pdf.py --build-command "npm run build:web:strict"

This does not change the normal project build scripts; it only runs the supplied command before that individual PDF export.

By default, the exporter keeps Chromium’s original PDF output unchanged:

--pdf-quality archive

This mode does not require Ghostscript. Optional PDF compression and image downsampling use Ghostscript as a second post-processing stage. A practical medium-quality export is:

python scripts/export-site-pdf.py --pdf-quality ebook --image-dpi 150 --jpeg-quality 75

The available quality presets are:

archive
printer
ebook
screen

The archive preset preserves the original output. The other presets require Ghostscript and may be further adjusted with --image-dpi and --jpeg-quality. If Ghostscript is not detected automatically, its executable can be supplied explicitly:

python scripts/export-site-pdf.py --pdf-quality ebook --image-dpi 150 --jpeg-quality 75 --ghostscript "C:\Program Files\gs\gs10.07.1\bin\gswin64c.exe"

--keep-uncompressed preserves the original uncompressed PDF alongside the compressed result.

The exporter renders pages through a temporary local server, but hyperlinks written into the PDF are converted to their public equivalents under https://vojtamaur.cz/. This prevents relative article, image, PDF, download, and other media links from pointing to a temporary address such as http://127.0.0.1:54321/. Loopback preview aliases such as localhost and 127.0.0.1 on the export server port are treated as the same temporary server. Existing external links remain external. Article images and other directly rendered media that do not already have a link are linked to their public source file, using the original HTML source attributes where possible rather than browser-resolved local preview URLs. The generated cover index also links each built route to the corresponding public article page.

If the same exporter is used for another deployed domain, the public root can be overridden:

python scripts/export-site-pdf.py --site-url https://example.com

In the Ghostscript-compressed modes (printer, ebook, and screen), iframes are replaced before printing by PNG snapshots. Each snapshot links to the original public embed target. YouTube embed URLs are converted to normal YouTube watch URLs; local PDF, scan, map, and other iframe sources link to their corresponding public file or page. The archive mode keeps Chromium’s original iframe rendering unchanged while still correcting normal page and media hyperlinks.

For English pages, the index title is read from the rendered HTML rather than copied from Czech MDX frontmatter. If the English postprocess marked a page as incomplete, the cover index and manifest label it as Incomplete / Czech fallback. An /en/ route can therefore exist even when some or all of its article content remains Czech.

The PDF is a paginated visual snapshot, not a lossless replacement for the website or source repository. Wide tables, long source-code lines, and other horizontally overflowing content can be clipped, wrapped differently, or extend beyond the printable area. The generated cover includes this limitation explicitly. The archived HTML, source files, repository, ALL_POSTS.txt, and other preservation layers remain the authoritative complete versions.

9.6.4 Ultra-compact PDF export

The project also includes a second PDF exporter designed for high-density archival copies:

scripts/export-site-pdf-ultra.py

Unlike scripts/export-site-pdf.py, this exporter does not reproduce the visual design of each web page. It reads the existing dist/ALL_POSTS.txt, reuses the compact serialization implemented by scripts/filter-all-posts.py, and lays the selected content out as a dense A4 document. The tool is intended for cases where page count and storage size matter, while images and links should still remain present and usable.

The ultra-compact export:

The exporter is manual and is not called by the normal web, USB, Arweave, Gemini, or integrity build workflows. It expects a current standard web build containing:

dist/ALL_POSTS.txt
dist/images/
dist/files/
dist/demos/

Create or refresh that input before exporting, for example with:

npm run build:web:strict

The Python packages used by the exporter are Playwright, pypdf, and Pillow. Playwright also requires its Chromium browser:

python -m pip install playwright pypdf Pillow
python -m playwright install chromium

The recommended high-resolution Czech export is:

python scripts/export-site-pdf-ultra.py --lang cs --image-dpi 400

The default output is:

exports/vojtamaur-web-export-ultra.pdf

The adjacent manifest is written as:

exports/vojtamaur-web-export-ultra.manifest.json

Language and section selection work in the same style as the normal PDF exporter:

python scripts/export-site-pdf-ultra.py --lang cs
python scripts/export-site-pdf-ultra.py --lang en
python scripts/export-site-pdf-ultra.py --lang both
python scripts/export-site-pdf-ultra.py --section cestovani
python scripts/export-site-pdf-ultra.py --section volna-tvorba,vystavy
python scripts/export-site-pdf-ultra.py --lang cs --section cestovani
python scripts/export-site-pdf-ultra.py --separate

--section can be repeated or supplied as a comma-separated list. Date selection is available through --from-date YYYY-MM-DD and --to-date YYYY-MM-DD. --separate creates one compact PDF for each selected article and language under exports/ultra-media-separate/ instead of creating one combined document.

Image quality can be adjusted independently from the physical thumbnail size:

python scripts/export-site-pdf-ultra.py --lang cs --image-dpi 300 --jpeg-quality 80

The default thumbnail raster resolution is 240 DPI and the default JPEG quality is 70. Higher values generally produce sharper images and a larger PDF. --thumbnail-width-mm and --thumbnail-height-mm control the maximum physical thumbnail box; images are fitted inside that box without changing their original proportions.

The page format, column count, text size, spacing, margins, and page numbers can also be adjusted. All available options and their current defaults can be displayed with:

python scripts/export-site-pdf-ultra.py --help

The script detects the project root from its own location, so it can be launched from the repository root or directly from the scripts/ directory. --project-root remains available when the script is stored or invoked from another location.

This output is a compact preservation and reading layer, not a lossless replacement for the website, source repository, original media, or ALL_POSTS.txt. Text is deliberately very small, images are downsampled, interactive media is represented by links, and missing built assets cannot be embedded. The source files and normal build artifacts remain the authoritative complete versions.

9.6.5 Metaweb archival PDF export

The project includes a dedicated manual exporter for the metaweb article and the preservation material linked from it:

scripts/export-metaweb-pdf.py

The exporter reads the existing finished standard web build in dist/. It does not run Astro, regenerate integrity metadata, sign the build, or run automatically from any web, USB, Arweave, Gemini, or integrity build command. This preserves the exact relationship between the rendered English page and the identity files already present in the selected build.

Before the first use, install the same Playwright and pypdf dependencies used by the normal PDF exporter:

python -m pip install -r requirements-pdf-export.txt
python -m playwright install chromium

Run the export manually with:

npm run export:pdf:metaweb

There is no generate:metaweb npm script. The command above is the supported npm entry point for this manual export.

All standard web and USB builds write to the same dist/ directory, so the most recently run build determines its routing and link format. Do not run the metaweb exporter against dist/ left by build:usb, build:usb:translate:signed, or another USB build variant. USB mode uses flat files such as metawebovy-clanek.html and rewrites links for file://, while the exporter expects the standard directory route /metawebovy-clanek/. A typical symptom of using USB output is Page.evaluate: Error: Article content container not found.

Restore a standard translated and signed web build before exporting:

npm run build:web:translate:signed
npm run export:pdf:metaweb

If the English translation cache is already complete and a new Gemini export is not required, the strict standard build can be used instead:

npm run build:web:strict:signed
npm run export:pdf:metaweb

The metaweb exporter intentionally does not rebuild or change dist/ by itself.

Equivalently:

python scripts/export-metaweb-pdf.py

The default output is one PDF plus an adjacent JSON manifest:

exports/vojtamaur-web-export-metaweb.pdf
exports/vojtamaur-web-export-metaweb.manifest.json

The PDF contains:

The payloads of ALL_POSTS.txt and source/vojtamaur-web-source.zip are deliberately not appended or embedded. Their descriptions and public links remain visible inside both rendered article versions because they are part of the article itself.

The physical archive registry is wider than an A4 page. During this export only, each table row is converted in the browser DOM into a labeled registry card. The card heading contains the ID and item name; the remaining table headers become field labels. This preserves every original cell and hyperlink while preventing the holder, location, access, and notes fields from being clipped. The website HTML and source MDX are not modified.

The English postprocessed HTML can split the build-integrity link paragraph at invalid positions and expose stray Markdown backticks. For the PDF only, the exporter normalizes those same six links and their descriptions into a readable list. It does not edit the finished build or change any linked payload.

The first page is a deliberately simple white bilingual title page. The generated timestamp, source build, section counts, notices, and outline are placed on a separate bilingual contents page. The image appendix is discovered from article links rather than a hardcoded filename list. The Czech and English pages point to the same images, so every image target is included only once while its card shows the shared archive item title and separate Czech and English article-derived descriptions. The exporter fails instead of silently creating a monolingual card if an image is missing from either article version. The article thumbnail is not included unless it is also linked from the article body. By default, three linked images are placed on each gallery page.

The combined PDF expects the ultra export at:

exports/vojtamaur-web-export-ultra.pdf

If that file does not exist, the metaweb exporter creates it automatically before rendering the combined document by running the equivalent of:

python scripts/export-site-pdf-ultra.py --lang cs --image-dpi 400

An existing ultra PDF is reused unchanged. Immediately after the bilingual image appendix, the metaweb exporter inserts one generated bilingual introduction page. It identifies the embedded filename and explains that the following pages are an ultra-compact Czech export of every website article with its images. It also states the number of following ultra pages and that their intentionally dense multi-column layout serves as an archival overview. The complete pages of the standalone ultra PDF follow this introduction without being edited or overprinted.

Useful options include:

python scripts/export-metaweb-pdf.py --images-per-page 2
python scripts/export-metaweb-pdf.py --images-per-page 4
python scripts/export-metaweb-pdf.py --output exports/metaweb-custom.pdf
python scripts/export-metaweb-pdf.py --site-url https://example.com
python scripts/export-metaweb-pdf.py --no-manifest

ARCHIVE.txt, checksums, JSON, signatures, and public-key material are rendered completely as wrapping monospace text. Long code blocks and URLs in the technical documentation are also wrapped for A4. The final PDF contains outline bookmarks and validates that no file payload was embedded.

An unsigned build legitimately has no SHA256SUMS.txt.asc. If that optional link is present in the article but the file is absent from the selected build, the PDF includes an explicit absence notice rather than inventing signature content or failing the whole export. Other missing identity files and missing linked images are treated as build errors.

The exporter writes a manifest describing page ranges, including the generated ultra introduction and the unchanged standalone ultra PDF as separate sections, source routes, bilingual linked-image descriptions and hashes, the included ultra PDF and its hash, identity-file hashes, the registry-card validation result, the final PDF hash, and the two deliberately excluded payloads. Use --no-manifest only when the adjacent machine-readable record is not wanted; that option also removes a stale adjacent manifest left by an earlier run.

Run all manual exports on Windows

The repository root contains export-all.bat. It switches the Windows console and Python standard streams to UTF-8, changes to the repository directory, runs the nine manual exports sequentially, and stops immediately if any command fails. The ultra export runs before the metaweb PDF export, so the combined PDF receives the freshly generated ultra PDF rather than a stale copy. The workflow begins with the JSON-LD article export and separate English and Czech compact text exports. After the PDF steps it creates the practical compact Metaweb EPUB and separate compact Czech and English site EPUBs, preserving animated GIFs. The SSTV PNG export is the final step:

node scripts/export-site-json.mjs
python scripts/filter-all-posts.py --language en --section volna-tvorba --format compact
python scripts/filter-all-posts.py --language cs --section volna-tvorba --format compact
python scripts/export-site-pdf.py --pdf-quality ebook --image-dpi 150 --jpeg-quality 75 --ghostscript "C:\Program Files\gs\gs10.07.1\bin\gswin64c.exe"
python scripts/export-site-pdf-ultra.py --lang cs --image-dpi 400
python scripts/export-metaweb-pdf.py
python scripts/export-metaweb-epub.py --image-quality compact --gif-mode preserve
python scripts/export-site-epub.py --lang both --image-quality compact --gif-mode preserve
python scripts/export-site-sstv.py

Run the complete workflow with:

export-all.bat

The batch file also reuses the current dist/ and does not create a web build. If the last build was a USB variant, restore a standard web build before running export-all.bat.

The SSTV PNG export runs last in export-all.bat and can also be run separately with npm run export:sstv (section 9.6.8). The batch does not generate SSTV audio.

9.6.6 Manual EPUB exports

The project includes two manual reflowable EPUB exporters:

scripts/export-site-epub.py
scripts/export-metaweb-epub.py

Both exporters read the finished HTML and assets from dist/. They do not convert the source MDX directly. This preserves the postprocessed English pages and makes the EPUB content reflect the selected finished build. The exporters support both normal directory routes such as dist/article/index.html and portable flat routes such as dist/article.html.

Install their Python dependencies once. Pillow 11.3 or newer is required so that any AVIF image discovered in a finished build can be decoded and converted to an EPUB core image format:

python -m pip install -r requirements-epub-export.txt

The general site exporter also reads article selection metadata from the generated dist/ALL_POSTS.txt. Its default command is:

npm run export:epub

The default --lang both selection intentionally creates two independent publications:

exports/vojtamaur-web-export-all-cs.epub
exports/vojtamaur-web-export-all-en.epub
exports/vojtamaur-web-export-epub.manifest.json

It does not combine the Czech and English website editions into one bilingual book. Language and section selection follow the normal PDF exporter:

python scripts/export-site-epub.py --lang cs
python scripts/export-site-epub.py --lang en
python scripts/export-site-epub.py --lang both
python scripts/export-site-epub.py --section cestovani
python scripts/export-site-epub.py --section volna-tvorba,vystavy
python scripts/export-site-epub.py --lang cs --section cestovani
python scripts/export-site-epub.py --separate

Each combined publication begins with a visible generated title/frontmatter and reflowable article index. It contains the same export metadata, limitations, language status, article dates, sections, titles, and built routes as the generated index in the normal site PDF. The routes are internal links to the corresponding EPUB chapters. This is an export index, not a copy of the website homepage. Pass --no-cover only when this frontmatter is not wanted.

--section can be repeated or comma-separated. --separate creates one EPUB per article under exports/<language>/<section>/; like the PDF exporter, these single-article files do not receive the combined export index. A custom --output can be used only for a combined single-language export, because --lang both always has two output files.

As with the PDF exporter, a build command is run only when explicitly requested:

python scripts/export-site-epub.py --build-command "npm run build:web:strict"

Each book keeps the existing article order and content structure. The technical EPUB transformation:

The exporter can reduce image size without changing article structure. The default preserves the exact source bytes of EPUB-core image formats; AVIF is the intentional exception and is always converted to JPEG or PNG:

--image-quality archive

Other presets are:

printer   maximum side 2400 px, JPEG quality 90
ebook     maximum side 1600 px, JPEG quality 82
screen    maximum side 1200 px, JPEG quality 72
compact   maximum side 800 px, JPEG quality 60, static GIF poster frames

For example:

python scripts/export-site-epub.py --image-quality ebook
python scripts/export-site-epub.py --image-quality compact
python scripts/export-site-epub.py --lang both --image-quality compact --gif-mode preserve
python scripts/export-site-epub.py --lang cs --image-max-px 1400 --jpeg-quality 78

The third command is the recommended practical complete-site export. It creates separate Czech and English EPUB files with compact still images while preserving animated GIFs. Use plain --image-quality compact only when the smallest practical files are more important than GIF animation.

--image-max-px and --jpeg-quality override the selected preset. JPEG files are downscaled and recompressed; PNG files are downscaled losslessly when necessary. The archive, printer, ebook, and screen presets preserve animated GIF and SVG resources unchanged. The deliberately lossy compact preset converts opaque PNG files to JPEG and, unless overridden, animated GIF files to static first-frame PNG posters. These two decisions can be controlled independently with --png-mode preserve|jpeg and --gif-mode preserve|poster. Preserved animated GIF files retain their original bytes and therefore remain the main size limit. The JSON manifest records the selected and actually used JPEG quality, original and packaged image byte counts, the number of optimized images, interactive fallbacks, and removed empty placeholders. The Metaweb manifest also records separate source and packaged byte sizes and SHA-256 hashes for every asset, so a converted or optimized file is never described by the hash of a different representation.

The dedicated Metaweb EPUB remains one bilingual publication:

npm run export:epub:metaweb

Its default outputs are:

exports/vojtamaur-web-export-metaweb.epub
exports/vojtamaur-web-export-metaweb.manifest.json

The Metaweb EPUB follows the semantic section order of export-metaweb-pdf.py:

  1. bilingual title page
  2. bilingual contents and export notes
  3. Czech Metaweb article
  4. English Metaweb article
  5. a visible bilingual image appendix with every locally linked physical-archive image, its Czech and English descriptions, filename, and source URL
  6. the complete ARCHIVE.txt payload
  7. the complete rendered technical documentation from /documentation/
  8. every file discovered in the article’s build identity and integrity section, each as a text appendix with source URL, byte size, SHA-256, and full payload

The publication metadata declares both cs and en. Both Metaweb article chapters keep their own language and heading IDs. Their wide physical archive tables are converted to reflowable record cards while preserving every cell and link, all <details> blocks are opened, and the malformed English integrity paragraph is normalized to the same six-item list used by the PDF exporter.

The PDF’s embedded vojtamaur-web-export-ultra.pdf is intentionally not inserted into the EPUB and is not replaced by expanded article chapters. The dense standalone PDF remains a separate publication artifact; the Metaweb EPUB contains no substitute for that section.

As in the PDF exporter, the ALL_POSTS.txt and source/vojtamaur-web-source.zip payloads are deliberately not appended; their mentions and public links remain in the article. A missing optional SHA256SUMS.txt.asc in an unsigned build receives an explicit explanatory appendix instead of invented content.

The Metaweb exporter supports the same image controls. The first command below is the recommended practical Metaweb export:

python scripts/export-metaweb-epub.py --image-quality compact --gif-mode preserve
python scripts/export-metaweb-epub.py --image-quality compact
python scripts/export-metaweb-epub.py --image-quality ebook
python scripts/export-metaweb-epub.py --output exports/metaweb-custom.epub
python scripts/export-metaweb-epub.py --no-manifest

The recommended command keeps animated GIFs intact while applying the compact preset to still images. Use plain --image-quality compact only when static first-frame posters are acceptable in exchange for a smaller EPUB.

The Metaweb EPUB therefore remains focused on the Metaweb material and its direct archival appendices instead of duplicating the complete website corpus.

Every generated book is validated before it replaces the destination file. When the site exporter creates multiple books, all uniquely named candidates are completed first; a conversion failure changes none of the requested outputs, and a handled failure during installation restores the previous set. JSON manifests are likewise written through unique temporary files and atomically replaced. The built-in validator checks the ZIP/mimetype rules, container and package documents, required metadata, correctly namespaced MathML and inline SVG properties, manifest, spine, navigation item, XML/XHTML well-formedness, duplicate IDs, and all packaged local references. The Metaweb exporter additionally verifies its section counts, visible image count, and deliberate payload exclusions before replacing an existing output. This does not replace a final EPUBCheck run when the external Java validator is available, but malformed or structurally incomplete output is rejected locally.

9.6.7 Gemini capsule and Gopher map generation

The project generates a separate bilingual Gemini capsule and a Gopher-compatible map layer with:

npm run generate:gemini

The generator is:

scripts/generate-gemini-capsule.mjs

The output directory is a sibling of the normal web build:

dist/
dist-gemini/

generate:gemini requires an existing finished standard web build in dist/. It does not run Astro or the English translation postprocess by itself. Do not use it against dist/ left by a USB build. Although the generator can locate both directory-style article files and flat slug.html files, USB postprocessing has already changed internal links and asset paths for file://. The Gemini command can therefore finish without an error while deriving incorrect web paths from that USB-specific HTML.

For a complete build from source, use:

npm run build:gemini

This command runs the strict standard web build first and then generates the text edition.

The translating production workflow also generates the text edition automatically. Therefore:

npm run build:web:translate:signed

builds and translates the normal website, generates dist-gemini/, and then signs the checksum manifest belonging to dist/.

The Gemini/Gopher text edition is deliberately generated from the finished HTML rather than directly from the MDX body. This preserves the actual postprocessed English output and the rendered behavior of shared Astro components. The generator also reads article frontmatter from src/content/posts/ for stable slugs, section membership, dates, draft filtering, and ordering.

The default output structure is:

dist-gemini/
  index.gmi
  gophermap
  favicon.txt
  keys/
  personal-work/
    index.gmi
    gophermap
    article-slug.gmi
  exhibitions/
    index.gmi
    gophermap
    article-slug.gmi
  travel/
    index.gmi
    gophermap
    article-slug.gmi
  cs/
    index.gmi
    gophermap
    volna-tvorba/
      index.gmi
      gophermap
      article-slug.gmi
    vystavy/
      index.gmi
      gophermap
      article-slug.gmi
    cestovani/
      index.gmi
      gophermap
      article-slug.gmi

English is intentionally the default language at the capsule root. Czech pages are stored below /cs/. Each article page includes navigation back to the homepage and section, a link to the counterpart language when available, and a link to the full HTTPS web version.

For envs.net, the Gemini capsule is published below the user path rather than directly at the host root. Internal generated Gemini links therefore use the configured prefix /~vojtamaur so that links resolve under gemini://envs.net/~vojtamaur/... instead of incorrectly resolving under gemini://envs.net/.... If the capsule is moved to a host where it is served directly from the Gemini host root, this prefix must be changed or removed in the generator.

The same dist-gemini/ output can also be uploaded to public_gopher/. Gemini clients ignore gophermap files as ordinary unrelated files, while the Gopher server uses them as directory menus. The intended envs.net deployment mapping is:

dist-gemini/  ->  public_gemini/   ->  gemini://envs.net/~vojtamaur/
dist-gemini/  ->  public_gopher/   ->  gopher://envs.net/1/~vojtamaur

Gopher item types matter in the generated maps. Directory/menu links use type 1; plain text or Gemtext files use type 0; informational separator or label lines use type i. Some Gopher clients or web gateways may visibly show the leading i on informational lines. For this reason, gophermap should remain a thin navigation layer, while the real page content stays in .gmi files. Clients such as Lagrange can still display those .gmi files as Gemtext after opening them through Gopher.

The Gemini homepage contains:

The generator converts the built HTML into Gemtext using the following rules:

The output is written as UTF-8. One client-specific workaround is applied during final writing: a literal Unicode replacement character U+FFFD () is serialized as the visible text \uFFFD. The original MDX and HTML remain unchanged. This exists because the tested Lagrange renderer stops drawing the remainder of a line immediately after a literal U+FFFD, even though the character is valid UTF-8.

The generator copies favicon.txt and the public keys/ directory from dist/, or falls back to public/ when necessary. Binary article media are not copied into dist-gemini/; they remain available through stable HTTPS links to the main website.

Before generation, the existing output directory is removed and recreated. The script contains safety checks that refuse to delete the project root, the normal dist/ directory, the filesystem root, or an output directory whose name does not contain gemini.

Available command-line overrides are:

--dist <dir>
--output <dir>
--content <dir>
--site-url <url>

Because dist-gemini/ is outside dist/, the existing web integrity and signing commands do not cover it. Even when the capsule is produced during build:web:translate:signed, the resulting detached signature authenticates only dist/SHA256SUMS.txt. A separate checksum or signing workflow for dist-gemini/ has not been implemented. This applies to both the Gemini files and the generated Gopher map layer.

9.6.8 Manual SSTV PNG export

The project includes a separate manual exporter for an SSTV (Slow-Scan Television) edition of its articles:

scripts/export-site-sstv.py
requirements-sstv-export.txt

It creates numbered RGB PNG pages at the native resolution of the selected standard SSTV mode. Each PNG represents one complete frame. The exporter is started explicitly with npm run export:sstv or as the final step of export-all.bat; it is not called by any normal web, USB, Arweave, or Gemini build.

Setup and build-before-export workflow

Install the Python dependencies once. Chromium is used to rasterize local SVG images and can reuse the installation already needed by the PDF exporters:

python -m pip install -r requirements-sstv-export.txt
python -m playwright install chromium

Run the export commands from the repository root. The exporter reads article selection metadata from dist/ALL_POSTS.txt, then takes the full article text and media from the corresponding finished HTML and local assets in dist/. It does not use the shortened text representation in ALL_POSTS.txt as the article body and does not render the source .mdx directly. This also preserves the English content produced by the translation postprocess.

After editing an article, a media component, or a source image, rebuild the website before exporting again. Running only npm run export:sstv reuses the existing dist/, including any old content or broken image references still present there. The exporter never starts a build or refreshes translations itself.

For a Czech export, or when the existing English translation cache is sufficient:

npm run build:web
npm run export:sstv

The usual translating and signing workflow can also be used:

npm run build:web:translate:signed
npm run export:sstv

The second workflow fills missing translations and signs the web build. Signing is not required to generate SSTV PNGs, and the web signature does not cover the separate SSTV export directory. If dist/ already contains the desired finished build, only the export command is needed. For the shared PDF, EPUB, and SSTV workflow, use a standard web build; the SSTV exporter itself can also resolve directory routes and flat USB article routes.

Mode, language, and preview selection

The default command exports all indexed Czech articles from all article sections, using PD120:

npm run export:sstv

The following mode choices are implemented:

--modeNative PNG dimensionsAspect ratio
pd120 (default)640 × 496 px40:31
pd180640 × 496 px40:31
pd240640 × 496 px40:31
pd290800 × 616 px100:77

--mode pd290 changes the PNG raster to 800 × 616 pixels and scales the default typography accordingly. PD120, PD180, and PD240 use the same raster and layout; their mode labels and export metadata differ. The exporter keeps each mode’s native frame dimensions without introducing a square format. Mode references are recorded in manifest.json and in the script’s MODE_SOURCES list.

Useful commands:

npm run export:sstv -- --max-pages 10
npm run export:sstv -- --slug koncepty --max-pages 10
npm run export:sstv -- --mode pd290
npm run export:sstv -- --lang en
npm run export:sstv -- --mode pd290 --lang both
npm run export:sstv -- --section volna-tvorba,vystavy
npm run export:sstv -- --help

The -- after the npm script name passes the remaining options to the exporter. --section and --slug accept repeated options or comma-separated values. --lang both puts the selected Czech and English article pages into one numbered sequence, with language labels on the frames and in the manifest. Incomplete English translations remain visibly marked as EN incomplete / Czech fallback.

--max-pages 10 is a preview limit of ten complete PNG frames for the whole run, not ten articles or ten frames per article. If the limit stops the export before all selected content has been rendered, the run is marked as partial. Omit this option for a complete export.

Body text defaults to 24 native pixels for PD120/PD180/PD240 and 30 pixels for PD290. --font-size accepts 16–48 pixels; --font and --bold-font accept local font files. Additional path options are --project-root, --dist, --output-dir, and --site-url; use --help for their current meanings.

Layout and output files

The export includes articles listed in the finished build index. Homepage sections and the contents of linked PDF, download, or source-package files are not appended automatically.

The SSTV layout uses:

Every successful run creates a new timestamped directory rather than mixing its frames with an earlier run. For example:

exports/sstv/pd120/<timestamp>-cs/
  000001.png
  000002.png
  ...
  manifest.json
  README.txt

A preview stopped by the page limit has a directory name ending in -preview. --output-dir overrides the parent directory; the exporter still creates a new timestamped child inside it. The default exports/ tree is already ignored by Git and remains outside the signed dist/ build.

Use the numbered PNGs in filename order. README.txt identifies the selected mode and dimensions. manifest.json records the selection, article page ranges, translation status, rendered text and image references, native dimensions, fonts, source and PNG hashes, warnings, and whether the export is partial.

partial: False means all content selected for that run was processed. It does not mean that the warnings list is empty or that SSTV transmission quality has been tested. An unfiltered Czech export covers the indexed Czech articles; a filtered export is complete only for its selected subset.

Warning diagnosis and stale build output

Read the warnings array in the run’s manifest.json for the reasons behind the console warning count.

Audio tools and working directories

The PNG-to-audio and recovery procedure below was verified on 2026-09-10 using the scripts, archival README, and WAV files described in this section. It documents the working sequence, including MMSSTV for continuous reception.

The project includes the following audio tools and archival README:

scripts/sstv-audio/
  encode_sstv.bat
  join_wavs.py
  README.txt

The complete operational instructions for these tools are maintained here in documentation.mdx. README.txt is the archival companion intended to travel with the finished WAV volumes, for example in an Internet Archive deposit. It describes the specific six-volume edition tested on 2026-09-10.

Both scripts resolve their input and output directories relative to their own location. For each new edition, copy scripts/sstv-audio/ to a new working directory under exports/sstv-audio/, then put the numbered PNGs from one finished PD120 export into its input_images/ directory. Keep the PNG export’s manifest as png-manifest.json beside the scripts. Keep the archival README.txt from scripts/sstv-audio/; the shorter README.txt generated with the PNGs is a different document.

For example, in Windows Explorer copy the tool directory to exports/sstv-audio/2026-09-10-cs/ and use this layout:

exports/sstv-audio/2026-09-10-cs/
  encode_sstv.bat
  join_wavs.py
  README.txt
  png-manifest.json
  input_images/
    000001.png
    000002.png
    ...
  input_wav/
  output_wav/

Run the audio commands below in the working copy, not in scripts/sstv-audio/. Use a new directory name for a new edition; reuse a working directory only to resume the same unchanged PNG set. Working files under exports/ are excluded from Git and the reconstructable source bundle. Keep scripts/sstv-audio/ for the small source files: the source-bundle generator traverses scripts/, so putting generated PNG/WAV files or an Open-SSTV installation there would also put them in the source ZIP.

All audio stages are manual. npm run export:sstv continues to generate PNGs only; neither the audio scripts nor the decoding software are invoked by the website build or export-all.bat.

Open-SSTV encoder setup

The tested encoder is open-sstv-encode from Open-SSTV. The working installation used a source checkout at %USERPROFILE%\Downloads\Open-SSTV with a Python 3.11.9 virtual environment. The inspected checkout declares version 0.6.10, requires Python 3.11 or newer, and was at commit 0cf68e0d2ee0ca85d1f9947403319a2a92043be3. These details record the tested installation rather than a promise about future releases.

The Windows GUI ZIP used in the experiment did not provide the required CLI executable. Installing with pip install open-sstv also failed in that test. The successful setup used the source repository and a Python 3.11 virtual environment. For a fresh installation, after installing Python 3.11 and Git, the commands are:

cd /d "%USERPROFILE%\Downloads"
git clone https://github.com/bucknova/Open-SSTV.git
cd Open-SSTV
py -3.11 -m venv .venv
.venv\Scripts\activate
python -m pip install -e .
open-sstv-encode --help

Reuse an existing working installation instead of recreating it. The test initially failed with Python 3.10; explicitly selecting Python 3.11 when creating the environment resolved that dependency requirement. Open-SSTV is separate from the Python environment used by the website’s PNG exporter.

encode_sstv.bat finds the encoder in this order:

  1. the executable path in OPEN_SSTV_ENCODER, if set and present
  2. open-sstv-encode.exe on PATH
  3. .venv\Scripts\open-sstv-encode.exe beside the batch
  4. Open-SSTV\.venv\Scripts\open-sstv-encode.exe beside the batch
  5. %USERPROFILE%\Downloads\Open-SSTV\.venv\Scripts\open-sstv-encode.exe

The final fallback matches the tested installation without hard-coding the account name. If the encoder is installed elsewhere, set OPEN_SSTV_ENCODER to its full executable path before running the batch. The installed encoder can then be used without manually activating its virtual environment for every export.

PNG to WAV encoding

Use PNGs from the website exporter’s --mode pd120 output: 640 × 496 pixels. The copied batch is fixed to PD120 and invokes Open-SSTV with the differently spelled mode identifier pd_120. It does not read the PNG manifest or accept a mode argument. Choosing --mode pd290 in the PNG exporter does not change this batch; using another audio mode requires a corresponding change in a working copy and a new test.

From the working directory containing the scripts and input_images/, run:

encode_sstv.bat

For each input PNG, the batch runs the equivalent of:

open-sstv-encode "input_images\000001.png" --mode pd_120 -o "input_wav\000001.wav"

The result is one complete SSTV transmission per image, with its own VIS mode-identification signal. The actual test files are uncompressed mono PCM WAVs at 48,000 Hz and 16 bits per sample. The batch uses the encoder’s default sample rate; it does not pass --sample-rate explicitly.

The batch creates input_wav/ if needed, reports missing input PNGs, stops on an encoder error, and pauses for keyboard input when finished. Existing WAV filenames are skipped. This allows an interrupted run to resume, but the skip checks only file existence: it does not check PNG changes or validate an existing WAV. Use a fresh working directory when the input edition changes. Inspect an output left by an interrupted encoding before reusing it.

Joining WAVs into archive volumes

After encoding has finished successfully, run this in the same working directory:

py join_wavs.py

join_wavs.py uses only Python’s standard library. It reads all .wav files from input_wav/, sorts them naturally by filename, and writes to output_wav/. There is no manually maintained files.txt list and no FFmpeg step in this final workflow.

The script checks that the input WAVs are uncompressed PCM with matching channel count, sample width, sample rate, and compression type. It writes their PCM sample data in sequence without resampling or re-encoding, inserting one second of digital silence between transmissions. A frame remains whole when a volume boundary is reached; the inter-frame silence is placed at the beginning of the next volume. No extra silence is appended after the final frame.

The configured data-size limit is MAX_WAV_DATA_BYTES = 3_900_000_000, below the capacity of a classic RIFF/WAV file. Splitting is determined by byte size, not a fixed number of images. If the complete sequence fits, the output is combined-with-gaps.wav. Otherwise the names are:

combined-with-gaps_001.wav
combined-with-gaps_002.wav
combined-with-gaps_003.wav
...

The joiner overwrites matching output names and does not remove leftover volumes from an older run. Use the separate working directory for the current edition to keep the inputs and output set consistent. Some messages in the preserved script still mention input or output; its actual directory constants are input_wav and output_wav.

WAV to images: tested MMSSTV reception

The successful continuous decoder was MMSSTV for Windows. Open-SSTV successfully encoded frames and decoded individual image WAVs during the experiment, but the tested continuous multi-image playback did not produce the desired sequence there. MMSSTV with automatic restart was then verified to reconstruct consecutive pages. This is a record of the tested setup, not a general claim about all Open-SSTV versions.

The working audio path is:

WAV volume played at normal speed
  -> Windows playback output
  -> Stereo Mix / Směšovač stereo (Realtek)
  -> MMSSTV recording input
  -> automatic VIS detection and PD120 reception
  -> progressively drawn pages and received-image history

The initial MMSSTV failure was caused by Stereo Mix being disabled while a microphone was the default recording device. The working Windows setup was:

  1. Open Control Panel → Sound → Recording (Ovládací panely → Zvuk → Záznam).
  2. Enable Stereo Mix / Směšovač stereo (Realtek). If hidden, show disabled devices first.
  3. Set it as the default recording device.
  4. Restart MMSSTV so it picks up the recording-device change.

Use these settings, as recorded in the successful test and the archival README:

LocationSettingValue
Main RX windowRX ModeAuto
Main RX windowAuto historyON
Option → Setup MMSSTV → RXAuto startVIS only
Option → Setup MMSSTV → RXAuto restartON
Option → Setup MMSSTV → RXAuto resyncON
Option → Setup MMSSTV → RXAuto stopOFF
Option → Setup MMSSTV → MiscSound Card → InDefault, with Stereo Mix selected as the Windows default recording input

If MMSSTV lists the Stereo Mix input directly, it can be selected there. Play the WAV in a normal Windows audio player at its original speed and let MMSSTV listen to that input. It does not need to open the long WAV as an offline image file. Each new VIS signal identifies another PD120 frame, and automatic restart allows the next page to begin after the previous one.

The tested installation keeps received images in C:\Ham\MMSSTV\History\ as Hist*.bmp; the shortcut in the experimental workspace points to that directory. The observed history is BMP, not a recreation of the original PNG filenames. Preserve wanted received images separately and use the page number printed in each image to relate them to the source PNG sequence. Other installations may use a different program or history location.

The user also verified recovery after seeking to an arbitrary position inside a long WAV volume. A partial frame at that point may be unusable; keep playback running until the next complete VIS signal, when reception can start from the next page. Decoding through playback happens in real time. Keep the original playback speed and pitch and use the original WAVs to retain the tested signal timing.

This recovers a readable visual edition of the web content. It does not recreate the original HTML/MDX or guarantee pixel-identical copies of the source PNGs. The observed decoded pages contain SSTV artifacts. Successful individual, consecutive, and seek-recovery tests have been reported; a page-by-page audit of every frame across the entire archive has not been recorded.

Archival README and the verified audio edition

The audio edition verified on 2026-09-10 contained 1,724 input PNGs, 1,724 individual WAVs, and these six final volumes. Their sizes and PCM format were checked directly:

VolumeBytes
combined-with-gaps_001.wav3,895,596,294
combined-with-gaps_002.wav3,895,692,294
combined-with-gaps_003.wav3,895,692,294
combined-with-gaps_004.wav3,895,692,294
combined-with-gaps_005.wav3,895,692,294
combined-with-gaps_006.wav1,708,205,794
Total21,186,571,264

All six volumes are 48 kHz, 16-bit mono PCM WAVs. These counts and sizes describe this edition; a later build or different PNG selection may produce a different set.

For an archive deposit, place the archival README.txt alongside the original numbered WAV volumes. It explains what the audio contains, the PD120 mode, volume order, the tested MMSSTV settings, audio routing, recovery after seeking, and the archival purpose; it includes a short Czech explanation as well as the English instructions.

The copied scripts/sstv-audio/README.txt already names the six volumes and their total size above. For a later edition, update those edition-specific details in the distribution copy to match its actual outputs. Preserve the source copy in scripts/sstv-audio/README.txt as the record of the tested edition. The scripts do not generate or update that archival README automatically. Retaining the corresponding png-manifest.json with the archive also preserves the article-to-page mapping. Archive publication, including an Internet Archive upload, is a separate manual step.

Calibration images and a systematic comparison of source and received pages remain future work. Basic PNG-to-WAV encoding, joined-volume playback, and visual recovery are now implemented and experimentally verified as described above.

9.6.9 Manual JSON-LD article export and vocabulary

The structured article export is started manually:

node scripts/export-site-json.mjs

It writes exports/ALL_POSTS.json as UTF-8 JSON without BOM, also valid as JSON-LD 1.1. It uses the existing Node.js and Cheerio project dependencies. It never runs a build, fills translation caches, or accesses the network, and no normal build command invokes it. It is also the first step of export-all.bat.

The finished dist/ALL_POSTS.txt supplies article selection, order and original metadata. Full content comes from the corresponding built HTML files, including postprocessed English titles and bodies. Both directory routes and flat USB article files are supported. The export includes all indexed article language versions; it does not automatically include homepage-only content, standalone pages such as this documentation or /ns/, or the contents of linked downloads.

The root is a Schema.org Collection with article records in hasPart. Each record is a BlogPosting with its canonical URL, rendered headline, language, section, publication date, author, full articleBody, structured media references, and translation links when the counterpart is present. Code blocks in the JSON article body are not truncated. vm:articleHtml separately retains the parsed article HTML fragment, including its heading and visible metadata. Images and other media remain references; binary files and external embed payloads are not downloaded or bundled.

The inline @context combines Schema.org with the custom namespace https://vojtamaur.cz/ns/. The vm: vocabulary documents every custom property and the vm:Link class, including JSON types, scopes, examples and interpretation limits. For example, vm:slug expands to https://vojtamaur.cz/ns/slug. The namespace ends with /, not #; anchors on the vocabulary page are navigation aids, not replacement identifiers. Each known term also has a static description page, for example /ns/slug/.

Vocabulary descriptions are maintained in src/lib/export-vocabulary.ts. The static route src/pages/ns/[...term].astro generates /ns/ and the individual term pages from this shared data. These ordinary website pages become public when the built website is deployed; no separate service or runtime is required. Keep this vocabulary synchronized whenever custom export fields change. The export’s inline context remains self-contained and does not fetch the vocabulary page during JSON-LD processing.

Important interpretation rules:

Useful options:

node scripts/export-site-json.mjs --dry-run
node scripts/export-site-json.mjs --dist dist --output exports/ALL_POSTS.json
node scripts/export-site-json.mjs --project-root "H:\vojtamaur-web"

Relative paths are resolved from the project root, which defaults to the parent of scripts/. The output must be a .json or .jsonld file under exports/ and outside the selected build. Dry run validates the inputs and constructs the export without writing it. Missing article files, malformed metadata, duplicate records and unsafe output paths cause a nonzero exit; successful writes replace the output through a temporary file. The exporter reports article count, byte size and the SHA-256 of the resulting JSON. The generated file remains outside dist/ and outside the existing build signature.

9.7 Arweave / Permaweb build

The Arweave / Permaweb build is created with:

npm run build:arweave

This command first runs the strict standard web build, then runs the Arweave postprocess, and finally regenerates integrity files for the finished Arweave output:

node scripts/make-arweave-build.mjs
npm run generate:integrity:arweave

The script creates a separate output directory:

dist-arweave/

This directory is intended for upload to ArDrive or another Arweave-compatible upload tool.

The Arweave build exists because a normal web build uses root-relative paths such as:

/_astro/...
/images/...
/files/...
/volna-tvorba/

These paths work on a normal domain root such as https://vojtamaur.cz/, but they do not work automatically when the site is served below an Arweave manifest transaction URL.

The Arweave postprocess copies the finished dist/ output into dist-arweave/ and rewrites root-relative references into relative paths appropriate for the location of each HTML or CSS file.

After this copy-and-rewrite step, the build runs the integrity generator again for dist-arweave/. This is necessary because the Arweave postprocess can modify files after the normal web build has already created integrity files for dist/.

The integrity files inside dist-arweave/ belong to the Arweave deployment output itself:

dist-arweave/SHA256SUMS.txt
dist-arweave/BUILD_SHA256.txt
dist-arweave/integrity.json

The source of truth remains the normal project source. dist-arweave/ is a generated deployment artifact and should not be edited manually.


10. Exact special build logic

The build:usb script is defined as a USB-targeted Astro build followed by postprocessing. In the current translation-aware workflow, the effective order is:

set "BUILD_TARGET=usb" && astro build && node scripts/en-postprocess.mjs && node scripts/usb-rewrite.mjs && npm run generate:all-posts && npm run generate:integrity

This implies six steps:

  1. BUILD_TARGET=usb is set
  2. the Astro build is run in the mode defined in astro.config.mjs
  3. scripts/en-postprocess.mjs applies the English translation layer using the available cache
  4. scripts/usb-rewrite.mjs rewrites root-based URLs into relative file paths
  5. scripts/generate-all-posts.mjs creates dist/ALL_POSTS.txt as a plain-text preservation export of the finished build
  6. scripts/generate-integrity.mjs creates dist/SHA256SUMS.txt, dist/BUILD_SHA256.txt, and dist/integrity.json for the final static output

10.1 What astro.config.mjs does in USB mode

When BUILD_TARGET=usb, the following are used:

This means that internal routes are generated as files of the form:

slug.html

instead of the directory form:

slug/index.html

10.2 What scripts/usb-rewrite.mjs does

The script:

  1. goes through all .html files in the dist directory
  2. reads their content
  3. rewrites selected root-based paths to relative paths
  4. saves the files again

Specific rewrites include:

The script calculates the relative path from each HTML file back to the dist/ root. This matters because a root-level file such as dist/about.html needs ./_astro/..., while a nested file such as dist/en/about.html needs ../_astro/....

The purpose of this step is to adjust the HTML so that the output works even outside a standard web server with root-relative URLs.

10.3 Exact build:arweave logic

The build:arweave script is defined as a strict standard web build followed by Arweave-specific postprocessing and a second integrity pass for the final Arweave output. The effective order is:

npm run build:web:strict && node scripts/make-arweave-build.mjs && npm run generate:integrity:arweave

This implies five main steps:

  1. the standard Astro web build is created in dist/
  2. scripts/en-postprocess.mjs verifies or applies the English translation layer according to the strict build workflow
  3. scripts/generate-all-posts.mjs creates dist/ALL_POSTS.txt and scripts/generate-integrity.mjs creates integrity files for dist/
  4. scripts/make-arweave-build.mjs copies dist/ to dist-arweave/ and rewrites root-relative paths
  5. scripts/generate-integrity.mjs dist-arweave creates fresh integrity files for the final Arweave output

The second integrity pass matters. The checksum files created during build:web:strict describe dist/. After scripts/make-arweave-build.mjs rewrites files in dist-arweave/, those original checksums would no longer be sufficient for the Arweave output. Therefore dist-arweave/ gets its own SHA256SUMS.txt, BUILD_SHA256.txt, and integrity.json.

The Arweave output keeps the directory-style route model of the normal web build, for example:

fotogrammetrie/index.html
en/fotogrammetrie/index.html

This is different from the USB build, which uses a file-based output model.

The purpose of dist-arweave/ is to create a static folder that can be uploaded to ArDrive and then exposed through an Arweave manifest. The manifest should be created at the level where index.html, _astro/, images/, files/, ALL_POSTS.txt, and ARCHIVE.txt are directly present.

10.4 Exact Gemini build logic

The standalone Gemini build is effectively:

npm run build:web:strict && npm run generate:gemini

This creates or verifies the strict bilingual HTML build in dist/ and then replaces dist-gemini/ with the generated capsule.

For rapid iteration on only the converter, the existing HTML build can be reused:

npm run generate:gemini

This avoids rebuilding Astro or rerunning translation checks after every parser change.

The translating signed production command reaches the Gemini generator through the underlying translating web build. Its relevant order is:

Astro build
-> English postprocess with missing translations enabled
-> ALL_POSTS and source-bundle generation
-> integrity generation for dist/
-> Gemini capsule generation in dist-gemini/
-> detached OpenPGP signing of dist/SHA256SUMS.txt

The final signing step remains scoped to the normal web build. dist-gemini/ is generated in the same workflow but is a separate unsigned artifact.


11. English translation workflow

The project has an English version generated at build time. The Czech MDX files remain the source of truth. The English version is a derived static artifact produced from the rendered HTML output.

There are two separate translation layers:

This separation is intentional. UI text is small and highly visible, so it is translated manually. Article content is larger and less convenient to maintain twice, so it is translated automatically.

11.1 Translation configuration

The translation configuration is stored in:

scripts/i18n-config.mjs

Important values:

The translation postprocess protects code, embeds, scripts, styles, SVG, canvas, iframes, and anything marked as notranslate or translate="no". Image alt text and thumbnailAlt metadata are not automatically translated. Captions are translated when they are normal HTML text inside a translated region.

11.1.1 Translation glossary and terminology preservation

Project-specific terminology used by the automatic translation layer is stored in:

translations/glossary-cs-en.tsv

The TSV file is the canonical source of the glossary. Each non-empty line contains exactly one Czech source term, one tab character, and one English target term:

Volná tvorba	Personal Work

The tab must be a real tab character, not a sequence of spaces. Duplicate Czech source terms, empty values, extra columns, and leading or trailing whitespace cause the translation postprocess to fail instead of silently accepting an ambiguous glossary.

The glossary serves two related purposes:

For example, Volná tvorba is mapped to Personal Work, not the superficially literal Free Creation. The TSV file therefore documents part of the conceptual structure of the project, not merely a temporary setting for an external translation service. Because it is stored and versioned with the source code, this intended terminology remains available in archived repository copies even if DeepL or the current build environment is no longer available.

The glossary integration is configured in scripts/i18n-config.mjs:

glossary: {
  enabled: true,
  name: "vojtamaur.cz CS-EN",
  sourceFile: "translations/glossary-cs-en.tsv",
  stateFile: "translations/glossary-cs-en.state.json"
}

During a build:*:translate run, scripts/en-postprocess.mjs:

  1. loads and validates the local TSV file
  2. reuses the remote DeepL glossary ID recorded in translations/glossary-cs-en.state.json when possible
  3. otherwise searches the DeepL account for a glossary with the configured name
  4. creates the remote glossary if no matching glossary exists
  5. updates the remote glossary when its entries differ from the local TSV source
  6. stores the resulting remote ID and local revision in the state file

The remote glossary ID does not need to be set manually as an environment variable. Only DEEPL_AUTH_KEY is required. The state JSON file is operational metadata and a reusable pointer to the remote DeepL object; it is not the authoritative glossary content. If the state file is missing, it can be recreated from the TSV and the DeepL account. If the TSV is missing, the intended terminology is no longer reliably reconstructable from the project source.

Glossary-aware cache invalidation is selective. For each translated fragment, only glossary rows whose Czech source phrase occurs in that fragment contribute to its glossary revision. Changing one glossary entry therefore invalidates translations that use that term without forcing unrelated pages to be translated again.

Translation requests are paced and retried with exponential backoff to reduce DeepL rate-limit failures. If DeepL still returns HTTP 429, translations completed before the failure remain cached and the same translate command can be run again.

11.2 DeepL API key

The DeepL key is not stored in the repository. It is provided through the DEEPL_AUTH_KEY environment variable.

In Windows CMD:

cd F:\vojtamaur-web
set "DEEPL_AUTH_KEY=YOUR_DEEPL_KEY"
npm run build:web:translate

One-line CMD version:

set "DEEPL_AUTH_KEY=YOUR_DEEPL_KEY" && npm run build:web:translate

In PowerShell:

cd F:\vojtamaur-web
$env:DEEPL_AUTH_KEY = "YOUR_DEEPL_KEY"
npm run build:web:translate

The key must never be committed to the repository, embedded in client-side JavaScript, or uploaded as a public file.

11.3 Web translation commands

Recommended workflow after changing content:

cd F:\vojtamaur-web
set "DEEPL_AUTH_KEY=YOUR_DEEPL_KEY"
npm run build:web:translate
npm run build:web:strict
npm run preview

Meaning:

For normal rebuilds with an already complete cache, npm run build:web can be enough. Before publishing, npm run build:web:strict is the safer check.

To force a full retranslation, use:

npm run build:web:refresh

This should be used carefully because it can change existing English output even if the Czech source text did not change. It is not a cache cleanup command.

To inspect unused English translation cache files without deleting them, use:

npm run build:web:prune:dry

To delete unused, unprotected English translation cache files, use:

npm run build:web:prune

The prune commands run the strict workflow, so they should only be used when the translation cache is already complete.

11.4 USB translation commands

For the portable build with missing translation generation:

cd F:\vojtamaur-web
set "DEEPL_AUTH_KEY=YOUR_DEEPL_KEY"
npm run build:usb:translate

For a strict USB check, use:

npm run build:usb:strict

To inspect unused English translation cache files during the USB workflow, use:

npm run build:usb:prune:dry

To delete unused, unprotected English translation cache files during the USB workflow, use:

npm run build:usb:prune

The USB wrapper scripts run usb-rewrite.mjs even if the translation postprocess fails. This prevents dist/ from being left with broken relative CSS and image paths. A rewritten dist/ is not proof of a successful translation build; the log must still be checked for [i18n] Postprocess failed:.

11.5 Translation cache

Translation cache files are stored in:

translations/en/

Each cache entry contains the original source fragment, the translated fragment, and metadata about the translation configuration. The cache key is derived from the route, purpose, source fragment, language direction, DeepL options, selector policy revision, and any glossary rows that apply to the fragment.

Practical consequences:

The cache is part of the project source, not a public runtime dependency. The published site uses the finished HTML in dist/.

11.6 Translation cache pruning

Translation cache pruning is handled by scripts/en-postprocess.mjs when the EN_PRUNE_CACHE environment variable is enabled. The script records every cache hash that is actually used while processing the current English build output. After that, it can remove cache files that are no longer used by the current site.

Recommended web workflow:

npm run build:web:prune:dry
npm run build:web:prune

Recommended USB workflow:

npm run build:usb:prune:dry
npm run build:usb:prune

The dry-run command must be checked first. It prints files that would be deleted and reports a summary such as cache-kept, cache-would-delete, cache-locked-kept, and cache-invalid-kept. The non-dry command deletes only unused cache files that are not protected.

A cache file is kept during pruning when at least one of these is true:

New automatically generated cache entries are marked as not manually edited:

"manual": false,
"locked": false,
"edited": false,
"editedAt": null,
"editedBy": null

When a translation is manually corrected, the cache entry can be marked like this:

"edited": true,
"editedAt": "2026-07-06",
"editedBy": "Vojta"

This protects the entry from future pruning even if the current source text changes and the hash is no longer used. manual: true and locked: true are also respected as stronger preservation markers.

Pruning is meant to keep translations/en/ searchable and maintainable. It should not change the published HTML except through the normal strict build process. If the goal is only to remove unused cache files, do not use build:web:refresh; refresh can retranslate existing content and change manually checked output.

11.7 Development server versus final build

npm run dev is useful for editing layout and content. The final publishing artifact is still the build output in dist/.

In some cases, npm run dev may correctly display manually translated UI elements and metadata, while article bodies remain untranslated in English routes. This usually means the DeepL postprocess has not been applied to the current output yet.

To verify the real final translated output, use:

npm run build:web:translate npm run preview

11.8 MDX hard line breaks inside translated paragraphs

Avoid using Markdown hard line breaks between a bold pseudo-heading and the text that follows it in translated article content.

Problematic pattern:

**2026-03-02: TŘÍDĚNÍ PIXELŮ FOTEK**  
Pokusil jsem se pomocí algoritmu roztřídit pixely fotografií.

The two trailing spaces after the bold text are meaningful Markdown. They produce a hard HTML line break:

<p><strong>2026-03-02: TŘÍDĚNÍ PIXELŮ FOTEK</strong><br>
Pokusil jsem se pomocí algoritmu roztřídit pixely fotografií.</p>

This structure is fragile when the fragment is sent through the English translation postprocess. DeepL receives one translated HTML paragraph containing both the bold title and the following sentence. During translation, it may preserve the <br> but move part of the translated title across it, producing output such as:

<strong>2026-03-02: SORTING PHOTO</strong><br><strong>PIXELS</strong>
I tried to sort the pixels...

The visual result looks like a broken heading even though the browser is only rendering the hard break that already exists in the HTML.

Safer pattern when the bold line is only a compact label:

**2026-03-02: TŘÍDĚNÍ PIXELŮ FOTEK**

Pokusil jsem se pomocí algoritmu roztřídit pixely fotografií.

This creates separate paragraphs and does not place a <br> between the label and the body text.

Best pattern when the line is structurally a subsection heading:

### 2026-03-02: TŘÍDĚNÍ PIXELŮ FOTEK

Pokusil jsem se pomocí algoritmu roztřídit pixely fotografií.

Use real headings for repeated article sections such as dated concept entries. If the default heading style is too visually large, fix that in CSS instead of simulating headings with bold text and hard breaks.

After changing this kind of structure, verify the generated English output:

npm run build:web:translate
npm run build:web:strict
npm run preview

If the broken English output persists, check whether an old translation cache entry in translations/en/ is still being reused. The cache stores translated HTML fragments, so a structural mistake can remain visible until the changed source fragment produces a new cache key or the relevant stale cache entry is removed.

11.9 Marking content as not translatable

Use NoTranslate.astro for MDX content that must remain unchanged.

Import it from an MDX file in src/content/posts/ like this:

import NoTranslate from "../../components/NoTranslate.astro";

Inline use:

The term <NoTranslate>anti-language</NoTranslate> should stay unchanged.

Block use:

<NoTranslate as="div">
This text should not be sent to DeepL.
It will remain exactly as written.
</NoTranslate>

Use as="div" for block content. The default element is span, which is better suited for inline text.

Typical uses:

11.10 notranslate inside MediaRow

MediaRow.astro is a special case because type: "text" items are rendered through set:html. That means the content value is an HTML string, not an Astro component.

This does not work:

<MediaRow
  items={[
    {
      type: "text",
      content: "<NoTranslate>This will not run as an Astro component.</NoTranslate>"
    }
  ]}
/>

Use a normal HTML marker instead:

<MediaRow
  bordered
  items={[
    {
      type: "text",
      content: "28. prosince 2014 jsem v Mombase vyfotil starou popsanou zeď."
    },
    {
      type: "text",
      content: `
<div class="notranslate" translate="no">
It doesn't matter which religion you claim you are.
It doesn't matter which country you're coming from.
It doesn't matter whether you're poor or rich.
It doesn't matter if you're black or white.
We are all the same in the eyes of God.
</div>
      `.trim()
    },
    {
      type: "text",
      content: "Nezáleží, jakého jsi vyznání. Nezáleží, z jaké země pocházíš."
    }
  ]}
/>

The important part is:

<div class="notranslate" translate="no">
  ...
</div>

The postprocess recognizes this marker, removes the protected block before sending the fragment to DeepL, and restores it afterward.

11.11 Large fragments

The current maximum translated fragment size is 80_000 bytes. If a translated region is larger, the build fails with an error similar to:

EN fragment for /en/example/ is 166405 bytes, above configured 80000.

Preferred solutions:

The size guardrail exists to avoid sending oversized and fragile HTML blobs to DeepL.

11.12 Troubleshooting

DEEPL_AUTH_KEY is not set

The build tried to create a new translation but no DeepL key was available. Set the key and run the translate command again:

set "DEEPL_AUTH_KEY=YOUR_DEEPL_KEY"
npm run build:web:translate

Missing EN translation cache

Strict mode found an EN page that needs a translation cache entry that does not exist yet. Run:

npm run build:web:translate

or, for USB:

npm run build:usb:translate

Then run the strict build again.

Remote DeepL glossary created from local TSV

This is an informational message, not an error. The TSV file in the repository is the local source of glossary entries, while DeepL requires a separate remote glossary object in the account. The message means that no reusable remote object was found through the saved state ID or configured glossary name, so the script created one and stored its ID in:

translations/glossary-cs-en.state.json

If this message appears on every translate build, check whether the state file is being deleted, whether a different DeepL account or API key is being used, or whether the remote glossary was removed.

DeepL HTTP 429

DeepL is rate-limiting translation requests. The script spaces requests apart, retries temporary failures with exponential backoff, and respects the Retry-After response header when provided. If all retries still fail, run the same translate command again:

npm run build:web:translate

Translations completed before the failure are already stored in translations/en/. Do not delete the translation cache or glossary state file merely because a 429 occurred; that would discard useful progress and create more requests.

Cache pruning would delete a manually edited translation

First run only the dry-run command:

npm run build:web:prune:dry

If an unused cache entry still needs to be preserved, open the corresponding JSON file and add edited: true, manual: true, or locked: true. Then run the dry-run command again and check that the file is counted as cache-locked-kept instead of cache-would-delete.

Header and UI are English, but the article body is Czech

The manual UI dictionary is working, but the automatic content translation did not run or did not have a cache entry. Check the build log and run build:web:translate.

Local dist/ is Czech after build:web:translate

Check the end of the build log. If it contains DEEPL_AUTH_KEY is not set or another Postprocess failed message, the Astro build completed but the translation postprocess failed.

Production is Czech, but local dist/ is English

The problem is upload or caching, not translation. Upload the entire dist/ directory again and force overwrite existing files. Avoid “skip if same size” and similar FTP shortcuts. Then test with a cache-busting URL parameter.

Metaweb PDF export reports Article content container not found

Check which build command last wrote to dist/. A preceding USB build, including npm run build:usb:translate:signed, leaves flat-file routes and file://-oriented links in that shared directory. The metaweb PDF exporter expects the standard web layout and cannot find the article at its directory route.

Recreate the standard web build and then rerun the export:

npm run build:web:translate:signed
npm run export:pdf:metaweb

If the translation cache is complete and strict mode is sufficient, npm run build:web:strict:signed can replace the first command. Do not use npm run generate:metaweb; that package script does not exist.

Gemini was generated after a USB build

Treat that dist-gemini/ output as invalid even if npm run generate:gemini completed successfully. A USB build rewrites relative links for opening HTML directly from disk, but the Gemini converter resolves them as web links. Rebuild the capsule from a fresh standard web build:

npm run build:gemini

For a release that may also need to fill missing English translations and sign the standard web build, use npm run build:web:translate:signed instead; that workflow already generates dist-gemini/. Both commands replace the existing Gemini output, so it does not need to be deleted manually.

11.13 Publishing checklist

Before uploading the web build to FTP:

  1. Run npm run build:web:translate.
  2. Run npm run build:web:strict.
  3. If translation cache cleanup is needed, run npm run build:web:prune:dry and check the summary.
  4. If the dry-run looks correct, run npm run build:web:prune.
  5. Run npm run preview.
  6. Check that dist/SHA256SUMS.txt, dist/BUILD_SHA256.txt, and dist/integrity.json exist.
  7. Open at least one EN article locally.
  8. Check that the article body is actually English, not only the header and metadata.
  9. Upload the complete dist/ directory, including the integrity files.
  10. Overwrite existing files on the server.

For USB/offline output:

  1. Run npm run build:usb:translate.
  2. Run npm run build:usb:strict.
  3. If translation cache cleanup is needed, run npm run build:usb:prune:dry and check the summary.
  4. If the dry-run looks correct, run npm run build:usb:prune.
  5. Check that SHA256SUMS.txt, BUILD_SHA256.txt, and integrity.json are present in the generated output.
  6. Open the generated HTML from disk.
  7. Check CSS, images, internal links, and EN article content.

For Arweave / Permaweb output:

  1. Run the metadata audit: python scripts/audit-public-metadata.py --exiftool "D:\Program Files\exiftool\exiftool.exe"
  2. Resolve unintended metadata findings before publishing.
  3. Run npm run build:arweave.
  4. Verify that the log ends with Arweave build prepared in dist-arweave.
  5. Verify that dist-arweave/SHA256SUMS.txt, dist-arweave/BUILD_SHA256.txt, and dist-arweave/integrity.json exist.
  6. Test the build locally under a fake manifest-like subdirectory.
  7. Upload only dist-arweave/ to ArDrive.
  8. Create the manifest at the level where index.html is directly present.
  9. Test the manifest gateway URL.
  10. Check several deep routes, English routes, ALL_POSTS.txt, ARCHIVE.txt, and the integrity files.

12. Deploy

12.1 dist structure

The dist/ directory contains the finished build intended for publishing.

Important generated preservation and verification files include:

dist/404.html
dist/ALL_POSTS.txt
dist/SHA256SUMS.txt
dist/BUILD_SHA256.txt
dist/integrity.json
dist/SIGNING_STATUS.txt
dist/SHA256SUMS.txt.asc       # only after an explicit signed build
dist/keys/vojta-maur-openpgp.asc
dist/keys/vojta-maur-openpgp-fingerprint.txt
dist/source/vojtamaur-web-source.zip

The source/ directory in dist/ is a generated output directory. It should not be treated as source input or committed as a hand-maintained project directory.

The Gemini capsule is not part of this tree. It is generated separately as the sibling directory dist-gemini/, and it is not included in the checksum manifest or detached signature stored in dist/.

12.2 Deployment of the standard web build

For the production website, the content corresponding to the standard web build is uploaded to the hosting server.

12.2.1 Neocities mirror limitation

The current Neocities mirror is subject to Neocities file-type restrictions, which reject .zip uploads. The following generated file is therefore not present on that mirror:

dist/source/vojtamaur-web-source.zip

Because the source-package link in the published content is root-relative, the link resolves on the Neocities mirror to /source/vojtamaur-web-source.zip. That URL is a known dead link on the Neocities deployment.

The source package remains part of the complete local dist/ output and other deployments that accept ZIP files. The Neocities deployment must therefore be treated as a partial hosting mirror rather than a byte-for-byte copy of dist/. Integrity files generated from the complete build may still list the source ZIP even though Neocities rejected it; this is a known deployment-specific omission, not evidence that the local build is corrupted.

12.2.2 Codeberg Pages deployment

The Codeberg repository is named pages and is available at:

https://codeberg.org/vojta_maur/pages

It contains two different branches with separate purposes:

The published website is available at:

https://vojta_maur.codeberg.page/

The repository name is intentionally pages. The normal web build contains root-relative URLs such as /_astro/..., /images/..., and /slug/. If the same build were served from a repository subpath such as /vojtamaur-web/, CSS, images, the web manifest, and internal links would resolve against the domain root and return 404. Using the special pages repository exposes the build at the user-domain root and preserves the same URL model as the production website and the other root-hosted mirrors.

Codeberg Pages is triggered by a repository webhook with the following relevant settings:

Target URL: https://vojta_maur.codeberg.page/
Event: push
Branch filter: pages
Content type: application/json

The local setup uses two Git worktrees so that the source branch and generated deployment branch can remain checked out at the same time:

G:\vojtamaur-web    -> main
G:\vojtamaur-pages  -> pages

The deployment helper is:

deploy-codeberg-pages.bat

It is intended to be stored in the project root. The script:

  1. verifies the expected directories, branches, and the codeberg remote
  2. refuses to deploy while the main worktree contains uncommitted changes
  3. runs npm run build in G:\vojtamaur-web
  4. resets and cleans only the pages worktree
  5. copies the finished dist/ contents into G:\vojtamaur-pages
  6. creates a deployment commit only when the generated output changed
  7. pushes only the pages branch to Codeberg

The script does not commit, modify, or push main. The normal source publishing workflow therefore remains separate:

git status
git add .
git commit -m "Commit message"

git push origin main
git push gitlab main
git push codeberg main

deploy-codeberg-pages.bat

An optional deployment commit message can be passed as an argument:

deploy-codeberg-pages.bat "Deploy updated website"

On a fresh workstation, the pages worktree can be recreated after configuring the codeberg remote and fetching the remote deployment branch:

git remote add codeberg https://codeberg.org/vojta_maur/pages.git
git fetch codeberg pages
git worktree add -b pages G:\vojtamaur-pages codeberg/pages

If a local pages branch already exists, omit -b pages and attach the worktree to that existing branch instead. The deployment branch is generated output and should not be merged into main.

12.2.3 OpenPGP signing policy

Only build artifacts explicitly produced and signed in the author’s controlled local environment are OpenPGP-signed. The private signing key is kept on the author’s local storage and is never committed to the repository, copied into the build, or uploaded as a CI secret.

Automated or provider-side deployments that do not run the explicit local signing step are intentionally unsigned. They may still publish the public key and fingerprint because those are public identity material, but their presence does not authenticate that particular build.

A valid signature belongs only to the exact SHA256SUMS.txt that was signed. A provider-side rebuild is a different artifact even when it was produced from the same source revision. Generated timestamps and deployment-specific rewriting can also make otherwise equivalent builds byte-wise different.

A mirror that receives an already completed locally signed build without rebuilding or modifying it can preserve the same signature. The absence of SHA256SUMS.txt.asc on an automated deployment therefore does not indicate failed checksum generation; it indicates that the explicit private-key operation was not performed.

12.3 .htaccess

For a static Astro website, a minimalist configuration is appropriate. A typical WordPress rewrite rule to index.php is not relevant for this type of project.

12.4 Portable build

The portable build can be used as a file-based snapshot or offline copy. However, it is not identical to normal web hosting, and some external services may behave differently.

In the portable build, root-relative paths must be rewritten so that the site works from file:// URLs. This includes the source package link. A correct portable build must link to the source package relatively, for example:

./source/vojtamaur-web-source.zip

rather than:

/source/vojtamaur-web-source.zip

The latter would point to the root of the local drive, such as C:\source\vojtamaur-web-source.zip on Windows.

12.5 Arweave / Permaweb deployment

For Arweave deployment, do not upload the normal dist/ directory directly. Use the generated Arweave-specific output:

dist-arweave/

Recommended workflow:

  1. run the public asset metadata audit
  2. run npm run build:arweave
  3. verify that dist-arweave/SHA256SUMS.txt, dist-arweave/BUILD_SHA256.txt, and dist-arweave/integrity.json exist
  4. optionally test dist-arweave/ locally under a fake manifest-like subdirectory
  5. upload only dist-arweave/ to a public ArDrive drive
  6. create an Arweave manifest at the level where index.html is directly present
  7. copy the manifest Data TX ID
  8. test the site through an Arweave or ArDrive gateway URL

The manifest must be created inside the uploaded build root, not above it. The correct manifest target level contains:

index.html
_astro/
images/
files/
ALL_POSTS.txt
ARCHIVE.txt
SHA256SUMS.txt
BUILD_SHA256.txt
integrity.json

The uploaded Arweave version is immutable. If a mistake is uploaded, the correction must be published as a new upload and a new manifest transaction. The old version remains available.

12.6 Gemini capsule deployment

For Gemini deployment, use the generated directory:

dist-gemini/

The Gemini server document root should correspond to the contents of this directory so that index.gmi is the English capsule homepage. The Czech homepage remains available at:

/cs/index.gmi

A typical update consists of:

  1. generating a fresh capsule with npm run build:gemini, or generating it through the translating production workflow
  2. previewing representative .gmi files locally in a Gemini client
  3. uploading the contents of dist-gemini/ through the hosting account’s supported transfer method, such as SFTP or FTPS
  4. verifying the homepage, language switch, section indexes, several article pages, the YouTube playlist link, direct media links, demo links, and the OpenPGP block

The capsule itself contains text and links. Images, PDFs, videos, maps, 3D models, and interactive HTML demonstrations remain on the normal HTTPS website and are opened through absolute URLs. The Gemini deployment therefore depends on the continued availability of https://vojtamaur.cz/ for non-text media, while article text and capsule navigation remain native Gemtext.

The capsule can be updated independently by rerunning npm run generate:gemini against an existing current dist/. However, publishing from stale HTML would also publish stale translated content, so the complete build command should be preferred for normal releases.


13. Known issues and solutions

13.1 YouTube embed in local or file-based mode

A YouTube iframe may fail in local or file-based mode with error 153. In that case, it is recommended to account for a fallback opening of the video via an external link.

13.2 Sketchfab warnings in the console

The Sketchfab iframe may generate console warnings such as:

If the viewer works, this is not a project error, but a limitation or behavior of a third party.

13.3 Broken CSS or assets with the wrong base model

If the build uses root-relative paths in an environment where no standard server root is available, styles, images, and internal links may break.

This affects more than one special output model:

For this reason, the portable file-based build is supplemented with scripts/usb-rewrite.mjs, and the Arweave build is supplemented with scripts/make-arweave-build.mjs.

If CSS, JavaScript, images, source package links, or internal links fail in the Arweave deployment, check whether any root-relative path such as /_astro/..., /images/..., /files/..., /source/..., or /slug/ remained in the generated dist-arweave/ output.

13.4 Dev server and new articles

If the listing or routes do not match after adding a new .mdx file, the recommended first step is to restart the development server.

13.5 Lagrange and the Unicode replacement character

A tested Lagrange client stops rendering the remainder of a normal text line immediately after a literal Unicode replacement character U+FFFD (). The source .gmi remains valid UTF-8, and the same character is allowed by Unicode; this is treated as a client rendering defect rather than malformed Gemtext.

The Gemini generator works around the issue only in the generated capsule by converting each literal U+FFFD to the visible ASCII sequence:

\uFFFD

This preserves the information that the replacement character occurred while preventing the client from truncating the rest of the line. The original MDX, rendered HTML, and other Unicode characters are not modified.

13.6 Lagrange indentation after intentional line breaks

When several normal Gemtext lines originate from intentional <br> line breaks inside one HTML paragraph (the equivalent of Shift+Enter), Lagrange may render the later lines with paragraph-like indentation. Kristall renders the same .gmi content without this indentation. This is a client-specific rendering difference, not an error in the Gemini generator.

Where the source text genuinely contains separate paragraphs, replacing <br> with separate MDX paragraphs improves both HTML and Gemini output. Intentional hard line breaks should remain unchanged if converting them to paragraphs would alter the intended HTML structure.


14. Future extensions

Possible future directions for the project:


15. Archival operations

15.1 Preservation priority under constraints

When storage capacity or snapshot quotas make it impossible to preserve the complete site, archive these artifacts in the following order:

  1. ALL_POSTS.txt – the smallest practical content-preservation layer
  2. ARCHIVE.txt – the map of repositories, mirrors, snapshots, and archive entry points
  3. this technical documentation (/documentation/ or its source file, src/pages/documentation.mdx)

This priority is especially useful for storage-constrained deposits such as Memory of Mankind and for services such as Perma.cc where the number of available snapshots may be limited.

15.2 Manual mirror updates

Some mirrors do not support automatic deployment from the canonical repository and therefore need to be updated manually. These currently include ChatGPT Sites, Neocities, ArDrive / Arweave, Pollux.casa, and envs.net.

The ChatGPT Sites deployment does not update automatically when the canonical repository changes. To refresh it, ask ChatGPT in the Sites chat to load the current state of the canonical repository, save or build a new version, and deploy that version explicitly.

The Vercel mirror requires this root-level vercel.json:

{
  "buildCommand": "npm run build:web",
  "outputDirectory": "dist",
  "framework": "astro"
}

Without this override, Vercel’s default Astro build may skip the post-build step that generates ALL_POSTS.txt.

Codeberg Pages is also updated separately through deploy-codeberg-pages.bat; the complete worktree-based procedure is documented in section 12.2.2.

15.3 External web archives

As an operating assumption, newly published article URLs and mirror URLs should be submitted to the Internet Archive. Seeding the archive with these URLs is expected to improve discovery and may lead to later automatic recrawling, but it is not a guarantee that every URL or later version will be captured.

The Czech Webarchiv likely captured https://vojtamaur.cz/ after a manual preservation request. Searching for the root URL currently returns the following access notice:

Tuto stránku Webarchiv nemůže zobrazit. Z důvodu autorského zákona nemůžeme tuto stránku zpřístupnit online. Archivované verze této stránky jsou dostupné pouze z Referenčního centra NK ČR.

This indicates restricted archived holdings rather than public online access; it does not by itself establish the completeness or capture dates of those holdings.

15.4 YouTube video archiving

YouTube videos are downloaded from Windows Command Prompt with yt-dlp. Put the video URLs in urls.txt, one URL per line, then run:

yt-dlp --write-info-json --write-thumbnail --write-description --write-subs --write-auto-subs --sub-langs "cs,en" --embed-metadata --download-archive downloaded.txt -f "bv*+ba/b" -o "%(title).200B [%(id)s]/%(title).200B [%(id)s].%(ext)s" -a urls.txt

The downloaded.txt archive prevents already recorded videos from being downloaded again.

15.5 Arweave gateway entry points

An Arweave manifest Data TX ID identifies the archived deployment independently of the HTTP gateway used to retrieve it. The current archived deployment uses this manifest Data TX ID:

GHwSYFJtzrRqt5AOZuwJfJHoV6Bc5qjwe5FtFP6UjAs

ARCHIVE.txt and other preservation records should list at least these two entry points to the same manifest:

https://db6beycsnxhli2vxsahgn3ajpsi6qv5alttkr4d3sfwrj7uurqfq.ardrive.net/GHwSYFJtzrRqt5AOZuwJfJHoV6Bc5qjwe5FtFP6UjAs/
https://arweave.net/GHwSYFJtzrRqt5AOZuwJfJHoV6Bc5qjwe5FtFP6UjAs/

These URLs are not separate archived copies. They are two HTTP gateway routes to the same manifest and the same underlying Arweave data. If one gateway returns intermittent 404 or 504 responses, or loads HTML while failing to load CSS or other assets, test the identical manifest path through the other gateway before concluding that archived data is missing.

On 2026-08-18, the ArDrive gateway was observed returning alternating 404 and 200 responses for the same immutable CSS path, while the corresponding arweave.net path returned 200 consistently. The failing ArDrive responses reported X-Cache-Status: UPDATING. This behavior indicates a gateway cache or retrieval failure rather than a change to the permanent stored data.

The manifest Data TX ID is the authoritative archival identifier. Gateway URLs are replaceable access routes and additional gateway entry points may be recorded without uploading the deployment again.

15.6 Arctic World Archive pricing note

For Shared piqlFilm, the observed minimum price is €139. An approximately 0.8 GB package cost €139; 1.19 GB was quoted at €165.48, close to €139/GB; and 9.22 GB was quoted at €1,113.21, which may indicate a different calculation or volume discount at larger sizes. Because the exact public pricing formula is not known, treat these figures as practical observations rather than guaranteed price tiers. To stay at the €139 minimum in practice, keep the package safely below 1 GB.


16. Summary

The project is designed as a file-oriented static website. Content is versioned directly in the repository, and the final published form is produced by the build process. This model makes it easier to archive, restore, and migrate the project without relying on a database runtime.

From a maintenance perspective, the following points are especially important:

This documentation describes the current architecture and operating model of the project in a form suitable for ongoing maintenance, handoff, or future migration.