codex rebase
Deploy on push / deploy (push) Successful in 16s

This commit is contained in:
2026-06-10 22:46:25 -07:00
parent bbc2dc6b58
commit 3c2dd93d0b
25 changed files with 7225 additions and 281 deletions
+11 -20
View File
@@ -2,34 +2,25 @@ name: Deploy on push
on: on:
push: push:
branches: [ "main" ] branches:
- main
jobs: jobs:
deploy: deploy:
runs-on: alpine-rsync runs-on: alpine-rsync
if: contains(gitea.event.head_commit.message, '[deploy]')
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup SSH - name: Install dependencies
env: run: npm ci
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
mkdir -p ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -p 22 "rchou.net" >> ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
- name: Rsync to server - name: Build site
env: run: npm run build
SSH_TARGET_DIR: /home/cab/docker/websites/blog-src
RSYNC_SOURCE: . - name: Deploy site
run: | run: |
rsync -rz --delete \ set -eu
--no-times --no-perms --no-owner --no-group \ mkdir -p /deploy/websites/blog-src
--omit-dir-times --mkpath \ rsync -r --delete dist/ /deploy/websites/blog-src/
-e "ssh -i ~/.ssh/id_ed25519 -p 22" \
"$RSYNC_SOURCE"/ "deploy@rchou.net:${SSH_TARGET_DIR}/"
+3
View File
@@ -1 +1,4 @@
.DS_Store .DS_Store
node_modules/
dist/
.astro/
+722
View File
@@ -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.
BIN
View File
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "astro/config";
export default defineConfig({
output: "static",
publicDir: "assets",
trailingSlash: "always",
});
-31
View File
@@ -1,31 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ryan's Blog</title>
<link rel="stylesheet" href="styles.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400;1,500;1,600;1,700;1,800&display=swap" rel="stylesheet">
<link rel="apple-touch-icon" sizes="180x180" href="./assets/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="./assets/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="./assets/favicon-16x16.png">
<link rel="manifest" href="./assets/site.webmanifest">
</head>
<body>
<main-header>
<h1>My Blog</h1>
<p>A collection of my posts.</p>
</main-header>
<main>
<section id="post-list">
<!-- Blog posts will be dynamically added here -->
</section>
</main>
<footer>
<p>© 2026 Ryan Chou</p>
</footer>
<script src="script.js"></script>
</body>
</html>
+6154
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "ryans-blog",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"check": "astro check"
},
"engines": {
"node": ">=22.12.0"
},
"devDependencies": {
"@astrojs/check": "^0.9.4",
"astro": "^6.4.6",
"typescript": "^5.9.3"
}
}
-31
View File
@@ -1,31 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Yap Sesh</title>
<link rel="stylesheet" href="styles.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400;1,500;1,600;1,700;1,800&display=swap" rel="stylesheet">
<link rel="apple-touch-icon" sizes="180x180" href="./assets/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="./assets/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="./assets/favicon-16x16.png">
<link rel="manifest" href="./assets/site.webmanifest">
</head>
<body>
<header>
<a href="index.html">
<img src="assets/cabby.jpg" alt="Home" class="home-button">
</a>
</header>
<main id="content">
<article id="post"></article>
</main>
<footer>
<p>© 2025 Ryan Chou</p>
</footer>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="script.js"></script>
</body>
</html>
+25 -14
View File
@@ -1,16 +1,27 @@
#### Title ---
### Excerpt title: Post title
## Date date: "2026-06-10"
excerpt: A short description shown on the homepage.
slug: post-title
draft: true
--- ---
yap Write normal prose as a paragraph. A single newline inside a paragraph is treated
** bold ** as normal wrapping, so use a blank line when you want a new paragraph.
* italic *
> quote Use standard Markdown formatting:
`code`
- list - **bold**
- list - *italic*
1. one - [link text](https://example.com)
2. two - `inline code`
---
[link](link.com) > Blockquotes begin with a greater-than sign.
1. Ordered list item
2. Another ordered list item
Use a trailing backslash when a poetic line needs an explicit break:\
the next source line will appear directly below it.
A blank line starts a new stanza.
-92
View File
@@ -1,92 +0,0 @@
document.addEventListener("DOMContentLoaded", function () {
let lastScrollY = window.scrollY;
const header = document.querySelector("header");
window.addEventListener("scroll", () => {
if (window.scrollY > lastScrollY) {
// Scrolling down
header.classList.add("hide-header");
} else {
// Scrolling up
header.classList.remove("hide-header");
}
lastScrollY = window.scrollY;
});
// Dynamically load posts on the home page
if (document.getElementById("post-list")) {
const posts = [
{
title: "The Gym Is Where My Labor Still Belongs to Me",
date: "Jun 09, 2025",
excerpt: "",
file: "post8.md",
},
{
title: "The Intimacy of Never Speaking Again",
date: "Oct 22, 2025",
excerpt: "",
file: "post7.md",
},
{
title: "The Weight of Wanting",
date: "Oct 14, 2025",
excerpt: "",
file: "post6.md",
},
{
title: "Home is a Moving Target.",
date: "Sep 18, 2025",
excerpt:
"The first thing I notice when I land in Taipei isnt the humidity, its the English.",
file: "post4.md",
},
{
title: "Hemming",
date: "Jul 28, 2025",
excerpt: "Everything I create lives on screens",
file: "post3.md",
},
{
title: "Scars, Sadness, and Soulmates",
date: "Jan 26, 2025",
excerpt: "Healing isn't a prerequisite for love, being human is.",
file: "post2.md",
},
{
title: "Meaningful Action",
date: "Jan 23, 2025",
excerpt: "wow this is barely comprehensible",
file: "post1.md",
},
];
const postList = document.getElementById("post-list");
posts.forEach((post) => {
const postElement = document.createElement("div");
postElement.classList.add("post-card");
postElement.innerHTML = `
<h4>${post.title}</h4>
<h3>${post.date}</h3>
<p>${post.excerpt}</p>
`;
postElement.addEventListener("click", () => {
window.location.href = `post.html?file=posts/${post.file}`;
});
postList.appendChild(postElement);
});
}
// Load specific post content on the post page
const urlParams = new URLSearchParams(window.location.search);
const postFile = urlParams.get("file");
if (postFile) {
fetch(postFile)
.then((response) => response.text())
.then((text) => {
document.getElementById("post").innerHTML = marked.parse(text);
});
}
});
+16
View File
@@ -0,0 +1,16 @@
import { defineCollection } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";
const posts = defineCollection({
loader: glob({ base: "./src/content/posts", pattern: "**/*.md" }),
schema: z.object({
title: z.string(),
date: z.coerce.date(),
excerpt: z.string().default(""),
slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
draft: z.boolean().default(false),
}),
});
export const collections = { posts };
@@ -1,6 +1,9 @@
#### Meaningful Action ---
### wow this is barely comprehensible title: Meaningful Action
## Jan 23, 2025 date: "2025-01-23"
excerpt: wow this is barely comprehensible
slug: meaningful-action
draft: false
--- ---
[Mirror neurons](https://www.wikiwand.com/en/articles/Mirror_neuron). Their entire role is to evoke the emotional response that we perceive in another being. They are considered a foundation for empathetic understanding. Your brain is, in essence, simulating the other persons experience. [Mirror neurons](https://www.wikiwand.com/en/articles/Mirror_neuron). Their entire role is to evoke the emotional response that we perceive in another being. They are considered a foundation for empathetic understanding. Your brain is, in essence, simulating the other persons experience.
@@ -1,6 +1,9 @@
#### Scars, Sadness, and Soulmates ---
### Healing isn't a prerequisite for love, being human is. title: Scars, Sadness, and Soulmates
## Jan 26, 2025 date: "2025-01-26"
excerpt: "Healing isn't a prerequisite for love, being human is."
slug: scars-sadness-and-soulmates
draft: false
--- ---
If youve ever felt like youre not “healed enough” or “whole enough” to love or be loved, you arent alone. Culture has this misconception in which you shouldnt date unless youre perfectly stitched together, free of scars, immune of the bouts of sadness that makes us human. If youve ever felt like youre not “healed enough” or “whole enough” to love or be loved, you arent alone. Culture has this misconception in which you shouldnt date unless youre perfectly stitched together, free of scars, immune of the bouts of sadness that makes us human.
@@ -1,6 +1,9 @@
#### Hemming the Divide ---
### Everything I create lives on a screen. title: Hemming the Divide
## Jul 28, 2025 date: "2025-07-28"
excerpt: Everything I create lives on a screen.
slug: hemming-the-divide
draft: false
--- ---
Growing up as a CS major in a hyper digitized world, Ive often found myself caught in a quiet discomfort,, a creeping sense of detachment from the physical world. Everything I create lives on screens. It runs in the cloud, floats in GitHub repos, is parsed, compiled, and rendered in pixels. Its a strange dissonance: Im building things, but I never touch them. Growing up as a CS major in a hyper digitized world, Ive often found myself caught in a quiet discomfort,, a creeping sense of detachment from the physical world. Everything I create lives on screens. It runs in the cloud, floats in GitHub repos, is parsed, compiled, and rendered in pixels. Its a strange dissonance: Im building things, but I never touch them.
@@ -1,5 +1,9 @@
#### Home is a Moving Target ---
## Sep 18, 2025 title: Home is a Moving Target
date: "2025-09-18"
excerpt: The first thing I notice when I land in Taipei isnt the humidity, its the English.
slug: home-is-a-moving-target
draft: false
--- ---
The first thing I notice when I land in Taipei isnt the humidity, its the English. The first thing I notice when I land in Taipei isnt the humidity, its the English.
@@ -1,6 +1,11 @@
#### Tipping in America
## Sep 25, 2025
--- ---
title: Tipping in America
date: "2025-09-25"
excerpt: A guide to tipping expectations in the United States.
slug: tipping-in-america
draft: true
---
# 1. Why tipping exists in U.S. restaurants # 1. Why tipping exists in U.S. restaurants
### Sub-minimum wage: ### Sub-minimum wage:
In most U.S. states, servers are legally allowed to be paid below the normal minimum wage (the federal "tipped minimum" is only $2.13/hr). The assumption is that tips will bring them up to at least regular minimum wage. In most U.S. states, servers are legally allowed to be paid below the normal minimum wage (the federal "tipped minimum" is only $2.13/hr). The assumption is that tips will bring them up to at least regular minimum wage.
@@ -1,6 +1,11 @@
#### The Weight of Wanting
## Oct 14, 2025
--- ---
title: The Weight of Wanting
date: "2025-10-14"
excerpt: ""
slug: the-weight-of-wanting
draft: false
---
At the gym, I am not a man or a woman or a queer body. I am a collection of movements. A set of repetitions. A calculus of effort and symmetry. The mirrors line the walls like an endless chorus of judges, and I study myself through them: the slope of a shoulder, the softness of a face that never quite leans masculine enough, the hint of fat that clings with memory. I used to be fat. Not in the nostalgic, “glow up” sense, but in the way that teaches you to apologize for existing in space. Now I am less fat. But my body remembers. The roundness lingers in my face, the old shame in my posture. Its a strange inheritance, to lose weight, but not escape its ghost. At the gym, I am not a man or a woman or a queer body. I am a collection of movements. A set of repetitions. A calculus of effort and symmetry. The mirrors line the walls like an endless chorus of judges, and I study myself through them: the slope of a shoulder, the softness of a face that never quite leans masculine enough, the hint of fat that clings with memory. I used to be fat. Not in the nostalgic, “glow up” sense, but in the way that teaches you to apologize for existing in space. Now I am less fat. But my body remembers. The roundness lingers in my face, the old shame in my posture. Its a strange inheritance, to lose weight, but not escape its ghost.
The gym is supposed to be a temple of transformation. For me, its also a confessional booth. Every rep feels like repentance, a small absolution for the sin of softness, of effeminacy, of wanting too much to be seen. The iron never lies, but it doesnt forgive either. When the weight rises above my chest, I imagine Im lifting something more abstract: the part of me that still flinches when someone calls me “fag.” The part that wants to be desired but not categorized. The part that keeps trying to reconcile masculinity and queerness in a world that demands they be kept apart. The gym is supposed to be a temple of transformation. For me, its also a confessional booth. Every rep feels like repentance, a small absolution for the sin of softness, of effeminacy, of wanting too much to be seen. The iron never lies, but it doesnt forgive either. When the weight rises above my chest, I imagine Im lifting something more abstract: the part of me that still flinches when someone calls me “fag.” The part that wants to be desired but not categorized. The part that keeps trying to reconcile masculinity and queerness in a world that demands they be kept apart.
+10 -3
View File
@@ -1,6 +1,11 @@
#### The Intimacy of Never Speaking Again
## Oct 22, 2025
--- ---
title: The Intimacy of Never Speaking Again
date: "2025-10-22"
excerpt: ""
slug: the-intimacy-of-never-speaking-again
draft: false
---
There is a certain intimacy in never speaking again. Not the soft kind people romanticize in love stories, but a quieter, stranger intimacy. The kind that comes only after being fully seen. Because intimacy isnt just late night conversations or bodies curled on a couch. Its the rare moment two people allow themselves to be known without performance. Its layered, deliberate. Its to be truly, dangerously known. There is a certain intimacy in never speaking again. Not the soft kind people romanticize in love stories, but a quieter, stranger intimacy. The kind that comes only after being fully seen. Because intimacy isnt just late night conversations or bodies curled on a couch. Its the rare moment two people allow themselves to be known without performance. Its layered, deliberate. Its to be truly, dangerously known.
People love the idea of “right person, wrong time,” but fate wasnt the author here. There was no cosmic theft and no tragic inevitability. I made the choice. I ended it. I didnt give you a say, or a vote, or even the dignity of mutual unraveling. I walked away first. Not because I didnt care, but because I cared more than I was capable of holding. You were the kind of person life doesnt hand you twice. Not perfect. Not destined. Just unrepeatable in a way that will follow me for the rest of my life. And that scared me. You took me back once. You pulled the thread of us through the tear I made and laid it, carefully, across the seam. That kind of grace is not the sort of thing you ask for a second time. So this silence is mine to keep. Not because it is merciful, or easy, or even right in any comforting way. But because I closed the door and I do not get to knock. Regret does not entitle me to return. The ending is my work. Carrying it is, too. People love the idea of “right person, wrong time,” but fate wasnt the author here. There was no cosmic theft and no tragic inevitability. I made the choice. I ended it. I didnt give you a say, or a vote, or even the dignity of mutual unraveling. I walked away first. Not because I didnt care, but because I cared more than I was capable of holding. You were the kind of person life doesnt hand you twice. Not perfect. Not destined. Just unrepeatable in a way that will follow me for the rest of my life. And that scared me. You took me back once. You pulled the thread of us through the tear I made and laid it, carefully, across the seam. That kind of grace is not the sort of thing you ask for a second time. So this silence is mine to keep. Not because it is merciful, or easy, or even right in any comforting way. But because I closed the door and I do not get to knock. Regret does not entitle me to return. The ending is my work. Carrying it is, too.
@@ -17,4 +22,6 @@ But I will know. And that knowing will stay, quiet as snowfall, heavy as stone.
We will not speak again. That is our last intimacy. Not closure. Not peace. We will not speak again. That is our last intimacy. Not closure. Not peace.
Just the echoing truth that <br> I lost something irreplaceable, <br> and I was the one who ended it. Just the echoing truth that\
I lost something irreplaceable,\
and I was the one who ended it.
@@ -1,7 +1,9 @@
#### The Gym Is Where My Labor Still Belongs to Me ---
title: The Gym Is Where My Labor Still Belongs to Me
## Jun 9, 2026 date: "2026-06-09"
excerpt: ""
slug: the-gym-is-where-my-labor-still-belongs-to-me
draft: false
--- ---
I dont think human beings hate labor. I dont think human beings hate labor.
+36
View File
@@ -0,0 +1,36 @@
---
import "../../styles.css";
interface Props {
title?: string;
description?: string;
}
const {
title = "Ryan's Blog",
description = "A collection of my posts.",
} = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<meta name="description" content={description} />
<title>{title}</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400;1,500;1,600;1,700;1,800&display=swap"
rel="stylesheet"
/>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
</head>
<body>
<slot />
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
---
import { getCollection } from "astro:content";
import BaseLayout from "../layouts/BaseLayout.astro";
const posts = (await getCollection("posts", ({ data }) => !data.draft)).sort(
(a, b) => b.data.date.getTime() - a.data.date.getTime(),
);
const formatDate = (date: Date) =>
new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
}).format(date);
---
<BaseLayout>
<header class="site-header">
<h1>My Blog</h1>
<p>A collection of my posts.</p>
</header>
<main>
<section class="post-list" aria-label="Blog posts">
{
posts.map((post) => (
<a class="post-card" href={`/posts/${post.data.slug}/`}>
<h2>{post.data.title}</h2>
<time datetime={post.data.date.toISOString()}>
{formatDate(post.data.date)}
</time>
{post.data.excerpt && <p>{post.data.excerpt}</p>}
</a>
))
}
</section>
</main>
<footer>
<p>&copy; {new Date().getFullYear()} Ryan Chou</p>
</footer>
</BaseLayout>
+47
View File
@@ -0,0 +1,47 @@
---
import { getCollection, render } from "astro:content";
import BaseLayout from "../../layouts/BaseLayout.astro";
export async function getStaticPaths() {
const posts = await getCollection("posts", ({ data }) => !data.draft);
return posts.map((post) => ({
params: { slug: post.data.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
const formattedDate = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
}).format(post.data.date);
---
<BaseLayout
title={`${post.data.title} | Ryan's Blog`}
description={post.data.excerpt || post.data.title}
>
<header class="post-header">
<a href="/" aria-label="Return to the homepage">
<img src="/cabby.jpg" alt="" class="home-button" />
</a>
</header>
<main>
<article class="post">
<div class="post-metadata">
<h1>{post.data.title}</h1>
{post.data.excerpt && <p class="post-excerpt">{post.data.excerpt}</p>}
<time datetime={post.data.date.toISOString()}>{formattedDate}</time>
</div>
<hr />
<Content />
</article>
</main>
<footer>
<p>&copy; {new Date().getFullYear()} Ryan Chou</p>
</footer>
</BaseLayout>
+87 -71
View File
@@ -1,4 +1,5 @@
html, body { html,
body {
font-family: 'EB Garamond', serif; font-family: 'EB Garamond', serif;
font-size: 20px; font-size: 20px;
line-height: 1.6; line-height: 1.6;
@@ -8,60 +9,42 @@ html, body {
color: #eee; /* Light text */ color: #eee; /* Light text */
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; min-height: 100%;
} }
/* Header */ .site-header {
main-header {
text-align: center; text-align: center;
padding: 10px; padding: 10px;
background: black; /* Dark header */ background: black;
position: sticky; position: sticky;
z-index: 100; z-index: 100;
top: 0; top: 0;
} }
header { .site-header h1,
.site-header p {
margin: 0;
}
.post-header {
text-align: center; text-align: center;
padding: 10px; padding: 10px;
background: black; /* Dark header */ background: black;
position: fixed; position: sticky;
top: 0; top: 0;
left: 0; z-index: 100;
width: 100%;
transition: transform 0.3s ease-in-out;
} }
.hide-header {
transform: translateY(-100%);
}
/* Main Content */
main { main {
max-width: 700px; max-width: 700px;
margin: 40px auto; margin: 40px auto;
padding: 20px; padding: 20px;
background: #121212; /* Dark content background */ background: #121212;
flex: 1; flex: 1;
width: calc(100% - 40px);
box-sizing: border-box;
} }
/* Buttons */
button {
font-size: 1.1rem;
padding: 10px 20px;
border: none;
border-radius: 5px;
background: #007BFF;
color: white;
cursor: pointer;
margin: 10px;
}
button:hover {
background: #0056b3;
}
/* Footer */
footer { footer {
text-align: center; text-align: center;
padding: 20px; padding: 20px;
@@ -69,35 +52,19 @@ footer {
background: #1e1e1e; background: #1e1e1e;
} }
h4 {
font-size: 38px;
margin: 0;
padding: 0;
padding-top: 45px;
}
h3 {
font-size: 22px;
color: grey;
margin: 0;
padding: 0;
}
h2 {
font-size: 14px;
color: grey;
margin: 0;
padding: 0;
}
p { p {
font-size: 22px; font-size: 22px;
line-height: 1.6; line-height: 1.6;
} }
a { a {
color: #eee; /* Light text */ color: #eee;
text-decoration: underline; text-decoration: underline;
} }
a:hover { a:hover {
text-decoration: none; text-decoration: none;
} }
blockquote { blockquote {
margin-top: 10px; margin-top: 10px;
margin-bottom: 10px; margin-bottom: 10px;
@@ -106,56 +73,105 @@ blockquote {
border-left: 3px solid #ccc; border-left: 3px solid #ccc;
} }
/* Home Page: Clickable Post Cards */
.post-card { .post-card {
display: block;
background: #1e1e1e; background: #1e1e1e;
padding: 20px; padding: 20px;
border-radius: 8px; border-radius: 8px;
margin-bottom: 20px; margin-bottom: 20px;
cursor: pointer;
transition: background 0.3s ease, transform 0.2s ease; transition: background 0.3s ease, transform 0.2s ease;
box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.4); box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.4);
text-decoration: none;
} }
.post-card p { .post-card p {
margin: 0; margin: 0;
} }
.post-card h4 { .post-card h2,
padding-top: 0; .post-card time,
.post-metadata {
text-align: center;
}
.post-card h2 {
font-size: 38px;
line-height: 1.2;
margin: 0;
}
.post-card time,
.post-metadata time {
display: block;
color: grey;
font-size: 18px;
} }
.post-card:hover { .post-card:hover {
background: #292929; background: #292929;
transform: translateY(-2px); transform: translateY(-2px);
text-decoration: none;
} }
.post-card h2 { .post-metadata h1 {
font-size: 38px;
line-height: 1.2;
margin: 0; margin: 0;
color: #ffffff;
} }
.post-card .post-date { .post-excerpt {
font-size: 1em;
color: #aaa;
}
.post-card .post-excerpt {
color: #ccc; color: #ccc;
margin: 0.25rem 0;
}
.post > hr {
margin: 1.5rem 0;
} }
/* Home button (circular image) */
.home-button { .home-button {
width: 50px; /* Adjust size as needed */ width: 50px;
height: 50px; height: 50px;
border-radius: 50%; /* Makes it circular */ border-radius: 50%;
object-fit: cover; /* Ensures image fills circle */ object-fit: cover;
border: 2px solid #ccc; /* Optional: adds a subtle border */ border: 2px solid #ccc;
cursor: pointer; cursor: pointer;
transition: transform 0.2s ease, opacity 0.2s ease; transition: transform 0.2s ease, opacity 0.2s ease;
} }
.home-button:hover { .home-button:hover {
transform: scale(1.1); /* Slight zoom effect */ transform: scale(1.1);
opacity: 0.8; opacity: 0.8;
} }
.post pre {
overflow-x: auto;
padding: 1rem;
background: #1e1e1e;
border-radius: 8px;
}
.post :not(pre) > code {
padding: 0.1em 0.3em;
background: #1e1e1e;
border-radius: 4px;
}
@media (max-width: 600px) {
html,
body {
font-size: 18px;
}
main {
margin: 20px auto;
}
p {
font-size: 20px;
}
.post-card h2,
.post-metadata h1 {
font-size: 32px;
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "astro/tsconfigs/strict"
}