initial commit

This commit is contained in:
2026-06-17 22:37:47 +05:30
commit bc31b2508b
100 changed files with 18050 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
# Payload
PAYLOAD_SECRET=change-me-to-a-long-random-string
DATABASE_URI=postgres://payload:payload@localhost:5432/softvowels
# Public origin of the admin UI
PAYLOAD_PUBLIC_SERVER_URL=https://cms.softvowels.example
SERVER_URL=https://cms.softvowels.example
# Origins allowed to call the API (Astro public site)
CORS_ORIGINS=https://softvowels.example,http://localhost:4321
# Internal rebuild webhook
ASTRO_REVALIDATE_URL=http://host.docker.internal:4321/__revalidate
ASTRO_REVALIDATE_TOKEN=change-me
PORT=3000
+22
View File
@@ -0,0 +1,22 @@
# syntax=docker/dockerfile:1.7
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare [email protected] --activate
WORKDIR /app
FROM base AS deps
COPY package.json pnpm-lock.yaml* ./
COPY ../../package.json ../../pnpm-workspace.yaml* ../../ ./
RUN pnpm install --frozen-lockfile
FROM base AS build
COPY --from=deps /app /app
COPY . .
RUN pnpm run build
FROM base AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /app ./
EXPOSE 3000
CMD ["pnpm", "start"]
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
+10
View File
@@ -0,0 +1,10 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
reactStrictMode: true,
experimental: {
reactCompiler: false,
},
};
export default nextConfig;
+36
View File
@@ -0,0 +1,36 @@
{
"name": "cms",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "cross-env NODE_OPTIONS=--no-deprecation payload dev",
"build": "cross-env NODE_OPTIONS=--no-deprecation payload build",
"start": "cross-env NODE_OPTIONS=--no-deprecation payload start",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
"lint": "echo 'no linter wired'",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@payloadcms/db-postgres": "3.36.0",
"@payloadcms/next": "3.36.0",
"@payloadcms/richtext-lexical": "3.36.0",
"cross-env": "7.0.3",
"graphql": "16.10.0",
"next": "15.1.4",
"payload": "3.36.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"sharp": "0.33.5"
},
"devDependencies": {
"@types/node": "22.10.5",
"@types/react": "19.0.7",
"@types/react-dom": "19.0.3",
"typescript": "5.7.2"
},
"engines": {
"node": ">=20.11.0"
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"importMap": {}
}
@@ -0,0 +1,22 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config';
import '@payloadcms/next/css';
import type { ServerFunctionClient } from 'payload';
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts';
import { importMap } from '../../../admin/importMap';
type Args = { children: React.ReactNode };
const serverFunction: ServerFunctionClient = async function (args) {
'use server';
return handleServerFunctions({ ...args, config, importMap });
};
const Layout = ({ children }: Args) => (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
);
export default Layout;
@@ -0,0 +1,19 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next';
import config from '@payload-config';
import { generatePageMetadata, RootPage } from '@payloadcms/next/views';
import { importMap } from '../../admin/importMap';
type Args = {
params: Promise<{ segments: string[] }>;
searchParams: Promise<{ [key: string]: string | string[] }>;
};
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams });
const Page = ({ params, searchParams }: Args) =>
RootPage({ config, params, searchParams, importMap });
export default Page;
+11
View File
@@ -0,0 +1,11 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config';
import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST, REST_PUT } from '@payloadcms/next/routes';
export const GET = REST_GET(config);
export const POST = REST_POST(config);
export const DELETE = REST_DELETE(config);
export const PATCH = REST_PATCH(config);
export const PUT = REST_PUT(config);
export const OPTIONS = REST_OPTIONS(config);
@@ -0,0 +1,7 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config';
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes';
export const POST = GRAPHQL_POST(config);
export const OPTIONS = REST_OPTIONS(config);
+7
View File
@@ -0,0 +1,7 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config';
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes';
export const POST = GRAPHQL_POST(config);
export const OPTIONS = REST_OPTIONS(config);
+4
View File
@@ -0,0 +1,4 @@
/* Custom Payload admin theme overrides — kept minimal so the stock look-and-feel wins. */
:root {
--theme-base-0: #ffffff;
}
+29
View File
@@ -0,0 +1,29 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config';
import '@payloadcms/next/css';
import type { ServerFunctionClient } from 'payload';
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts';
import { importMap } from './admin/importMap';
import './custom.scss';
type Args = {
children: React.ReactNode;
};
const serverFunction: ServerFunctionClient = async function (args) {
'use server';
return handleServerFunctions({
...args,
config,
importMap,
});
};
const Layout = ({ children }: Args) => (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
);
export default Layout;
+17
View File
@@ -0,0 +1,17 @@
import type { Metadata } from 'next';
import config from '@payload-config';
import { generatePageMetadata, NotFoundPage } from '@payloadcms/next/views';
import { importMap } from '../admin/importMap';
type Args = {
params: Promise<{ segments: string[] }>;
searchParams: Promise<{ [key: string]: string | string[] }>;
};
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams });
const NotFound = ({ params, searchParams }: Args) =>
NotFoundPage({ config, params, searchParams, importMap });
export default NotFound;
+23
View File
@@ -0,0 +1,23 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next';
import config from '@payload-config';
import { generatePageMetadata, RootPage } from '@payloadcms/next/views';
import { importMap } from '../admin/importMap';
type Args = {
params: Promise<{
segments: string[];
}>;
searchParams: Promise<{
[key: string]: string | string[];
}>;
};
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams });
const Page = ({ params, searchParams }: Args) =>
RootPage({ config, params, searchParams, importMap });
export default Page;
+101
View File
@@ -0,0 +1,101 @@
import type { CollectionConfig, FieldHook } from 'payload';
const slugify = (s: string) =>
s
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
const autoSlug: FieldHook = ({ value, data }) => {
if (value) return slugify(String(value));
if (data?.title) return slugify(String(data.title));
return value;
};
export const Articles: CollectionConfig = {
slug: 'articles',
admin: {
useAsTitle: 'title',
defaultColumns: ['title', 'status', 'category', 'author', 'publishedAt'],
},
access: {
read: () => true,
create: ({ req }) => Boolean(req.user),
update: ({ req }) => Boolean(req.user),
delete: ({ req }) => req.user?.roles?.includes('admin') ?? false,
},
versions: { drafts: { autosave: true } },
fields: [
{ name: 'title', type: 'text', required: true },
{
name: 'slug',
type: 'text',
required: true,
unique: true,
index: true,
hooks: { beforeValidate: [autoSlug] },
},
{ name: 'excerpt', type: 'textarea', required: true, maxLength: 280 },
{
name: 'body',
type: 'richText',
required: true,
},
{ name: 'coverImage', type: 'upload', relationTo: 'media' },
{ name: 'category', type: 'relationship', relationTo: 'categories', required: true },
{ name: 'tags', type: 'relationship', relationTo: 'tags', hasMany: true },
{ name: 'author', type: 'relationship', relationTo: 'authors', required: true },
{
name: 'status',
type: 'select',
required: true,
defaultValue: 'draft',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Published', value: 'published' },
],
admin: { position: 'sidebar' },
},
{
name: 'publishedAt',
type: 'date',
admin: { date: { pickerAppearance: 'dayAndTime' }, position: 'sidebar' },
},
{
name: 'readingMinutes',
type: 'number',
admin: {
description: 'Auto-computed if empty. Manual override takes precedence.',
},
},
{
name: 'featured',
type: 'checkbox',
defaultValue: false,
admin: { position: 'sidebar' },
},
{ name: 'featuredRank', type: 'number', defaultValue: 0, admin: { position: 'sidebar' } },
{
name: 'seo',
type: 'group',
label: 'SEO',
fields: [
{ name: 'title', type: 'text' },
{ name: 'description', type: 'textarea' },
{ name: 'ogImage', type: 'upload', relationTo: 'media' },
],
},
],
hooks: {
beforeChange: [
({ data }) => {
if (data?.status === 'published' && !data.publishedAt) {
return { ...data, publishedAt: new Date().toISOString() };
}
return data;
},
],
},
};
+38
View File
@@ -0,0 +1,38 @@
import type { CollectionConfig } from 'payload';
export const Authors: CollectionConfig = {
slug: 'authors',
admin: { useAsTitle: 'name', defaultColumns: ['name', 'role', 'updatedAt'] },
access: {
read: () => true,
create: ({ req }) => Boolean(req.user),
update: ({ req }) => Boolean(req.user),
delete: ({ req }) => req.user?.roles?.includes('admin') ?? false,
},
fields: [
{ name: 'name', type: 'text', required: true },
{
name: 'slug',
type: 'text',
required: true,
unique: true,
index: true,
},
{ name: 'role', type: 'text', admin: { description: 'e.g. Founding Editor' } },
{
name: 'avatar',
type: 'upload',
relationTo: 'media',
},
{ name: 'bio', type: 'textarea' },
{
name: 'socials',
type: 'group',
fields: [
{ name: 'twitter', type: 'text' },
{ name: 'github', type: 'text' },
{ name: 'site', type: 'text' },
],
},
],
};
+33
View File
@@ -0,0 +1,33 @@
import type { CollectionConfig } from 'payload';
export const Categories: CollectionConfig = {
slug: 'categories',
admin: { useAsTitle: 'name', defaultColumns: ['name', 'order', 'updatedAt'] },
access: {
read: () => true,
create: ({ req }) => Boolean(req.user),
update: ({ req }) => Boolean(req.user),
delete: ({ req }) => req.user?.roles?.includes('admin') ?? false,
},
fields: [
{ name: 'name', type: 'text', required: true },
{
name: 'slug',
type: 'text',
required: true,
unique: true,
index: true,
admin: { description: 'Lowercase, dash-separated.' },
},
{ name: 'description', type: 'textarea' },
{
name: 'icon',
type: 'text',
admin: {
description: 'Material Symbols icon name. e.g. memory, code, layers.',
},
},
{ name: 'color', type: 'text', admin: { description: 'Optional accent color (hex).' } },
{ name: 'order', type: 'number', defaultValue: 0 },
],
};
+25
View File
@@ -0,0 +1,25 @@
import type { CollectionConfig } from 'payload';
export const Editions: CollectionConfig = {
slug: 'editions',
admin: { useAsTitle: 'title', defaultColumns: ['number', 'title', 'publishedAt'] },
access: {
read: () => true,
create: ({ req }) => Boolean(req.user),
update: ({ req }) => Boolean(req.user),
delete: ({ req }) => req.user?.roles?.includes('admin') ?? false,
},
fields: [
{ name: 'number', type: 'number', required: true, unique: true },
{ name: 'title', type: 'text', required: true },
{ name: 'intro', type: 'textarea' },
{ name: 'cover', type: 'upload', relationTo: 'media' },
{
name: 'publishedAt',
type: 'date',
admin: { date: { pickerAppearance: 'dayAndTime' } },
},
{ name: 'articles', type: 'relationship', relationTo: 'articles', hasMany: true },
{ name: 'sentCount', type: 'number', defaultValue: 0, admin: { readOnly: true } },
],
};
+35
View File
@@ -0,0 +1,35 @@
import type { CollectionConfig } from 'payload';
import { lexicalEditor } from '@payloadcms/richtext-lexical';
export const Media: CollectionConfig = {
slug: 'media',
admin: { useAsTitle: 'alt' },
access: {
read: () => true,
create: ({ req }) => Boolean(req.user),
update: ({ req }) => Boolean(req.user),
delete: ({ req }) => Boolean(req.user),
},
upload: {
staticDir: 'media',
mimeTypes: ['image/*'],
imageSizes: [
{ name: 'thumbnail', width: 160, height: 160, position: 'centre' },
{ name: 'card', width: 640, height: 400, position: 'centre' },
{ name: 'hero', width: 1280, height: 720, position: 'centre' },
],
},
fields: [
{
name: 'alt',
type: 'text',
required: true,
},
{ name: 'caption', type: 'text' },
{
name: 'credit',
type: 'richText',
editor: lexicalEditor({}),
},
],
};
+38
View File
@@ -0,0 +1,38 @@
import type { CollectionConfig } from 'payload';
export const Subscribers: CollectionConfig = {
slug: 'subscribers',
admin: { useAsTitle: 'email', defaultColumns: ['email', 'source', 'createdAt'] },
access: {
read: ({ req }) => Boolean(req.user),
create: () => true,
update: ({ req }) => Boolean(req.user),
delete: ({ req }) => req.user?.roles?.includes('admin') ?? false,
},
fields: [
{
name: 'email',
type: 'email',
required: true,
unique: true,
index: true,
},
{
name: 'source',
type: 'text',
defaultValue: 'web',
},
{
name: 'unsubscribedAt',
type: 'date',
},
],
hooks: {
beforeChange: [
({ data }) => {
if (data?.email) data.email = String(data.email).toLowerCase().trim();
return data;
},
],
},
};
+22
View File
@@ -0,0 +1,22 @@
import type { CollectionConfig } from 'payload';
export const Tags: CollectionConfig = {
slug: 'tags',
admin: { useAsTitle: 'name' },
access: {
read: () => true,
create: ({ req }) => Boolean(req.user),
update: ({ req }) => Boolean(req.user),
delete: ({ req }) => req.user?.roles?.includes('admin') ?? false,
},
fields: [
{ name: 'name', type: 'text', required: true },
{
name: 'slug',
type: 'text',
required: true,
unique: true,
index: true,
},
],
};
+37
View File
@@ -0,0 +1,37 @@
import type { CollectionConfig } from 'payload';
export const Users: CollectionConfig = {
slug: 'users',
admin: {
useAsTitle: 'email',
defaultColumns: ['email', 'roles', 'updatedAt'],
},
auth: true,
access: {
create: ({ req }) => req.user?.roles?.includes('admin') ?? false,
delete: ({ req }) => req.user?.roles?.includes('admin') ?? false,
update: ({ req }) => Boolean(req.user),
read: ({ req }) => Boolean(req.user),
},
fields: [
{
name: 'name',
type: 'text',
required: true,
},
{
name: 'roles',
type: 'select',
hasMany: true,
defaultValue: ['editor'],
options: [
{ label: 'Admin', value: 'admin' },
{ label: 'Editor', value: 'editor' },
{ label: 'Author', value: 'author' },
],
access: {
update: ({ req }) => req.user?.roles?.includes('admin') ?? false,
},
},
],
};
+20
View File
@@ -0,0 +1,20 @@
import type { CollectionAfterChangeHook } from 'payload';
import { logger as payloadLogger } from 'payload';
const TRIGGERING = new Set(['articles', 'authors', 'categories', 'editions', 'tags']);
export const revalidateAstro: CollectionAfterChangeHook = async ({ doc, operation, collection }) => {
if (!TRIGGERING.has(collection.slug)) return doc;
if (process.env.ASTRO_REVALIDATE_URL && process.env.ASTRO_REVALIDATE_TOKEN) {
try {
const url = `${process.env.ASTRO_REVALIDATE_URL}?secret=${encodeURIComponent(
process.env.ASTRO_REVALIDATE_TOKEN,
)}&collection=${collection.slug}&op=${operation}`;
const res = await fetch(url, { method: 'POST' });
payloadLogger.info(`Revalidate ${collection.slug} ${operation}: ${res.status}`);
} catch (e) {
payloadLogger.warn(`Revalidate failed: ${(e as Error).message}`);
}
}
return doc;
};
+58
View File
@@ -0,0 +1,58 @@
import path from 'path';
import { fileURLToPath } from 'url';
import { buildConfig } from 'payload';
import { postgresAdapter } from '@payloadcms/db-postgres';
import { lexicalEditor } from '@payloadcms/richtext-lexical';
import { Users } from './collections/Users';
import { Media } from './collections/Media';
import { Articles } from './collections/Articles';
import { Authors } from './collections/Authors';
import { Categories } from './collections/Categories';
import { Tags } from './collections/Tags';
import { Editions } from './collections/Editions';
import { Subscribers } from './collections/Subscribers';
import { revalidateAstro } from './hooks/revalidateAstro';
const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);
const corsOrigins = (process.env.CORS_ORIGINS || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
export default buildConfig({
serverURL: process.env.SERVER_URL,
cors: corsOrigins,
csrf: corsOrigins,
admin: {
user: Users.slug,
importMap: { baseDir: path.resolve(dirname) },
meta: {
titleSuffix: ' · SoftVowels CMS',
},
},
collections: [Users, Media, Articles, Authors, Categories, Tags, Editions, Subscribers],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || 'dev-secret-change-me',
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
db: postgresAdapter({
pool: {
connectionString: process.env.DATABASE_URI || '',
},
}),
sharp: (sharp) => sharp,
upload: {
limits: {
fileSize: 25_000_000,
},
},
endpoints: [],
hooks: {
afterChange: [revalidateAstro],
},
});
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "preserve",
"allowJs": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"incremental": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@payload-config": ["./src/payload.config.ts"]
}
},
"include": ["src/**/*", "next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules", "dist", "build", ".next"]
}
+11
View File
@@ -0,0 +1,11 @@
# Public site origin used in canonical/OG URLs
PUBLIC_SITE_URL=https://softvowels.example
# Public (browser-reachable) Payload base URL
PUBLIC_PAYLOAD_API=https://cms.softvowels.example
# Internal Payload base URL used at build time (container-to-container)
PAYLOAD_API_INTERNAL=http://cms:3000
# Token required to call the /__revalidate webhook
REVALIDATE_TOKEN=change-me
+33
View File
@@ -0,0 +1,33 @@
# syntax=docker/dockerfile:1.7
#
# Single-shot build image for the Astro site. The production deployment uses
# this Dockerfile to produce apps/web/dist, then `docker-compose.yml` mounts
# that folder into a plain nginx:alpine container (so re-builds from the
# webhook don't require a `docker build` / push step).
#
# You only need this image if you want a self-contained static site image
# (e.g. for a registry). For the normal homeserver flow, just use
# `docker compose up -d --build web` — there is no `web` service to build.
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare [email protected] --activate
WORKDIR /app
FROM base AS deps
COPY package.json pnpm-lock.yaml* ./
COPY ../../package.json ../../pnpm-workspace.yaml* ../../ ./
RUN pnpm install --frozen-lockfile
FROM base AS build
ENV PUBLIC_PAYLOAD_API=https://cms.softvowels.example
ENV PUBLIC_SITE_URL=https://softvowels.example
ENV PAYLOAD_API_INTERNAL=http://cms:3000
COPY --from=deps /app /app
COPY . .
RUN pnpm run build
FROM nginx:1.27-alpine AS runtime
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx-default.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+28
View File
@@ -0,0 +1,28 @@
// @ts-check
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';
import icon from 'astro-icon';
import tailwindcss from '@tailwindcss/vite';
const SITE = process.env.PUBLIC_SITE_URL || 'https://softvowels.example';
export default defineConfig({
site: SITE,
output: 'static',
trailingSlash: 'never',
integrations: [mdx(), sitemap(), icon()],
vite: {
plugins: [/** @type {any} */ (tailwindcss())],
ssr: {
noExternal: ['@payloadcms/richtext-lexical'],
},
},
build: {
inlineStylesheets: 'auto',
},
prefetch: {
prefetchAll: true,
defaultStrategy: 'viewport',
},
});
+38
View File
@@ -0,0 +1,38 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# gzip everything
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss image/svg+xml;
# long cache for fingerprinted assets
location ~* \.(woff2|css|js|svg|png|jpg|jpeg|webp|avif|ico)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# sitemap / rss
location = /sitemap.xml { rewrite ^ /sitemap-index.xml redirect; }
location ~* ^/(rss\.xml|sitemap.*\.xml|robots\.txt)$ {
add_header Cache-Control "public, max-age=300";
try_files $uri =404;
}
# Astro static fallback to .html
location / {
try_files $uri $uri/ $uri.html $uri/index.html /index.html;
}
# /admin goes to the CMS upstream
location /admin {
return 301 https://cms.softvowels.example$request_uri;
}
error_page 404 /index.html;
}
+12999
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"typecheck": "astro check"
},
"dependencies": {
"@astrojs/check": "0.9.4",
"@astrojs/mdx": "4.0.3",
"@astrojs/rss": "4.0.11",
"@astrojs/sitemap": "3.2.1",
"@iconify-json/heroicons": "1.2.0",
"@iconify-json/material-symbols": "1.2.0",
"@payloadcms/richtext-lexical": "3.36.0",
"@tailwindcss/oxide": "4.1.13",
"@tailwindcss/vite": "4.1.13",
"astro": "5.1.5",
"astro-icon": "1.1.5",
"sharp": "0.33.5",
"tailwindcss": "4.1.13",
"typescript": "5.7.2"
}
}
View File
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<rect width="32" height="32" rx="6" fill="#318db8"/>
<text x="16" y="22" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="18" font-weight="700" fill="#ffffff" letter-spacing="-0.5">SV</text>
</svg>

After

Width:  |  Height:  |  Size: 320 B

+8
View File
@@ -0,0 +1,8 @@
# Fonts
Place the self-hosted variable woff2 files here:
- `Geist-Variable.woff2` (https://github.com/vercel/geist-font — `GeistVF.woff2` rename)
- `GeistMono-Variable.woff2`
The `src/styles/global.css` `@font-face` rules reference these paths.
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://softvowels.example/sitemap-index.xml
@@ -0,0 +1,14 @@
---
import '../../styles/prose.css';
import type { Article } from '../../lib/types';
import { lexicalToHtml } from '../../lib/lexical';
interface Props {
article: Article;
}
const { article } = Astro.props;
const html = lexicalToHtml(article.body);
---
<div class="prose-article mx-auto" set:html={html} />
@@ -0,0 +1,96 @@
---
import type { Article } from '../../lib/types';
import {
formatDate,
readingMinutes,
categoryOf,
authorOf,
coverOf,
avatarOf,
} from '../../lib/format';
import Chip from '../ui/Chip.astro';
interface Props {
article: Article;
variant?: 'card' | 'list' | 'feature';
showCategory?: boolean;
showCover?: boolean;
}
const { article, variant = 'card', showCategory = true, showCover = true } = Astro.props;
const cat = categoryOf(article);
const author = authorOf(article);
const cover = showCover ? coverOf(article, 'card') : null;
const minutes = readingMinutes(article);
const categoryName = cat?.name ?? 'Article';
const categorySlug = cat?.slug ?? '';
---
<article class="group flex flex-col">
{
cover && variant !== 'list' && (
<a
href={`/articles/${article.slug}`}
class="bg-surface-raised border-border-subtle mb-5 block aspect-[16/10] overflow-hidden rounded-lg border"
aria-label={article.title}
>
<img
src={cover}
alt={article.title}
width="640"
height="400"
loading="lazy"
decoding="async"
class="h-full w-full object-cover opacity-90 grayscale transition-all duration-500 group-hover:scale-105 group-hover:grayscale-0"
/>
</a>
)
}
<div class="mb-3 flex flex-wrap items-center gap-2">
{
showCategory && cat && (
<Chip variant="outline" href={`/categories/${categorySlug}`}>
{categoryName}
</Chip>
)
}
<span class="text-text-tertiary text-[11px]">
{formatDate(article.publishedAt)} · {minutes} min read
</span>
</div>
<h3
class:list={[
'font-display text-on-surface group-hover:text-primary m-0 mb-3 text-balance transition-colors',
variant === 'feature' ? 'text-headline-lg leading-tight' : 'text-headline-md leading-snug',
]}
>
<a href={`/articles/${article.slug}`}>{article.title}</a>
</h3>
{
article.excerpt && variant !== 'list' && (
<p class="text-text-secondary mb-4 line-clamp-3 text-sm leading-relaxed">{article.excerpt}</p>
)
}
{
(author || variant === 'list') && (
<div class="mt-auto flex items-center gap-3">
{author && avatarOf(author) && (
<img
src={avatarOf(author) ?? ''}
alt={author.name}
width="32"
height="32"
loading="lazy"
class="border-border-subtle h-8 w-8 rounded-full border object-cover"
/>
)}
{author && (
<div class="leading-tight">
<p class="text-label-md text-on-surface">{author.name}</p>
{author.role && <p class="text-text-tertiary text-[11px]">{author.role}</p>}
</div>
)}
</div>
)
}
</article>
@@ -0,0 +1,99 @@
---
import type { Article } from '../../lib/types';
import {
formatDate,
categoryOf,
authorOf,
coverOf,
avatarOf,
readingMinutes,
} from '../../lib/format';
import Chip from '../ui/Chip.astro';
interface Props {
article: Article;
}
const { article } = Astro.props;
const cat = categoryOf(article);
const author = authorOf(article);
const cover = coverOf(article, 'hero');
const minutes = readingMinutes(article);
const categoryName = cat?.name ?? 'Article';
const categorySlug = cat?.slug ?? '';
---
<header class="mb-12">
<div class="mb-6 flex flex-wrap items-center gap-3">
{
cat && (
<Chip variant="outline" href={`/categories/${categorySlug}`}>
{categoryName}
</Chip>
)
}
<span class="text-text-tertiary text-label-md">{minutes} min read</span>
</div>
<h1 class="font-display text-display-lg text-on-surface mb-8 text-balance leading-tight">
{article.title}
</h1>
{
author && (
<div class="border-border-subtle flex items-center gap-4 border-y py-6">
{avatarOf(author) && (
<img
src={avatarOf(author) ?? ''}
alt={author.name}
width="48"
height="48"
loading="eager"
class="border-border-subtle h-12 w-12 rounded-full border object-cover"
/>
)}
<div class="leading-tight">
<p class="text-on-surface text-body-md font-semibold">
<a href={`/authors/${author.slug}`} class="hover:text-primary transition-colors">
{author.name}
</a>
</p>
<p class="text-text-tertiary text-label-md">
{author.role ?? 'Contributor'} ·{' '}
{formatDate(article.publishedAt, { dateStyle: 'long' })}
</p>
</div>
<div class="ml-auto hidden gap-2 sm:flex">
<button
type="button"
class="border-border-subtle hover:bg-surface-floated rounded border p-2 transition-colors"
aria-label="Bookmark article"
>
<span class="material-symbols-outlined text-text-secondary text-[20px]">bookmark</span>
</button>
<button
type="button"
class="border-border-subtle hover:bg-surface-floated rounded border p-2 transition-colors"
aria-label="Share article"
>
<span class="material-symbols-outlined text-text-secondary text-[20px]">share</span>
</button>
</div>
</div>
)
}
</header>
{
cover && (
<div class="border-border-subtle bg-surface-raised mb-12 aspect-video overflow-hidden rounded-lg border">
<img
src={cover}
alt={article.title}
width="1280"
height="720"
loading="eager"
decoding="async"
class="h-full w-full object-cover"
/>
</div>
)
}
@@ -0,0 +1,26 @@
---
import Card from '../ui/Card.astro';
import SubscribeForm from '../newsletter/SubscribeForm.astro';
import Icon from '../ui/Icon.astro';
---
<aside class="my-16">
<Card variant="flat" padding="lg" class="group relative overflow-hidden">
<div class="relative z-10">
<h3 class="font-display text-headline-md text-on-surface m-0 mb-2">
Deep tech in your inbox
</h3>
<p class="text-text-secondary mb-6 max-w-md">
Join 40,000+ engineers receiving our weekly deep dives on distributed systems and software
architecture.
</p>
<SubscribeForm source="article-inline" />
</div>
<div
class="pointer-events-none absolute -bottom-12 -right-12 opacity-5 transition-transform duration-700 group-hover:scale-110"
aria-hidden="true"
>
<Icon name="terminal" size="lg" class="text-[200px]" filled />
</div>
</Card>
</aside>
@@ -0,0 +1,38 @@
---
import type { Article } from '../../lib/types';
import { formatDate, readingMinutes, categoryOf } from '../../lib/format';
interface Props {
articles: Article[];
title?: string;
}
const { articles, title = 'Related Deep Dives' } = Astro.props;
---
<section>
<h4 class="font-label-md text-label-md text-text-tertiary mb-6 uppercase tracking-widest">
{title}
</h4>
<div class="space-y-6">
{
articles.map((a) => {
const cat = categoryOf(a);
return (
<a class="group block" href={`/articles/${a.slug}`}>
<div class="text-primary mb-1 text-[12px]">{cat?.name ?? 'Article'}</div>
<div class="text-on-surface group-hover:text-primary mb-2 text-balance font-semibold leading-snug transition-colors">
{a.title}
</div>
<div class="text-text-tertiary flex items-center gap-2 text-[12px]">
<span>{readingMinutes(a)} min read</span>
<span aria-hidden="true">·</span>
<span>{formatDate(a.publishedAt)}</span>
</div>
</a>
);
})
}
{articles.length === 0 && <p class="text-text-tertiary text-sm">No related articles yet.</p>}
</div>
</section>
@@ -0,0 +1,69 @@
---
import type { Category } from '../../lib/types';
import Icon from '../ui/Icon.astro';
interface Props {
categories: Category[];
}
const { categories } = Astro.props;
---
<section class="mb-20">
<div class="mb-8 flex items-center justify-between">
<h2 class="font-display text-headline-lg text-headline-lg text-on-surface m-0">
Browse intelligence
</h2>
<div class="flex gap-2">
<button
type="button"
class="border-border-subtle text-text-tertiary hover:text-on-surface hover:border-primary rounded-md border p-2 transition-all"
aria-label="Scroll left"
data-cat-scroll="prev"
>
<Icon name="chevron_left" size="sm" />
</button>
<button
type="button"
class="border-border-subtle text-text-tertiary hover:text-on-surface hover:border-primary rounded-md border p-2 transition-all"
aria-label="Scroll right"
data-cat-scroll="next"
>
<Icon name="chevron_right" size="sm" />
</button>
</div>
</div>
<div
class="scrollbar-none -mx-4 grid auto-cols-[160px] grid-flow-col gap-6 overflow-x-auto px-4 sm:auto-cols-[180px] md:mx-0 md:auto-cols-auto md:grid-flow-row md:grid-cols-2 md:overflow-visible md:px-0 lg:grid-cols-3 xl:grid-cols-6"
id="category-grid"
>
{
categories.map((c) => (
<a
href={`/categories/${c.slug}`}
class="bg-surface-raised border-border-subtle hover:border-primary group min-w-[160px] rounded-lg border p-6 transition-all"
>
<Icon name={c.icon || 'tag'} size="lg" class="text-primary mb-4 block" />
<p class="font-label-md text-label-md group-hover:text-primary m-0 transition-colors">
{c.name}
</p>
<p class="text-text-tertiary m-0 mt-1 text-[11px]">
{c.description ? c.description.split(' ').slice(0, 3).join(' ') + '…' : 'View all'}
</p>
</a>
))
}
</div>
</section>
<script is:inline>
(() => {
const grid = document.getElementById('category-grid');
if (!grid) return;
const prev = document.querySelector('[data-cat-scroll="prev"]');
const next = document.querySelector('[data-cat-scroll="next"]');
const step = 200;
prev?.addEventListener('click', () => grid.scrollBy({ left: -step, behavior: 'smooth' }));
next?.addEventListener('click', () => grid.scrollBy({ left: step, behavior: 'smooth' }));
})();
</script>
@@ -0,0 +1,47 @@
---
import type { Article } from '../../lib/types';
import { coverOf, readingMinutes, categoryOf } from '../../lib/format';
import SectionHead from '../ui/SectionHead.astro';
interface Props {
articles: Article[];
}
const { articles } = Astro.props;
---
<aside>
<SectionHead eyebrow="Editor's picks" level={3} />
<div class="space-y-6">
{
articles.slice(0, 3).map((a) => {
const cover = coverOf(a, 'thumbnail');
const cat = categoryOf(a);
return (
<a href={`/articles/${a.slug}`} class="group flex gap-4">
{cover ? (
<img
src={cover}
alt={a.title}
width="80"
height="80"
loading="lazy"
class="border-border-subtle h-20 w-20 rounded-lg border object-cover grayscale"
/>
) : (
<div class="bg-surface-container border-border-subtle h-20 w-20 rounded-lg border" />
)}
<div class="min-w-0 flex-1">
<h5 class="font-label-md text-label-md text-on-surface group-hover:text-primary m-0 mb-1 line-clamp-2 text-balance transition-colors">
{a.title}
</h5>
<p class="text-text-tertiary m-0 text-xs">
{cat?.name ?? 'Article'} · {readingMinutes(a)} min
</p>
</div>
</a>
);
})
}
</div>
</aside>
+96
View File
@@ -0,0 +1,96 @@
---
import type { Article } from '../../lib/types';
import { coverOf, categoryOf, authorOf, avatarOf, readingMinutes } from '../../lib/format';
import Chip from '../ui/Chip.astro';
import Icon from '../ui/Icon.astro';
interface Props {
article: Article;
}
const { article } = Astro.props;
const cover = coverOf(article, 'hero');
const cat = categoryOf(article);
const author = authorOf(article);
---
<section class="mb-16 grid grid-cols-1 gap-6 lg:grid-cols-12">
<div class="flex flex-col justify-center lg:col-span-8">
<div class="mb-6 flex flex-wrap items-center gap-2">
<Chip variant="soft">Featured</Chip>
<span class="text-text-tertiary text-xs">
{readingMinutes(article)} min read · {cat?.name ?? 'Article'}
</span>
</div>
<h1
class="font-display text-display-lg text-on-surface mb-6 max-w-[90%] text-balance leading-tight"
>
{article.title}
</h1>
{
article.excerpt && (
<p class="text-text-secondary text-body-lg mb-8 max-w-2xl leading-relaxed">
{article.excerpt}
</p>
)
}
<div class="mb-10 flex items-center gap-6">
{
author && (
<div class="flex items-center gap-3">
{avatarOf(author) && (
<img
src={avatarOf(author) ?? ''}
alt={author.name}
width="40"
height="40"
loading="eager"
class="border-border-subtle h-10 w-10 rounded-full border object-cover"
/>
)}
<div class="leading-tight">
<p class="text-on-surface font-label-md m-0">{author.name}</p>
{author.role && <p class="text-text-tertiary m-0 text-xs">{author.role}</p>}
</div>
</div>
)
}
<a
href={`/articles/${article.slug}`}
class="text-primary font-label-md flex items-center gap-2 transition-all duration-300 hover:gap-4"
>
<span>Start Reading</span>
<Icon name="arrow_forward" size="sm" />
</a>
</div>
</div>
<div class="relative flex min-h-[360px] items-center justify-center lg:col-span-4">
{
cover && (
<a
href={`/articles/${article.slug}`}
class="bg-surface-raised border-border-subtle group absolute inset-0 block overflow-hidden rounded-lg border"
aria-label={article.title}
>
<img
src={cover}
alt={article.title}
width="640"
height="400"
loading="eager"
class="h-full w-full object-cover opacity-70 grayscale transition-all duration-700 group-hover:opacity-100 group-hover:grayscale-0"
/>
<div class="absolute inset-0 bg-gradient-to-t from-white/95 via-white/0 to-transparent" />
<div class="absolute bottom-6 left-6 right-6">
<span class="text-primary mb-2 block font-mono text-xs">
// {cat?.name ?? 'Feature'}
</span>
<h3 class="font-display text-headline-md text-on-surface m-0 line-clamp-2">
{article.title}
</h3>
</div>
</a>
)
}
</div>
</section>
@@ -0,0 +1,31 @@
---
import Card from '../ui/Card.astro';
import SubscribeForm from '../newsletter/SubscribeForm.astro';
interface Props {
title?: string;
body?: string;
}
const {
title = 'Stay ahead of tech.',
body = 'Receive a weekly distillation of the most impactful architectural shifts and technical breakthroughs.',
} = Astro.props;
---
<Card padding="none" class="relative overflow-hidden text-center">
<div class="pointer-events-none absolute inset-0 opacity-10" aria-hidden="true">
<div
class="border-primary/30 absolute left-1/2 top-1/2 h-[600px] w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-full border"
>
</div>
<div
class="border-primary/20 absolute left-1/2 top-1/2 h-[420px] w-[420px] -translate-x-1/2 -translate-y-1/2 rounded-full border"
>
</div>
</div>
<div class="relative z-10 mx-auto max-w-xl p-12 md:p-20">
<h2 class="font-display text-headline-lg text-headline-lg text-on-surface m-0 mb-6">{title}</h2>
<p class="text-text-secondary text-body-lg mb-10">{body}</p>
<SubscribeForm source="home-cta" layout="stacked" />
</div>
</Card>
@@ -0,0 +1,39 @@
---
import type { Article } from '../../lib/types';
import { categoryOf, readingMinutes } from '../../lib/format';
import SectionHead from '../ui/SectionHead.astro';
interface Props {
articles: Article[];
}
const { articles } = Astro.props;
---
<aside class="space-y-12">
<section>
<SectionHead eyebrow="Popular this week" level={3} />
<div class="space-y-4">
{
articles.slice(0, 3).map((a, i) => {
const cat = categoryOf(a);
return (
<a
href={`/articles/${a.slug}`}
class="bg-surface-raised border-border-subtle hover:border-primary group block rounded-lg border p-5 transition-colors"
>
<span class="text-primary mb-2 block font-mono text-[10px]">
{String(i + 1).padStart(2, '0')} / {cat?.name?.toUpperCase() ?? 'ARTICLE'}
</span>
<h4 class="font-display group-hover:text-primary m-0 text-balance text-[18px] leading-snug transition-colors">
{a.title}
</h4>
<p class="text-text-tertiary m-0 mt-2 text-[12px]">{readingMinutes(a)} min read</p>
</a>
);
})
}
{articles.length === 0 && <p class="text-text-tertiary text-sm">No articles yet.</p>}
</div>
</section>
</aside>
@@ -0,0 +1,36 @@
---
import Icon from '../ui/Icon.astro';
interface Props {
current?: 'home' | 'search' | 'saved' | 'account';
}
const { current = 'home' } = Astro.props;
const items = [
{ id: 'home' as const, label: 'Home', href: '/', icon: 'home' },
{ id: 'search' as const, label: 'Search', href: '/search', icon: 'search' },
{ id: 'saved' as const, label: 'Saved', href: '/saved', icon: 'bookmarks' },
{ id: 'account' as const, label: 'Account', href: '/account', icon: 'person' },
];
---
<nav
class="bg-surface-container-lowest border-border-subtle fixed inset-x-0 bottom-0 z-50 flex h-16 items-stretch border-t md:hidden"
aria-label="Mobile primary"
>
{
items.map((it) => (
<a
href={it.href}
class:list={[
'flex flex-1 flex-col items-center justify-center gap-1 transition-colors',
current === it.id ? 'text-primary' : 'text-on-surface-variant hover:text-on-surface',
]}
aria-current={current === it.id ? 'page' : undefined}
>
<Icon name={it.icon} size="md" filled={current === it.id} />
<span class="font-label-md text-[10px] uppercase tracking-widest">{it.label}</span>
</a>
))
}
</nav>
@@ -0,0 +1,67 @@
---
const year = new Date().getFullYear();
---
<footer class="bg-surface-container-lowest border-border-subtle border-t">
<div class="mx-auto grid max-w-[1440px] grid-cols-2 gap-6 px-4 py-16 md:grid-cols-6 md:px-10">
<div class="col-span-2">
<a
href="/"
class="font-display text-headline-md text-on-surface mb-4 block font-semibold tracking-tight"
>
SoftVowels
</a>
<p class="text-text-tertiary text-label-md max-w-xs leading-relaxed">
Engineering-focused insights for the modern software architect. © {year} SoftVowels. Engineered
for clarity.
</p>
</div>
<div class="flex flex-col gap-3">
<h6 class="text-on-surface text-label-md mb-2">Connect</h6>
<a class="text-text-tertiary hover:text-primary text-label-md transition-colors" href="#"
>Twitter</a
>
<a class="text-text-tertiary hover:text-primary text-label-md transition-colors" href="#"
>GitHub</a
>
<a
class="text-text-tertiary hover:text-primary text-label-md transition-colors"
href="/rss.xml">RSS</a
>
</div>
<div class="flex flex-col gap-3">
<h6 class="text-on-surface text-label-md mb-2">Platform</h6>
<a
class="text-text-tertiary hover:text-primary text-label-md transition-colors"
href="/articles">Articles</a
>
<a
class="text-text-tertiary hover:text-primary text-label-md transition-colors"
href="/categories">Categories</a
>
<a
class="text-text-tertiary hover:text-primary text-label-md transition-colors"
href="/newsletter">Newsletter</a
>
</div>
<div class="flex flex-col gap-3">
<h6 class="text-on-surface text-label-md mb-2">Company</h6>
<a class="text-text-tertiary hover:text-primary text-label-md transition-colors" href="#"
>Security</a
>
<a class="text-text-tertiary hover:text-primary text-label-md transition-colors" href="#"
>Privacy</a
>
<a class="text-text-tertiary hover:text-primary text-label-md transition-colors" href="#"
>Terms</a
>
</div>
<div class="flex flex-col gap-3">
<h6 class="text-on-surface text-label-md mb-2">Status</h6>
<div class="flex items-center gap-2">
<span class="bg-success h-1.5 w-1.5 rounded-full" aria-hidden="true"></span>
<span class="text-text-tertiary text-label-md">All systems operational</span>
</div>
</div>
</div>
</footer>
+91
View File
@@ -0,0 +1,91 @@
---
import Icon from '../ui/Icon.astro';
type NavCurrent =
| 'articles'
| 'changelog'
| 'philosophy'
| 'categories'
| 'pricing'
| 'newsletter'
| 'search'
| 'home';
interface Props {
current?: NavCurrent;
}
const { current = 'articles' } = Astro.props;
const links: { id: NavCurrent; label: string; href: string }[] = [
{ id: 'articles', label: 'Articles', href: '/articles' },
{ id: 'categories', label: 'Categories', href: '/categories' },
{ id: 'philosophy', label: 'Philosophy', href: '/philosophy' },
{ id: 'changelog', label: 'Changelog', href: '/changelog' },
{ id: 'newsletter', label: 'Newsletter', href: '/newsletter' },
];
---
<header class="glass-nav border-border-subtle fixed inset-x-0 top-0 z-50 h-16 border-b">
<div class="mx-auto flex h-full max-w-[1440px] items-center justify-between gap-6 px-4 md:px-10">
<div class="flex items-center gap-8">
<a
href="/"
class="font-display text-headline-md text-on-surface font-semibold tracking-tight"
aria-label="SoftVowels home"
>
SoftVowels
</a>
<nav class="hidden items-center gap-6 md:flex" aria-label="Primary">
{
links.map((l) => (
<a
href={l.href}
class:list={[
'font-label-md text-label-md transition-colors duration-200',
current === l.id
? 'text-primary border-primary border-b pb-1'
: 'text-text-secondary hover:text-on-surface',
]}
aria-current={current === l.id ? 'page' : undefined}
>
{l.label}
</a>
))
}
</nav>
</div>
<div class="flex items-center gap-3">
<form
action="/search"
method="get"
class="bg-surface-raised border-border-subtle focus-within:border-primary relative hidden items-center rounded-md border transition-all sm:flex"
>
<span class="text-text-tertiary absolute left-3">
<Icon name="search" size="sm" />
</span>
<input
type="search"
name="q"
placeholder="Search articles…"
class="placeholder:text-text-tertiary w-48 bg-transparent py-1.5 pl-9 pr-3 text-sm outline-none md:w-64"
aria-label="Search"
/>
</form>
<a
href="/search"
class="text-text-secondary hover:text-on-surface p-2 sm:hidden"
aria-label="Search"
>
<Icon name="search" size="md" />
</a>
<a
href="/newsletter"
class="bg-primary font-label-md inline-flex h-10 items-center justify-center rounded-md px-5 text-sm text-white transition-all hover:brightness-110 active:scale-[0.98]"
>
Subscribe
</a>
</div>
</div>
</header>
@@ -0,0 +1,59 @@
---
interface Props {
/** Selector for the article body to track scroll progress against. */
target?: string;
/** Sticky offset from the top (matches the fixed nav). */
offset?: number;
}
const { target = 'article', offset = 64 } = Astro.props;
---
<div
id="scroll-progress"
class="bg-primary fixed left-0 z-[60] h-[2px] transition-[width] duration-75 ease-out"
style="top: 64px; width: 0%;"
role="progressbar"
aria-label="Reading progress"
data-target={target}
data-offset={offset}
>
</div>
<script is:inline>
(() => {
const bar = document.getElementById('scroll-progress');
if (!bar) return;
const targetSelector = bar.dataset.target;
const offset = Number(bar.dataset.offset || 64);
const target = targetSelector ? document.querySelector(targetSelector) : null;
function update() {
let pct = 0;
if (target instanceof HTMLElement) {
const rect = target.getBoundingClientRect();
const total = rect.height - window.innerHeight + offset;
const seen = Math.min(Math.max(-rect.top + offset, 0), Math.max(total, 1));
pct = (seen / Math.max(total, 1)) * 100;
} else {
const h = document.documentElement;
const total = h.scrollHeight - h.clientHeight;
pct = total > 0 ? (h.scrollTop / total) * 100 : 0;
}
bar.style.width = `${Math.min(100, Math.max(0, pct))}%`;
}
let ticking = false;
const onScroll = () => {
if (!ticking) {
requestAnimationFrame(() => {
update();
ticking = false;
});
ticking = true;
}
};
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll);
update();
})();
</script>
@@ -0,0 +1,34 @@
---
import Card from '../ui/Card.astro';
import Icon from '../ui/Icon.astro';
const items = [
{
icon: 'insights',
title: 'Deep Dives',
body: 'We don\u2019t do summaries. We explore the implementation details that actually matter.',
},
{
icon: 'shield_person',
title: 'Zero Noise',
body: 'Carefully curated by senior architects. No AI-generated fluff or sponsored filler.',
},
{
icon: 'hub',
title: 'Exclusive Access',
body: 'Subscribers get first access to our private community and beta technical tools.',
},
];
---
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
{
items.map((it) => (
<Card padding="lg" class="flex flex-col gap-4">
<Icon name={it.icon} size="lg" class="text-primary text-3xl" />
<h4 class="font-display text-headline-md text-on-surface m-0">{it.title}</h4>
<p class="text-text-secondary m-0">{it.body}</p>
</Card>
))
}
</div>
@@ -0,0 +1,54 @@
---
import type { Edition } from '../../lib/types';
import { formatDate } from '../../lib/format';
import { imageUrl } from '../../lib/payload';
import Icon from '../ui/Icon.astro';
import Card from '../ui/Card.astro';
interface Props {
edition: Edition | null;
}
const { edition } = Astro.props;
const cover = edition?.cover ? imageUrl(edition.cover) : null;
---
<Card padding="none" class="group overflow-hidden">
{
cover && (
<div class="border-border-subtle h-48 overflow-hidden border-b">
<img
src={cover}
alt={`Edition #${edition?.number ?? ''}`}
width="640"
height="320"
loading="lazy"
class="h-full w-full object-cover transition-transform duration-700 group-hover:scale-105"
/>
</div>
)
}
<div class="space-y-3 p-6">
<div class="text-primary font-label-md text-label-md flex items-center gap-2 tracking-wider">
<Icon name="terminal" size="sm" />
<span>Engineering Architecture</span>
</div>
<h3 class="font-display text-headline-md text-on-background m-0 text-balance">
{edition?.title ?? 'The latest edition of SoftVowels'}
</h3>
{
edition?.intro && (
<p class="text-text-secondary text-body-md m-0 line-clamp-2">{edition.intro}</p>
)
}
<div class="border-border-subtle flex items-center justify-between border-t pt-4">
<span class="text-label-md text-text-tertiary">
{edition ? `Issue #${edition.number} · ${formatDate(edition.publishedAt)}` : 'Weekly'}
</span>
<div class="text-primary text-label-md flex items-center gap-1">
<span>Read Preview</span>
<Icon name="chevron_right" size="sm" />
</div>
</div>
</div>
</Card>
@@ -0,0 +1,106 @@
---
import Icon from '../ui/Icon.astro';
interface Props {
source?: string;
layout?: 'inline' | 'stacked';
class?: string;
}
const { source = 'web', layout = 'inline', class: className = '' } = Astro.props;
const endpoint = `${import.meta.env.PUBLIC_PAYLOAD_API || 'http://localhost:3000'}/api/subscribers`;
---
<form
method="post"
action={endpoint}
data-subscribe
data-source={source}
class:list={[
'flex w-full gap-3',
layout === 'stacked' ? 'flex-col' : 'flex-col sm:flex-row',
className,
]}
>
<label class="sr-only" for={`email-${source}`}>Email</label>
<input
id={`email-${source}`}
type="email"
name="email"
required
placeholder="you@company.com"
class:list={[
'border-border-subtle flex-1 rounded-md border bg-white outline-none transition-colors',
'focus:border-primary text-body-md',
layout === 'stacked' ? 'h-14 px-4' : 'h-10 px-4',
]}
autocomplete="email"
/>
<button
type="submit"
class:list={[
'bg-primary font-label-md inline-flex items-center justify-center gap-2 rounded-md text-sm text-white transition-all hover:brightness-110 active:scale-[0.98] disabled:opacity-70',
layout === 'stacked' ? 'text-label-md h-14 px-7' : 'h-10 px-5',
]}
>
<span data-subscribe-label>Subscribe</span>
<span data-subscribe-spinner class="hidden">
<Icon name="progress_activity" size="sm" class="animate-spin" />
</span>
</button>
<p
data-subscribe-status
role="status"
aria-live="polite"
class="text-text-tertiary m-0 basis-full text-[12px]"
>
Zero spam. One email every Tuesday. Unsubscribe anytime.
</p>
</form>
<script is:inline>
(() => {
const forms = document.querySelectorAll('form[data-subscribe]');
forms.forEach((form) => {
if (form.dataset.bound === '1') return;
form.dataset.bound = '1';
form.addEventListener('submit', async (e) => {
e.preventDefault();
const status = form.querySelector('[data-subscribe-status]');
const label = form.querySelector('[data-subscribe-label]');
const spinner = form.querySelector('[data-subscribe-spinner]');
const button = form.querySelector('button[type="submit"]');
const input = form.querySelector('input[name="email"]');
const source = form.dataset.source || 'web';
const action = form.getAttribute('action');
if (!input || !action || !status || !label || !spinner || !button) return;
if (!input.value) return;
button.disabled = true;
spinner.classList.remove('hidden');
try {
const res = await fetch(action, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ email: input.value, source }),
});
if (res.ok) {
label.textContent = 'Subscribed';
status.textContent = 'Welcome aboard. See you Tuesday.';
status.classList.add('text-success');
} else {
const data = await res.json().catch(() => null);
const msg = data?.errors?.[0]?.message || 'Subscription failed. Try again.';
status.textContent = msg;
status.classList.add('text-error');
}
} catch {
status.textContent = 'Network error. Try again.';
status.classList.add('text-error');
} finally {
spinner.classList.add('hidden');
button.disabled = false;
}
});
});
})();
</script>
@@ -0,0 +1,139 @@
---
import type { Article, Author } from '../../lib/types';
import { formatDate, readingMinutes, categoryOf } from '../../lib/format';
import Icon from '../ui/Icon.astro';
interface Props {
q: string;
articles: Article[];
authors: Author[];
}
const { q, articles, authors } = Astro.props;
const hasResults = articles.length > 0 || authors.length > 0;
---
<section class="mb-32 mt-16 space-y-12">
{
!hasResults && q && (
<div class="text-text-tertiary py-16 text-center">
<Icon name="search_off" size="lg" class="mb-4 text-5xl" />
<p class="font-display text-headline-md text-on-surface">
No matches for &ldquo;{q}&rdquo;
</p>
<p class="text-body-md">
Try a different keyword, or browse{' '}
<a class="text-primary hover:underline" href="/articles">
all articles
</a>
.
</p>
</div>
)
}
{
!q && (
<div class="text-text-tertiary py-16 text-center">
<Icon name="manage_search" size="lg" class="mb-4 text-5xl" />
<p class="font-display text-headline-md text-on-surface">Search SoftVowels</p>
<p class="text-body-md">Type a topic, technology, or author name.</p>
</div>
)
}
{
articles.length > 0 && (
<section>
<h2 class="font-display text-headline-md text-on-surface m-0 mb-6 flex items-center gap-2">
Articles
<span class="text-text-tertiary text-label-md ml-2 font-sans">
{articles.length} matches
</span>
</h2>
<div class="space-y-4">
{articles.map((a) => {
const cat = categoryOf(a);
return (
<a
href={`/articles/${a.slug}`}
class="bg-surface border-border-subtle hover:bg-surface-floated group block rounded-lg border p-6 transition-all hover:shadow-sm"
>
<div class="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div>
<div class="mb-2 flex items-center gap-3">
<span class="text-primary text-[10px] font-bold uppercase tracking-widest">
{cat?.name ?? 'Article'}
</span>
<span class="text-text-tertiary text-[10px]">·</span>
<span class="text-text-tertiary text-[10px]">
{formatDate(a.publishedAt)}
</span>
<span class="text-text-tertiary text-[10px]">·</span>
<span class="text-text-tertiary text-[10px]">{readingMinutes(a)} min</span>
</div>
<h3 class="font-display text-headline-md text-on-surface group-hover:text-primary m-0 text-balance transition-colors">
{a.title}
</h3>
{a.excerpt && (
<p class="text-text-secondary text-body-md m-0 mt-1 line-clamp-1">
{a.excerpt}
</p>
)}
</div>
<div class="hidden md:block">
<Icon
name="arrow_forward"
size="md"
class="text-text-tertiary transition-transform group-hover:translate-x-1"
/>
</div>
</div>
</a>
);
})}
</div>
</section>
)
}
{
authors.length > 0 && (
<section>
<h2 class="font-display text-headline-md text-on-surface m-0 mb-6 flex items-center gap-2">
Authors
<span class="text-text-tertiary text-label-md ml-2 font-sans">
{authors.length} matches
</span>
</h2>
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
{authors.map((au) => (
<a
href={`/authors/${au.slug}`}
class="bg-surface border-border-subtle hover:border-primary flex items-center gap-4 rounded-lg border p-6 transition-colors"
>
{au.avatar ? (
<img
src={
typeof au.avatar === 'string' ? au.avatar : (au.avatar as { url: string }).url
}
alt={au.name}
width="48"
height="48"
loading="lazy"
class="bg-surface-container h-12 w-12 rounded-full object-cover grayscale"
/>
) : (
<div class="bg-surface-container h-12 w-12 rounded-full" aria-hidden="true" />
)}
<div>
<h4 class="font-label-md text-label-md text-on-surface m-0">{au.name}</h4>
{au.role && <p class="text-text-tertiary m-0 text-xs">{au.role}</p>}
</div>
</a>
))}
</div>
</section>
)
}
</section>
+56
View File
@@ -0,0 +1,56 @@
---
type Variant = 'primary' | 'secondary' | 'ghost' | 'inverse';
type Size = 'sm' | 'md' | 'lg';
interface Props {
variant?: Variant;
size?: Size;
href?: string;
type?: 'button' | 'submit' | 'reset';
class?: string;
ariaLabel?: string;
disabled?: boolean;
id?: string;
}
const {
variant = 'primary',
size = 'md',
href,
type = 'button',
class: className = '',
ariaLabel,
disabled,
id,
} = Astro.props;
const base =
'inline-flex items-center justify-center gap-2 font-label-md rounded-md transition-all duration-200 select-none disabled:opacity-50 disabled:cursor-not-allowed active:scale-[0.98]';
const sizes: Record = {
sm: 'h-9 px-4 text-[13px]',
md: 'h-10 px-5 text-sm',
lg: 'h-12 px-7 text-label-md',
};
const variants: Record = {
primary: 'bg-primary text-white hover:brightness-110',
secondary: 'bg-white text-on-surface border border-border-subtle hover:border-primary',
ghost: 'text-primary hover:bg-primary/5',
inverse: 'bg-inverse-surface text-white hover:bg-primary',
};
const cls = [base, sizes[size], variants[variant], className].filter(Boolean).join(' ');
---
{
href ? (
<a href={href} class={cls} aria-label={ariaLabel} id={id}>
<slot />
</a>
) : (
<button type={type} class={cls} aria-label={ariaLabel} disabled={disabled} id={id}>
<slot />
</button>
)
}
+45
View File
@@ -0,0 +1,45 @@
---
interface Props {
variant?: 'raised' | 'flat';
padding?: 'sm' | 'md' | 'lg' | 'none';
interactive?: boolean;
href?: string;
class?: string;
ariaLabel?: string;
}
const {
variant = 'raised',
padding = 'md',
interactive = false,
href,
class: className = '',
ariaLabel,
} = Astro.props;
const surfaces: Record = {
raised: 'bg-surface-raised',
flat: 'bg-surface-container',
};
const pads: Record = {
none: '',
sm: 'p-4',
md: 'p-6',
lg: 'p-8',
};
const base = `${surfaces[variant]} ${pads[padding]} border border-border-subtle rounded-lg`;
const hover = interactive ? 'transition-all duration-200 hover:border-primary' : '';
const cls = [base, hover, className].filter(Boolean).join(' ');
---
{
href ? (
<a href={href} class={cls} aria-label={ariaLabel}>
<slot />
</a>
) : (
<div class={cls}>
<slot />
</div>
)
}
+33
View File
@@ -0,0 +1,33 @@
---
type Variant = 'solid' | 'outline' | 'soft' | 'mono';
interface Props {
variant?: Variant;
href?: string;
class?: string;
ariaLabel?: string;
}
const { variant = 'solid', href, class: className = '', ariaLabel } = Astro.props;
const base =
'inline-flex items-center gap-1.5 rounded-full font-label-md tracking-wider uppercase text-[11px] px-2.5 py-1 transition-colors duration-200';
const variants: Record = {
solid: 'bg-primary text-white',
outline: 'border border-primary/40 text-primary',
soft: 'bg-primary/10 text-primary',
mono: 'bg-on-surface text-background font-mono tracking-tighter',
};
const cls = [base, variants[variant], className].filter(Boolean).join(' ');
---
{
href ? (
<a href={href} class={cls} aria-label={ariaLabel}>
<slot />
</a>
) : (
<span class={cls}>
<slot />
</span>
)
}
+28
View File
@@ -0,0 +1,28 @@
---
import { Icon } from 'astro-icon/components';
interface Props {
name: string;
size?: 'sm' | 'md' | 'lg';
class?: string;
filled?: boolean;
ariaLabel?: string;
}
const { name, size = 'md', class: className = '', filled = false, ariaLabel } = Astro.props;
const sizes: Record = {
sm: 'text-[18px]',
md: 'text-[20px]',
lg: 'text-[24px]',
};
const iconifyName = name.replace(/_/g, '-');
const cls =
`material-symbols-outlined ${sizes[size]} ${filled ? 'fill-1' : ''} ${className}`.trim();
---
<Icon
name={`material-symbols:${iconifyName}`}
class={cls}
aria-label={ariaLabel}
role={ariaLabel ? 'img' : 'presentation'}
/>
@@ -0,0 +1,33 @@
---
interface Props {
eyebrow?: string;
title?: string;
href?: string;
level?: 2 | 3;
class?: string;
}
const { eyebrow, title, href, level = 2, class: className = '' } = Astro.props;
const Tag = `h${level}` as 'h2' | 'h3';
const wrapper = `flex items-end justify-between gap-6 mb-6 pb-4 border-b border-border-subtle ${className}`;
---
<div class={wrapper}>
<div class="flex items-center gap-3">
<span class="bg-primary block h-1.5 w-1.5"></span>
<Tag class="font-headline-md text-headline-md text-on-surface m-0">
{eyebrow}
{title}
</Tag>
</div>
{
href && (
<a
class="text-text-tertiary hover:text-primary transition-colors"
href={href}
aria-label={`See all ${eyebrow}`}
>
<span class="material-symbols-outlined text-[20px]">arrow_forward</span>
</a>
)
}
</div>
+13
View File
@@ -0,0 +1,13 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />
interface ImportMetaEnv {
readonly PUBLIC_SITE_URL: string;
readonly PUBLIC_PAYLOAD_API: string;
readonly PAYLOAD_API_INTERNAL: string;
readonly REVALIDATE_TOKEN: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+28
View File
@@ -0,0 +1,28 @@
---
import BaseLayout from './BaseLayout.astro';
import ScrollProgress from '../components/layout/ScrollProgress.astro';
import type { SeoInput } from '../lib/seo';
interface Props {
seo: SeoInput;
current?:
| 'articles'
| 'changelog'
| 'philosophy'
| 'categories'
| 'pricing'
| 'newsletter'
| 'search'
| 'home';
/** When true the 2px primary scroll progress bar is rendered. */
progress?: boolean;
bodyClass?: string;
}
const { seo: s, current = 'articles', progress = true, bodyClass = '' } = Astro.props;
---
<BaseLayout seo={s} current={current} bodyClass={bodyClass}>
{progress && <ScrollProgress target="article" offset={64} />}
<slot />
</BaseLayout>
+97
View File
@@ -0,0 +1,97 @@
---
import '../styles/global.css';
import Nav from '../components/layout/Nav.astro';
import Footer from '../components/layout/Footer.astro';
import BottomNav from '../components/layout/BottomNav.astro';
import { seo, type SeoInput } from '../lib/seo';
interface Props {
seo: SeoInput;
current?:
| 'articles'
| 'changelog'
| 'philosophy'
| 'categories'
| 'pricing'
| 'newsletter'
| 'search'
| 'home'
| 'saved'
| 'account'
| 'sitemap';
/** When true the bottom mobile tab bar is rendered (default true). */
bottomNav?: boolean;
/** When true the main content gets extra bottom padding to clear the mobile tab bar. */
padForBottomNav?: boolean;
bodyClass?: string;
}
const {
seo: s,
current = 'home',
bottomNav = true,
padForBottomNav = true,
bodyClass = '',
} = Astro.props;
const meta = seo(s);
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="generator" content={Astro.generator} />
<meta name="theme-color" content="#ffffff" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="alternate" type="application/rss+xml" title="SoftVowels" href="/rss.xml" />
<link rel="sitemap" href="/sitemap-index.xml" />
<title>{meta.title}</title>
<meta name="description" content={meta.description} />
<link rel="canonical" href={meta.canonical} />
<meta property="og:title" content={meta.openGraph.title} />
<meta property="og:description" content={meta.openGraph.description} />
<meta property="og:url" content={meta.openGraph.url} />
<meta property="og:site_name" content={meta.openGraph.siteName} />
<meta property="og:image" content={meta.openGraph.images[0]?.url} />
<meta property="og:type" content={meta.openGraph.type} />
{
meta.openGraph.publishedTime && (
<meta property="article:published_time" content={meta.openGraph.publishedTime} />
)
}
{meta.openGraph.authors?.map((a) => <meta property="article:author" content={a} />)}
{meta.openGraph.tags?.map((t) => <meta property="article:tag" content={t} />)}
<meta name="twitter:card" content={meta.twitter.card} />
<meta name="twitter:title" content={meta.twitter.title} />
<meta name="twitter:description" content={meta.twitter.description} />
<meta name="twitter:image" content={meta.twitter.images[0]} />
</head>
<body class:list={[bodyClass, padForBottomNav && bottomNav ? 'pb-16 md:pb-0' : '']}>
<a
href="#main"
class="focus:text-on-surface focus:border-primary sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-[100] focus:rounded-md focus:border focus:bg-white focus:px-3 focus:py-2"
>
Skip to content
</a>
<Nav
current={current as
| 'articles'
| 'changelog'
| 'philosophy'
| 'categories'
| 'pricing'
| 'newsletter'
| 'search'
| 'home'}
/>
<main id="main" class="pt-16">
<slot />
</main>
<Footer />
{bottomNav && <BottomNav current={current === 'home' ? 'home' : (current as 'search')} />}
</body>
</html>
+51
View File
@@ -0,0 +1,51 @@
import type { Article, Author, Category } from './types';
import { imageUrl } from './payload';
export function formatDate(d: string | Date, opts: Intl.DateTimeFormatOptions = {}): string {
const date = typeof d === 'string' ? new Date(d) : d;
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
...opts,
}).format(date);
}
export function readingMinutes(article: Article): number {
if (article.readingMinutes) return article.readingMinutes;
const body = article.body as { root?: { children?: unknown[] } } | null;
const n = JSON.stringify(body ?? '').split(/\s+/).length;
return Math.max(1, Math.round(n / 220));
}
export function categoryOf(article: Article): Category | null {
return typeof article.category === 'object' ? article.category : null;
}
export function tagsOf(article: Article) {
return Array.isArray(article.tags) ? article.tags.filter((t) => typeof t === 'object') : [];
}
export function authorOf(article: Article): Author | null {
return typeof article.author === 'object' ? article.author : null;
}
export function avatarOf(author: Author | null): string | null {
if (!author) return null;
return imageUrl(author.avatar);
}
export function coverOf(
article: Article,
size: 'card' | 'hero' | 'thumbnail' = 'card',
): string | null {
const cover = article.coverImage;
if (!cover || typeof cover === 'string') return imageUrl(cover);
const sized = cover.sizes?.[size]?.url;
if (sized) {
return sized.startsWith('http')
? sized
: `${(cover.sizes as Record<string, { url: string }>)[size]?.url}`;
}
return imageUrl(cover);
}
+14
View File
@@ -0,0 +1,14 @@
import { convertLexicalToHTML, defaultHTMLConverters } from '@payloadcms/richtext-lexical/html';
import type { SerializedEditorState } from 'lexical';
export type LexicalDoc = SerializedEditorState | unknown;
export function lexicalToHtml(doc: LexicalDoc): string {
if (!doc || typeof doc !== 'object') return '';
const data = doc as SerializedEditorState;
try {
return convertLexicalToHTML({ data, converters: defaultHTMLConverters });
} catch (err) {
return `<p class="text-error">Failed to render content: ${(err as Error).message}</p>`;
}
}
+241
View File
@@ -0,0 +1,241 @@
import type { Article, Author, Category, Edition } from './types';
const API = (import.meta.env.PAYLOAD_API_INTERNAL ||
import.meta.env.PUBLIC_PAYLOAD_API ||
'http://localhost:3000') as string;
const ORIGIN = (import.meta.env.PUBLIC_SITE_URL || 'http://localhost:4321') as string;
interface ListOpts {
limit?: number;
page?: number;
sort?: string;
where?: Record<string, unknown>;
depth?: number;
draft?: boolean;
}
function qs(opts: ListOpts = {}): string {
const sp = new URLSearchParams();
if (opts.limit) sp.set('limit', String(opts.limit));
if (opts.page) sp.set('page', String(opts.page));
if (opts.sort) sp.set('sort', opts.sort);
if (opts.depth) sp.set('depth', String(opts.depth));
if (opts.draft) sp.set('draft', 'true');
if (opts.where) sp.set('where', JSON.stringify(opts.where));
const s = sp.toString();
return s ? `?${s}` : '';
}
async function get<T>(path: string, opts: ListOpts = {}): Promise<T> {
let res: Response;
try {
res = await fetch(`${API}${path}${qs(opts)}`, {
headers: { Accept: 'application/json' },
});
} catch (err) {
if (isBuildTime() && process.env.PAYLOAD_BUILD_TOLERANT !== 'false') {
console.warn(`[payload] ${path} unreachable at build time: ${(err as Error).message}`);
return emptyResult() as T;
}
throw new Error(`Payload unreachable for ${path}: ${(err as Error).message}`);
}
if (!res.ok) {
if (isBuildTime() && process.env.PAYLOAD_BUILD_TOLERANT !== 'false') {
console.warn(`[payload] ${path} returned ${res.status} at build time`);
return emptyResult() as T;
}
throw new Error(`Payload ${res.status} ${res.statusText} for ${path}`);
}
return (await res.json()) as T;
}
function isBuildTime(): boolean {
return process.env.NODE_ENV === 'production' || process.env.ASTRO_BUILD === '1';
}
function emptyResult(): Paginated<unknown> {
return { docs: [], totalDocs: 0, totalPages: 0, page: 1, hasNextPage: false, hasPrevPage: false };
}
export const payloadOrigin = () => ORIGIN;
export const payloadApiOrigin = () => API;
export interface Paginated<T> {
docs: T[];
totalDocs: number;
totalPages: number;
page: number;
hasNextPage: boolean;
hasPrevPage: boolean;
}
const PUBLISHED = { status: { equals: 'published' } };
export async function getCategories(): Promise<Category[]> {
const r = await get<Paginated<Category>>('/api/categories', { limit: 100, sort: 'order' });
return r.docs;
}
export async function getCategoryBySlug(slug: string): Promise<Category | null> {
const r = await get<Paginated<Category>>('/api/categories', {
where: { and: [{ slug: { equals: slug } }] },
limit: 1,
depth: 1,
});
return r.docs[0] ?? null;
}
export async function getAuthors(): Promise<Author[]> {
const r = await get<Paginated<Author>>('/api/authors', { limit: 100, sort: 'name' });
return r.docs;
}
export async function getAuthorBySlug(slug: string): Promise<Author | null> {
const r = await get<Paginated<Author>>('/api/authors', {
where: { and: [{ slug: { equals: slug } }] },
limit: 1,
depth: 1,
});
return r.docs[0] ?? null;
}
export async function getArticles(
opts: {
limit?: number;
page?: number;
categorySlug?: string;
tagSlug?: string;
featured?: boolean;
} = {},
): Promise<Paginated<Article>> {
const where: Record<string, unknown>[] = [PUBLISHED];
if (opts.categorySlug) where.push({ 'category.slug': { equals: opts.categorySlug } });
if (opts.tagSlug) where.push({ 'tags.slug': { equals: opts.tagSlug } });
if (opts.featured) where.push({ featured: { equals: true } });
return get<Paginated<Article>>('/api/articles', {
limit: opts.limit ?? 24,
page: opts.page ?? 1,
sort: '-publishedAt',
depth: 1,
where: { and: where },
});
}
export async function getArticleBySlug(slug: string): Promise<Article | null> {
const r = await get<Paginated<Article>>('/api/articles', {
where: { and: [PUBLISHED, { slug: { equals: slug } }] },
limit: 1,
depth: 2,
});
return r.docs[0] ?? null;
}
export async function getAllArticleSlugs(): Promise<string[]> {
const r = await get<Paginated<Article>>('/api/articles', {
limit: 1000,
sort: '-publishedAt',
where: PUBLISHED,
});
return r.docs.map((a) => a.slug);
}
export async function getRelated(article: Article, limit = 3): Promise<Article[]> {
const cat = typeof article.category === 'object' ? article.category.slug : article.category;
const r = await get<Paginated<Article>>('/api/articles', {
limit,
sort: '-publishedAt',
where: {
and: [PUBLISHED, { 'category.slug': { equals: cat } }, { id: { not_equals: article.id } }],
},
});
return r.docs;
}
export async function getPopular(limit = 3): Promise<Article[]> {
const r = await get<Paginated<Article>>('/api/articles', {
limit,
sort: '-publishedAt',
where: PUBLISHED,
});
return r.docs;
}
export async function getEditorsPicks(limit = 3): Promise<Article[]> {
const r = await get<Paginated<Article>>('/api/articles', {
limit,
where: { and: [PUBLISHED, { featured: { equals: true } }] },
sort: 'featuredRank',
});
return r.docs;
}
export async function search(q: string): Promise<{ articles: Article[]; authors: Author[] }> {
if (!q || q.length < 2) return { articles: [], authors: [] };
const like = { like: q };
let ar: Paginated<Article> = emptyResult() as Paginated<Article>;
let au: Paginated<Author> = emptyResult() as Paginated<Author>;
try {
[ar, au] = await Promise.all([
get<Paginated<Article>>('/api/articles', {
limit: 12,
sort: '-publishedAt',
where: { and: [PUBLISHED, { or: [{ title: like }, { excerpt: like }] }] },
}),
get<Paginated<Author>>('/api/authors', {
limit: 6,
where: { or: [{ name: like }, { role: like }] },
}),
]);
} catch (e) {
console.warn(`[payload] search("${q}") failed: ${(e as Error).message}`);
}
return { articles: ar.docs, authors: au.docs };
}
export async function getLatestEdition(): Promise<Edition | null> {
try {
const r = await get<Paginated<Edition>>('/api/editions', {
limit: 1,
sort: '-publishedAt',
depth: 1,
});
return r.docs[0] ?? null;
} catch {
return null;
}
}
export interface SubscribeResult {
ok: boolean;
message: string;
}
export async function subscribe(email: string, source = 'web'): Promise<SubscribeResult> {
const url = `${API}/api/subscribers`;
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ email, source }),
});
if (res.ok) return { ok: true, message: 'Subscribed. See you Tuesday.' };
const data = (await res.json().catch(() => null)) as { errors?: { message: string }[] } | null;
const msg = data?.errors?.[0]?.message || 'Could not subscribe. Try again.';
return { ok: false, message: msg };
} catch {
return { ok: false, message: 'Network error. Try again.' };
}
}
export function imageUrl(m: unknown): string | null {
if (!m) return null;
if (typeof m === 'string') return m;
if (typeof m === 'object' && m && 'url' in m) {
const url = (m as { url: string }).url;
if (url.startsWith('http')) return url;
return `${API}${url}`;
}
return null;
}
+43
View File
@@ -0,0 +1,43 @@
const SITE = (import.meta.env.PUBLIC_SITE_URL || 'https://softvowels.example') as string;
export interface SeoInput {
title: string;
description?: string;
image?: string | null;
url?: string;
type?: 'website' | 'article';
publishedTime?: string;
author?: string;
tags?: string[];
}
export function seo(input: SeoInput) {
const title = input.title.endsWith('· SoftVowels') ? input.title : `${input.title} · SoftVowels`;
const description =
input.description ?? 'Engineering-focused insights for the modern software architect.';
const url = input.url ?? SITE;
const image = input.image ?? `${SITE}/og-default.png`;
return {
title,
description,
canonical: url,
openGraph: {
title,
description,
url,
siteName: 'SoftVowels',
images: [{ url: image }],
locale: 'en_US',
type: input.type ?? 'website',
...(input.publishedTime ? { publishedTime: input.publishedTime } : {}),
...(input.author ? { authors: [input.author] } : {}),
...(input.tags?.length ? { tags: input.tags } : {}),
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [image],
},
};
}
+81
View File
@@ -0,0 +1,81 @@
export interface Media {
id: string;
url: string;
alt?: string;
width?: number;
height?: number;
mimeType?: string;
sizes?: {
thumbnail?: { url: string; width: number; height: number };
card?: { url: string; width: number; height: number };
hero?: { url: string; width: number; height: number };
};
}
export interface Author {
id: string;
name: string;
slug: string;
role?: string;
bio?: string;
avatar?: Media | string | null;
socials?: {
twitter?: string;
github?: string;
site?: string;
};
}
export interface Category {
id: string;
name: string;
slug: string;
description?: string;
icon?: string;
color?: string;
order?: number;
}
export interface Tag {
id: string;
name: string;
slug: string;
}
export interface Article {
id: string;
title: string;
slug: string;
excerpt?: string;
body: unknown; // Lexical SerializedEditorState — rendered via lib/lexical.ts
coverImage?: Media | string | null;
category: Category | string;
tags?: (Tag | string)[];
author: Author | string;
status: 'draft' | 'published';
publishedAt: string;
readingMinutes?: number;
featured?: boolean;
featuredRank?: number;
seo?: {
title?: string;
description?: string;
ogImage?: Media | string | null;
};
}
export interface Edition {
id: string;
number: number;
title: string;
intro?: string;
publishedAt: string;
cover?: Media | string | null;
articles?: (Article | string)[];
}
export type Resolved<T> = T extends string | number | null | undefined
? T
: T extends object
? { [K in keyof T]: Resolved<T[K]> }
: T;
+40
View File
@@ -0,0 +1,40 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import Card from '../components/ui/Card.astro';
import Icon from '../components/ui/Icon.astro';
---
<BaseLayout
seo={{
title: '404 — Not found',
description: 'The page you were looking for could not be found.',
}}
current="home"
>
<section class="mx-auto max-w-[800px] px-4 pb-32 pt-24 text-center md:px-10">
<span class="text-primary text-label-md font-mono uppercase tracking-widest">Error 404</span>
<h1 class="font-display text-display-lg text-on-surface m-0 mb-6 mt-3 text-balance">
This page wandered off.
</h1>
<p class="text-text-secondary text-body-lg mb-12">
The link may be broken, or the article may have been moved. Try one of these instead:
</p>
<div class="grid grid-cols-1 gap-4 text-left sm:grid-cols-3">
<Card padding="lg" interactive href="/" class="flex flex-col gap-2">
<Icon name="home" size="md" class="text-primary" />
<h3 class="font-display text-label-md text-on-surface m-0">Home</h3>
<p class="text-text-tertiary m-0 text-xs">Latest intelligence</p>
</Card>
<Card padding="lg" interactive href="/articles" class="flex flex-col gap-2">
<Icon name="article" size="md" class="text-primary" />
<h3 class="font-display text-label-md text-on-surface m-0">Articles</h3>
<p class="text-text-tertiary m-0 text-xs">Browse all</p>
</Card>
<Card padding="lg" interactive href="/search" class="flex flex-col gap-2">
<Icon name="search" size="md" class="text-primary" />
<h3 class="font-display text-label-md text-on-surface m-0">Search</h3>
<p class="text-text-tertiary m-0 text-xs">Find anything</p>
</Card>
</div>
</section>
</BaseLayout>
+19
View File
@@ -0,0 +1,19 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout
seo={{ title: 'Account', description: 'Your account.' }}
current="home"
bottomNav={true}
>
<section class="mx-auto max-w-[800px] px-4 pb-24 pt-24 text-center md:px-10">
<span class="text-primary text-label-md font-mono uppercase tracking-widest">Account</span>
<h1 class="font-display text-headline-lg text-on-surface m-0 mb-4 mt-3 text-balance">
Sign in to continue.
</h1>
<p class="text-text-secondary text-body-lg">
Account features (saved articles, comments) are coming soon.
</p>
</section>
</BaseLayout>
+95
View File
@@ -0,0 +1,95 @@
---
import ArticleLayout from '../../layouts/ArticleLayout.astro';
import ArticleHeader from '../../components/article/ArticleHeader.astro';
import ArticleBody from '../../components/article/ArticleBody.astro';
import RelatedRail from '../../components/article/RelatedRail.astro';
import InlineNewsletter from '../../components/article/InlineNewsletter.astro';
import Icon from '../../components/ui/Icon.astro';
import { getArticleBySlug, getAllArticleSlugs, getRelated } from '../../lib/payload';
import { formatDate, readingMinutes, authorOf } from '../../lib/format';
export async function getStaticPaths() {
const slugs = await getAllArticleSlugs();
return slugs.map((slug) => ({ params: { slug } }));
}
const { slug } = Astro.params as { slug: string };
const article = await getArticleBySlug(slug);
if (!article) {
return Astro.redirect('/404');
}
const related = await getRelated(article, 3);
const author = authorOf(article);
---
<ArticleLayout
seo={{
title: article.seo?.title || article.title,
description: article.seo?.description || article.excerpt || '',
type: 'article',
publishedTime: article.publishedAt,
author: author?.name,
tags: Array.isArray(article.tags)
? article.tags.filter((t) => typeof t === 'object').map((t) => t.name)
: [],
}}
current="articles"
>
<article
class="mx-auto grid max-w-[1440px] grid-cols-1 gap-16 px-4 pb-24 pt-12 md:px-10 lg:grid-cols-[1fr_300px]"
>
<div class="mx-auto w-full max-w-[720px] lg:mx-0">
<ArticleHeader article={article} />
<ArticleBody article={article} />
<InlineNewsletter />
<footer
class="border-border-subtle mt-12 flex flex-wrap items-center justify-between gap-3 border-t pt-8"
>
<div class="flex items-center gap-2">
<button
class="border-border-subtle hover:bg-surface-floated rounded-md border p-2 transition-colors"
aria-label="Applaud"
>
<Icon name="thumb_up" size="md" class="text-text-secondary" />
</button>
<button
class="border-border-subtle hover:bg-surface-floated rounded-md border p-2 transition-colors"
aria-label="Comment"
>
<Icon name="chat_bubble" size="md" class="text-text-secondary" />
</button>
</div>
<p class="text-text-tertiary text-label-md m-0">
Published {formatDate(article.publishedAt, { dateStyle: 'long' })} · {
readingMinutes(article)
} min read
</p>
</footer>
</div>
<aside class="hidden lg:block">
<div class="sticky top-24 space-y-12">
<RelatedRail articles={related} />
<section class="bg-surface-dim border-border-subtle rounded-lg border p-6">
<h4 class="font-label-md text-label-md text-on-surface m-0 mb-4">Table of contents</h4>
<ul class="text-text-secondary m-0 list-none space-y-3 p-0 text-[13px]">
<li class="hover:text-primary cursor-pointer transition-colors">Introduction</li>
<li
class="hover:text-primary border-border-subtle cursor-pointer border-l pl-4 transition-colors"
>
Context
</li>
<li class="text-primary border-primary cursor-pointer border-l pl-4 font-medium">
Implementation
</li>
<li
class="hover:text-primary border-border-subtle cursor-pointer border-l pl-4 transition-colors"
>
Observability
</li>
<li class="hover:text-primary cursor-pointer transition-colors">Conclusion</li>
</ul>
</section>
</div>
</aside>
</article>
</ArticleLayout>
+76
View File
@@ -0,0 +1,76 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import ArticleCard from '../../components/article/ArticleCard.astro';
import SectionHead from '../../components/ui/SectionHead.astro';
import { getArticles, getPopular } from '../../lib/payload';
const page = Number(Astro.url.searchParams.get('page') || 1);
const [{ docs, hasPrevPage, hasNextPage }, popular] = await Promise.all([
getArticles({ limit: 12, page }),
getPopular(3),
]);
---
<BaseLayout
seo={{ title: 'Articles', description: 'All articles published on SoftVowels.' }}
current="articles"
>
<div
class="mx-auto grid max-w-[1440px] grid-cols-1 gap-12 px-4 pb-24 pt-12 md:px-10 lg:grid-cols-12"
>
<div class="lg:col-span-8">
<SectionHead eyebrow="All articles" level={2} />
<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
{docs.map((a) => <ArticleCard article={a} />)}
</div>
<nav class="mt-12 flex items-center justify-between" aria-label="Pagination">
{
hasPrevPage ? (
<a
href={`/articles?page=${page - 1}`}
class="border-border-subtle text-label-md hover:border-primary inline-flex h-10 items-center rounded-md border px-5 transition-colors"
>
← Newer
</a>
) : (
<span />
)
}
{
hasNextPage ? (
<a
href={`/articles?page=${page + 1}`}
class="border-border-subtle text-label-md hover:border-primary inline-flex h-10 items-center rounded-md border px-5 transition-colors"
>
Older →
</a>
) : (
<span />
)
}
</nav>
</div>
<aside class="space-y-8 lg:col-span-4">
<h3 class="font-label-md text-label-md text-text-tertiary m-0 uppercase tracking-widest">
Popular this week
</h3>
<div class="space-y-4">
{
popular.map((a, i) => (
<a
href={`/articles/${a.slug}`}
class="bg-surface-raised border-border-subtle hover:border-primary group block rounded-lg border p-5 transition-colors"
>
<span class="text-primary mb-2 block font-mono text-[10px]">
{String(i + 1).padStart(2, '0')}
</span>
<h4 class="font-display group-hover:text-primary m-0 text-balance text-[18px] leading-snug transition-colors">
{a.title}
</h4>
</a>
))
}
</div>
</aside>
</div>
</BaseLayout>
+97
View File
@@ -0,0 +1,97 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import ArticleCard from '../../components/article/ArticleCard.astro';
import { getAuthorBySlug, getAuthors, getArticles } from '../../lib/payload';
import { imageUrl } from '../../lib/payload';
export async function getStaticPaths() {
const authors = await getAuthors();
return authors.map((a) => ({ params: { slug: a.slug } }));
}
const { slug } = Astro.params as { slug: string };
const author = await getAuthorBySlug(slug);
if (!author) return Astro.redirect('/404');
const { docs } = await getArticles({ limit: 24 });
const myArticles = docs.filter((a) => {
const aId = typeof a.author === 'object' ? a.author.id : a.author;
return aId === author.id;
});
const avatar = imageUrl(author.avatar);
---
<BaseLayout
seo={{ title: author.name, description: author.role ?? `Articles by ${author.name}` }}
current="articles"
>
<div class="mx-auto max-w-[1100px] px-4 pb-24 pt-12 md:px-10">
<header class="border-border-subtle mb-12 flex items-center gap-6 border-b pb-8">
{
avatar && (
<img
src={avatar}
alt={author.name}
width="96"
height="96"
loading="eager"
class="border-border-subtle h-24 w-24 rounded-full border object-cover"
/>
)
}
<div>
<h1 class="font-display text-headline-lg text-on-surface m-0 mb-2 text-balance">
{author.name}
</h1>
{author.role && <p class="text-text-secondary text-label-md m-0">{author.role}</p>}
{
author.bio && (
<p class="text-text-secondary text-body-md m-0 mt-3 max-w-prose">{author.bio}</p>
)
}
<div class="mt-4 flex gap-3">
{
author.socials?.twitter && (
<a
class="text-text-tertiary hover:text-primary transition-colors"
href={`https://twitter.com/${author.socials.twitter}`}
rel="me noopener"
>
Twitter
</a>
)
}
{
author.socials?.github && (
<a
class="text-text-tertiary hover:text-primary transition-colors"
href={`https://github.com/${author.socials.github}`}
rel="me noopener"
>
GitHub
</a>
)
}
{
author.socials?.site && (
<a
class="text-text-tertiary hover:text-primary transition-colors"
href={author.socials.site}
rel="me noopener"
>
Site
</a>
)
}
</div>
</div>
</header>
<div class="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3">
{myArticles.map((a) => <ArticleCard article={a} />)}
{
myArticles.length === 0 && (
<p class="text-text-tertiary text-sm">No published articles yet.</p>
)
}
</div>
</div>
</BaseLayout>
+127
View File
@@ -0,0 +1,127 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import ArticleCard from '../../components/article/ArticleCard.astro';
import Icon from '../../components/ui/Icon.astro';
import { getArticles, getCategories, getCategoryBySlug, getPopular } from '../../lib/payload';
export async function getStaticPaths() {
const cats = await getCategories();
return cats.map((c) => ({ params: { slug: c.slug } }));
}
const { slug } = Astro.params as { slug: string };
const category = await getCategoryBySlug(slug);
if (!category) return Astro.redirect('/404');
const [{ docs }, popular] = await Promise.all([
getArticles({ categorySlug: slug, limit: 24 }),
getPopular(3),
]);
const featured = docs.find((a) => a.featured) ?? docs[0];
const rest = docs.filter((a) => a.id !== featured?.id);
---
<BaseLayout
seo={{
title: category.name,
description: category.description ?? `Articles in ${category.name}`,
}}
current="categories"
>
<div class="mx-auto max-w-[1440px] px-4 pb-24 pt-12 md:px-10">
<div class="max-w-article mb-12">
<div class="text-primary mb-4 flex items-center gap-2">
<Icon name={category.icon || 'psychology'} size="sm" />
<span class="font-label-md text-label-md uppercase tracking-widest">Intelligence</span>
</div>
<h1 class="font-display text-display-lg text-on-surface m-0 mb-6 text-balance">
{category.name}
</h1>
{
category.description && (
<p class="text-text-secondary text-body-lg leading-relaxed">{category.description}</p>
)
}
</div>
<section class="mb-12 flex flex-wrap gap-3">
<button
class="border-border-subtle bg-surface-floated text-on-surface font-label-md text-label-md hover:bg-surface-container rounded-full border px-4 py-1.5 transition-all"
>
All {category.name}
</button>
</section>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-12">
<aside class="order-2 lg:order-1 lg:col-span-3">
<div class="sticky top-24 space-y-8">
<div>
<h3
class="font-label-md text-label-md text-text-tertiary mb-6 uppercase tracking-widest"
>
Trending now
</h3>
<div class="space-y-6">
{
popular.map((a, i) => (
<a class="group block" href={`/articles/${a.slug}`}>
<span class="text-text-tertiary mb-1 block font-mono text-xs">
{String(i + 1).padStart(2, '0')}
</span>
<h4 class="font-display text-body-md text-on-surface group-hover:text-primary mb-2 text-balance transition-colors">
{a.title}
</h4>
<div class="text-text-tertiary flex items-center gap-2 text-[12px]">
<span>Quick read</span>
</div>
</a>
))
}
</div>
</div>
<div
class="bg-surface-raised border-border-subtle relative overflow-hidden rounded-lg border p-6"
>
<h5 class="font-display text-label-md text-on-surface relative z-10 m-0 mb-2">
SoftVowels Pro
</h5>
<p class="text-text-secondary relative z-10 mb-4 text-[13px] leading-snug">
Unlock deep-dive technical reports and exclusive datasets on the {category.name} sector.
</p>
<a
href="/newsletter"
class="bg-on-surface text-background font-label-md text-label-md hover:bg-primary relative z-10 block w-full rounded py-2 text-center transition-colors"
>
Join the waitlist
</a>
</div>
</div>
</aside>
<div class="order-1 lg:order-2 lg:col-span-9">
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
{
featured && (
<div class="md:col-span-2">
<ArticleCard article={featured} variant="feature" />
</div>
)
}
{rest.map((a) => <ArticleCard article={a} />)}
</div>
<div class="mt-16 flex justify-center">
<a
href={`/articles?category=${slug}`}
class="border-border-subtle font-label-md text-label-md text-on-surface hover:bg-surface-container group flex items-center gap-2 rounded border px-8 py-3 transition-colors"
>
Load more articles
<Icon
name="expand_more"
size="sm"
class="transition-transform group-hover:translate-y-0.5"
/>
</a>
</div>
</div>
</div>
</div>
</BaseLayout>
+47
View File
@@ -0,0 +1,47 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import Card from '../../components/ui/Card.astro';
import Icon from '../../components/ui/Icon.astro';
import { getCategories } from '../../lib/payload';
const categories = await getCategories();
---
<BaseLayout
seo={{ title: 'Categories', description: 'Browse SoftVowels articles by topic.' }}
current="categories"
>
<div class="mx-auto max-w-[1440px] px-4 pb-24 pt-12 md:px-10">
<div class="max-w-article mb-12">
<span class="text-primary text-label-md font-mono uppercase tracking-widest"
>Intelligence</span
>
<h1 class="font-display text-display-lg text-on-surface m-0 mb-6 mt-2 text-balance">
All categories
</h1>
<p class="text-text-secondary text-body-lg leading-relaxed">
Deep dives, technical analysis, and architectural patterns — organized by topic.
</p>
</div>
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{
categories.map((c) => (
<Card
padding="lg"
interactive
href={`/categories/${c.slug}`}
class="flex items-start gap-4"
>
<Icon name={c.icon || 'tag'} size="lg" class="text-primary text-3xl" />
<div>
<h3 class="font-display text-headline-md text-on-surface group-hover:text-primary m-0 mb-2 transition-colors">
{c.name}
</h3>
{c.description && <p class="text-text-secondary m-0 text-sm">{c.description}</p>}
</div>
</Card>
))
}
</div>
</div>
</BaseLayout>
+51
View File
@@ -0,0 +1,51 @@
---
import '../styles/global.css';
import BaseLayout from '../layouts/BaseLayout.astro';
const changes = [
{
v: '0.1.0',
d: '2026-06-17',
items: ['Initial release.', 'Articles, categories, authors, search, newsletter.'],
},
{ v: '0.0.1', d: '2026-06-10', items: ['Scaffold.'] },
];
---
<BaseLayout
seo={{ title: 'Changelog', description: 'What is new on SoftVowels.' }}
current="changelog"
>
<article class="max-w-article mx-auto px-4 pb-24 pt-16 md:px-10">
<header class="mb-12">
<span class="text-primary text-label-md font-mono uppercase tracking-widest">Changelog</span>
<h1 class="font-display text-display-lg text-on-surface m-0 mb-6 mt-3 text-balance">
What is new.
</h1>
<p class="text-text-secondary text-body-lg leading-relaxed">
A running log of changes to the site. Frequency: roughly once a week.
</p>
</header>
<ol class="m-0 list-none space-y-12 p-0">
{
changes.map((c) => (
<li class="border-border-subtle relative border-l-2 pl-6">
<span class="bg-primary absolute -left-[7px] top-1 h-3 w-3 rounded-full" />
<div class="mb-3 flex items-baseline gap-3">
<span class="text-primary text-label-md font-mono">v{c.v}</span>
<span class="text-text-tertiary text-xs">{c.d}</span>
</div>
<ul class="m-0 list-none space-y-2 p-0">
{c.items.map((it) => (
<li class="text-text-secondary text-body-md flex gap-2">
<span class="text-primary">+</span>
<span>{it}</span>
</li>
))}
</ul>
</li>
))
}
</ol>
</article>
</BaseLayout>
+73
View File
@@ -0,0 +1,73 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import Hero from '../components/home/Hero.astro';
import CategoryGrid from '../components/home/CategoryGrid.astro';
import ArticleCard from '../components/article/ArticleCard.astro';
import PopularList from '../components/home/PopularList.astro';
import EditorsPicks from '../components/home/EditorsPicks.astro';
import NewsletterCTA from '../components/home/NewsletterCTA.astro';
import SectionHead from '../components/ui/SectionHead.astro';
import { getArticles, getCategories, getEditorsPicks, getPopular } from '../lib/payload';
const [categories, popular, picks, latest] = await Promise.all([
getCategories(),
getPopular(3),
getEditorsPicks(6),
getArticles({ limit: 4 }),
]);
const featured = picks[0] ?? latest.docs[0] ?? null;
const feed = latest.docs;
---
<BaseLayout
seo={{
title: 'SoftVowels — Engineered for clarity',
description: 'A premium tech publication for the modern software architect.',
}}
current="home"
>
<div class="mx-auto max-w-[1440px] px-4 pb-24 pt-12 md:px-10">
{featured && <Hero article={featured} />}
<CategoryGrid categories={categories} />
<div class="grid grid-cols-1 gap-12 lg:grid-cols-12">
<div class="lg:col-span-8">
<SectionHead eyebrow="Latest intelligence" level={3} href="/articles" />
<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
{feed.map((a) => <ArticleCard article={a} />)}
</div>
</div>
<div class="space-y-12 lg:col-span-4">
<PopularList articles={popular} />
<EditorsPicks articles={picks.slice(1, 4)} />
<aside
class="bg-surface-container border-border-subtle relative overflow-hidden rounded-lg border p-8"
>
<div
class="bg-primary/5 absolute right-0 top-0 -mr-16 -mt-16 h-32 w-32 rounded-full blur-3xl"
aria-hidden="true"
>
</div>
<h4 class="font-display text-headline-md text-on-surface relative z-10 m-0 mb-4">
Join the collective
</h4>
<p class="text-text-secondary relative z-10 mb-6 text-sm">
Access our full archives and exclusive technical whitepapers by becoming a member today.
</p>
<a
href="/newsletter"
class="bg-inverse-surface font-label-md text-label-md hover:bg-primary block w-full rounded-md py-3 text-center text-white transition-colors"
>
Learn more
</a>
</aside>
</div>
</div>
<div class="mt-24">
<NewsletterCTA />
</div>
</div>
</BaseLayout>
+130
View File
@@ -0,0 +1,130 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import SubscribeForm from '../components/newsletter/SubscribeForm.astro';
import BentoBenefits from '../components/newsletter/BentoBenefits.astro';
import { getLatestEdition } from '../lib/payload';
import { imageUrl } from '../lib/payload';
const edition = await getLatestEdition();
const cover = edition?.cover ? imageUrl(edition.cover) : null;
---
<BaseLayout
seo={{
title: 'Stay ahead of tech',
description:
'Weekly insights into the architectural patterns, engineering philosophies, and technical breakthroughs shaping the next decade of software.',
}}
current="newsletter"
>
<div class="mx-auto max-w-[1440px] px-4 pb-24 pt-12 md:px-10">
<div class="mx-auto mb-16 max-w-[600px] text-center">
<span
class="font-label-md text-label-md text-primary bg-primary/5 border-primary/10 mb-6 inline-block rounded-full border px-3 py-1"
>
Weekly intelligence
</span>
<h1
class="font-display text-display-lg text-on-surface mb-6 text-balance tracking-tighter md:text-[64px]"
>
Stay ahead of tech.
</h1>
<p class="text-text-secondary text-body-lg">
Weekly insights into the architectural patterns, engineering philosophies, and technical
breakthroughs shaping the next decade of software.
</p>
</div>
<div
class="border-border-subtle bg-surface-raised mx-auto grid max-w-[1000px] grid-cols-1 gap-0 overflow-hidden rounded-lg border md:grid-cols-2"
>
<div
class="border-border-subtle flex flex-col justify-center border-b p-8 md:border-b-0 md:border-r md:p-12"
>
<div class="mb-8 flex -space-x-2">
<div class="border-surface-raised bg-primary/30 h-8 w-8 rounded-full border-2"></div>
<div class="border-surface-raised bg-primary/40 h-8 w-8 rounded-full border-2"></div>
<div class="border-surface-raised bg-primary/50 h-8 w-8 rounded-full border-2"></div>
<div
class="border-surface-raised bg-surface-variant text-on-surface-variant flex h-8 w-8 items-center justify-center rounded-full border-2 text-[10px] font-bold"
>
+50k
</div>
</div>
<p class="font-label-md text-label-md text-text-tertiary mb-8">
Joined by 50k+ engineers from OpenAI, Stripe, and Vercel.
</p>
<SubscribeForm source="newsletter" layout="stacked" />
<p class="font-label-md text-label-md text-text-tertiary m-0 mt-6 text-center md:text-left">
No spam. One email every Tuesday. Unsubscribe anytime.
</p>
</div>
<div class="bg-surface-container-lowest flex flex-col p-8 md:p-12">
<div class="mb-8 flex items-center justify-between">
<span class="font-label-md text-label-md text-text-tertiary uppercase tracking-widest"
>Latest edition</span
>
<span class="text-primary font-mono">#{edition?.number ?? '—'}</span>
</div>
<div class="flex-grow space-y-6">
{
edition ? (
<>
<h3 class="font-display text-headline-md text-on-surface m-0 text-balance">
{edition.title}
</h3>
{edition.intro && (
<p class="text-text-secondary text-body-md m-0">{edition.intro}</p>
)}
{cover && (
<img
src={cover}
alt={`Edition #${edition.number}`}
width="640"
height="320"
loading="lazy"
class="border-border-subtle rounded-lg border"
/>
)}
</>
) : (
<p class="text-text-secondary text-body-md m-0">
No editions yet. Add one in /admin to see it here.
</p>
)
}
</div>
<a
href="/articles"
class="text-primary font-label-md text-label-md mt-12 inline-flex items-center gap-2 hover:underline"
>
Read full archive
<span class="material-symbols-outlined">arrow_forward</span>
</a>
</div>
</div>
<div class="mt-24">
<BentoBenefits />
</div>
<div
class="border-border-subtle bg-surface-container-lowest relative mt-32 h-[400px] w-full overflow-hidden rounded-2xl border"
>
<div class="grid-noise pointer-events-none absolute inset-0 opacity-20" aria-hidden="true">
</div>
<div
class="absolute inset-0 flex flex-col items-center justify-center bg-gradient-to-t from-white to-transparent p-12 text-center"
>
<h2
class="font-display text-headline-lg text-headline-lg text-on-surface m-0 mb-4 text-balance"
>
Engineered for clarity.
</h2>
<p class="text-text-secondary m-0 max-w-[500px]">
Join the mailing list for technical minds who value precision over hype.
</p>
</div>
</div>
</div>
</BaseLayout>
+41
View File
@@ -0,0 +1,41 @@
---
import '../styles/global.css';
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout
seo={{ title: 'Philosophy', description: 'How we think about the work.' }}
current="philosophy"
>
<article class="max-w-article mx-auto px-4 pb-24 pt-16 md:px-10">
<header class="mb-12">
<span class="text-primary text-label-md font-mono uppercase tracking-widest">Philosophy</span>
<h1 class="font-display text-display-lg text-on-surface m-0 mb-6 mt-3 text-balance">
Engineered for clarity.
</h1>
<p class="text-text-secondary text-body-lg leading-relaxed">
SoftVowels is a publication, not a feed. Every article is selected, edited, and signed by a
human being. The goal is not volume; it is signal.
</p>
</header>
<div class="prose-article">
<h2>Why this exists</h2>
<p>
Modern software is built on an enormous amount of invisible craft. We want to surface that
craft: the architectural trade-offs, the failure modes, the small decisions that compound
over years. We do not chase trends. We do not publish summaries.
</p>
<h2>How we choose what to publish</h2>
<p>
An article makes the cut when it teaches a senior engineer something they did not know an
hour earlier. We do not run sponsored content. We do not auto-generate copy. The byline is
the brand.
</p>
<h2>What we will not do</h2>
<p>
We will not pad articles for SEO. We will not run popups. We will not invent urgency. If the
work does not deserve your attention, it does not get published.
</p>
</div>
</article>
</BaseLayout>
+27
View File
@@ -0,0 +1,27 @@
import rss from '@astrojs/rss';
import { getArticles } from '../lib/payload';
import { authorOf } from '../lib/format';
export async function GET(context: { site: URL | undefined }) {
const { docs } = await getArticles({ limit: 50 });
return rss({
title: 'SoftVowels',
description: 'Engineering-focused insights for the modern software architect.',
site: context.site ?? 'https://softvowels.example',
items: docs.map((a) => {
const author = authorOf(a);
return {
title: a.title,
pubDate: new Date(a.publishedAt),
description: a.excerpt ?? '',
link: `/articles/${a.slug}`,
author: author?.name,
categories:
a.tags && Array.isArray(a.tags)
? a.tags.filter((t) => typeof t === 'object').map((t) => t.name)
: [],
};
}),
customData: '<language>en-us</language>',
});
}
+19
View File
@@ -0,0 +1,19 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout
seo={{ title: 'Saved', description: 'Your saved articles.' }}
current="home"
bottomNav={true}
>
<section class="mx-auto max-w-[800px] px-4 pb-24 pt-24 text-center md:px-10">
<span class="text-primary text-label-md font-mono uppercase tracking-widest">Saved</span>
<h1 class="font-display text-headline-lg text-on-surface m-0 mb-4 mt-3 text-balance">
Your reading list.
</h1>
<p class="text-text-secondary text-body-lg">
Sign in to save articles for later. (Coming soon.)
</p>
</section>
</BaseLayout>
+70
View File
@@ -0,0 +1,70 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import Results from '../components/search/Results.astro';
import { search } from '../lib/payload';
const q = (Astro.url.searchParams.get('q') || '').trim();
const { articles, authors } = q ? await search(q) : { articles: [], authors: [] };
---
<BaseLayout
seo={{
title: q ? `Search · ${q}` : 'Search',
description: 'Search articles, authors, and tutorials.',
}}
current="search"
>
<section class="max-w-article mx-auto max-w-[1440px] px-4 pb-24 pt-12 md:px-10">
<form
method="get"
action="/search"
class="bg-surface border-border-subtle focus-within:border-primary flex items-center rounded-lg border p-4 shadow-sm transition-all duration-300"
>
<span class="material-symbols-outlined text-text-tertiary mr-4">search</span>
<input
autofocus
name="q"
value={q}
type="search"
placeholder="Search engineering docs, philosophy, or authors…"
class="text-headline-md font-display text-on-surface placeholder:text-text-tertiary w-full border-0 bg-transparent outline-none"
aria-label="Search query"
/>
<kbd
class="bg-surface-variant/50 border-border-subtle text-text-tertiary ml-4 hidden items-center rounded border px-2 py-1 font-mono text-[10px] md:inline-flex"
>
ESC
</kbd>
</form>
<div class="mt-8 flex flex-wrap gap-3">
<span class="text-text-tertiary font-label-md text-label-md mr-2 flex items-center"
>Popular:</span
>
{
['Distributed Systems', 'Rust', 'UI Precision', 'LLM Ops'].map((t) => (
<a
href={`/search?q=${encodeURIComponent(t)}`}
class="bg-surface-floated border-border-subtle font-label-md text-label-md text-text-secondary hover:border-primary rounded-full border px-3 py-1 transition-colors"
>
{t}
</a>
))
}
</div>
<Results q={q} articles={articles} authors={authors} />
</section>
</BaseLayout>
<script is:inline>
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const input = document.querySelector('input[type="search"][name="q"]');
if (input instanceof HTMLInputElement) {
input.value = '';
input.blur();
}
}
});
</script>
+84
View File
@@ -0,0 +1,84 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import NewsletterCTA from '../components/home/NewsletterCTA.astro';
import { getArticles } from '../lib/payload';
const { docs } = await getArticles({ limit: 50 });
const map = new Map();
for (const a of docs) {
const cat = typeof a.category === 'object' ? a.category : null;
if (!cat) continue;
const list = map.get(cat.id) ?? { name: cat.name, slug: cat.slug, count: 0, authors: new Set() };
list.count += 1;
if (typeof a.author === 'object' && a.author) list.authors.add(a.author.name);
map.set(cat.id, list);
}
const summary = [...map.values()].map((v) => ({ ...v, authors: [...v.authors] }));
---
<BaseLayout seo={{ title: 'Sitemap', description: 'Every page on SoftVowels.' }} current="home">
<div class="mx-auto max-w-[1100px] px-4 pb-24 pt-12 md:px-10">
<header class="mb-12">
<span class="text-primary text-label-md font-mono uppercase tracking-widest">Index</span>
<h1 class="font-display text-display-lg text-on-surface m-0 mt-3 text-balance">Sitemap</h1>
</header>
<div class="grid grid-cols-1 gap-12 md:grid-cols-2">
<section>
<h2 class="font-display text-headline-md text-on-surface m-0 mb-4">Sections</h2>
<ul class="m-0 list-none space-y-2 p-0">
{
['/articles', '/categories', '/search', '/newsletter', '/philosophy', '/changelog'].map(
(p) => (
<li>
<a class="text-text-secondary hover:text-primary transition-colors" href={p}>
{p}
</a>
</li>
),
)
}
</ul>
</section>
<section>
<h2 class="font-display text-headline-md text-on-surface m-0 mb-4">Categories</h2>
<ul class="m-0 list-none space-y-2 p-0">
{
summary.map((c) => (
<li>
<a
class="text-text-secondary hover:text-primary transition-colors"
href={`/categories/${c.slug}`}
>
{c.name}
</a>
<span class="text-text-tertiary ml-2 text-xs">
{c.count} articles · {c.authors.length} authors
</span>
</li>
))
}
</ul>
</section>
<section class="md:col-span-2">
<h2 class="font-display text-headline-md text-on-surface m-0 mb-4">Articles</h2>
<ul class="m-0 grid list-none grid-cols-1 gap-2 p-0 md:grid-cols-2">
{
docs.map((a) => (
<li>
<a
class="text-text-secondary hover:text-primary transition-colors"
href={`/articles/${a.slug}`}
>
{a.title}
</a>
</li>
))
}
</ul>
</section>
</div>
<div class="mt-16">
<NewsletterCTA />
</div>
</div>
</BaseLayout>
+256
View File
@@ -0,0 +1,256 @@
@import 'tailwindcss';
/* ------------------------------------------------------------------ */
/* Fonts — Geist Sans + Geist Mono (self-hosted, .woff2) */
/* ------------------------------------------------------------------ */
@font-face {
font-family: 'Geist';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/Geist-Variable.woff2') format('woff2-variations');
}
@font-face {
font-family: 'Geist Mono';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/GeistMono-Variable.woff2') format('woff2-variations');
}
/* ------------------------------------------------------------------ */
/* Material Symbols (variable) — used for the icon set */
/* Add new icons in markup as <span class="material-symbols-outlined"> */
/* ------------------------------------------------------------------ */
.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
font-variation-settings:
'FILL' 0,
'wght' 400,
'GRAD' 0,
'opsz' 24;
}
.material-symbols-outlined.fill-1 {
font-variation-settings:
'FILL' 1,
'wght' 400,
'GRAD' 0,
'opsz' 24;
}
/* ------------------------------------------------------------------ */
/* Design tokens — lifted from .opencode/stitch/DESIGN.md */
/* ------------------------------------------------------------------ */
@theme {
/* surfaces */
--color-background: #ffffff;
--color-surface: #ffffff;
--color-surface-dim: #f9fafb;
--color-surface-raised: #f9fafb;
--color-surface-container-low: #ffffff;
--color-surface-container: #f9fafb;
--color-surface-container-high: #f3f4f6;
--color-surface-container-highest: #f3f4f6;
--color-surface-variant: #f3f4f6;
--color-border-subtle: #e5e7eb;
--color-outline: #d1d5db;
--color-outline-variant: #e5e7eb;
/* text */
--color-on-surface: #111827;
--color-on-surface-variant: #4b5563;
--color-on-background: #111827;
--color-text-secondary: #4b5563;
--color-text-tertiary: #6b7280;
--color-inverse-surface: #111827;
--color-inverse-on-surface: #ffffff;
/* brand */
--color-primary: #318db8;
--color-primary-container: #dcf0fa;
--color-on-primary: #ffffff;
--color-on-primary-container: #001e2c;
--color-accent-blue: #318db8;
--color-accent-gold: #eab308;
/* secondary */
--color-secondary: #4b5563;
--color-on-secondary: #ffffff;
--color-secondary-container: #f3f4f6;
--color-on-secondary-container: #4b5563;
/* tertiary */
--color-tertiary: #ffb95f;
--color-on-tertiary: #ffffff;
/* status */
--color-error: #ef4444;
--color-on-error: #ffffff;
--color-success: #10b981;
/* radius */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 0.75rem;
--radius-2xl: 1rem;
--radius-full: 9999px;
/* type */
--font-sans:
'Geist', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
sans-serif;
--font-display: 'Geist', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--text-display-lg: 48px;
--text-display-lg--line-height: 1.1;
--text-display-lg--letter-spacing: -0.04em;
--text-display-lg--font-weight: 700;
--text-headline-lg: 32px;
--text-headline-lg--line-height: 1.2;
--text-headline-lg--letter-spacing: -0.02em;
--text-headline-lg--font-weight: 600;
--text-headline-md: 24px;
--text-headline-md--line-height: 1.3;
--text-headline-md--letter-spacing: -0.01em;
--text-headline-md--font-weight: 600;
--text-body-lg: 18px;
--text-body-lg--line-height: 1.6;
--text-body-md: 16px;
--text-body-md--line-height: 1.5;
--text-label-md: 14px;
--text-label-md--line-height: 1;
--text-label-md--letter-spacing: 0.02em;
--text-label-md--font-weight: 500;
--text-code: 14px;
--text-code--line-height: 1.6;
/* spacing (4px baseline) */
--spacing-gutter: 24px;
--spacing-margin-mobile: 16px;
--spacing-margin-desktop: 40px;
--spacing-section: 80px;
--spacing-xxl: 80px;
/* containers */
--container-site: 1440px;
--container-article: 720px;
--container-reading-lane: 800px;
}
/* ------------------------------------------------------------------ */
/* Base layer */
/* ------------------------------------------------------------------ */
@layer base {
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
background-color: var(--color-background);
color: var(--color-on-surface);
font-family: var(--font-sans);
scroll-behavior: smooth;
}
body {
background-color: var(--color-background);
color: var(--color-on-surface);
font-size: var(--text-body-md);
line-height: var(--text-body-md--line-height);
min-height: 100dvh;
overflow-x: hidden;
}
::selection {
background-color: color-mix(in oklab, var(--color-primary) 20%, transparent);
}
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-radius: 2px;
}
a {
color: inherit;
}
img,
video,
svg {
max-width: 100%;
height: auto;
}
}
/* ------------------------------------------------------------------ */
/* Utilities (component classes referenced across pages) */
/* ------------------------------------------------------------------ */
@layer components {
.hairline {
border: 1px solid var(--color-border-subtle);
}
.hairline-hover {
border: 1px solid var(--color-border-subtle);
transition: border-color 0.2s ease;
}
.hairline-hover:hover {
border-color: var(--color-primary);
}
.mask-fade-b {
mask-image: linear-gradient(to bottom, black 80%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 80%, transparent 100%);
}
.glass-nav {
background-color: color-mix(in oklab, var(--color-background) 80%, transparent);
backdrop-filter: saturate(180%) blur(12px);
-webkit-backdrop-filter: saturate(180%) blur(12px);
}
.grid-noise {
background-image:
linear-gradient(var(--color-border-subtle) 1px, transparent 1px),
linear-gradient(90deg, var(--color-border-subtle) 1px, transparent 1px);
background-size: 40px 40px;
}
.text-balance {
text-wrap: balance;
}
.scrollbar-none {
scrollbar-width: none;
-ms-overflow-style: none;
}
.scrollbar-none::-webkit-scrollbar {
display: none;
}
}
/* ------------------------------------------------------------------ */
/* Reduced motion */
/* ------------------------------------------------------------------ */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
+112
View File
@@ -0,0 +1,112 @@
/* Prose styles applied to rendered article body (Lexical → HTML). */
.prose-article {
color: var(--color-on-surface);
font-family: var(--font-sans);
font-size: var(--text-body-lg);
line-height: 1.8;
max-width: var(--container-article);
}
.prose-article > * + * {
margin-top: 1.5rem;
}
.prose-article h2 {
font-family: var(--font-display);
font-size: var(--text-headline-lg);
line-height: 1.2;
letter-spacing: -0.02em;
font-weight: 600;
margin-top: 3.5rem;
margin-bottom: 1.25rem;
color: var(--color-on-surface);
}
.prose-article h3 {
font-family: var(--font-display);
font-size: var(--text-headline-md);
line-height: 1.3;
letter-spacing: -0.01em;
font-weight: 600;
margin-top: 2.5rem;
margin-bottom: 1rem;
color: var(--color-on-surface);
}
.prose-article p {
color: var(--color-on-surface);
}
.prose-article a {
color: var(--color-primary);
text-decoration: underline;
text-decoration-color: color-mix(in oklab, var(--color-primary) 30%, transparent);
text-underline-offset: 3px;
transition: text-decoration-color 0.2s ease;
}
.prose-article a:hover {
text-decoration-color: var(--color-primary);
}
.prose-article strong {
font-weight: 600;
color: var(--color-on-surface);
}
.prose-article em {
font-style: italic;
}
.prose-article blockquote {
border-left: 3px solid var(--color-primary);
padding-left: 1.25rem;
color: var(--color-on-surface-variant);
font-style: italic;
margin-block: 2rem;
}
.prose-article ul,
.prose-article ol {
padding-left: 1.5rem;
}
.prose-article ul {
list-style: disc;
}
.prose-article ol {
list-style: decimal;
}
.prose-article li + li {
margin-top: 0.5rem;
}
.prose-article hr {
border: none;
border-top: 1px solid var(--color-border-subtle);
margin-block: 3rem;
}
.prose-article img {
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-subtle);
margin-block: 2rem;
}
.prose-article pre,
.prose-article code {
font-family: var(--font-mono);
font-size: 0.875em;
}
.prose-article code:not(pre code) {
background-color: var(--color-surface-container-high);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-sm);
padding: 0.1em 0.35em;
color: var(--color-on-surface);
}
.prose-article pre {
background-color: var(--color-inverse-surface);
color: #e5e7eb;
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-lg);
padding: 1.25rem 1.5rem;
overflow-x: auto;
line-height: 1.6;
margin-block: 2rem;
}
.prose-article pre code {
background: transparent;
border: 0;
padding: 0;
color: inherit;
}
.prose-article .inline-newsletter {
margin-block: 2.5rem;
}
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@components/*": ["src/components/*"],
"@layouts/*": ["src/layouts/*"],
"@lib/*": ["src/lib/*"],
"@styles/*": ["src/styles/*"]
}
}
}