723 lines
18 KiB
Markdown
723 lines
18 KiB
Markdown
# Ryan's Blog
|
|
|
|
This repository contains a static blog built with [Astro](https://astro.build/).
|
|
Markdown files are the source of truth for every post. Astro validates the post
|
|
metadata, converts the Markdown to HTML, creates the homepage and post routes,
|
|
and writes the finished website to `dist/`.
|
|
|
|
The production site is deployed automatically by Gitea Actions whenever a
|
|
commit reaches the `main` branch.
|
|
|
|
## How the site works
|
|
|
|
The publishing pipeline has four stages:
|
|
|
|
1. A post is written as Markdown in `src/content/posts/`.
|
|
2. Astro reads and validates its frontmatter using `src/content.config.ts`.
|
|
3. `npm run build` renders all non-draft posts into static HTML in `dist/`.
|
|
4. Gitea Actions copies `dist/` into the website directory on the VPS.
|
|
|
|
The VPS serves ordinary HTML, CSS, and image files. Astro and Node.js are only
|
|
needed while developing or building the site; they do not run as part of the
|
|
production website.
|
|
|
|
```text
|
|
src/content/posts/my-post.md
|
|
|
|
|
| npm run build
|
|
v
|
|
dist/posts/my-post/index.html
|
|
|
|
|
| Gitea Actions and rsync
|
|
v
|
|
/home/cab/docker/websites/blog-src/
|
|
|
|
|
| web server
|
|
v
|
|
https://the-blog-domain.example/posts/my-post/
|
|
```
|
|
|
|
## Repository structure
|
|
|
|
```text
|
|
.
|
|
├── .gitea/workflows/pipeline.yml Automatic build and deployment workflow
|
|
├── assets/ Images, icons, and the web manifest
|
|
├── posts/template.md Template used when starting a post
|
|
├── src/
|
|
│ ├── content/posts/ Published posts and drafts in Markdown
|
|
│ ├── content.config.ts Frontmatter schema and content loader
|
|
│ ├── layouts/BaseLayout.astro Shared HTML document, fonts, and icons
|
|
│ └── pages/
|
|
│ ├── index.astro Homepage and date ordering
|
|
│ └── posts/[slug].astro Generated page for each published post
|
|
├── astro.config.mjs Astro static-output configuration
|
|
├── package.json Commands and development dependencies
|
|
├── package-lock.json Exact dependency versions used by CI
|
|
├── styles.css Site-wide styles
|
|
└── tsconfig.json TypeScript settings used by Astro
|
|
```
|
|
|
|
### Content and source files
|
|
|
|
- `src/content/posts/` contains the actual blog posts. Each Markdown file has
|
|
frontmatter followed by the post body. The filename is for organization; the
|
|
public URL comes from the `slug` field.
|
|
- `posts/template.md` is the starting point for new posts. It is outside the
|
|
content collection, so it can never accidentally become a page.
|
|
- `src/content.config.ts` defines the required post metadata. The build fails
|
|
when metadata is invalid instead of silently publishing a broken page.
|
|
- `src/pages/index.astro` loads non-draft posts and sorts them by date, newest
|
|
first. No post list or manual ordering file needs to be maintained.
|
|
- `src/pages/posts/[slug].astro` creates one static route for every non-draft
|
|
post. A post with the slug `my-post` becomes `/posts/my-post/`.
|
|
- `src/layouts/BaseLayout.astro` contains shared document markup, metadata,
|
|
font loading, favicons, and the global stylesheet import.
|
|
- `assets/` is Astro's public directory. Its contents are copied to the root of
|
|
`dist/`, so `assets/cabby.jpg` is available as `/cabby.jpg`.
|
|
|
|
### Generated directories
|
|
|
|
Do not edit or commit these directories:
|
|
|
|
- `node_modules/` contains packages installed by npm.
|
|
- `.astro/` contains generated content indexes and type information.
|
|
- `dist/` is the complete production website generated by `npm run build`.
|
|
Deployment copies it to the VPS. It can be deleted and rebuilt at any time.
|
|
|
|
These directories are listed in `.gitignore`.
|
|
|
|
## Requirements
|
|
|
|
Local development requires:
|
|
|
|
- Node.js 22.12 or newer
|
|
- npm 9.6 or newer
|
|
- Git
|
|
|
|
Check the installed versions:
|
|
|
|
```sh
|
|
node --version
|
|
npm --version
|
|
git --version
|
|
```
|
|
|
|
## Initial setup
|
|
|
|
Clone the repository and install the exact locked dependencies:
|
|
|
|
```sh
|
|
git clone <repository-url>
|
|
cd blogs
|
|
npm ci
|
|
```
|
|
|
|
Use `npm ci` for normal work and CI because it reproduces
|
|
`package-lock.json` exactly. Use `npm install` when intentionally adding or
|
|
updating a dependency.
|
|
|
|
## Development commands
|
|
|
|
Start the local development server:
|
|
|
|
```sh
|
|
npm run dev
|
|
```
|
|
|
|
Astro prints the local URL, normally `http://localhost:4321`. The development
|
|
server watches source and Markdown files and refreshes the site as they change.
|
|
|
|
Validate the Astro source and content without producing a deployment build:
|
|
|
|
```sh
|
|
npm run check
|
|
```
|
|
|
|
Create a production build:
|
|
|
|
```sh
|
|
npm run build
|
|
```
|
|
|
|
This runs `astro check` first and then generates the static site in `dist/`.
|
|
Always run it before opening a pull request.
|
|
|
|
Preview the generated production build:
|
|
|
|
```sh
|
|
npm run preview
|
|
```
|
|
|
|
The preview command serves `dist/` locally. It is useful for checking the exact
|
|
files that would be deployed.
|
|
|
|
## Post frontmatter
|
|
|
|
Every file in `src/content/posts/` begins with YAML frontmatter:
|
|
|
|
```yaml
|
|
---
|
|
title: Post title
|
|
date: "2026-06-10"
|
|
excerpt: A short description shown on the homepage.
|
|
slug: post-title
|
|
draft: true
|
|
---
|
|
```
|
|
|
|
### Frontmatter fields
|
|
|
|
- `title` is required. It is displayed on the homepage, post page, and browser
|
|
tab.
|
|
- `date` is required. Use the ISO format `YYYY-MM-DD`, quoted as a string. The
|
|
homepage sorts posts by this value from newest to oldest.
|
|
- `excerpt` may be an empty string. A non-empty excerpt appears on the homepage
|
|
and is used as the page description.
|
|
- `slug` is required and becomes the public URL. It may contain lowercase
|
|
letters, numbers, and single hyphens between words.
|
|
|
|
```text
|
|
slug: the-weight-of-wanting
|
|
URL: /posts/the-weight-of-wanting/
|
|
```
|
|
|
|
Keep slugs unique. Do not change a published slug without intentionally
|
|
changing its URL and accepting that old links will stop working.
|
|
|
|
- `draft` should be `true` while a post is excluded from the homepage and
|
|
generated routes. Set it to `false` when the post is ready to publish.
|
|
|
|
Branches other than `main` do not deploy, so it is safe to set `draft: false`
|
|
on a post branch while testing the final homepage and URL locally.
|
|
|
|
## Markdown formatting
|
|
|
|
Post bodies use standard Markdown.
|
|
|
|
### Paragraphs
|
|
|
|
Separate paragraphs with a blank line:
|
|
|
|
```md
|
|
This is one paragraph.
|
|
|
|
This is another paragraph.
|
|
```
|
|
|
|
A single newline within normal prose does not create a visible line break.
|
|
This allows source text to wrap without changing the rendered paragraph.
|
|
|
|
### Headings
|
|
|
|
```md
|
|
# Main heading
|
|
## Section heading
|
|
### Smaller heading
|
|
```
|
|
|
|
The post title already appears above the body, so body sections should normally
|
|
begin with `##`.
|
|
|
|
### Emphasis
|
|
|
|
```md
|
|
**bold text**
|
|
*italic text*
|
|
***bold and italic text***
|
|
```
|
|
|
|
Do not put spaces inside the markers. Write `**bold**`, not `** bold **`.
|
|
|
|
### Links and quotations
|
|
|
|
```md
|
|
[Link text](https://example.com)
|
|
|
|
> This is a blockquote.
|
|
```
|
|
|
|
### Lists
|
|
|
|
```md
|
|
- First item
|
|
- Second item
|
|
|
|
1. First step
|
|
2. Second step
|
|
```
|
|
|
|
Leave a blank line before a list when it follows a paragraph.
|
|
|
|
### Code
|
|
|
|
Inline code uses backticks:
|
|
|
|
```md
|
|
Run `npm run build` before merging.
|
|
```
|
|
|
|
Use a fenced block for multiple lines:
|
|
|
|
````md
|
|
```js
|
|
const message = "Hello";
|
|
console.log(message);
|
|
```
|
|
````
|
|
|
|
### Horizontal rules
|
|
|
|
```md
|
|
---
|
|
```
|
|
|
|
Place a blank line before and after a horizontal rule. The first `---` at the
|
|
top of a post belongs to YAML frontmatter and has a different purpose.
|
|
|
|
### Poetry and intentional line breaks
|
|
|
|
Normal Markdown joins single source newlines into a paragraph. End a line with
|
|
a backslash when the rendered text must break at that exact point:
|
|
|
|
```md
|
|
I lost something irreplaceable,\
|
|
and I was the one who ended it.
|
|
```
|
|
|
|
Use a blank line between stanzas:
|
|
|
|
```md
|
|
The first line,\
|
|
the second line.
|
|
|
|
A new stanza begins here.
|
|
```
|
|
|
|
This keeps normal prose correctly formatted while still supporting poetry.
|
|
|
|
## Creating and publishing a new post
|
|
|
|
The standard publishing workflow uses one Git branch and one Gitea pull request
|
|
per post. Branches use the naming convention `post/<title-slug>`.
|
|
|
|
Merging the pull request into `main` publishes the post automatically.
|
|
|
|
### 1. Update local `main`
|
|
|
|
Start from a clean, current `main` branch:
|
|
|
|
```sh
|
|
git switch main
|
|
git pull --ff-only origin main
|
|
git status
|
|
```
|
|
|
|
Do not start a post branch while unrelated uncommitted changes are present.
|
|
|
|
### 2. Create the post branch
|
|
|
|
Choose a short lowercase slug and create the branch:
|
|
|
|
```sh
|
|
git switch -c post/my-new-post
|
|
```
|
|
|
|
Examples:
|
|
|
|
```text
|
|
post/my-new-post
|
|
post/notes-on-community
|
|
post/the-cost-of-convenience
|
|
```
|
|
|
|
### 3. Copy the post template
|
|
|
|
Copy the template into the content collection:
|
|
|
|
```sh
|
|
cp posts/template.md src/content/posts/my-new-post.md
|
|
```
|
|
|
|
The Markdown filename should normally match the slug because that makes the
|
|
repository easier to navigate, even though Astro does not require it.
|
|
|
|
Edit the frontmatter and write the post:
|
|
|
|
```yaml
|
|
---
|
|
title: My New Post
|
|
date: "2026-06-10"
|
|
excerpt: A concise summary for the homepage.
|
|
slug: my-new-post
|
|
draft: true
|
|
---
|
|
```
|
|
|
|
### 4. Preview while writing
|
|
|
|
Install dependencies if necessary and start Astro:
|
|
|
|
```sh
|
|
npm ci
|
|
npm run dev
|
|
```
|
|
|
|
With `draft: true`, the post is intentionally absent from the website. Set
|
|
`draft: false` when you want to test the homepage card and generated route.
|
|
This does not deploy anything while working on a non-`main` branch.
|
|
|
|
Check:
|
|
|
|
- title, date, and excerpt;
|
|
- homepage ordering;
|
|
- `/posts/my-new-post/`;
|
|
- paragraphs and poetic line breaks;
|
|
- links, lists, quotations, and code;
|
|
- desktop and narrow-screen layouts.
|
|
|
|
### 5. Validate the production build
|
|
|
|
Before committing:
|
|
|
|
```sh
|
|
npm run build
|
|
npm run preview
|
|
```
|
|
|
|
The build must complete without errors. Confirm that the post appears in
|
|
`dist/posts/my-new-post/index.html` when `draft` is `false`.
|
|
|
|
### 6. Commit and push the branch
|
|
|
|
Review what will be committed:
|
|
|
|
```sh
|
|
git status
|
|
git diff
|
|
```
|
|
|
|
Then commit and push:
|
|
|
|
```sh
|
|
git add src/content/posts/my-new-post.md
|
|
git commit -m "Add My New Post"
|
|
git push -u origin post/my-new-post
|
|
```
|
|
|
|
If the post includes new images, add those files from `assets/` to the same
|
|
commit.
|
|
|
|
### 7. Open a Gitea pull request
|
|
|
|
In Gitea:
|
|
|
|
1. Open the repository.
|
|
2. Create a pull request from `post/my-new-post` into `main`.
|
|
3. Review the changed Markdown and confirm `draft: false`.
|
|
4. Confirm the branch builds locally and any configured checks pass.
|
|
5. Merge the pull request.
|
|
|
|
The merge pushes a new commit to `main`. That push triggers the deployment
|
|
workflow. No `[deploy]` text or manual deployment command is required.
|
|
|
|
### 8. Confirm deployment
|
|
|
|
Open the repository's **Actions** page in Gitea and inspect the deployment job.
|
|
It should complete these steps:
|
|
|
|
1. check out the merged commit;
|
|
2. run `npm ci`;
|
|
3. run `npm run build`;
|
|
4. mirror `dist/` into `/deploy/websites/blog-src/`.
|
|
|
|
After the job succeeds, open the homepage and the new post URL in a browser.
|
|
|
|
### 9. Clean up the branch
|
|
|
|
Gitea may offer to delete the source branch after merging. If it does not,
|
|
clean it up manually:
|
|
|
|
```sh
|
|
git switch main
|
|
git pull --ff-only origin main
|
|
git branch -d post/my-new-post
|
|
git push origin --delete post/my-new-post
|
|
```
|
|
|
|
Deleting the branch does not delete the post because the merged post is now
|
|
part of `main`.
|
|
|
|
## Updating a pull request
|
|
|
|
Continue editing on the same post branch:
|
|
|
|
```sh
|
|
git switch post/my-new-post
|
|
```
|
|
|
|
Commit and push additional changes:
|
|
|
|
```sh
|
|
git add src/content/posts/my-new-post.md
|
|
git commit -m "Revise My New Post"
|
|
git push
|
|
```
|
|
|
|
Gitea adds those commits to the existing pull request automatically.
|
|
|
|
## Updating a branch from `main`
|
|
|
|
If `main` changes while a post is being written, update the branch before
|
|
merging:
|
|
|
|
```sh
|
|
git switch main
|
|
git pull --ff-only origin main
|
|
git switch post/my-new-post
|
|
git merge main
|
|
```
|
|
|
|
Resolve any conflicts, then validate and push:
|
|
|
|
```sh
|
|
npm run build
|
|
git push
|
|
```
|
|
|
|
This repository uses a normal merge in the documented workflow because it is
|
|
easy to understand and does not rewrite a branch that may already be under
|
|
review.
|
|
|
|
## Editing an existing post
|
|
|
|
Use the same branch and pull-request process:
|
|
|
|
```sh
|
|
git switch main
|
|
git pull --ff-only origin main
|
|
git switch -c post/revise-my-post
|
|
```
|
|
|
|
Edit the existing file, run `npm run build`, commit it, push the branch, and
|
|
open a pull request into `main`.
|
|
|
|
Avoid changing the slug unless a URL change is intentional.
|
|
|
|
## Removing a post
|
|
|
|
Choose one of two approaches on a branch:
|
|
|
|
- Set `draft: true` to keep the Markdown in Git but remove it from the website.
|
|
- Delete the Markdown file to remove both the source and generated page.
|
|
|
|
After the change is merged, `rsync --delete` removes the old generated route
|
|
from the deployment directory.
|
|
|
|
## Automatic deployment
|
|
|
|
The workflow is defined in `.gitea/workflows/pipeline.yml` and runs on every
|
|
push to `main`.
|
|
|
|
```text
|
|
Pull request merged
|
|
|
|
|
v
|
|
Push to main
|
|
|
|
|
v
|
|
Gitea creates an Actions job
|
|
|
|
|
v
|
|
alpine-rsync runner starts a temporary job container
|
|
|
|
|
v
|
|
npm ci
|
|
|
|
|
v
|
|
npm run build
|
|
|
|
|
v
|
|
rsync dist/ /deploy/websites/blog-src/
|
|
```
|
|
|
|
### Why deployment does not use SSH
|
|
|
|
Gitea, the Actions runner, and the website are hosted on the same VPS. Runner
|
|
job containers receive this bind mount:
|
|
|
|
```text
|
|
VPS host: /home/cab/docker/websites
|
|
Job container: /deploy/websites
|
|
```
|
|
|
|
Writing to this path in the job container:
|
|
|
|
```text
|
|
/deploy/websites/blog-src
|
|
```
|
|
|
|
therefore writes directly to this path on the VPS:
|
|
|
|
```text
|
|
/home/cab/docker/websites/blog-src
|
|
```
|
|
|
|
SSH would send files over the network only to return to the same server. The
|
|
bind mount is simpler and requires no deployment key or Gitea secret.
|
|
|
|
### What the deployment command does
|
|
|
|
The workflow runs:
|
|
|
|
```sh
|
|
mkdir -p /deploy/websites/blog-src
|
|
rsync -r --delete dist/ /deploy/websites/blog-src/
|
|
```
|
|
|
|
The trailing slash on `dist/` means "copy the contents of `dist`." Without it,
|
|
the destination could contain an unwanted nested `dist` directory.
|
|
|
|
`--delete` makes the production directory exactly mirror the current build.
|
|
When a route or asset is removed from the repository, its old generated file is
|
|
also removed from production.
|
|
|
|
The destination must contain only generated website output. Do not place
|
|
uploads, databases, or manually maintained files there because `--delete` will
|
|
remove anything not present in `dist/`.
|
|
|
|
### Runner requirements
|
|
|
|
The Gitea runner must:
|
|
|
|
- provide the `alpine-rsync` label;
|
|
- use an image containing Node.js 22.12 or newer, npm, Bash, and rsync;
|
|
- mount `/home/cab/docker/websites` at `/deploy/websites`;
|
|
- have write permission for `/deploy/websites/blog-src`;
|
|
- have network access to Gitea and the npm registry.
|
|
|
|
The web server must read
|
|
`/home/cab/docker/websites/blog-src` as its document root, either directly or
|
|
through its own read-only bind mount.
|
|
|
|
## Manual deployment
|
|
|
|
Normal deployments should always go through a pull request and Gitea Actions.
|
|
For troubleshooting on the VPS, the equivalent operations are:
|
|
|
|
```sh
|
|
npm ci
|
|
npm run build
|
|
mkdir -p /home/cab/docker/websites/blog-src
|
|
rsync -r --delete dist/ /home/cab/docker/websites/blog-src/
|
|
```
|
|
|
|
Only run this from a checked-out copy of the repository on the VPS. A later
|
|
successful Actions run will replace the destination with the build from
|
|
`main`.
|
|
|
|
## Troubleshooting
|
|
|
|
### A post does not appear
|
|
|
|
Check:
|
|
|
|
- the file is inside `src/content/posts/`;
|
|
- `draft` is `false`;
|
|
- the date and other frontmatter fields are valid;
|
|
- the build completed successfully;
|
|
- the pull request was merged into `main`;
|
|
- the deployment job succeeded.
|
|
|
|
### The build reports a content error
|
|
|
|
Run:
|
|
|
|
```sh
|
|
npm run check
|
|
```
|
|
|
|
Compare the post frontmatter with `posts/template.md`. Common causes include:
|
|
|
|
- a missing required field;
|
|
- an unquoted or malformed date;
|
|
- uppercase letters, spaces, or repeated separators in the slug;
|
|
- a non-boolean value for `draft`;
|
|
- invalid YAML punctuation.
|
|
|
|
### The post is in the wrong homepage position
|
|
|
|
The homepage sorts the `date` field newest first. Correct the frontmatter date;
|
|
do not manually reorder files or rename them to control ordering.
|
|
|
|
### The URL is wrong or returns 404
|
|
|
|
Confirm the `slug` and look for:
|
|
|
|
```text
|
|
dist/posts/<slug>/index.html
|
|
```
|
|
|
|
If the slug changed after publication, the old URL is not redirected
|
|
automatically.
|
|
|
|
### A Gitea job never starts
|
|
|
|
Confirm:
|
|
|
|
- Gitea Actions is enabled;
|
|
- the runner is online;
|
|
- the runner advertises the `alpine-rsync` label;
|
|
- the workflow exists on `main`;
|
|
- the event is a push to `main`.
|
|
|
|
### Deployment fails with `Permission denied`
|
|
|
|
The job container's user cannot write through the bind mount. On the VPS,
|
|
inspect the destination numerically:
|
|
|
|
```sh
|
|
ls -ldn /home/cab/docker/websites
|
|
ls -ldn /home/cab/docker/websites/blog-src
|
|
```
|
|
|
|
Set ownership or an ACL deliberately for the runner's job user. Do not solve
|
|
the problem by making the directory world-writable.
|
|
|
|
### Deployment succeeds but the site is stale
|
|
|
|
Check:
|
|
|
|
- the Actions log shows the expected merged commit;
|
|
- `/home/cab/docker/websites/blog-src/index.html` has a current timestamp;
|
|
- the web server mounts that exact directory;
|
|
- a reverse proxy or browser cache is not serving an older response.
|
|
|
|
### Files disappear from the deployment directory
|
|
|
|
This is expected for files that are not in `dist/` because deployment uses
|
|
`rsync --delete`. Keep all public static files in `assets/` so Astro includes
|
|
them in every build.
|
|
|
|
### Dependencies differ locally and in CI
|
|
|
|
Use:
|
|
|
|
```sh
|
|
rm -rf node_modules
|
|
npm ci
|
|
```
|
|
|
|
Commit `package-lock.json` whenever dependencies are intentionally changed.
|
|
|
|
## Production safety
|
|
|
|
- Protect `main` because every push to it deploys immediately.
|
|
- Merge reviewed pull requests rather than committing posts directly to
|
|
`main`.
|
|
- Do not expose this runner to untrusted repositories. Access to the Docker
|
|
socket and deployment bind mount is highly privileged.
|
|
- Keep each repository in a separate deployment directory.
|
|
- Keep generated production output separate from persistent application data.
|
|
- Inspect failed or unexpected jobs in Gitea's **Actions** page before retrying.
|