mirror of
https://github.com/AzzyAz-net/AzzyBlog.git
synced 2026-08-25 19:24:58 +00:00
Initial commit
This commit is contained in:
commit
c799a82f9b
30 changed files with 12030 additions and 0 deletions
4
.env.example
Normal file
4
.env.example
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
# Google Search Console verification code
|
||||||
|
# Get this from: Google Search Console > Settings > Ownership verification > HTML tag
|
||||||
|
# Example: PUBLIC_GOOGLE_SITE_VERIFICATION=abc123xyz
|
||||||
|
PUBLIC_GOOGLE_SITE_VERIFICATION=
|
||||||
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Astro
|
||||||
|
.astro/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 UINUX
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
196
README.md
Normal file
196
README.md
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
# UINUX Blog
|
||||||
|
|
||||||
|
A calm, writing-first blogging system built with Astro.
|
||||||
|
|
||||||
|
## What it is
|
||||||
|
|
||||||
|
UINUX Blog is a static blog system designed for long-form writing. It prioritizes typography, whitespace, and readability over visual complexity. It uses Markdown for content, file-based routing, and ships minimal client-side JavaScript (search only).
|
||||||
|
|
||||||
|
## Quick start (template users)
|
||||||
|
|
||||||
|
If you clicked "Use this template" on GitHub:
|
||||||
|
|
||||||
|
1. Clone your new repository
|
||||||
|
2. Run `npm install`
|
||||||
|
3. Delete the example posts in `src/content/posts/`
|
||||||
|
4. Update `astro.config.mjs` with your own `site` URL
|
||||||
|
5. Update the blog name and tagline in `src/pages/index.astro` and `src/components/Nav.astro`
|
||||||
|
6. Update `src/pages/about.astro` with your own info
|
||||||
|
7. Run `npm run dev` and start writing
|
||||||
|
|
||||||
|
## What it is not
|
||||||
|
|
||||||
|
- Not a theme. There are no color presets, dark mode toggles, or visual variants.
|
||||||
|
- Not a CMS. There is no admin panel, no database, no integrations.
|
||||||
|
- Not a marketing tool. There are no newsletter signups, analytics, or growth hacks.
|
||||||
|
- Not a portfolio. There are no image galleries, project showcases, or landing pages.
|
||||||
|
|
||||||
|
## Who it is for
|
||||||
|
|
||||||
|
Writers who want a quiet place to publish. People who value clarity over features. Anyone who prefers to write in Markdown and deploy a static site.
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 18 or later
|
||||||
|
|
||||||
|
### Install
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Start writing
|
||||||
|
|
||||||
|
1. Create a new `.md` or `.mdx` file in `src/content/posts/`
|
||||||
|
2. Add frontmatter:
|
||||||
|
|
||||||
|
```md
|
||||||
|
---
|
||||||
|
title: "Your Post Title"
|
||||||
|
description: "A short description of the post."
|
||||||
|
date: 2026-02-07
|
||||||
|
tags: ["topic-a", "topic-b"]
|
||||||
|
---
|
||||||
|
|
||||||
|
Your content here.
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Run the dev server:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Open `http://localhost:4321`
|
||||||
|
|
||||||
|
That is it. You are writing.
|
||||||
|
|
||||||
|
### Google Search Console
|
||||||
|
|
||||||
|
To enable Google Search Console verification, create a `.env` file:
|
||||||
|
|
||||||
|
```
|
||||||
|
PUBLIC_GOOGLE_SITE_VERIFICATION=your-verification-code
|
||||||
|
```
|
||||||
|
|
||||||
|
Get the code from Google Search Console > Settings > Ownership verification > HTML tag.
|
||||||
|
|
||||||
|
See `.env.example` for reference.
|
||||||
|
|
||||||
|
### Build for production
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Output is in the `dist/` directory. Deploy it anywhere that serves static files.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
components/
|
||||||
|
Layout.astro Page shell, meta, fonts
|
||||||
|
Nav.astro Site navigation
|
||||||
|
Footer.astro Site footer
|
||||||
|
Heading.astro Typographic heading
|
||||||
|
Article.astro Post wrapper with metadata and tags
|
||||||
|
Prose.astro Markdown content styling
|
||||||
|
content/
|
||||||
|
config.ts Content collection schema
|
||||||
|
posts/ Markdown files go here
|
||||||
|
pages/
|
||||||
|
index.astro Home (paginated post list)
|
||||||
|
page/[page].astro Paginated post listing (page 2+)
|
||||||
|
search.astro Search page (client-side)
|
||||||
|
search-index.json.ts Static search index (built at build time)
|
||||||
|
about.astro About page
|
||||||
|
rss.xml.ts RSS feed
|
||||||
|
tags/index.astro All tags
|
||||||
|
tags/[tag].astro Posts filtered by tag
|
||||||
|
posts/[...slug].astro Individual post pages
|
||||||
|
styles/
|
||||||
|
global.css Design tokens and base styles
|
||||||
|
```
|
||||||
|
|
||||||
|
## Content model
|
||||||
|
|
||||||
|
Posts use these frontmatter fields:
|
||||||
|
|
||||||
|
| Field | Type | Required | Default |
|
||||||
|
|---------------|----------|----------|---------|
|
||||||
|
| `title` | string | yes | — |
|
||||||
|
| `description` | string | yes | — |
|
||||||
|
| `date` | date | yes | — |
|
||||||
|
| `tags` | string[] | no | `[]` |
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Tags
|
||||||
|
|
||||||
|
Posts can have zero or more tags. Tags appear on post pages and the home listing. Browse all tags at `/tags` or filter by tag at `/tags/[tag-name]`.
|
||||||
|
|
||||||
|
### Pagination
|
||||||
|
|
||||||
|
The home page shows 10 posts per page. When there are more than 10 posts, pagination links appear at the bottom. Pages 2+ are at `/page/2`, `/page/3`, etc.
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
Client-side search powered by a static JSON index built at build time.
|
||||||
|
|
||||||
|
- No external services or APIs
|
||||||
|
- No dependencies
|
||||||
|
- Index includes title, description, tags, and full post body
|
||||||
|
- Debounced input with instant results
|
||||||
|
- Supports `?q=` query parameter for direct linking
|
||||||
|
- Available at `/search`
|
||||||
|
|
||||||
|
## Design decisions
|
||||||
|
|
||||||
|
- **Serif body text** (Newsreader): optimized for sustained reading
|
||||||
|
- **Sans-serif headings** (Inter): clear hierarchy without competing with body text
|
||||||
|
- **640px content width**: prevents eye fatigue on long lines
|
||||||
|
- **Minimal client-side JS**: only used for search; all other pages are static HTML and CSS
|
||||||
|
- **No dark mode**: one mode, one experience, no maintenance surface
|
||||||
|
- **No component library**: six components, no more
|
||||||
|
|
||||||
|
## SEO
|
||||||
|
|
||||||
|
Built-in, zero-dependency SEO on every page:
|
||||||
|
|
||||||
|
- **Canonical URLs** on all pages
|
||||||
|
- **Open Graph meta** (og:title, og:description, og:url, og:type, og:site_name, og:image)
|
||||||
|
- **Twitter Card meta** (summary or summary_large_image)
|
||||||
|
- **JSON-LD structured data**:
|
||||||
|
- `WebSite` on the home page
|
||||||
|
- `BlogPosting` on each post (headline, datePublished, author, publisher, keywords)
|
||||||
|
- `AboutPage` on the about page
|
||||||
|
- **Google Search Console** verification via environment variable
|
||||||
|
- **RSS feed** at `/rss.xml`
|
||||||
|
|
||||||
|
No third-party SEO libraries. No sitemap generator. No tracking.
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
| Variable | Required | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `PUBLIC_GOOGLE_SITE_VERIFICATION` | no | Google Search Console verification code |
|
||||||
|
|
||||||
|
## Customization checklist
|
||||||
|
|
||||||
|
When using this as a template, update these files:
|
||||||
|
|
||||||
|
- [ ] `astro.config.mjs` — set your `site` URL
|
||||||
|
- [ ] `src/pages/index.astro` — update blog name and tagline
|
||||||
|
- [ ] `src/components/Nav.astro` — update brand name
|
||||||
|
- [ ] `src/pages/about.astro` — write your own about page
|
||||||
|
- [ ] `src/pages/rss.xml.ts` — update feed title and description
|
||||||
|
- [ ] `src/components/Layout.astro` — update default description and OG site_name
|
||||||
|
- [ ] `src/content/posts/` — delete example posts, add your own
|
||||||
|
- [ ] `.env` — add your Google Search Console code (optional)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
12
astro.config.mjs
Normal file
12
astro.config.mjs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { defineConfig } from "astro/config";
|
||||||
|
import mdx from "@astrojs/mdx";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
site: "https://uinuxblog.getuinux.com",
|
||||||
|
integrations: [mdx()],
|
||||||
|
markdown: {
|
||||||
|
shikiConfig: {
|
||||||
|
theme: "github-light",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
6258
package-lock.json
generated
Normal file
6258
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
16
package.json
Normal file
16
package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"name": "uinux-blog",
|
||||||
|
"type": "module",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev",
|
||||||
|
"build": "astro build",
|
||||||
|
"preview": "astro preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"astro": "^5.0.0",
|
||||||
|
"@astrojs/mdx": "^4.0.0",
|
||||||
|
"@astrojs/rss": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
3973
pnpm-lock.yaml
generated
Normal file
3973
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
78
src/components/Article.astro
Normal file
78
src/components/Article.astro
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
---
|
||||||
|
import Heading from "./Heading.astro";
|
||||||
|
import Prose from "./Prose.astro";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
date: Date;
|
||||||
|
description: string;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, date, description, tags = [] } = Astro.props;
|
||||||
|
|
||||||
|
const formattedDate = date.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
---
|
||||||
|
|
||||||
|
<article class="article">
|
||||||
|
<header class="article-header">
|
||||||
|
<Heading as="h1">{title}</Heading>
|
||||||
|
<p class="article-meta">
|
||||||
|
<time datetime={date.toISOString()}>{formattedDate}</time>
|
||||||
|
</p>
|
||||||
|
{tags.length > 0 && (
|
||||||
|
<div class="article-tags">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<a href={`/tags/${tag}`} class="tag">{tag}</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<Prose>
|
||||||
|
<slot />
|
||||||
|
</Prose>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.article {
|
||||||
|
max-width: var(--width-content);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-header {
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-meta {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
border-color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
21
src/components/Footer.astro
Normal file
21
src/components/Footer.astro
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
---
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
---
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<p>© {year} UINUX. All rights reserved.</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.footer {
|
||||||
|
margin-top: var(--space-2xl);
|
||||||
|
padding: var(--space-lg) 0;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer p {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
40
src/components/Heading.astro
Normal file
40
src/components/Heading.astro
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
as?: "h1" | "h2" | "h3";
|
||||||
|
}
|
||||||
|
|
||||||
|
const { as: Tag = "h1" } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Tag class:list={["heading", `heading-${Tag}`]}>
|
||||||
|
<slot />
|
||||||
|
</Tag>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.heading {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
color: var(--color-text);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading-h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading-h2 {
|
||||||
|
font-size: 1.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: var(--space-xl);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading-h3 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: var(--space-lg);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
83
src/components/Layout.astro
Normal file
83
src/components/Layout.astro
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
---
|
||||||
|
import Nav from "./Nav.astro";
|
||||||
|
import Footer from "./Footer.astro";
|
||||||
|
import "../styles/global.css";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
ogType?: "website" | "article";
|
||||||
|
ogImage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
description = "A quiet place to read.",
|
||||||
|
ogType = "website",
|
||||||
|
ogImage,
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
||||||
|
const googleVerification = import.meta.env.PUBLIC_GOOGLE_SITE_VERIFICATION;
|
||||||
|
---
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
{googleVerification && <meta name="google-site-verification" content={googleVerification} />}
|
||||||
|
<title>{title}</title>
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
<link rel="canonical" href={canonicalURL.href} />
|
||||||
|
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta property="og:type" content={ogType} />
|
||||||
|
<meta property="og:title" content={title} />
|
||||||
|
<meta property="og:description" content={description} />
|
||||||
|
<meta property="og:url" content={canonicalURL.href} />
|
||||||
|
<meta property="og:site_name" content="UINUX Blog" />
|
||||||
|
{ogImage && <meta property="og:image" content={ogImage} />}
|
||||||
|
|
||||||
|
<!-- Twitter -->
|
||||||
|
<meta name="twitter:card" content={ogImage ? "summary_large_image" : "summary"} />
|
||||||
|
<meta name="twitter:title" content={title} />
|
||||||
|
<meta name="twitter:description" content={description} />
|
||||||
|
{ogImage && <meta name="twitter:image" content={ogImage} />}
|
||||||
|
|
||||||
|
<!-- JSON-LD -->
|
||||||
|
<slot name="head" />
|
||||||
|
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="UINUX Blog" href="/rss.xml" />
|
||||||
|
<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=Inter:wght@400;500;600;700&family=Newsreader:ital,wght@0,400;0,500;0,600;1,400;1,500&family=JetBrains+Mono:wght@400&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<Nav />
|
||||||
|
<main>
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
max-width: var(--width-page);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 var(--space-lg);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
54
src/components/Nav.astro
Normal file
54
src/components/Nav.astro
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
---
|
||||||
|
const currentPath = Astro.url.pathname;
|
||||||
|
---
|
||||||
|
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="/" class="nav-brand">UINUX Blog</a>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/tags" class:list={[{ active: currentPath.startsWith("/tags") }]}>Tags</a>
|
||||||
|
<a href="/search" class:list={[{ active: currentPath === "/search" || currentPath === "/search/" }]}>Search</a>
|
||||||
|
<a href="/about" class:list={[{ active: currentPath === "/about" || currentPath === "/about/" }]}>About</a>
|
||||||
|
<a href="/rss.xml">RSS</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: var(--space-lg) 0;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
margin-bottom: var(--space-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand:hover {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover,
|
||||||
|
.nav-links a.active {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
104
src/components/Prose.astro
Normal file
104
src/components/Prose.astro
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
<div class="prose">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.prose {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(p) {
|
||||||
|
margin-bottom: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(h2) {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 1.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-top: 2.5em;
|
||||||
|
margin-bottom: 0.75em;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(h3) {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-top: 2em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(a) {
|
||||||
|
color: var(--color-link);
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(a:hover) {
|
||||||
|
color: var(--color-link-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(blockquote) {
|
||||||
|
border-left: 2px solid var(--color-border);
|
||||||
|
padding-left: var(--space-lg);
|
||||||
|
margin: 1.5em 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(ul),
|
||||||
|
.prose :global(ol) {
|
||||||
|
margin-bottom: 1.5em;
|
||||||
|
padding-left: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(li) {
|
||||||
|
margin-bottom: 0.375em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(code) {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.85em;
|
||||||
|
background-color: var(--color-code-bg);
|
||||||
|
padding: 0.125em 0.375em;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(pre) {
|
||||||
|
background-color: var(--color-code-bg);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 1.5em 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(pre code) {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(hr) {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
margin: 2.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(img) {
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 1.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(strong) {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
14
src/content.config.ts
Normal file
14
src/content.config.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { defineCollection, z } from "astro:content";
|
||||||
|
import { glob } from "astro/loaders";
|
||||||
|
|
||||||
|
const posts = defineCollection({
|
||||||
|
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/posts" }),
|
||||||
|
schema: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
date: z.coerce.date(),
|
||||||
|
tags: z.array(z.string()).default([]),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collections = { posts };
|
||||||
36
src/content/posts/on-calm-software.md
Normal file
36
src/content/posts/on-calm-software.md
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
---
|
||||||
|
title: "On Calm Software"
|
||||||
|
description: "Most software demands attention. The best software respects it."
|
||||||
|
date: 2026-01-15
|
||||||
|
tags: ["design", "philosophy"]
|
||||||
|
---
|
||||||
|
|
||||||
|
Open most applications and they compete for your attention. Notifications, badges, banners, modals. Every surface is an opportunity to interrupt.
|
||||||
|
|
||||||
|
This is not a design failure. It is a design choice. These products are optimized for engagement, and engagement requires interruption.
|
||||||
|
|
||||||
|
But there is another way to build software.
|
||||||
|
|
||||||
|
## Attention as a resource
|
||||||
|
|
||||||
|
Your attention is finite. Every notification, every animation, every unexpected change in the interface costs something. Most software treats this cost as zero. It is not.
|
||||||
|
|
||||||
|
Calm software acknowledges this. It does its job and gets out of the way. It does not celebrate itself. It does not demand that you notice it.
|
||||||
|
|
||||||
|
## What calm software looks like
|
||||||
|
|
||||||
|
- It loads fast and stays stable
|
||||||
|
- It does not move things around after the page renders
|
||||||
|
- It uses typography and whitespace instead of color and motion
|
||||||
|
- It provides information without demanding a response
|
||||||
|
- It respects the back button
|
||||||
|
|
||||||
|
These are not features. They are qualities. You cannot put them in a changelog, but you feel their absence immediately.
|
||||||
|
|
||||||
|
## Why this matters
|
||||||
|
|
||||||
|
The tools we use shape how we think. Frantic tools produce frantic work. Calm tools create space for focus.
|
||||||
|
|
||||||
|
We build UINUX products with this in mind. Not every product needs to be calm. But the ones that do — the ones used for writing, thinking, and building — should be.
|
||||||
|
|
||||||
|
This blog is one of them.
|
||||||
46
src/content/posts/systems-not-themes.md
Normal file
46
src/content/posts/systems-not-themes.md
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
---
|
||||||
|
title: "Systems, Not Themes"
|
||||||
|
description: "Why we build design systems instead of selling themes, and what that means for long-term product thinking."
|
||||||
|
date: 2026-02-01
|
||||||
|
tags: ["design", "systems"]
|
||||||
|
---
|
||||||
|
|
||||||
|
There is a difference between a theme and a system.
|
||||||
|
|
||||||
|
A theme is a surface. It gives you colors, fonts, and a layout. You install it, adjust a few settings, and move on. It works until it does not. When your needs change, the theme becomes a constraint.
|
||||||
|
|
||||||
|
A system is a structure. It gives you rules, relationships, and a way of thinking about your product. It grows with you. It does not need to be replaced — it needs to be understood.
|
||||||
|
|
||||||
|
## The problem with themes
|
||||||
|
|
||||||
|
Themes optimize for first impressions. They are built to look good in a preview. They are designed to convert browsers into buyers.
|
||||||
|
|
||||||
|
But the people who use them — the ones who build real products — quickly discover the gap between what was promised and what is possible.
|
||||||
|
|
||||||
|
- Customization hits a wall
|
||||||
|
- Components do not compose well together
|
||||||
|
- Updates break things
|
||||||
|
- The original design intent gets lost
|
||||||
|
|
||||||
|
This is not a failure of any individual theme. It is a failure of the model.
|
||||||
|
|
||||||
|
## What a system provides
|
||||||
|
|
||||||
|
A system does not try to look good in a screenshot. It tries to work well over time.
|
||||||
|
|
||||||
|
It provides:
|
||||||
|
|
||||||
|
- **Consistent spacing** that scales across screen sizes
|
||||||
|
- **Typography rules** that maintain hierarchy without manual adjustment
|
||||||
|
- **Component patterns** that compose predictably
|
||||||
|
- **Constraints** that prevent drift
|
||||||
|
|
||||||
|
The value is not in what it gives you. The value is in what it prevents.
|
||||||
|
|
||||||
|
## Building with restraint
|
||||||
|
|
||||||
|
The hardest part of building a system is saying no. Every feature request feels reasonable in isolation. But systems degrade one reasonable addition at a time.
|
||||||
|
|
||||||
|
Good systems are opinionated. They make decisions so you do not have to. They trade flexibility for coherence.
|
||||||
|
|
||||||
|
This is the approach we take at UINUX. Not because constraints are fashionable, but because they work.
|
||||||
47
src/content/posts/writing-as-interface.md
Normal file
47
src/content/posts/writing-as-interface.md
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
---
|
||||||
|
title: "Writing as Interface"
|
||||||
|
description: "The best interface for communicating complex ideas is still plain text."
|
||||||
|
date: 2026-01-02
|
||||||
|
tags: ["writing", "design"]
|
||||||
|
---
|
||||||
|
|
||||||
|
We spend enormous effort designing interfaces. Layouts, components, interactions, animations. All of it in service of communicating something to someone.
|
||||||
|
|
||||||
|
But for complex ideas — the kind that require nuance, context, and careful reasoning — the best interface is still a paragraph.
|
||||||
|
|
||||||
|
## The limits of visual design
|
||||||
|
|
||||||
|
Visual design excels at orientation. It tells you where you are, what you can do, and what matters most. It is essential for applications, dashboards, and tools.
|
||||||
|
|
||||||
|
But it struggles with depth. A card component can hold a title and a summary. It cannot hold an argument. A grid layout can organize information. It cannot develop a thought.
|
||||||
|
|
||||||
|
For that, you need prose.
|
||||||
|
|
||||||
|
## Why Markdown works
|
||||||
|
|
||||||
|
Markdown is not a design tool. It is a writing tool. It gives you:
|
||||||
|
|
||||||
|
- Headings for structure
|
||||||
|
- Paragraphs for ideas
|
||||||
|
- Lists for enumeration
|
||||||
|
- Code blocks for precision
|
||||||
|
- Links for references
|
||||||
|
|
||||||
|
Nothing else. And that is exactly right.
|
||||||
|
|
||||||
|
The constraint of Markdown forces clarity. You cannot hide weak thinking behind a beautiful layout. The words have to do the work.
|
||||||
|
|
||||||
|
## Building for writers
|
||||||
|
|
||||||
|
When we built this blog, we started with the reading experience. Not the homepage, not the navigation, not the visual identity. The paragraph.
|
||||||
|
|
||||||
|
Every decision flows from there:
|
||||||
|
|
||||||
|
- **Font choice**: optimized for long-form reading
|
||||||
|
- **Line length**: constrained to prevent eye fatigue
|
||||||
|
- **Spacing**: generous, to let ideas breathe
|
||||||
|
- **Color**: neutral, to keep focus on the text
|
||||||
|
|
||||||
|
The result is not impressive. It is not meant to be. It is meant to be readable.
|
||||||
|
|
||||||
|
That is enough.
|
||||||
1
src/env.d.ts
vendored
Normal file
1
src/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference path="../.astro/types.d.ts" />
|
||||||
44
src/pages/about.astro
Normal file
44
src/pages/about.astro
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
---
|
||||||
|
import Layout from "../components/Layout.astro";
|
||||||
|
import Heading from "../components/Heading.astro";
|
||||||
|
import Prose from "../components/Prose.astro";
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title="About — UINUX Blog" description="About UINUX Blog.">
|
||||||
|
<script slot="head" type="application/ld+json" set:html={JSON.stringify({
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "AboutPage",
|
||||||
|
"name": "About UINUX Blog",
|
||||||
|
"description": "About UINUX Blog.",
|
||||||
|
"url": new URL("/about", Astro.site).href,
|
||||||
|
"isPartOf": {
|
||||||
|
"@type": "WebSite",
|
||||||
|
"name": "UINUX Blog",
|
||||||
|
"url": Astro.site?.href,
|
||||||
|
},
|
||||||
|
})} />
|
||||||
|
<section class="about">
|
||||||
|
<Heading as="h1">About</Heading>
|
||||||
|
<Prose>
|
||||||
|
<p>
|
||||||
|
UINUX Blog is a quiet place for writing about systems, design decisions,
|
||||||
|
and the craft of building digital products with restraint.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
It is part of <a href="https://uinux.co">UINUX</a>, a design practice
|
||||||
|
focused on systems over themes, clarity over complexity, and long-term
|
||||||
|
thinking over trends.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
This blog exists to share ideas, not to sell. There are no newsletters,
|
||||||
|
no pop-ups, no tracking. Just writing.
|
||||||
|
</p>
|
||||||
|
</Prose>
|
||||||
|
</section>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.about {
|
||||||
|
max-width: var(--width-content);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
182
src/pages/index.astro
Normal file
182
src/pages/index.astro
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
---
|
||||||
|
import Layout from "../components/Layout.astro";
|
||||||
|
import Heading from "../components/Heading.astro";
|
||||||
|
import { getCollection } from "astro:content";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
const allPosts = (await getCollection("posts")).sort(
|
||||||
|
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
||||||
|
);
|
||||||
|
|
||||||
|
const posts = allPosts.slice(0, PAGE_SIZE);
|
||||||
|
const totalPages = Math.ceil(allPosts.length / PAGE_SIZE);
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title="UINUX Blog">
|
||||||
|
<script slot="head" type="application/ld+json" set:html={JSON.stringify({
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebSite",
|
||||||
|
"name": "UINUX Blog",
|
||||||
|
"description": "Writing about systems, design, and building with restraint.",
|
||||||
|
"url": Astro.site?.href,
|
||||||
|
})} />
|
||||||
|
<section class="home">
|
||||||
|
<header class="home-header">
|
||||||
|
<Heading as="h1">UINUX Blog</Heading>
|
||||||
|
<p class="home-tagline">Writing about systems, design, and building with restraint.</p>
|
||||||
|
</header>
|
||||||
|
<ul class="post-list">
|
||||||
|
{
|
||||||
|
posts.map((post) => {
|
||||||
|
const formattedDate = post.data.date.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<li class="post-item">
|
||||||
|
<a href={`/posts/${post.id}`}>
|
||||||
|
<span class="post-title">{post.data.title}</span>
|
||||||
|
<span class="post-description">{post.data.description}</span>
|
||||||
|
<time datetime={post.data.date.toISOString()}>{formattedDate}</time>
|
||||||
|
</a>
|
||||||
|
{post.data.tags.length > 0 && (
|
||||||
|
<div class="post-tags">
|
||||||
|
{post.data.tags.map((tag) => (
|
||||||
|
<a href={`/tags/${tag}`} class="tag">{tag}</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<nav class="pagination" aria-label="Pagination">
|
||||||
|
<span class="pagination-info">Page 1 of {totalPages}</span>
|
||||||
|
<a href="/page/2" class="pagination-link">Older →</a>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.home {
|
||||||
|
max-width: var(--width-content);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-header {
|
||||||
|
margin-bottom: var(--space-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-tagline {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-item {
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-item:first-child {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-item a {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
padding-top: var(--space-lg);
|
||||||
|
padding-bottom: var(--space-lg);
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-item:has(.post-tags) a {
|
||||||
|
padding-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-item a:hover .post-title {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-title {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
line-height: 1.3;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-description {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-item time {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
padding-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: 0.0625rem 0.375rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
border-color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: var(--space-xl);
|
||||||
|
padding-top: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-link {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-link:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-info {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
179
src/pages/page/[page].astro
Normal file
179
src/pages/page/[page].astro
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
---
|
||||||
|
import Layout from "../../components/Layout.astro";
|
||||||
|
import Heading from "../../components/Heading.astro";
|
||||||
|
import { getCollection } from "astro:content";
|
||||||
|
import type { GetStaticPathsOptions } from "astro";
|
||||||
|
|
||||||
|
export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
|
||||||
|
const posts = (await getCollection("posts")).sort(
|
||||||
|
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
||||||
|
);
|
||||||
|
const pages = paginate(posts, { pageSize: 10 });
|
||||||
|
// Skip page 1 — home page (index.astro) handles it
|
||||||
|
return pages.filter((p) => p.params.page !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { page } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title={`Page ${page.currentPage} — UINUX Blog`}>
|
||||||
|
<section class="home">
|
||||||
|
<header class="home-header">
|
||||||
|
<Heading as="h1">UINUX Blog</Heading>
|
||||||
|
<p class="home-tagline">Writing about systems, design, and building with restraint.</p>
|
||||||
|
</header>
|
||||||
|
<ul class="post-list">
|
||||||
|
{page.data.map((post) => {
|
||||||
|
const formattedDate = post.data.date.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<li class="post-item">
|
||||||
|
<a href={`/posts/${post.id}`}>
|
||||||
|
<span class="post-title">{post.data.title}</span>
|
||||||
|
<span class="post-description">{post.data.description}</span>
|
||||||
|
<time datetime={post.data.date.toISOString()}>{formattedDate}</time>
|
||||||
|
</a>
|
||||||
|
{post.data.tags.length > 0 && (
|
||||||
|
<div class="post-tags">
|
||||||
|
{post.data.tags.map((tag) => (
|
||||||
|
<a href={`/tags/${tag}`} class="tag">{tag}</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<nav class="pagination" aria-label="Pagination">
|
||||||
|
{page.url.prev && (
|
||||||
|
<a href={page.currentPage === 2 ? "/" : page.url.prev} class="pagination-link">← Newer</a>
|
||||||
|
)}
|
||||||
|
<span class="pagination-info">Page {page.currentPage} of {page.lastPage}</span>
|
||||||
|
{page.url.next && (
|
||||||
|
<a href={page.url.next} class="pagination-link">Older →</a>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</section>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.home {
|
||||||
|
max-width: var(--width-content);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-header {
|
||||||
|
margin-bottom: var(--space-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-tagline {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-item {
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-item:first-child {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-item a {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
padding-top: var(--space-lg);
|
||||||
|
padding-bottom: var(--space-lg);
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-item:has(.post-tags) a {
|
||||||
|
padding-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-item a:hover .post-title {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-title {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
line-height: 1.3;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-description {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-item time {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
padding-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: 0.0625rem 0.375rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
border-color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: var(--space-xl);
|
||||||
|
padding-top: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-link {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-link:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-info {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
49
src/pages/posts/[...slug].astro
Normal file
49
src/pages/posts/[...slug].astro
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
---
|
||||||
|
import Layout from "../../components/Layout.astro";
|
||||||
|
import Article from "../../components/Article.astro";
|
||||||
|
import { getCollection, render } from "astro:content";
|
||||||
|
|
||||||
|
export async function getStaticPaths() {
|
||||||
|
const posts = await getCollection("posts");
|
||||||
|
return posts.map((post) => ({
|
||||||
|
params: { slug: post.id },
|
||||||
|
props: post,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = Astro.props;
|
||||||
|
const { Content } = await render(post);
|
||||||
|
|
||||||
|
const postUrl = new URL(`/posts/${post.id}`, Astro.site).href;
|
||||||
|
|
||||||
|
const jsonLd = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "BlogPosting",
|
||||||
|
"headline": post.data.title,
|
||||||
|
"description": post.data.description,
|
||||||
|
"datePublished": post.data.date.toISOString(),
|
||||||
|
"url": postUrl,
|
||||||
|
"keywords": post.data.tags.length > 0 ? post.data.tags.join(", ") : undefined,
|
||||||
|
"mainEntityOfPage": {
|
||||||
|
"@type": "WebPage",
|
||||||
|
"@id": postUrl,
|
||||||
|
},
|
||||||
|
"author": {
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "UINUX",
|
||||||
|
"url": "https://uinux.co",
|
||||||
|
},
|
||||||
|
"publisher": {
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "UINUX",
|
||||||
|
"url": "https://uinux.co",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title={`${post.data.title} — UINUX Blog`} description={post.data.description} ogType="article">
|
||||||
|
<script slot="head" type="application/ld+json" set:html={JSON.stringify(jsonLd)} />
|
||||||
|
<Article title={post.data.title} date={post.data.date} description={post.data.description} tags={post.data.tags}>
|
||||||
|
<Content />
|
||||||
|
</Article>
|
||||||
|
</Layout>
|
||||||
22
src/pages/rss.xml.ts
Normal file
22
src/pages/rss.xml.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import rss from "@astrojs/rss";
|
||||||
|
import { getCollection } from "astro:content";
|
||||||
|
import type { APIContext } from "astro";
|
||||||
|
|
||||||
|
export async function GET(context: APIContext) {
|
||||||
|
const posts = (await getCollection("posts")).sort(
|
||||||
|
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
||||||
|
);
|
||||||
|
|
||||||
|
return rss({
|
||||||
|
title: "UINUX Blog",
|
||||||
|
description: "Writing about systems, design, and building with restraint.",
|
||||||
|
site: context.site!,
|
||||||
|
items: posts.map((post) => ({
|
||||||
|
title: post.data.title,
|
||||||
|
pubDate: post.data.date,
|
||||||
|
description: post.data.description,
|
||||||
|
link: `/posts/${post.id}`,
|
||||||
|
categories: post.data.tags,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
26
src/pages/search-index.json.ts
Normal file
26
src/pages/search-index.json.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import type { APIContext } from "astro";
|
||||||
|
import { getCollection } from "astro:content";
|
||||||
|
|
||||||
|
export async function GET(context: APIContext) {
|
||||||
|
const posts = (await getCollection("posts")).sort(
|
||||||
|
(a, b) => b.data.date.valueOf() - a.data.date.valueOf()
|
||||||
|
);
|
||||||
|
|
||||||
|
const index = posts.map((post) => ({
|
||||||
|
slug: post.id,
|
||||||
|
title: post.data.title,
|
||||||
|
description: post.data.description,
|
||||||
|
date: post.data.date.toISOString(),
|
||||||
|
tags: post.data.tags,
|
||||||
|
body: (post.body ?? "")
|
||||||
|
.replace(/^---[\s\S]*?\n---\n?/, "")
|
||||||
|
.replace(/[#*`\[\]()>_~|\\]/g, "")
|
||||||
|
.replace(/\n+/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(index), {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
238
src/pages/search.astro
Normal file
238
src/pages/search.astro
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
---
|
||||||
|
import Layout from "../components/Layout.astro";
|
||||||
|
import Heading from "../components/Heading.astro";
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title="Search — UINUX Blog" description="Search posts on UINUX Blog.">
|
||||||
|
<section class="search-page">
|
||||||
|
<Heading as="h1">Search</Heading>
|
||||||
|
<div class="search-input-wrap">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="search-input"
|
||||||
|
placeholder="Search posts..."
|
||||||
|
autocomplete="off"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ul id="search-results" class="search-results"></ul>
|
||||||
|
<p id="search-empty" class="search-empty" hidden>No posts found.</p>
|
||||||
|
</section>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
interface SearchEntry {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
date: string;
|
||||||
|
tags: string[];
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let index: SearchEntry[] = [];
|
||||||
|
let loaded = false;
|
||||||
|
|
||||||
|
const input = document.getElementById("search-input") as HTMLInputElement;
|
||||||
|
const resultsList = document.getElementById("search-results") as HTMLUListElement;
|
||||||
|
const emptyMsg = document.getElementById("search-empty") as HTMLParagraphElement;
|
||||||
|
|
||||||
|
async function loadIndex(): Promise<void> {
|
||||||
|
if (loaded) return;
|
||||||
|
const res = await fetch("/search-index.json");
|
||||||
|
index = await res.json();
|
||||||
|
loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function search(query: string): SearchEntry[] {
|
||||||
|
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
|
if (terms.length === 0) return [];
|
||||||
|
|
||||||
|
return index.filter((entry) => {
|
||||||
|
const haystack = `${entry.title} ${entry.description} ${entry.tags.join(" ")} ${entry.body}`.toLowerCase();
|
||||||
|
return terms.every((term) => haystack.includes(term));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnippet(entry: SearchEntry, query: string): string | null {
|
||||||
|
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
|
const titleDesc = `${entry.title} ${entry.description} ${entry.tags.join(" ")}`.toLowerCase();
|
||||||
|
const allInTitleDesc = terms.every((t) => titleDesc.includes(t));
|
||||||
|
if (allInTitleDesc) return null;
|
||||||
|
|
||||||
|
const bodyLower = entry.body.toLowerCase();
|
||||||
|
for (const term of terms) {
|
||||||
|
const pos = bodyLower.indexOf(term);
|
||||||
|
if (pos === -1) continue;
|
||||||
|
const start = Math.max(0, pos - 40);
|
||||||
|
const end = Math.min(entry.body.length, pos + term.length + 80);
|
||||||
|
const prefix = start > 0 ? "…" : "";
|
||||||
|
const suffix = end < entry.body.length ? "…" : "";
|
||||||
|
return prefix + entry.body.slice(start, end).trim() + suffix;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(results: SearchEntry[], query: string): void {
|
||||||
|
resultsList.innerHTML = "";
|
||||||
|
|
||||||
|
if (query.trim() === "") {
|
||||||
|
emptyMsg.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
emptyMsg.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyMsg.hidden = true;
|
||||||
|
|
||||||
|
for (const entry of results) {
|
||||||
|
const snippet = getSnippet(entry, query);
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = "search-result-item";
|
||||||
|
li.innerHTML = `
|
||||||
|
<a href="/posts/${encodeURIComponent(entry.slug)}">
|
||||||
|
<span class="result-title">${escapeHtml(entry.title)}</span>
|
||||||
|
<span class="result-description">${escapeHtml(entry.description)}</span>
|
||||||
|
${snippet ? `<span class="result-snippet">${escapeHtml(snippet)}</span>` : ""}
|
||||||
|
<time datetime="${escapeHtml(entry.date)}">${formatDate(entry.date)}</time>
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
resultsList.appendChild(li);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(async () => {
|
||||||
|
await loadIndex();
|
||||||
|
const query = input.value;
|
||||||
|
const results = search(query);
|
||||||
|
render(results, query);
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Support ?q= query param
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const initialQuery = params.get("q");
|
||||||
|
if (initialQuery) {
|
||||||
|
input.value = initialQuery;
|
||||||
|
loadIndex().then(() => {
|
||||||
|
const results = search(initialQuery);
|
||||||
|
render(results, initialQuery);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.search-page {
|
||||||
|
max-width: var(--width-content);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input-wrap {
|
||||||
|
margin-top: var(--space-lg);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-input {
|
||||||
|
width: 100%;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-input:focus {
|
||||||
|
border-color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#search-input::placeholder {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-item {
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-item:first-child {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-item a {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
padding: var(--space-lg) 0;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-item a:hover .result-title {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-title {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
line-height: 1.3;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-description {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-item time {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-snippet {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
line-height: 1.5;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-empty {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
122
src/pages/tags/[tag].astro
Normal file
122
src/pages/tags/[tag].astro
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
---
|
||||||
|
import Layout from "../../components/Layout.astro";
|
||||||
|
import Heading from "../../components/Heading.astro";
|
||||||
|
import { getCollection } from "astro:content";
|
||||||
|
|
||||||
|
export async function getStaticPaths() {
|
||||||
|
const posts = await getCollection("posts");
|
||||||
|
const tags = [...new Set(posts.flatMap((p) => p.data.tags))];
|
||||||
|
return tags.map((tag) => ({
|
||||||
|
params: { tag },
|
||||||
|
props: {
|
||||||
|
tag,
|
||||||
|
posts: posts
|
||||||
|
.filter((p) => p.data.tags.includes(tag))
|
||||||
|
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf()),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { tag, posts } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title={`Posts tagged "${tag}" — UINUX Blog`} description={`All posts tagged "${tag}".`}>
|
||||||
|
<section class="tag-page">
|
||||||
|
<Heading as="h1">Tagged: {tag}</Heading>
|
||||||
|
<p class="tag-count">{posts.length} post{posts.length !== 1 ? "s" : ""}</p>
|
||||||
|
<ul class="post-list">
|
||||||
|
{posts.map((post) => {
|
||||||
|
const formattedDate = post.data.date.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<li class="post-item">
|
||||||
|
<a href={`/posts/${post.id}`}>
|
||||||
|
<span class="post-title">{post.data.title}</span>
|
||||||
|
<span class="post-description">{post.data.description}</span>
|
||||||
|
<time datetime={post.data.date.toISOString()}>{formattedDate}</time>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<a href="/tags" class="back-link" aria-label="Back to all tags">← All tags</a>
|
||||||
|
</section>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tag-page {
|
||||||
|
max-width: var(--width-content);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-count {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-item {
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-item:first-child {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-item a {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
padding: var(--space-lg) 0;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-item a:hover .post-title {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-title {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
line-height: 1.3;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-description {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-item time {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: var(--space-xl);
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
65
src/pages/tags/index.astro
Normal file
65
src/pages/tags/index.astro
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
---
|
||||||
|
import Layout from "../../components/Layout.astro";
|
||||||
|
import Heading from "../../components/Heading.astro";
|
||||||
|
import { getCollection } from "astro:content";
|
||||||
|
|
||||||
|
const posts = await getCollection("posts");
|
||||||
|
const tagCounts = new Map<string, number>();
|
||||||
|
for (const post of posts) {
|
||||||
|
for (const tag of post.data.tags) {
|
||||||
|
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tags = [...tagCounts.entries()].sort((a, b) => b[1] - a[1]);
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title="Tags — UINUX Blog" description="Browse posts by tag.">
|
||||||
|
<section class="tags-page">
|
||||||
|
<Heading as="h1">Tags</Heading>
|
||||||
|
<ul class="tag-list">
|
||||||
|
{tags.map(([tag, count]) => (
|
||||||
|
<li>
|
||||||
|
<a href={`/tags/${tag}`} class="tag-link">
|
||||||
|
{tag} <span class="tag-count">({count})</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tags-page {
|
||||||
|
max-width: var(--width-content);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-top: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-link {
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-link:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
border-color: var(--color-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-count {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
73
src/styles/global.css
Normal file
73
src/styles/global.css
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
/* ============================================
|
||||||
|
UINUX Blog — Global Styles
|
||||||
|
A calm, typography-first design system.
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--font-body: "Newsreader", Georgia, "Times New Roman", serif;
|
||||||
|
--font-heading: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||||
|
--font-mono: "JetBrains Mono", "Fira Code", Consolas, monospace;
|
||||||
|
|
||||||
|
--color-text: #1a1a1a;
|
||||||
|
--color-text-secondary: #555;
|
||||||
|
--color-text-tertiary: #888;
|
||||||
|
--color-bg: #fafaf9;
|
||||||
|
--color-surface: #fff;
|
||||||
|
--color-border: #e5e5e3;
|
||||||
|
--color-link: #1a1a1a;
|
||||||
|
--color-link-hover: #555;
|
||||||
|
--color-code-bg: #f4f4f2;
|
||||||
|
|
||||||
|
--width-content: 640px;
|
||||||
|
--width-page: 720px;
|
||||||
|
|
||||||
|
--space-xs: 0.25rem;
|
||||||
|
--space-sm: 0.5rem;
|
||||||
|
--space-md: 1rem;
|
||||||
|
--space-lg: 2rem;
|
||||||
|
--space-xl: 3rem;
|
||||||
|
--space-2xl: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 18px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-body);
|
||||||
|
color: var(--color-text);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--color-link);
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--color-link-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background-color: #d4d4d0;
|
||||||
|
}
|
||||||
3
tsconfig.json
Normal file
3
tsconfig.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"extends": "astro/tsconfigs/strict"
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue