@@ -0,0 +1,722 @@
|
||||
# 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.
|
||||
|
||||
Reference in New Issue
Block a user