Initial commit

This commit is contained in:
Ahyan Z 2026-04-11 21:33:52 +01:00 committed by GitHub
commit c799a82f9b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 12030 additions and 0 deletions

4
.env.example Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

16
package.json Normal file
View 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

File diff suppressed because it is too large Load diff

View 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>

View file

@ -0,0 +1,21 @@
---
const year = new Date().getFullYear();
---
<footer class="footer">
<p>&copy; {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>

View 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>

View 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
View 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
View 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
View 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 };

View 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.

View 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.

View 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
View file

@ -0,0 +1 @@
/// <reference path="../.astro/types.d.ts" />

44
src/pages/about.astro Normal file
View 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
View 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 &rarr;</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
View 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">&larr; 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 &rarr;</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>

View 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
View 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,
})),
});
}

View 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
View 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
View 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">&larr; 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>

View 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
View 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
View file

@ -0,0 +1,3 @@
{
"extends": "astro/tsconfigs/strict"
}