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/

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

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.

package.json

Basic project workflow:

npm run dev
npm run build:web
npm run build:web:translate
npm run build:web:strict
npm run build:web:prune:dry
npm run build:web:prune
npm run preview
npm run build:usb
npm run build:usb:translate
npm run build:usb:strict
npm run build:usb:prune:dry
npm run build:usb:prune
npm run build:arweave
npm run generate:all-posts
npm run generate:source-bundle
npm run generate:integrity
npm run generate:integrity:arweave
npm run export:pdf

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. This includes images in public/images/ and downloadable or embeddable files in public/files/. Embedded metadata in these files must therefore 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:


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, and writes the result to dist/ALL_POSTS.txt.

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.

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 files

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

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

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 metadata is stored in:

integrity.json

The following files are intentionally excluded from the checksum manifest:

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

This avoids a recursive checksum loop where the checksum files would change the checksum they are trying to describe.

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.

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 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/.

The source package is generated before the final integrity pass. Therefore SHA256SUMS.txt includes the source ZIP as part of the final published or portable build artifact.

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 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.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.


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 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.9 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.10 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.11 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.

11.12 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/ALL_POSTS.txt
dist/SHA256SUMS.txt
dist/BUILD_SHA256.txt
dist/integrity.json
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.

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.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.


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.


14. Future extensions

Possible future directions for the project:


15. 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.