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