Skip to content
13 changes: 13 additions & 0 deletions .zed/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"languages": {
"TypeScript": {
"tab_size": 2,
},
"TSX": {
"tab_size": 2,
},
"JavaScript": {
"tab_size": 2,
},
},
}
85 changes: 85 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# AGENTS.md

Personal blog and photo site built with SolidJS + Vite. No test suite - verification is lint and typecheck only.

## Setup

Requires Nix. Devshell provides Node 25, TypeScript, and `cog` (conventional commits linter).

## Commands

| Task | Command |
|------|---------|
| Lint | `npx eslint src plugin` |
| Fix lint errors | `npx eslint src plugin --fix` |
| Typecheck | `tsc` |

Run lint then typecheck before committing. Use

## Commit convention

CI enforces [Conventional Commits](https://www.conventionalcommits.org/) via `cocogitto` (`cog check`).

## Virtual modules (codegen)

The custom Vite plugin (`plugin/plugin.ts`) generates two virtual modules at build/dev time by scanning content directories:

- `virtual:data` — exports `photos: ImageInfo[]` and `posts: PostInfo[]`
- `virtual:photo` — type-only helper used in `info.tsx` files
- `virtual:post` — type-only helper used in `post.tsx` files

Type declarations live in `src/Virtual/`.

App can read this information from `src/Data/Database.ts`

## Content authoring

### Photos

Each photo lives in `src/Content/photos/<NNNN>-<slug>/`:
- `image.jpg` — source image
- `info.tsx` — metadata file, must call `photo({...})` from `virtual:photo`

Directory name prefix (`NNNN-`) controls sort order (descending). The clean slug (without the numeric prefix) becomes the photo `id` at runtime.

Example `info.tsx`:
```tsx
import photo from "virtual:photo";

photo({
name: "Title",
camera: "Sony α7c",
tags: ["digital", "architecture", "moscow"],
});
```

Valid tag values are defined in `src/Virtual/photo.d.ts`.

### Blog posts

Each post lives in `src/Content/blogs/<slug>/`:
- `post.tsx` — calls `post({...})` from `virtual:post` at the top, then exports a default SolidJS component

Posts with `status: "draft"` are visible in dev but excluded from production builds.

## Image processing

`sharp` handles image resizing. In dev, images are served on-the-fly via a Vite middleware at `/__images__/<id>/{full,preview}.jpg`. In production builds, Vite emits them as assets under `assets/images/<id>/`.

SVGs imported as components are processed by `vite-plugin-solid-svg` with SVGO (config in `svgo.config.mjs`), defined in `src/Icons` and imported as components in `src/Components/Devicons.ts`.

## Routing

Defined in `src/index.tsx`. Routes:
- `/` — Home (no header, no footer)
- `/photo` — photo gallery
- `/photo/:id` — single photo (no header)
- `/blog` — blog list
- `/about` — about page

## Tech stack quirks

- **SolidJS**, not React. Reactivity model is signals-based. Do not use React patterns (no `useState`, `useEffect` only when necessary).
- JSX is configured with `jsxImportSource: "solid-js"` — no React import needed.
- CSS Modules are used throughout (`*.module.css`). TypeScript support via `typescript-plugin-css-modules`.
- `strict: true` TypeScript. `noEmit: true` — `tsc` is typecheck only, not a build step.
12 changes: 12 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,16 @@ export default [
},
},
eslintPluginPrettierRecommended,
{
rules: {
"no-restricted-syntax": [
"error",
{
selector:
"CallExpression[callee.property.name='addEventListener'][arguments.0.value=/^(keydown|keyup|keypress)$/]",
message: "Do not add keyboard listeners addEventListener(). Use ",
},
],
},
},
];
76 changes: 44 additions & 32 deletions plugin/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fs from "node:fs/promises";
import fs_sync from "node:fs";
import path from "node:path";
import sharp, { Metadata } from "sharp";
import { Plugin } from "vite";
Expand Down Expand Up @@ -28,6 +29,9 @@ function isDev(): boolean {
return process.env.NODE_ENV === "development";
}

// Maps clean photo id (e.g. "electric-trinity") → source dir name (e.g. "0014-electric-trinity")
const idToDirName = new Map<string, string>();

function prepareImageForDev(id: string): ImagePrepareInfo {
const imageUrl = `"${path.join(
IMAGES_SERVE_PREFIX,
Expand Down Expand Up @@ -73,37 +77,44 @@ async function prepareImageForBuild(
}

async function makePhotos(ctx: PluginContext) {
const photos = [];
for await (const info of fs.glob(path.join(CONTENT_DIR, "*", "info.tsx"))) {
const contents = await fs.readFile(info, { encoding: "utf-8" });
const match = contents.match(/photo\(\{(?<description>[\S\s]*)\}\);/);
if (match === null) {
throw new Error(`Could not parse ${info}`);
}
const infoMap = match.groups!["description"];

const infoDir = path.dirname(info);
const id = path.basename(infoDir);

const src = path.join(infoDir, CONTENT_PHOTO);
const previewWidth = calcWidth(512, await sharp(src).metadata());

// imageOutPath and previewOutPath are
// code, strings need to be quoted
const { imageUrl, previewUrl } = isDev()
? prepareImageForDev(id)
: await prepareImageForBuild(ctx, src, id);

photos.push(`{
${infoMap}
id: "${id}",
previewWidth: ${previewWidth},
imageUrl: ${imageUrl},
previewUrl: ${previewUrl},
}`);
}

return photos;
const infoPaths = fs_sync.globSync(path.join(CONTENT_DIR, "*", "info.tsx"));
infoPaths.sort((a, b) =>
path
.basename(path.dirname(b))
.localeCompare(path.basename(path.dirname(a))),
);
return Promise.all(
infoPaths.map(async (info) => {
const contents = await fs.readFile(info, { encoding: "utf-8" });
const match = contents.match(/photo\(\{(?<description>[\S\s]*)\}\);/);
if (match === null) {
throw new Error(`Could not parse ${info}`);
}
const infoMap = match.groups!["description"];

const infoDir = path.dirname(info);
const dirName = path.basename(infoDir);
const id = dirName.replace(/^\d+-/, "");
idToDirName.set(id, dirName);

const src = path.join(infoDir, CONTENT_PHOTO);
const previewWidth = calcWidth(512, await sharp(src).metadata());

// imageOutPath and previewOutPath are
// code, strings need to be quoted
const { imageUrl, previewUrl } = isDev()
? prepareImageForDev(id)
: await prepareImageForBuild(ctx, src, id);

return `{
${infoMap}
id: "${id}",
previewWidth: ${previewWidth},
imageUrl: ${imageUrl},
previewUrl: ${previewUrl},
}`;
}),
);
}

async function makePosts(): Promise<string[]> {
Expand Down Expand Up @@ -168,8 +179,9 @@ function contentPlugin(): Plugin {

try {
const [, , id, filename] = req.url.split("/");
const dirName = idToDirName.get(id) ?? id;

let s = sharp(path.join(CONTENT_DIR, id, CONTENT_PHOTO));
let s = sharp(path.join(CONTENT_DIR, dirName, CONTENT_PHOTO));

switch (filename) {
case OUTPUT_PREVIEW_FILE_NAME:
Expand Down
13 changes: 7 additions & 6 deletions src/Components/Devicons.ts → src/Components/Icons.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
import { Component, JSX, lazy } from "solid-js";
import { Component, JSX } from "solid-js";

const icons = import.meta.glob("../Icons/**/*.svg", {
query: "?component-solid",
eager: true,
});

console.log("icons", icons);

type IconComponent = Component<JSX.SvgSVGAttributes<SVGSVGElement>>;

function load(name: string, location?: string): IconComponent {
if (location === undefined) {
location = "devicon";
}

return lazy(() => {
return icons[`../Icons/${location}/${name}.svg`]() as Promise<{
default: IconComponent;
}>;
});
return (
icons[`../Icons/${location}/${name}.svg`] as { default: IconComponent }
).default;
}

export const Java = load("java");
Expand Down
Loading
Loading