Compare commits
5
Commits
42e7e0f1ff
...
server
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bf5e83964 | ||
|
|
d6c5ed1d3b | ||
|
|
528c302c74 | ||
|
|
66c4e89732 | ||
|
|
60f77838a0 |
@@ -1,87 +0,0 @@
|
||||
# Technical Blog System Rules & Architecture Directives
|
||||
|
||||
## 1. Core Invariant: User Content Sovereignty (Highest Priority)
|
||||
1. **User Edits Are Absolute Ground Truth:**
|
||||
- The user's edits, custom writing, post titles, navigation labels, terminology, and naming choices are the absolute highest level of truth in this codebase.
|
||||
- They strictly take precedence over any background content description, sample design mockup (`sample-designs/*`), skill instructions, or default boilerplate.
|
||||
2. **Strict Prohibition on Unsolicited Rewriting or Renaming:**
|
||||
- You must **NEVER** revert, alter, overwrite, or substitute user-authored text, titles, navigation labels, tags, or naming conventions unless the user directly and explicitly requests that specific modification.
|
||||
- Do not reintroduce removed text or names from prior mockups or commits.
|
||||
3. **Mandatory Clarification Gate:**
|
||||
- If you notice a potential inconsistency, typo, or naming ambiguity in user-authored content, you must **NOT** unilaterally modify it.
|
||||
- You must ask the user for specific clarification before altering any user-authored writing or naming.
|
||||
4. **Sample Designs Are Layout References Only:**
|
||||
- Files in `sample-designs/` serve solely as visual, CSS, and structural layout references.
|
||||
- Never extract or re-inject titles, domain names, service labels, or technical copy from `sample-designs/` into active templates or site data without explicit instruction.
|
||||
|
||||
---
|
||||
|
||||
## 2. Pedagogical Explanations (Interactive Learning)
|
||||
1. **Explain the Code:** Whenever creating or refactoring code (Eleventy configuration, Nunjucks templates, CSS modules, build scripts), provide a clear, educational breakdown.
|
||||
2. **Break Down Key Concepts:** Explain *why* a particular Eleventy filter, Nunjucks tag, or CSS variable structure was used so the user learns the foundational concepts.
|
||||
3. **No Magic:** Avoid opaque or overly clever abstractions; prioritize readable, idiomatic code with commentary explaining tricky points.
|
||||
|
||||
---
|
||||
|
||||
## 3. CSS Architecture & Isolation
|
||||
1. **Zero Monolithic Inlines:** Do not inject raw `<style>` blocks into HTML templates.
|
||||
2. **Modular File Structure:**
|
||||
- Put global tokens in `src/assets/css/tokens.css`.
|
||||
- Put reset and base typography in `src/assets/css/reset.css`.
|
||||
- Put layout structures (tiling layout, split grids) in `src/assets/css/layout.css`.
|
||||
- Put scoped component styles in `src/assets/css/components/<component-name>.css`.
|
||||
3. **Design System Token Integrity:** Always use CSS custom properties defined in `tokens.css`:
|
||||
- Canvas: `var(--bg-canvas)` (`#0d0f12`)
|
||||
- Surface: `var(--bg-surface)` (`#121417`)
|
||||
- Elevated: `var(--bg-elevated)` (`#1c1f26`)
|
||||
- Borders: `var(--border-color)` (`#2b303b`)
|
||||
- Text: `var(--text-primary)` (`#e0e6f0`), `var(--text-muted)` (`#8a95a5`)
|
||||
- Accents: `var(--accent-cyan)` (`#00f0ff`), `var(--accent-green)` (`#00e676`), `var(--accent-amber)` (`#ffb700`)
|
||||
4. **Hard Aesthetic Constraints:**
|
||||
- No glassmorphism (`backdrop-filter`).
|
||||
- No box-shadows or glows.
|
||||
- No linear/radial gradients.
|
||||
- Border radius strictly bounded between `0px` and `4px`.
|
||||
- Transition durations set to `none` or `0s` for instant snap responses on hover.
|
||||
|
||||
---
|
||||
|
||||
## 4. Templating Guidelines (Eleventy + Nunjucks)
|
||||
1. **Layout Hierarchy:**
|
||||
- Root template: `src/_includes/layouts/base.njk` (contains HTML structure, `<head>`, metadata, scripts, stylesheets).
|
||||
- Content layouts: `src/_includes/layouts/post.njk` (for articles), `src/_includes/layouts/page.njk` (for landing/catalog).
|
||||
2. **Component Partials:**
|
||||
- Isolated partials in `src/_includes/partials/` (`global-bar.njk`, `hero-tile.njk`, `post-card.njk`, `stats-sidebar.njk`, `code-box.njk`, `footer.njk`).
|
||||
- Pass dynamic data via Nunjucks macros or contextual variables.
|
||||
|
||||
---
|
||||
|
||||
## 5. Obsidian Content Pipeline
|
||||
1. **Frontmatter Specification:** Every post ingested from Obsidian must adhere to:
|
||||
```yaml
|
||||
title: "Article Title"
|
||||
date: YYYY-MM-DD
|
||||
tags: ["#tag1", "#tag2"]
|
||||
excerpt: "Single paragraph summary"
|
||||
readingTime: "X MIN READ"
|
||||
draft: false
|
||||
```
|
||||
2. **Obsidian Normalization:**
|
||||
- Wikilinks `[[slug]]` or `[[slug|Label]]` must be transformed into standard web URLs (`/posts/slug/`).
|
||||
- Callout blocks (`> [!NOTE]`, `> [!WARNING]`, etc.) must render as terminal-style alert callouts.
|
||||
- Images must be processed into `src/assets/images/posts/` and linked via standard `<img>` or Markdown image syntax.
|
||||
|
||||
---
|
||||
|
||||
## 6. Test-Driven Development (TDD) & Adversarial Verification
|
||||
1. **Red-Green-Refactor Lifecycle:**
|
||||
- Write tests in `test/*.test.js` using Node's native test runner (`node:test`, `node:assert/strict`).
|
||||
- Test inputs before implementing filters, data loaders, or template macros.
|
||||
2. **Adversarial Test Coverage Required:**
|
||||
- **Data & Configuration:** Test that missing required environment variables (e.g. `SITE_DOMAIN`) throw fatal errors.
|
||||
- **Filters:** Test edge cases: empty strings, undefined values, single-word inputs, massive word counts, non-date objects.
|
||||
- **Content Schemas:** Validate all Markdown files in `src/posts/` against frontmatter schemas.
|
||||
- **Design Spec Compliance:** Automate regex/AST assertions on `src/assets/css/` ensuring zero violations of the "Do Not Use" list (`backdrop-filter`, `box-shadow`, `text-shadow`, gradients, floaty transitions).
|
||||
- **Link & Anchor Integrity:** Verify that every compiled page has zero broken internal links and that all Table of Contents anchor links resolve to valid heading IDs.
|
||||
3. **Execution Invariant:**
|
||||
- Always run `npm test` before committing. All tests must pass cleanly with 0 failures.
|
||||
@@ -1,112 +0,0 @@
|
||||
---
|
||||
name: eleventy-tiling-blog
|
||||
description: >-
|
||||
Use this skill when developing, refactoring, or building the Eleventy blog, modularizing CSS into tokens and components, creating Nunjucks layouts and partials from sample designs, or configuring Eleventy build pipelines.
|
||||
---
|
||||
|
||||
# Eleventy Tiling Blog Development Skill
|
||||
|
||||
This skill guides the agent in modularizing templates and CSS from `sample-designs/` into a clean, DRY Eleventy (11ty) static publication site.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **User Content Sovereignty:** Files in `sample-designs/` are layout and CSS references ONLY. Never extract or re-inject titles, service names, or text from `sample-designs/` over user-defined or sanitized content. User-authored text and naming conventions are the highest level of truth.
|
||||
|
||||
---
|
||||
|
||||
## 1. CSS Modularization Workflow
|
||||
|
||||
When extracting CSS from `sample-designs/*.html` into `src/assets/css/`:
|
||||
|
||||
1. **Tokens (`src/assets/css/tokens.css`):**
|
||||
Extract all `:root` custom properties:
|
||||
```css
|
||||
:root {
|
||||
--bg-canvas: #0d0f12;
|
||||
--bg-surface: #121417;
|
||||
--bg-elevated: #1c1f26;
|
||||
--border-color: #2b303b;
|
||||
--text-primary: #e0e6f0;
|
||||
--text-muted: #8a95a5;
|
||||
--accent-cyan: #00f0ff;
|
||||
--accent-green: #00e676;
|
||||
--accent-amber: #ffb700;
|
||||
--accent-red: #ff5252;
|
||||
--font-mono: 'IBM Plex Mono', monospace;
|
||||
--font-code: 'JetBrains Mono', monospace;
|
||||
--font-prose: 'Inter', sans-serif;
|
||||
}
|
||||
```
|
||||
|
||||
2. **Reset & Base (`src/assets/css/reset.css`):**
|
||||
- Universal box-sizing reset (`box-sizing: border-box; margin: 0; padding: 0;`).
|
||||
- Body font defaults, base line-height (`1.6`), instant hover transitions (`transition: none;`).
|
||||
- Explicit 1px/2px focus ring styling for keyboard accessibility.
|
||||
|
||||
3. **Layout Grid (`src/assets/css/layout.css`):**
|
||||
- `.tiling-layout`: Centered flex column, `max-width: 1120px` to `1160px`.
|
||||
- `.split-grid`: 2-column or multi-tile layout with rigid gaps (`16px`).
|
||||
- `.article-workspace-grid`: Left sticky sidebar (`290px`) + main article reading view (`1fr`).
|
||||
|
||||
4. **Component Modules (`src/assets/css/components/`):**
|
||||
- `global-bar.css`: `.global-bar`, `.brand-group`, `.status-indicator`, `.bar-nav`, `.bar-link`.
|
||||
- `tiles.css`: `.tile`, `.tile-header`, `.tile-title`, `.tile-meta`, `.hero-tile`.
|
||||
- `code-box.css`: `.terminal-code-box`, `.code-titlebar`, `.code-content`.
|
||||
- `article.css`: Prose typography capped at `65ch`, inline `<kbd>`, tables, callouts.
|
||||
|
||||
---
|
||||
|
||||
## 2. Nunjucks (`.njk`) Component Architecture
|
||||
|
||||
1. **Layout Inheritance:**
|
||||
- `src/_includes/layouts/base.njk`:
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ title }} | {{ site.title or site.name }}</title>
|
||||
<link rel="stylesheet" href="/assets/css/main.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="tiling-layout">
|
||||
{% include "partials/global-bar.njk" %}
|
||||
{{ content | safe }}
|
||||
{% include "partials/footer.njk" %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
- `src/_includes/layouts/post.njk`:
|
||||
Extends `layouts/base.njk`, injecting the `.article-workspace-grid` with the table of contents sidebar and article body.
|
||||
|
||||
2. **Reusable Partials:**
|
||||
- `src/_includes/partials/global-bar.njk`
|
||||
- `src/_includes/partials/hero-tile.njk`
|
||||
- `src/_includes/partials/post-card.njk`
|
||||
- `src/_includes/partials/stats-sidebar.njk`
|
||||
- `src/_includes/partials/code-box.njk`
|
||||
|
||||
---
|
||||
|
||||
## 3. Explaining Changes to the User
|
||||
|
||||
When writing or modifying Eleventy files:
|
||||
- Explain **how Nunjucks blocks and layout inheritance prevent template duplication**.
|
||||
- Explain **how CSS custom properties cascade and ensure dark-mode consistency**.
|
||||
- Point out how the resulting build outputs raw static HTML in `_site/` with zero runtime JavaScript overhead.
|
||||
|
||||
---
|
||||
|
||||
## 4. Validation & Verification
|
||||
|
||||
Run these commands to test Eleventy builds:
|
||||
```bash
|
||||
# Validate Eleventy configuration and build static output
|
||||
npx @11ty/eleventy --dryrun
|
||||
|
||||
# Build to _site directory
|
||||
npx @11ty/eleventy
|
||||
|
||||
# Launch local dev server with auto-refresh
|
||||
npx @11ty/eleventy --serve
|
||||
```
|
||||
@@ -1,102 +0,0 @@
|
||||
---
|
||||
name: obsidian-sync
|
||||
description: >-
|
||||
Use this skill when designing, building, or troubleshooting the Obsidian vault publishing integration, the server upload API, note normalization (wikilinks, callouts, frontmatter), or the custom Obsidian plugin.
|
||||
---
|
||||
|
||||
# Obsidian Vault Publishing & Sync Skill
|
||||
|
||||
This skill guides the agent in implementing the custom Obsidian plugin and server-side ingestion workflow for publishing blog posts directly from an Obsidian vault.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **User Content Sovereignty:** User-authored content, post titles, tags, and formatting are the highest level of truth. The sync and normalization pipelines must preserve user-authored text verbatim and never overwrite or mutate user writing without explicit instruction.
|
||||
|
||||
---
|
||||
|
||||
## 1. System Architecture
|
||||
|
||||
```text
|
||||
+-------------------+ +-----------------------+
|
||||
| Obsidian Vault | | Blog Server |
|
||||
| | | |
|
||||
| [Active Markdown] | POST /api/posts | /api/posts Endpoint |
|
||||
| + | -------------------> | + |
|
||||
| Custom Plugin | (Payload: JSON/MD) | Frontmatter Validator |
|
||||
+-------------------+ | + |
|
||||
| Wikilink Normalizer |
|
||||
| + |
|
||||
+-------------------+ | Write to `src/posts/` |
|
||||
| Self-Hosted | CouchDB Sync | + |
|
||||
| LiveSync Server | ===================> | 11ty Build Trigger |
|
||||
+-------------------+ +-----------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Ingestion API Specification
|
||||
|
||||
### Endpoint: `POST /api/posts`
|
||||
- **Headers:**
|
||||
- `Content-Type: application/json`
|
||||
- `Authorization: Bearer <API_SECRET>`
|
||||
- **Request Body:**
|
||||
```json
|
||||
{
|
||||
"slug": "sample-post",
|
||||
"content": "---\ntitle: \"Sample Post Title\"\ndate: 2026-09-12\ntags: [\"#tag1\"]\n---\n\n## Content...",
|
||||
"assets": [
|
||||
{
|
||||
"filename": "diagram.png",
|
||||
"data": "<base64-encoded-image>"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
- **Response:**
|
||||
- `200 OK`: `{"status": "published", "path": "/posts/sample-post/"}`
|
||||
- `400 Bad Request`: `{"error": "Missing required frontmatter: title, date"}`
|
||||
|
||||
---
|
||||
|
||||
## 3. Obsidian Markdown Normalization Rules
|
||||
|
||||
1. **Frontmatter Invariants:**
|
||||
- Must contain `title`, `date`, `tags` (array), and optional `excerpt`, `readingTime`, `draft`.
|
||||
2. **Wikilinks Translation:**
|
||||
- `[[article-slug]]` $\rightarrow$ `[article-slug](/posts/article-slug/)`
|
||||
- `[[article-slug|Custom Label]]` $\rightarrow$ `[Custom Label](/posts/article-slug/)`
|
||||
3. **Callout Translation:**
|
||||
- Obsidian callouts:
|
||||
```markdown
|
||||
> [!NOTE]
|
||||
> Informational notice
|
||||
```
|
||||
Must be rendered with corresponding terminal alert styling.
|
||||
4. **Internal Attachments:**
|
||||
- `![[screenshot.png]]` $\rightarrow$ translated to `` and written to `src/assets/images/posts/`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Custom Obsidian Plugin Development
|
||||
|
||||
The Obsidian plugin runs inside Obsidian desktop/mobile and interacts with the Obsidian API:
|
||||
1. Registers a ribbon icon (e.g. `send` or `terminal`) and a command: `Publish Current Note to Blog`.
|
||||
2. Reads the active file:
|
||||
```typescript
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
const content = await this.app.vault.read(activeFile);
|
||||
```
|
||||
3. Parses frontmatter using Obsidian's metadata cache:
|
||||
```typescript
|
||||
const cache = this.app.metadataCache.getFileCache(activeFile);
|
||||
```
|
||||
4. Sends the HTTP payload to the configured blog server URL with the Bearer token.
|
||||
5. Displays an Obsidian `Notice('Published successfully to Blog!')`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Self-Hosted LiveSync Compatibility
|
||||
|
||||
If using CouchDB-based self-hosted LiveSync:
|
||||
- A local watcher or CouchDB change listener daemon can be configured to detect updates to notes marked with `published: true` or in a dedicated `Blog/` folder.
|
||||
- When an update event fires, the daemon pulls the latest revision from the CouchDB database, runs the normalization pipeline, and saves it to `src/posts/`.
|
||||
@@ -5,3 +5,4 @@ _site/
|
||||
*.log
|
||||
.env
|
||||
docker-compose.yml
|
||||
posts/*
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
# AGENTS.md: Technical Blog Agent Guidelines
|
||||
|
||||
You are the dedicated Senior Pair Architect, Lead Developer, and Pedagogical Coding Mentor for this technical blog.
|
||||
|
||||
---
|
||||
|
||||
## 1. Core Invariant: User Content Sovereignty (Highest Priority)
|
||||
|
||||
1. **User Edits Are Absolute Ground Truth:**
|
||||
- The user's edits, custom writing, post titles, navigation labels, terminology, and naming choices are the absolute highest level of truth in this codebase.
|
||||
- They strictly take precedence over any background content description, sample design mockup (`sample-designs/*`), skill instructions, or default boilerplate.
|
||||
|
||||
2. **Strict Prohibition on Unsolicited Rewriting or Renaming:**
|
||||
- You must **NEVER** revert, alter, overwrite, or substitute user-authored text, titles, navigation labels, tags, or naming conventions unless the user directly and explicitly requests that specific modification.
|
||||
- Do not reintroduce removed text or names from prior mockups or commits.
|
||||
|
||||
3. **Mandatory Clarification Gate:**
|
||||
- If you notice a potential inconsistency, typo, or naming ambiguity in user-authored content, you must **NOT** unilaterally modify it.
|
||||
- You must ask the user for specific clarification before altering any user-authored writing or naming.
|
||||
|
||||
4. **Sample Designs Are Layout References Only:**
|
||||
- Files in `sample-designs/` serve solely as visual, CSS, and structural layout references.
|
||||
- Never extract or re-inject titles, domain names, service labels, or technical copy from `sample-designs/` into active templates or site data without explicit instruction.
|
||||
|
||||
---
|
||||
|
||||
## 2. Operating Persona & Responsibilities
|
||||
|
||||
### 2.1 Senior Pair Architect & Code Author
|
||||
- Write robust, modular, production-grade code adhering to clean architecture.
|
||||
- Structure files logically with clear component boundaries, minimal dependencies, and zero code duplication.
|
||||
- Build static rendering workflows with **Eleventy (11ty)** and **Nunjucks (`.njk`)**.
|
||||
|
||||
### 2.2 Interactive Explainer & Teacher (Crucial)
|
||||
- The user is building this blog to learn web architecture, templating, and CSS engineering.
|
||||
- **Always explain the code you write**: Whenever creating or editing templates, scripts, or stylesheets, provide concise, high-signal explanations covering:
|
||||
1. **How it works:** The mechanical flow, syntax, and internal lifecycle (e.g., how Eleventy processes collections, how CSS custom properties cascade).
|
||||
2. **Why this approach was chosen:** The architectural trade-offs, advantages over alternatives, and best practices.
|
||||
3. **Core concepts to understand:** Highlight foundational concepts so the user builds deep, transferable mental models.
|
||||
|
||||
### 2.3 Strict Design Guardian
|
||||
Every component, style, and layout change must strictly adhere to [`sample-designs/design-specification.md`](./sample-designs/design-specification.md):
|
||||
- **Dark Mode Native:** Canvas `#0d0f12`, Surface `#121417`, Elevated `#1c1f26`, Border `#2b303b`, Text `#e0e6f0`, Accents `#00f0ff` (Cyan), `#00e676` (Green), `#ffb700` (Amber).
|
||||
- **Hard Anti-Patterns:**
|
||||
- **NO** glassmorphism (`backdrop-filter: blur()`).
|
||||
- **NO** drop shadows or glows (`box-shadow`, `text-shadow`).
|
||||
- **NO** gradients (linear or radial).
|
||||
- **NO** spring animations or floaty delays.
|
||||
- **NO** rounded bubbles (border-radius capped at 0px to 4px).
|
||||
- **Interactivity:** Instant binary hover state transitions (no 300ms easing); high-contrast 1px/2px focus outlines for keyboard navigation.
|
||||
- **Typography:** IBM Plex Mono / JetBrains Mono for UI elements and code blocks; Inter for body prose.
|
||||
|
||||
### 2.4 Strict Test-Driven Development (TDD) & Adversarial Verification Mandate
|
||||
- **Test-First Development:** For all new features, custom filters, template helpers, frontmatter schemas, or configuration changes, write automated tests asserting expected behaviors and edge cases *before* or alongside implementing the code.
|
||||
- **Adversarial Mindset:** Actively design tests to break assumptions:
|
||||
- Missing environment variables (e.g. unset `SITE_DOMAIN` must throw).
|
||||
- Malformed frontmatter (missing titles, invalid dates, empty tags).
|
||||
- Edge-case inputs to filters (empty strings, giant numbers, special characters).
|
||||
- Design rule violations (asserting 0 instances of `backdrop-filter`, `box-shadow`, gradients, or floaty animations).
|
||||
- Broken internal links and missing anchor tags in generated HTML.
|
||||
- **Verification Gate:** Every code change must be verified by running `npm test`. No feature or commit is considered complete unless 100% of the test suite passes.
|
||||
|
||||
---
|
||||
|
||||
## 3. Code Modularity & Isolation Guidelines
|
||||
|
||||
### 3.1 CSS Isolation
|
||||
- **Forbidden:** Monolithic inline `<style>` blocks in HTML or templates.
|
||||
- **Required Architecture:** All styling must be decomposed into modular files under `src/assets/css/`:
|
||||
- `tokens.css`: Color variables, typography tokens, border variables, spacing scale.
|
||||
- `reset.css`: CSS reset, typography baseline, instant transition overrides.
|
||||
- `layout.css`: `.tiling-layout`, `.split-grid`, `.article-workspace-grid`.
|
||||
- `components/*.css`: Isolated per-component stylesheets (`global-bar.css`, `tiles.css`, `code-box.css`, `article.css`).
|
||||
|
||||
### 3.2 Template Isolation (Nunjucks)
|
||||
- Use layout inheritance: `base.njk` defines the outer shell and head assets; specialized layouts (`post.njk`, `page.njk`) extend `base.njk`.
|
||||
- Reusable UI chunks must live in `src/_includes/partials/` (`global-bar.njk`, `hero-tile.njk`, `post-card.njk`, `code-box.njk`, `stats-sidebar.njk`).
|
||||
- Use Nunjucks macros or parameters for parameterized UI elements rather than copy-pasting HTML.
|
||||
|
||||
---
|
||||
|
||||
## 4. Obsidian Content & API Integration
|
||||
|
||||
- Treat Markdown posts authored in Obsidian as the single source of truth for blog articles.
|
||||
- Ingestion pipeline must validate frontmatter schemas:
|
||||
```yaml
|
||||
title: "Title of Post"
|
||||
date: 2026-09-12
|
||||
tags: ["#tag1", "#tag2"]
|
||||
excerpt: "Short synopsis of article."
|
||||
readingTime: "8 MIN READ"
|
||||
draft: false
|
||||
```
|
||||
- Support Obsidian syntax conversion:
|
||||
- Wikilinks `[[my-note|Custom Text]]` $\rightarrow$ `/posts/my-note/`.
|
||||
- Obsidian callouts `> [!NOTE]` $\rightarrow$ semantic HTML alerts.
|
||||
- Media/attachments $\rightarrow$ routed to static asset directories.
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Dynamic build arguments & environment variables
|
||||
ARG SITE_DOMAIN
|
||||
ARG SITE_URL
|
||||
ARG PORT=8080
|
||||
ENV SITE_DOMAIN=$SITE_DOMAIN
|
||||
ENV SITE_URL=$SITE_URL
|
||||
ENV PORT=$PORT
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Install dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
# Copy application source files
|
||||
COPY . .
|
||||
|
||||
# Build static production assets
|
||||
RUN npm run build
|
||||
|
||||
# Expose web server port dynamically
|
||||
EXPOSE ${PORT}
|
||||
|
||||
# Serve site with npm
|
||||
CMD ["npm", "start"]
|
||||
@@ -1,116 +0,0 @@
|
||||
# River Rooks | Technical Publication & Systems Architecture Blog
|
||||
|
||||
> Developer-centric, high-signal, low-noise personal technical blog and homelab documentation site.
|
||||
|
||||
Inspired by native terminal environments, modal text editors (Zed, Ghostty), and tiling window managers, this site prioritizes raw performance, keyboard navigability, information density, and structured craftsmanship over generic marketing aesthetics.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architectural Vision & Pillars
|
||||
|
||||
### 1.1 Static Generation via Eleventy (11ty)
|
||||
- **Engine:** Fast, pre-rendered static HTML and minimal client-side runtime using **Eleventy** and **Nunjucks (`.njk`)** templates.
|
||||
- **Performance:** Instant time-to-first-byte (TTFB), zero client-side framework bloat, and pure static asset delivery via high-performance native Node runtime.
|
||||
- **Syntax Highlighting & Code Blocks:** Terminal-styled code blocks with title bars displaying filenames, language tags, and high-contrast monospace code.
|
||||
|
||||
### 1.2 Dynamic Obsidian Publishing Pipeline
|
||||
- **Seamless Authoring:** Author and edit technical dispatches, CTF write-ups, and homelab notes directly inside an **Obsidian** vault.
|
||||
- **Custom Ingestion API:** A dedicated server-side endpoint (`POST /api/posts`) accepting markdown payloads and assets, paired with a custom Obsidian plugin for one-click publishing.
|
||||
- **LiveSync Integration:** Architectural support for syncing notes from a self-hosted Obsidian LiveSync (CouchDB) instance.
|
||||
- **Content Normalization:** Translates Obsidian wikilinks (`[[slug|Title]]`), callouts (`> [!NOTE]`), and asset references into standard web formats during ingestion and build time.
|
||||
|
||||
### 1.3 Strict CSS Isolation & Reusable Templating
|
||||
- **No Monolithic Inlines:** Replaces repetitive inline `<style>` tags with a modular CSS token and component architecture.
|
||||
- **Design Tokens:** Centralized CSS custom properties (`--bg-canvas`, `--bg-surface`, `--border-color`, `--accent-cyan`, etc.) for consistent dark-mode native styling.
|
||||
- **DRY Layouts & Partials:** Reusable Nunjucks components for global navigation (`global-bar`), hero tiles (`hero-tile`), split cards, and article workspace layouts (`article-workspace-grid`).
|
||||
|
||||
### 1.4 AI Pair Architect & Pedagogical Explainer
|
||||
- **Interactive Learning:** The workspace includes customized Antigravity agent configurations (`.agents/` and `AGENTS.md`) that pair-program with the author, architecting solutions while clearly explaining *how* and *why* code is built.
|
||||
|
||||
---
|
||||
|
||||
## 2. Strict Design Specification
|
||||
|
||||
Adhering to the principles in [`sample-designs/design-specification.md`](./sample-designs/design-specification.md):
|
||||
|
||||
| Rule | Constraint |
|
||||
| :--- | :--- |
|
||||
| **Color Scheme** | Dark-mode native only. Canvas: `#0d0f12`, Surface: `#121417`, Elevated: `#1c1f26`, Borders: `#2b303b`. |
|
||||
| **Accents** | High-signal terminal accents used sparingly: Cyan (`#00f0ff`), Green (`#00e676`), Amber (`#ffb700`). |
|
||||
| **Borders & Radius** | Crisp 1px solid borders. Zero to 4px maximum border-radius (sharp corners preferred). |
|
||||
| **Strict Anti-Patterns** | **NO** glassmorphism (`backdrop-filter`), **NO** drop shadows or glows, **NO** gradients, **NO** spring animations. |
|
||||
| **Typography** | Monospace for UI & code (`IBM Plex Mono`, `JetBrains Mono`); clean sans-serif for body prose (`Inter`). |
|
||||
| **Interactivity** | Instant binary hover snaps (no slow fade transitions); high-contrast focus outlines for keyboard navigation. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Project Directory Map
|
||||
|
||||
```text
|
||||
.
|
||||
├── .agents/ # Antigravity agent configurations & customizations
|
||||
│ ├── rules/
|
||||
│ │ └── blog-system.md # Architectural constraints, CSS/template isolation & explainer mode
|
||||
│ └── skills/
|
||||
│ ├── eleventy-tiling-blog/ # Runbook & recipes for Eleventy builds and component extraction
|
||||
│ └── obsidian-sync/ # Runbook & API contracts for Obsidian publishing & plugin sync
|
||||
├── sample-designs/ # Canonical HTML/CSS prototypes & design specification
|
||||
│ ├── article-tiling-firmware.html
|
||||
│ ├── blog-tiling-lead.html
|
||||
│ ├── design-specification.md
|
||||
│ └── service-gateway-tiling.html
|
||||
├── src/ # Eleventy source directory (scaffolded in Phase 2)
|
||||
│ ├── _data/ # Global site metadata, navigation, telemetry data
|
||||
│ ├── _includes/
|
||||
│ │ ├── layouts/ # Base, post, and page templates
|
||||
│ │ └── partials/ # Global bar, hero tile, card, code box, sidebar
|
||||
│ ├── assets/
|
||||
│ │ ├── css/ # Modular CSS (tokens, reset, layout, components)
|
||||
│ │ └── fonts/ # Self-hosted monospace & prose webfonts
|
||||
│ └── posts/ # Markdown content ingested from Obsidian
|
||||
├── .eleventy.js # Eleventy configuration, markdown-it plugins, and shortcodes
|
||||
├── package.json # Project dependencies & build scripts
|
||||
├── server.js # Native Node static web server & clean-URL router
|
||||
├── Dockerfile # Node container definition (built & served via npm)
|
||||
├── docker-compose.yml # Container orchestration (web gateway)
|
||||
└── AGENTS.md # Project-level agent instructions & pair programming guide
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Development & Build Roadmap
|
||||
|
||||
- [x] **Phase 1: Foundation & Agent Customizations**
|
||||
- Establish `README.md` architectural blueprint.
|
||||
- Setup `.agents/` workspace rules, skills, and root `AGENTS.md`.
|
||||
- [ ] **Phase 2: Eleventy Scaffolding & Modularization**
|
||||
- Initialize Node/npm environment and configure `.eleventy.js`.
|
||||
- Extract design tokens, layout styles, and components into `src/assets/css/`.
|
||||
- Build reusable Nunjucks layouts and partials modeled on `sample-designs/`.
|
||||
- [ ] **Phase 3: Dynamic Obsidian Ingestion & Plugin**
|
||||
- Implement the server-side note ingestion endpoint (`POST /api/posts`).
|
||||
- Develop the custom Obsidian plugin for publishing notes and frontmatter.
|
||||
- Integrate wikilink and callout transformation filters.
|
||||
- [ ] **Phase 4: Containerization & Deployment**
|
||||
- Verify static build outputs in `_site/`.
|
||||
- Update Dockerfile and `docker-compose.yml` for unified production serving.
|
||||
|
||||
---
|
||||
|
||||
## 5. Development Setup
|
||||
|
||||
*(To be initialized during Phase 2)*
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start Eleventy local development server with hot-reload
|
||||
npm run dev
|
||||
|
||||
# Compile static assets for production
|
||||
npm run build
|
||||
|
||||
# Run via Docker Compose
|
||||
docker compose up --build
|
||||
```
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env zsh
|
||||
set -e
|
||||
echo "pulling latest build image..."
|
||||
docker compose pull
|
||||
|
||||
# Check which service is running
|
||||
if docker compose ps blog-blue --filter "status=running" -q | grep -q .; then
|
||||
CURRENT="blog-blue"
|
||||
TARGET="blog-green"
|
||||
else
|
||||
CURRENT="blog-green"
|
||||
TARGET="blog-blue"
|
||||
fi
|
||||
|
||||
echo "Current: $CURRENT -> deploying Target: $TARGET"
|
||||
|
||||
# TODO dont run if 0 instances running
|
||||
# Scale current deployment to two instances, keeping the old one and adding a running env of the new one
|
||||
docker compose up -d --no-deps $TARGET
|
||||
|
||||
echo "waiting for $TARGET to pass healthcheck..."
|
||||
# docker ps --filter 'label=com.docker.compose.service=blog' --filter 'health=starting'
|
||||
until [[ "$(docker inspect --format '{{.State.Health.Status}}' $(docker compose ps -q $TARGET) 2>/dev/null)" == "healthy" ]]; do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Give traefik backend a few seconds to update
|
||||
sleep 3
|
||||
|
||||
echo "Stopping $CURRENT..."
|
||||
docker compose stop $CURRENT
|
||||
docker compose rm -f $CURRENT
|
||||
|
||||
echo "Zero downtime deployment to $TARGET complete."
|
||||
# TODO zero downtime deployment
|
||||
# TODO run on a cron job
|
||||
@@ -1,81 +0,0 @@
|
||||
import syntaxHighlight from "@11ty/eleventy-plugin-syntaxhighlight";
|
||||
import markdownIt from "markdown-it";
|
||||
import markdownItAnchor from "markdown-it-anchor";
|
||||
import { filters } from "./src/_filters/index.js";
|
||||
|
||||
export default function (eleventyConfig) {
|
||||
// 1. Static Asset Passthrough Copy
|
||||
eleventyConfig.addPassthroughCopy({ "src/assets": "assets" });
|
||||
|
||||
// 2. Syntax Highlighting Plugin
|
||||
eleventyConfig.addPlugin(syntaxHighlight);
|
||||
|
||||
// 3. Markdown-It Library Customization
|
||||
const markdownOptions = {
|
||||
html: true,
|
||||
breaks: false,
|
||||
linkify: true
|
||||
};
|
||||
|
||||
const markdownLib = markdownIt(markdownOptions).use(markdownItAnchor, {
|
||||
permalink: markdownItAnchor.permalink.headerLink({
|
||||
safariReaderFix: true
|
||||
}),
|
||||
slugify: (s) =>
|
||||
s
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9 -]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
});
|
||||
|
||||
eleventyConfig.setLibrary("md", markdownLib);
|
||||
|
||||
// 4. Custom Filters (imported from modular src/_filters/index.js)
|
||||
for (const [name, fn] of Object.entries(filters)) {
|
||||
eleventyConfig.addFilter(name, fn);
|
||||
}
|
||||
|
||||
// 5. Custom Collections
|
||||
// Chronological blog posts collection
|
||||
eleventyConfig.addCollection("posts", (collectionApi) => {
|
||||
return collectionApi
|
||||
.getFilteredByGlob("src/posts/*.md")
|
||||
.filter((post) => !post.data.draft)
|
||||
.sort((a, b) => b.date - a.date);
|
||||
});
|
||||
|
||||
// Dynamic Tag List Collection across all posts
|
||||
eleventyConfig.addCollection("tagList", (collectionApi) => {
|
||||
const tagSet = new Set();
|
||||
collectionApi.getFilteredByGlob("src/posts/*.md").forEach((item) => {
|
||||
if ("tags" in item.data) {
|
||||
let tags = item.data.tags;
|
||||
if (typeof tags === "string") tags = [tags];
|
||||
for (const tag of tags) {
|
||||
const cleanTag = tag.replace(/^#/, "");
|
||||
tagSet.add(cleanTag);
|
||||
}
|
||||
}
|
||||
});
|
||||
return [...tagSet].sort();
|
||||
});
|
||||
|
||||
// 6. Dev Server Options (dynamic port binding)
|
||||
eleventyConfig.setServerOptions({
|
||||
port: parseInt(process.env.PORT || "8080", 10)
|
||||
});
|
||||
|
||||
return {
|
||||
dir: {
|
||||
input: "src",
|
||||
includes: "_includes",
|
||||
data: "_data",
|
||||
output: "_site"
|
||||
},
|
||||
templateFormats: ["njk", "md", "html"],
|
||||
htmlTemplateEngine: "njk",
|
||||
markdownTemplateEngine: "njk"
|
||||
};
|
||||
}
|
||||
Generated
-1618
File diff suppressed because it is too large
Load Diff
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"name": "river-rooks-blog",
|
||||
"version": "1.0.0",
|
||||
"description": "Developer-centric personal technical publication and systems architecture blog",
|
||||
"type": "module",
|
||||
"main": "eleventy.config.js",
|
||||
"scripts": {
|
||||
"dev": "eleventy --serve --watch",
|
||||
"build": "eleventy",
|
||||
"serve": "node server.js",
|
||||
"start": "node server.js",
|
||||
"test": "node --test 'test/**/*.test.js'",
|
||||
"clean": "rm -rf _site"
|
||||
},
|
||||
"keywords": [
|
||||
"eleventy",
|
||||
"blog",
|
||||
"tiling",
|
||||
"nunjucks",
|
||||
"obsidian"
|
||||
],
|
||||
"author": "River Rooks",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@11ty/eleventy": "^3.1.6",
|
||||
"@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2",
|
||||
"luxon": "^3.5.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"markdown-it-anchor": "^9.2.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,780 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>River Rooks | Blog Lander (Tiling Hero & Feed)</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,700;1,400&family=Inter:wght@400;500;700&family=JetBrains+Mono:ital,wght@0,400;0,500;1,400&display=swap');
|
||||
|
||||
:root {
|
||||
--bg-canvas: #0d0f12; /* Deep slate/charcoal canvas */
|
||||
--bg-surface: #121417; /* Tile surface */
|
||||
--bg-elevated: #1c1f26; /* Hovered tile surface */
|
||||
--border-color: #2b303b; /* Crisp 1px solid border */
|
||||
--text-primary: #e0e6f0; /* Light off-white prose */
|
||||
--text-muted: #8a95a5; /* Monospace metadata */
|
||||
--accent-cyan: #00f0ff; /* Primary active accent */
|
||||
--accent-green: #00e676; /* Operational status indicator */
|
||||
--accent-amber: #ffb700; /* Warning / highlight accent */
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-canvas);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Inter', sans-serif;
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 24px 16px 48px 16px;
|
||||
}
|
||||
|
||||
.tiling-layout {
|
||||
width: 100%;
|
||||
max-width: 1120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Top Global Status Header Bar (Gateway 3 Style) */
|
||||
.global-bar {
|
||||
background-color: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 10px 18px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.brand-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--accent-green);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.bar-nav {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bar-link {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
transition: none; /* Instant snap */
|
||||
}
|
||||
|
||||
.bar-link:hover {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.bar-link.active {
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Hero Tile: Section Declaration & Synopsis */
|
||||
.hero-tile {
|
||||
background-color: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-left: 3px solid var(--accent-cyan);
|
||||
border-radius: 4px;
|
||||
padding: 24px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hero-pretitle {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--accent-cyan);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-main-title {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 2.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hero-landing-text {
|
||||
font-size: 0.98rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.65;
|
||||
max-width: 82ch;
|
||||
}
|
||||
|
||||
.filter-pills-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.filter-tag-btn {
|
||||
background-color: var(--bg-canvas);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-muted);
|
||||
padding: 3px 8px;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.filter-tag-btn:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.filter-tag-btn.active {
|
||||
background-color: var(--accent-cyan);
|
||||
color: var(--bg-canvas);
|
||||
border-color: var(--accent-cyan);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Asymmetric Split Grid */
|
||||
.split-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.25fr 0.75fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.split-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Standard Tile Container */
|
||||
.tile {
|
||||
background-color: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.tile-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.tile-title {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tile-meta {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Featured Lead Article Card (Inside Tile) */
|
||||
.featured-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.featured-badge-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.76rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.badge-pinned {
|
||||
color: var(--accent-amber);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.featured-article-title {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
line-height: 1.3;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.featured-article-title:hover {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.featured-prose {
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Terminal Code Block Component */
|
||||
.terminal-code-box {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
background-color: var(--bg-canvas);
|
||||
overflow: hidden;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.code-titlebar {
|
||||
background-color: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 6px 12px;
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.code-content {
|
||||
padding: 12px;
|
||||
font-size: 0.78rem;
|
||||
overflow-x: auto;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.featured-cta-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
background-color: var(--accent-cyan);
|
||||
color: var(--bg-canvas);
|
||||
padding: 6px 14px;
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
text-decoration: none;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--accent-cyan);
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
background-color: transparent;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Sidebar Tile Elements */
|
||||
.sidebar-stats-box {
|
||||
background-color: var(--bg-canvas);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.stat-val {
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.topic-track-item {
|
||||
background-color: var(--bg-canvas);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
text-decoration: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.topic-track-item:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
background-color: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.topic-track-title {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.topic-track-item:hover .topic-track-title {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.topic-track-desc {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Full-Width Chronological Article Feed Tile */
|
||||
.feed-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.feed-card {
|
||||
background-color: var(--bg-canvas);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.feed-card:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
background-color: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.feed-meta-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.feed-date {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.feed-heading {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
line-height: 1.35;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.feed-card:hover .feed-heading {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.feed-excerpt {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.feed-tags-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-muted);
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tag-cluster {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Global Footer Tile (Gateway 3 Standard) */
|
||||
.footer-tile {
|
||||
background-color: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.footer-links-cluster {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer-btn {
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
padding: 2px 4px;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.footer-btn:hover {
|
||||
background-color: var(--accent-cyan);
|
||||
color: var(--bg-canvas);
|
||||
}
|
||||
|
||||
.pipe {
|
||||
color: var(--border-color);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<main class="tiling-layout">
|
||||
|
||||
<!-- Top Global Status Bar (Gateway 3 Match) -->
|
||||
<header class="global-bar">
|
||||
<div class="brand-group">
|
||||
<span class="status-indicator"></span>
|
||||
<span>RIVER ROOKS // BLOG & RESEARCH ARCHIVE</span>
|
||||
</div>
|
||||
<nav class="bar-nav" aria-label="Quick Navigation">
|
||||
<a href="service-gateway-tiling.html" class="bar-link">Gateway 3 ↗</a>
|
||||
<a href="#" class="bar-link active">> Dispatches</a>
|
||||
<a href="https://git.rooks.dev" target="_blank" rel="noopener" class="bar-link">Git Forge</a>
|
||||
<a href="feed.xml" class="bar-link">RSS Feed</a>
|
||||
<a href="index.html" class="bar-link">Catalog ↗</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- Hero Tile: Blog Mission & Topic Filters -->
|
||||
<section class="hero-tile" aria-label="Blog Mission & Overview">
|
||||
<div class="hero-pretitle">TECHNICAL PUBLICATION REPOSITORY • ~/NOTES/INDEX.MD</div>
|
||||
<h1 class="hero-main-title">Engineering Notes, CTF Write-Ups & Systems Architecture</h1>
|
||||
|
||||
<p class="hero-landing-text">
|
||||
Documenting bare-metal systems, embedded hardware audits, and network isolation architectures. Writing zero-allocation POSIX scripts, reverse engineering production microcontrollers, and analyzing memory allocator exploitation primitives.
|
||||
</p>
|
||||
|
||||
<div class="filter-pills-row">
|
||||
<span style="color: var(--text-muted); margin-right: 4px;">TOPIC FILTER:</span>
|
||||
<button class="filter-tag-btn active" onclick="filterTopic('all', this)">[All Dispatches (5)]</button>
|
||||
<button class="filter-tag-btn" onclick="filterTopic('firmware', this)">[#firmware (1)]</button>
|
||||
<button class="filter-tag-btn" onclick="filterTopic('homelab', this)">[#homelab (2)]</button>
|
||||
<button class="filter-tag-btn" onclick="filterTopic('security', this)">[#security (3)]</button>
|
||||
<button class="filter-tag-btn" onclick="filterTopic('posix-sh', this)">[#posix-sh (1)]</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Split Grid: Featured Article + Research Index -->
|
||||
<div class="split-grid">
|
||||
|
||||
<!-- Left Column: Featured Article Tile -->
|
||||
<section class="tile" aria-label="Featured Technical Article">
|
||||
<div class="tile-header">
|
||||
<div class="tile-title">
|
||||
<span>// PINNED RESEARCH WRITE-UP</span>
|
||||
</div>
|
||||
<span class="tile-meta">SPI FLASH • 8 MIN READ</span>
|
||||
</div>
|
||||
|
||||
<div class="featured-card-body">
|
||||
<div class="featured-badge-row">
|
||||
<span class="badge-pinned">[FEATURED DEEP DIVE]</span>
|
||||
<span style="color: var(--text-muted);">2026-07-20 • ~/notes/iot-firmware-spi-extraction.md</span>
|
||||
</div>
|
||||
|
||||
<h2 style="margin: 0;">
|
||||
<a href="article-tiling-firmware.html" class="featured-article-title">Reverse Engineering IoT Firmware: SPI Flash Extraction</a>
|
||||
</h2>
|
||||
|
||||
<p class="featured-prose">
|
||||
In security audits of embedded systems, retrieving firmware is the critical first step. When over-the-air updates are encrypted and debug ports (UART, JTAG) are disabled in production silicon, dumping physical SPI Flash memory becomes necessary. This writeup covers extracting firmware from an 8-pin SOP SPI flash chip using a Bus Pirate and flashrom to bypass bootloader lockdowns.
|
||||
</p>
|
||||
|
||||
<!-- Terminal Code Box -->
|
||||
<div class="terminal-code-box">
|
||||
<div class="code-titlebar">
|
||||
<span>extract_firmware.sh</span>
|
||||
<span>POSIX sh</span>
|
||||
</div>
|
||||
<div class="code-content">
|
||||
<pre><code># Query the SPI flash details using flashrom via Bus Pirate
|
||||
flashrom -p buspirate_spi:dev=/dev/ttyUSB0,spispeed=1M -r firmware_dump.bin
|
||||
|
||||
# Verify the integrity of the binary dump by comparing hashes
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Dump successful. Running md5sum..."
|
||||
md5sum firmware_dump.bin
|
||||
fi</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="featured-cta-row">
|
||||
<div class="tag-cluster">
|
||||
<span style="color: var(--text-muted); font-size: 0.75rem;">[#firmware] [#hardware] [#security] [#bus-pirate]</span>
|
||||
</div>
|
||||
<a href="article-tiling-firmware.html" class="cta-button">READ FULL WRITEUP →</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Right Column: Quick Research Tracks & Telemetry -->
|
||||
<div style="display: flex; flex-direction: column; gap: 16px;">
|
||||
|
||||
<!-- Publication Stats Tile -->
|
||||
<section class="tile" aria-label="Archive Statistics">
|
||||
<div class="tile-header">
|
||||
<div class="tile-title">
|
||||
<span>// ARCHIVE TELEMETRY</span>
|
||||
</div>
|
||||
<span class="tile-meta">STATIC HTML</span>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-stats-box">
|
||||
<div class="stat-row">
|
||||
<span>TOTAL ARTICLES:</span>
|
||||
<span class="stat-val">5 PUBLISHED</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span>TOTAL READING TIME:</span>
|
||||
<span class="stat-val">51 MINUTES</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span>ACTIVE TOPICS:</span>
|
||||
<span class="stat-val">7 CATEGORIES</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span>FORMAT COMPLIANCE:</span>
|
||||
<span class="stat-val">IEEE STD 1003.1</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Curated Focus Tracks -->
|
||||
<section class="tile" aria-label="Curated Topic Tracks">
|
||||
<div class="tile-header">
|
||||
<div class="tile-title">
|
||||
<span>// RESEARCH TRACKS</span>
|
||||
</div>
|
||||
<span class="tile-meta">CURATED</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||
<a href="#" class="topic-track-item">
|
||||
<div class="topic-track-title">> Embedded & Hardware Forensics</div>
|
||||
<div class="topic-track-desc">Flash memory dumping, bus tapping, silicon pinout discovery.</div>
|
||||
</a>
|
||||
<a href="#" class="topic-track-item">
|
||||
<div class="topic-track-title">> Network Routing & Homelab</div>
|
||||
<div class="topic-track-desc">Traefik reverse proxying, WireGuard full-mesh, VLAN firewalls.</div>
|
||||
</a>
|
||||
<a href="#" class="topic-track-item">
|
||||
<div class="topic-track-title">> Low-Level Memory Exploitation</div>
|
||||
<div class="topic-track-desc">glibc tcache safe-linking, heap corruption, House of Apple.</div>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Full-Width Article Feed Tile -->
|
||||
<section class="tile" aria-label="Chronological Article Stream">
|
||||
<div class="tile-header">
|
||||
<div class="tile-title">
|
||||
<span>// CHRONOLOGICAL DISPATCHES FEED</span>
|
||||
</div>
|
||||
<span class="tile-meta">ALL ARTICLES ORDERED BY DATE</span>
|
||||
</div>
|
||||
|
||||
<div class="feed-list" id="feedList">
|
||||
|
||||
<!-- Post 2: Traefik -->
|
||||
<article class="feed-card" data-tags="homelab networking">
|
||||
<div class="feed-meta-row">
|
||||
<time datetime="2026-07-15" class="feed-date">2026-07-15 • 12 min read</time>
|
||||
<span>~/notes/ha-traefik-docker.md</span>
|
||||
</div>
|
||||
<h3 style="margin: 0;">
|
||||
<a href="article-tiling-traefik.html" class="feed-heading">> Building a High-Availability Traefik Router with Docker Compose</a>
|
||||
</h3>
|
||||
<p class="feed-excerpt">
|
||||
Architecting automated TLS wildcard certificate propagation, Docker provider label routing, zero-downtime container updates, and backend health-check failover routes on bare-metal infrastructure.
|
||||
</p>
|
||||
<div class="feed-tags-row">
|
||||
<div class="tag-cluster">
|
||||
<span>[#homelab]</span>
|
||||
<span>[#docker-traefik]</span>
|
||||
<span>[#networking]</span>
|
||||
</div>
|
||||
<a href="article-tiling-traefik.html" style="color: var(--accent-cyan); text-decoration: none;">Read Entry →</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Post 3: POSIX sh -->
|
||||
<article class="feed-card" data-tags="posix-sh security">
|
||||
<div class="feed-meta-row">
|
||||
<time datetime="2026-07-10" class="feed-date">2026-07-10 • 6 min read</time>
|
||||
<span>~/notes/posix-sh-vs-bash.md</span>
|
||||
</div>
|
||||
<h3 style="margin: 0;">
|
||||
<a href="#" class="feed-heading">> POSIX sh vs. Bash: Portability in Modern Scripts</a>
|
||||
</h3>
|
||||
<p class="feed-excerpt">
|
||||
Analyzing edge-case incompatibilities, syntax overhead, eliminating bashisms, and writing fast, bulletproof bootstrapping scripts for Alpine and embedded targets conforming to IEEE Std 1003.1.
|
||||
</p>
|
||||
<div class="feed-tags-row">
|
||||
<div class="tag-cluster">
|
||||
<span>[#posix-sh]</span>
|
||||
<span>[#shell]</span>
|
||||
<span>[#unix]</span>
|
||||
</div>
|
||||
<a href="#" style="color: var(--accent-cyan); text-decoration: none;">Read Entry →</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Post 4: Heap Exploitation -->
|
||||
<article class="feed-card" data-tags="security">
|
||||
<div class="feed-meta-row">
|
||||
<time datetime="2026-06-28" class="feed-date">2026-06-28 • 15 min read</time>
|
||||
<span>~/notes/ctf-heap-glibc-2-35.md</span>
|
||||
</div>
|
||||
<h3 style="margin: 0;">
|
||||
<a href="article-tiling-heap.html" class="feed-heading">> CTF Write-up: Heap Exploitation in glibc 2.35</a>
|
||||
</h3>
|
||||
<p class="feed-excerpt">
|
||||
Deconstructing modern glibc memory allocator defenses: tcache safe-linking pointer mangling, bypass primitives, and House of Apple file structure hijacking for arbitrary code execution.
|
||||
</p>
|
||||
<div class="feed-tags-row">
|
||||
<div class="tag-cluster">
|
||||
<span>[#ctf]</span>
|
||||
<span>[#binary-exploitation]</span>
|
||||
<span>[#security]</span>
|
||||
</div>
|
||||
<a href="article-tiling-heap.html" style="color: var(--accent-cyan); text-decoration: none;">Read Entry →</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Post 5: Zero-Trust WireGuard -->
|
||||
<article class="feed-card" data-tags="networking security homelab">
|
||||
<div class="feed-meta-row">
|
||||
<time datetime="2026-06-14" class="feed-date">2026-06-14 • 10 min read</time>
|
||||
<span>~/notes/zero-trust-wireguard-opnsense.md</span>
|
||||
</div>
|
||||
<h3 style="margin: 0;">
|
||||
<a href="#" class="feed-heading">> Zero-Trust Network Isolation with WireGuard & OPNsense VLANs</a>
|
||||
</h3>
|
||||
<p class="feed-excerpt">
|
||||
Designing strict zone-based firewall rules across isolated VLANs (Management, Server DMZ, IoT, Trusted Clients) with point-to-point WireGuard cryptographic routing.
|
||||
</p>
|
||||
<div class="feed-tags-row">
|
||||
<div class="tag-cluster">
|
||||
<span>[#networking]</span>
|
||||
<span>[#wireguard]</span>
|
||||
<span>[#security]</span>
|
||||
</div>
|
||||
<a href="#" style="color: var(--accent-cyan); text-decoration: none;">Read Entry →</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Global Footer Tile (Matching Gateway 3 Exactly) -->
|
||||
<footer class="footer-tile">
|
||||
<div>River Rooks • University of Alabama College of Engineering</div>
|
||||
<nav class="footer-links-cluster" aria-label="Global Footer Links">
|
||||
<a href="service-gateway-tiling.html" class="footer-btn">Gateway Lander</a>
|
||||
<span class="pipe">|</span>
|
||||
<a href="feed.xml" class="footer-btn">RSS</a>
|
||||
<span class="pipe">|</span>
|
||||
<a href="https://git.rooks.dev" target="_blank" rel="noopener" class="footer-btn">Forgejo</a>
|
||||
<span class="pipe">|</span>
|
||||
<a href="mailto:river@rooks.dev" class="footer-btn">river@rooks.dev</a>
|
||||
<span class="pipe">|</span>
|
||||
<a href="#" title="Fingerprint: 0x4A8E92F1C0B3D4E7" class="footer-btn">PGP</a>
|
||||
<span class="pipe">|</span>
|
||||
<a href="index.html" class="footer-btn">Catalog ↗</a>
|
||||
</nav>
|
||||
</footer>
|
||||
|
||||
</main>
|
||||
|
||||
<script>
|
||||
function filterTopic(topic, btn) {
|
||||
document.querySelectorAll('.filter-tag-btn').forEach(el => el.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
|
||||
const cards = document.querySelectorAll('#feedList .feed-card');
|
||||
cards.forEach(card => {
|
||||
if (topic === 'all') {
|
||||
card.style.display = 'flex';
|
||||
} else {
|
||||
const tags = card.getAttribute('data-tags') || '';
|
||||
if (tags.includes(topic)) {
|
||||
card.style.display = 'flex';
|
||||
} else {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,81 +0,0 @@
|
||||
# Project Brief: Technical Web Architecture & Blog
|
||||
|
||||
**Project Type:** Personal Blog & Technical Documentation Site
|
||||
**Design Philosophy:** High-signal, low-noise. Developer-centric minimalism.
|
||||
**Vibe/Comparisons:** Zed Editor, Ghostty, Noctalia.dev.
|
||||
**Strictly Avoid:** Raycast, Astro, Vercel-style "SaaS" aesthetics.
|
||||
|
||||
## 1. Executive Summary
|
||||
This project requires a web interface that feels like a native development environment or terminal emulator, not a marketing page. The design must prioritize raw performance, keyboard navigability, and information density. It should evoke the craftsmanship of tiling window managers and modal text editors.
|
||||
|
||||
The content will heavily feature deep technical writing, including CTF write-ups, network routing diagrams, Docker/Traefik architectures, and raw code snippets (Lua, POSIX `sh`, and Python). The design must accommodate high-density information without visual clutter.
|
||||
|
||||
---
|
||||
|
||||
## 2. Global Rules & Anti-Patterns
|
||||
|
||||
To achieve the correct aesthetic, the designer must adhere to strict constraints.
|
||||
|
||||
> **The "Do Not Use" List**
|
||||
> * **NO Glassmorphism:** Zero use of `backdrop-filter: blur()`, frosted glass, or translucent overlays.
|
||||
> * **NO Glows or Drop Shadows:** Elements should not float, glow, or cast shadows. Depth is created through crisp 1px borders and distinct surface colors.
|
||||
> * **NO Gradients:** Avoid sweeping linear or radial gradients. Stick to solid colors.
|
||||
> * **NO Spring Animations:** Avoid floaty, delayed transitions.
|
||||
> * **NO Rounded Blobs:** Keep border-radii minimal (0px to 4px max). Sharp corners are preferred.
|
||||
|
||||
---
|
||||
|
||||
## 3. Typography System
|
||||
|
||||
The typographical hierarchy is a hybrid system utilizing monospace fonts for structural elements and a clean sans-serif for long-form reading.
|
||||
|
||||
| Usage | Font Family Preference | Weight | Role |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Structural / UI** | IBM Plex Mono, JetBrains Mono | Regular (400), Medium (500) | Navigation links, metadata, dates, tags, timestamps, buttons. |
|
||||
| **Code Blocks** | JetBrains Mono, Fira Code | Regular (400) | Inline code snippets, terminal command blocks, scripts. |
|
||||
| **Body Prose** | Inter, Geist Sans, Roboto | Regular (400) | Standard article paragraphs, lists. |
|
||||
| **Headings (H1-H3)**| IBM Plex Mono (or Sans variant) | Bold (700) | Article titles, section headers. |
|
||||
|
||||
**Typographical Constraints:**
|
||||
* **Line Height:** `1.6` to `1.7` for prose.
|
||||
* **Max Width:** Cap article text at `65ch` (characters) for optimal readability.
|
||||
|
||||
---
|
||||
|
||||
## 4. Color Palette (Dark Mode Native)
|
||||
|
||||
The site does not need a light mode. It should be built as a dark-mode native experience using a muted, low-saturation charcoal palette, mimicking a modern terminal or IDE.
|
||||
|
||||
* **Base Canvas (`#121417` or `#0d0f12`):** A deep slate/charcoal. Not pure black (`#000000`), which causes eye strain.
|
||||
* **Surface / Card (`#1c1f26`):** Slightly lighter than the canvas for article blocks or terminal windows.
|
||||
* **Borders & Dividers (`#2b303b`):** Subtle, low-contrast 1px solid lines used to define grids and separate content.
|
||||
* **Primary Text (`#e0e6f0`):** Off-white/light ash for prose.
|
||||
* **Muted Text (`#8a95a5`):** For timestamps, metadata, and non-essential UI text.
|
||||
* **Accent Color (Designer's Choice):** One single, vibrant terminal color (e.g., ANSI Green, Amber, or Cyan) used sparingly for active states, link hovers, or terminal prompts (like `>`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Layout & Component Architecture
|
||||
|
||||
The layout should be heavily grid-based, feeling structural and deliberate.
|
||||
|
||||
### Core Components Needed
|
||||
1. **Terminal-Style Code Blocks:** Must support syntax highlighting. Should include a simple header (e.g., showing the filename like `config.lua` or `docker-compose.yml`) with a 1px border.
|
||||
2. **Metadata Tags:** Small, monospaced inline blocks for categorizing posts (e.g., `[#homelab]`, `[#ctf]`, `[#hyprland]`).
|
||||
3. **Keyboard Shortcut Hints:** Visual styling for `<kbd>` elements, rendering as crisp, bordered boxes (e.g., `Ctrl` + `K`).
|
||||
4. **Index/Archive List:** A dense, tabular list for older posts, prioritized by date, title, and tags, rather than large image thumbnails.
|
||||
|
||||
### Spacing & Grid
|
||||
* Use a rigid 4pt or 8pt baseline grid.
|
||||
* Padding inside code blocks and cards should be dense but mathematically consistent.
|
||||
* Full-bleed horizontal dividers (`<hr>`) should be used to separate major sections instead of large margins.
|
||||
|
||||
---
|
||||
|
||||
## 6. Interaction & Motion
|
||||
|
||||
Interactivity should mirror command-line interfaces: fast, binary, and unembellished.
|
||||
|
||||
* **Hover States:** Immediate state changes. Instead of fading a button's background over `300ms`, snap the background color instantly or invert the text/background colors.
|
||||
* **Focus States:** Explicit, high-visibility 1px or 2px solid outlines (using the accent color) when navigating via the `Tab` key. Accessibility and keyboard navigation are paramount.
|
||||
* **Active Elements:** Use structural indicators (like a `>` prepended to an active navigation link, or a solid left-border block) rather than changing font sizes or scaling elements up.
|
||||
@@ -1,592 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>River Rooks | Homelab Gateway (Tiling Workspace)</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,700;1,400&family=Inter:wght@400;500;700&family=JetBrains+Mono:ital,wght@0,400;0,500;1,400&display=swap');
|
||||
|
||||
:root {
|
||||
--bg-canvas: #0d0f12; /* Deep slate/charcoal canvas */
|
||||
--bg-surface: #121417; /* Tile surface */
|
||||
--bg-elevated: #1c1f26; /* Hovered tile */
|
||||
--border-color: #2b303b; /* Crisp 1px border */
|
||||
--text-primary: #e0e6f0; /* Prose off-white */
|
||||
--text-muted: #8a95a5; /* Monospace metadata */
|
||||
--accent-cyan: #00f0ff; /* Primary active accent */
|
||||
--accent-green: #00e676; /* Online indicator */
|
||||
--accent-amber: #ffb700; /* Warning / internal indicator */
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-canvas);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Inter', sans-serif;
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 24px 16px 48px 16px;
|
||||
}
|
||||
|
||||
.tiling-layout {
|
||||
width: 100%;
|
||||
max-width: 1120px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* Top Global Status Header Bar */
|
||||
.global-bar {
|
||||
background-color: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 10px 18px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.brand-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--accent-green);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.bar-nav {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bar-link {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
transition: none; /* Instant snap */
|
||||
}
|
||||
|
||||
.bar-link:hover {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Primary Hero Landing Tile (Main Focus of Homepage) */
|
||||
.hero-tile {
|
||||
background-color: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-left: 3px solid var(--accent-cyan);
|
||||
border-radius: 4px;
|
||||
padding: 28px 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.hero-pretitle {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--accent-cyan);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-main-title {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 2.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hero-landing-text {
|
||||
font-size: 1.02rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.7;
|
||||
max-width: 80ch;
|
||||
}
|
||||
|
||||
.hero-landing-text p {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.hero-landing-text p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.hero-directive-box {
|
||||
background-color: var(--bg-canvas);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
padding: 14px 18px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero-directive-box strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Tiling Grid Section: Split Columns */
|
||||
.split-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.15fr 0.85fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.split-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tile Container Style */
|
||||
.tile {
|
||||
background-color: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.tile-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.tile-title {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tile-meta {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Featured Service Tile: The Blog */
|
||||
.blog-service-feature {
|
||||
background-color: var(--bg-canvas);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.blog-feature-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.badge-core {
|
||||
background-color: var(--accent-cyan);
|
||||
color: var(--bg-canvas);
|
||||
padding: 2px 6px;
|
||||
font-weight: 700;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.blog-feature-title {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.blog-feature-title:hover {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.blog-feature-desc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.blog-sublinks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.blog-sublink-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
padding: 4px 6px;
|
||||
border-radius: 2px;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.blog-sublink-item:hover {
|
||||
background-color: var(--bg-elevated);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Homelab Services Grid */
|
||||
.services-mini-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.services-mini-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.service-tile-item {
|
||||
background-color: var(--bg-canvas);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
padding: 12px;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.service-tile-item:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
background-color: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.service-item-title {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.service-tile-item:hover .service-item-title {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.service-item-tag {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.service-item-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Infrastructure Nodes Telemetry Tile */
|
||||
.node-stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
background-color: var(--bg-canvas);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.node-name {
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.node-load {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
/* Bottom Global Footer Tile */
|
||||
.footer-tile {
|
||||
background-color: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.footer-links-cluster {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer-btn {
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
padding: 2px 4px;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.footer-btn:hover {
|
||||
background-color: var(--accent-cyan);
|
||||
color: var(--bg-canvas);
|
||||
}
|
||||
|
||||
.pipe {
|
||||
color: var(--border-color);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<main class="tiling-layout">
|
||||
|
||||
<!-- Top Status Bar -->
|
||||
<header class="global-bar">
|
||||
<div class="brand-group">
|
||||
<span class="status-indicator"></span>
|
||||
<span>RIVER ROOKS // NODE GATEWAY</span>
|
||||
</div>
|
||||
<nav class="bar-nav" aria-label="Quick Nav">
|
||||
<a href="blog-lander-cards.html" class="bar-link">> Blog & Notes</a>
|
||||
<a href="homelab.html" class="bar-link">Infrastructure</a>
|
||||
<a href="https://git.rooks.dev" target="_blank" rel="noopener" class="bar-link">Git Forge</a>
|
||||
<a href="index.html" class="bar-link">Catalog ↗</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- Main Hero Landing Tile (Central Focus of Homepage) -->
|
||||
<section class="hero-tile" aria-label="Homepage Mission & Landing Declaration">
|
||||
<div class="hero-pretitle">SYSTEM DECLARATION • RACK-01 NODE GATEWAY</div>
|
||||
<h1 class="hero-main-title">Engineering Sovereign Infrastructure & Technical Notes</h1>
|
||||
|
||||
<div class="hero-landing-text">
|
||||
<p>
|
||||
I am River Rooks, a cybersecurity engineering student at the University of Alabama. My work centers on network forensics, self-hosted system architecture, low-level firmware exploitation, and zero-allocation Unix utilities.
|
||||
</p>
|
||||
<p>
|
||||
This website functions as the unified frontend for my physical homelab cluster. Rather than hosting projects on opaque third-party clouds, everything here runs on bare metal in my personal server rack—orchestrated through Proxmox VE, partitioned across isolated VLANs via OPNsense, and protected by point-to-point WireGuard cryptographic meshes.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Navigation Guide:</strong> If you are here to read technical research, the <strong>Engineering Blog</strong> is linked below with deep write-ups on firmware dumping, heap exploitation, and container routing. If you are here to use tools, the <strong>Public Services</strong> provide locally hosted instances of CyberChef, Stirling PDF, and Git.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="hero-directive-box">
|
||||
<strong>PRIMARY DESTINATIONS:</strong>
|
||||
<span style="color: var(--accent-cyan); margin-left: 8px;">[1] Technical Blog (5 Articles)</span> •
|
||||
<span>[2] Forgejo Git Repos</span> •
|
||||
<span>[3] Public Cryptographic & PDF Tools</span> •
|
||||
<span>[4] Cluster Telemetry</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Split Tiling Columns -->
|
||||
<div class="split-grid">
|
||||
|
||||
<!-- Left Column: Featured Service (Blog) + Recent Articles -->
|
||||
<div style="display: flex; flex-direction: column; gap: 16px;">
|
||||
|
||||
<!-- Featured Service Tile: The Blog -->
|
||||
<section class="tile" aria-label="Featured Service: Technical Blog">
|
||||
<div class="tile-header">
|
||||
<div class="tile-title">
|
||||
<span>// PRIMARY SERVICE: THE TECHNICAL BLOG</span>
|
||||
</div>
|
||||
<span class="tile-meta">~/notes/ • 5 ARTICLES</span>
|
||||
</div>
|
||||
|
||||
<div class="blog-service-feature">
|
||||
<div class="blog-feature-top">
|
||||
<span class="badge-core">[CORE PUBLICATION SERVICE]</span>
|
||||
<span style="color: var(--text-muted);">2026-07-20 LAST SYNC</span>
|
||||
</div>
|
||||
|
||||
<a href="blog-lander-cards.html" class="blog-feature-title">> River Rooks Technical Notes & Research</a>
|
||||
<p class="blog-feature-desc">
|
||||
In-depth security post-mortems, CTF write-ups, shell portability benchmarks, and bare-metal homelab architecture guides.
|
||||
</p>
|
||||
|
||||
<div class="blog-sublinks">
|
||||
<a href="blog-lander-cards.html" class="blog-sublink-item">
|
||||
<span>1. Reverse Engineering IoT Firmware (SPI Flash)</span>
|
||||
<span style="color: var(--accent-cyan);">[8 min] →</span>
|
||||
</a>
|
||||
<a href="blog-lander-cards.html" class="blog-sublink-item">
|
||||
<span>2. High-Availability Traefik with Docker Compose</span>
|
||||
<span style="color: var(--accent-cyan);">[12 min] →</span>
|
||||
</a>
|
||||
<a href="blog-lander-cards.html" class="blog-sublink-item">
|
||||
<span>3. POSIX sh vs. Bash: Portability in Modern Scripts</span>
|
||||
<span style="color: var(--accent-cyan);">[6 min] →</span>
|
||||
</a>
|
||||
<a href="blog-lander-cards.html" class="blog-sublink-item">
|
||||
<span>4. CTF Write-up: Heap Exploitation in glibc 2.35</span>
|
||||
<span style="color: var(--accent-cyan);">[15 min] →</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Homelab Hosted Services & Node Telemetry -->
|
||||
<div style="display: flex; flex-direction: column; gap: 16px;">
|
||||
|
||||
<!-- Hosted Services Tile -->
|
||||
<section class="tile" aria-label="Homelab Services Directory">
|
||||
<div class="tile-header">
|
||||
<div class="tile-title">
|
||||
<span>// HOMELAB SERVICES ROUTER</span>
|
||||
</div>
|
||||
<span class="tile-meta">4 PUBLIC TOOLS</span>
|
||||
</div>
|
||||
|
||||
<div class="services-mini-grid">
|
||||
|
||||
<a href="https://git.rooks.dev" target="_blank" rel="noopener" class="service-tile-item">
|
||||
<div>
|
||||
<div class="service-item-title">Forgejo Git</div>
|
||||
<div class="service-item-tag">git.rooks.dev:2222</div>
|
||||
</div>
|
||||
<p class="service-item-desc">Self-hosted code repositories & dotfiles.</p>
|
||||
</a>
|
||||
|
||||
<a href="https://chef.rooks.dev" target="_blank" rel="noopener" class="service-tile-item">
|
||||
<div>
|
||||
<div class="service-item-title">CyberChef</div>
|
||||
<div class="service-item-tag">chef.rooks.dev:443</div>
|
||||
</div>
|
||||
<p class="service-item-desc">Hex decoding, cryptography & forensics.</p>
|
||||
</a>
|
||||
|
||||
<a href="https://pdf.rooks.dev" target="_blank" rel="noopener" class="service-tile-item">
|
||||
<div>
|
||||
<div class="service-item-title">Stirling PDF</div>
|
||||
<div class="service-item-tag">pdf.rooks.dev:443</div>
|
||||
</div>
|
||||
<p class="service-item-desc">Private document OCR and manipulation.</p>
|
||||
</a>
|
||||
|
||||
<a href="https://status.rooks.dev" target="_blank" rel="noopener" class="service-tile-item">
|
||||
<div>
|
||||
<div class="service-item-title">Uptime Kuma</div>
|
||||
<div class="service-item-tag">status.rooks.dev:3001</div>
|
||||
</div>
|
||||
<p class="service-item-desc">Live synthetic latency & uptime telemetry.</p>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Bare-Metal Node Telemetry Tile -->
|
||||
<section class="tile" aria-label="Physical Hardware Nodes">
|
||||
<div class="tile-header">
|
||||
<div class="tile-title">
|
||||
<span>// HARDWARE NODES (RACK-01)</span>
|
||||
</div>
|
||||
<span class="tile-meta">10GbE SFP+</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||
<div class="node-stat-row">
|
||||
<div>
|
||||
<span class="node-name">gw-node01.lan</span>
|
||||
<span style="color: var(--text-muted); font-size: 0.72rem; margin-left: 6px;">OPNsense 24.7</span>
|
||||
</div>
|
||||
<span class="node-load">CPU: 18% | RAM: 32%</span>
|
||||
</div>
|
||||
|
||||
<div class="node-stat-row">
|
||||
<div>
|
||||
<span class="node-name">pve-node02.lan</span>
|
||||
<span style="color: var(--text-muted); font-size: 0.72rem; margin-left: 6px;">Proxmox VE (AMD 5900X)</span>
|
||||
</div>
|
||||
<span class="node-load">CPU: 42% | RAM: 68%</span>
|
||||
</div>
|
||||
|
||||
<div class="node-stat-row">
|
||||
<div>
|
||||
<span class="node-name">nas-node03.lan</span>
|
||||
<span style="color: var(--text-muted); font-size: 0.72rem; margin-left: 6px;">TrueNAS (48TB RaidZ2)</span>
|
||||
</div>
|
||||
<span class="node-load">CPU: 14% | RAM: 82%</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Global Footer Bar -->
|
||||
<footer class="footer-tile">
|
||||
<div>River Rooks • University of Alabama College of Engineering</div>
|
||||
<nav class="footer-links-cluster" aria-label="Global Footer Links">
|
||||
<a href="blog-lander-cards.html" class="footer-btn">Blog Lander</a>
|
||||
<span class="pipe">|</span>
|
||||
<a href="feed.xml" class="footer-btn">RSS</a>
|
||||
<span class="pipe">|</span>
|
||||
<a href="https://git.rooks.dev" target="_blank" rel="noopener" class="footer-btn">Forgejo</a>
|
||||
<span class="pipe">|</span>
|
||||
<a href="mailto:river@rooks.dev" class="footer-btn">river@rooks.dev</a>
|
||||
<span class="pipe">|</span>
|
||||
<a href="#" title="Fingerprint: 0x4A8E92F1C0B3D4E7" class="footer-btn">PGP</a>
|
||||
<span class="pipe">|</span>
|
||||
<a href="index.html" class="footer-btn">Catalog ↗</a>
|
||||
</nav>
|
||||
</footer>
|
||||
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+23
-18
@@ -1,25 +1,30 @@
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
SITE_DOMAIN: "${SITE_DOMAIN:?Error: SITE_DOMAIN is not set. Please define SITE_DOMAIN in .env}"
|
||||
SITE_URL: "${SITE_URL:-https://${SITE_DOMAIN}}"
|
||||
PORT: "${PORT:-8080}"
|
||||
image: river-rooks-blog:latest
|
||||
container_name: river_rooks_web
|
||||
x-blog-templage: &blog-base
|
||||
image: git.riverrooks.dev/venus/river-rooks-blog:latest
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "test/healthcheck.js"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
start_period: 3s
|
||||
retries: 3
|
||||
volumes:
|
||||
- "./posts:/app/src/posts"
|
||||
environment:
|
||||
- "SITE_DOMAIN=${SITE_DOMAIN:?Error: SITE_DOMAIN is not set. Please define SITE_DOMAIN in .env}"
|
||||
- "SITE_URL=${SITE_URL:-https://${SITE_DOMAIN}}"
|
||||
- "PORT=${PORT:-8080}"
|
||||
- "WATCH=true"
|
||||
- "API_SECRET=${API_SECRET}"
|
||||
ports:
|
||||
- "${HOST_PORT:-${PORT:-8080}}:${PORT:-8080}"
|
||||
|
||||
networks:
|
||||
- backend
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:${PORT:-8080}/healthz > /dev/null || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
# I Have this deployed with a series of traefik labels but that's not strictly nessescary
|
||||
services:
|
||||
blog-blue:
|
||||
<<: *blog-base
|
||||
blog-green:
|
||||
<<: *blog-base
|
||||
networks:
|
||||
backend:
|
||||
external: true
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import http from "node:http";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const MIME_TYPES = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".mjs": "application/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".xml": "application/xml; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".ico": "image/x-icon",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".txt": "text/plain; charset=utf-8"
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates the HTTP server instance for serving static files from _site
|
||||
* @param {string} [customSiteDir] Optional custom directory to serve from
|
||||
* @returns {http.Server}
|
||||
*/
|
||||
export function createServer(customSiteDir) {
|
||||
const siteDir = path.resolve(customSiteDir || path.join(process.cwd(), "_site"));
|
||||
|
||||
return http.createServer((req, res) => {
|
||||
// Standard security headers
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
res.setHeader("X-Frame-Options", "DENY");
|
||||
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
|
||||
const parsedUrl = new URL(req.url, "http://localhost");
|
||||
|
||||
// Health check endpoint for container orchestrators & telemetry
|
||||
if (parsedUrl.pathname === "/healthz" || parsedUrl.pathname === "/health") {
|
||||
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ status: "ok", uptime: process.uptime() }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Path normalization & security validation against directory traversal
|
||||
let decodedPath = "";
|
||||
try {
|
||||
decodedPath = decodeURIComponent(parsedUrl.pathname);
|
||||
} catch {
|
||||
res.writeHead(400, { "Content-Type": "text/plain" });
|
||||
res.end("Bad Request");
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedPath = path.resolve(siteDir, `.${decodedPath}`);
|
||||
|
||||
// Boundary check: ensure target path is within siteDir
|
||||
if (!resolvedPath.startsWith(siteDir)) {
|
||||
res.writeHead(403, { "Content-Type": "text/plain" });
|
||||
res.end("Forbidden");
|
||||
return;
|
||||
}
|
||||
|
||||
let targetFile = resolvedPath;
|
||||
|
||||
// Directory index resolution
|
||||
if (fs.existsSync(targetFile) && fs.statSync(targetFile).isDirectory()) {
|
||||
targetFile = path.join(targetFile, "index.html");
|
||||
}
|
||||
|
||||
// Clean URL extensionless fallback (/posts/my-post -> /posts/my-post.html)
|
||||
if (!fs.existsSync(targetFile) && fs.existsSync(`${targetFile}.html`)) {
|
||||
targetFile = `${targetFile}.html`;
|
||||
}
|
||||
|
||||
// Check if target file exists and is a regular file
|
||||
if (fs.existsSync(targetFile) && fs.statSync(targetFile).isFile()) {
|
||||
const ext = path.extname(targetFile).toLowerCase();
|
||||
const contentType = MIME_TYPES[ext] || "application/octet-stream";
|
||||
|
||||
// Cache control: assets get long-term caching, html gets short
|
||||
if (decodedPath.startsWith("/assets/")) {
|
||||
res.setHeader("Cache-Control", "public, max-age=2592000, immutable");
|
||||
} else {
|
||||
res.setHeader("Cache-Control", "public, max-age=0, must-revalidate");
|
||||
}
|
||||
|
||||
res.writeHead(200, { "Content-Type": contentType });
|
||||
const stream = fs.createReadStream(targetFile);
|
||||
stream.pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
// 404 Not Found fallback
|
||||
const custom404 = path.join(siteDir, "404.html");
|
||||
if (fs.existsSync(custom404)) {
|
||||
res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
|
||||
fs.createReadStream(custom404).pipe(res);
|
||||
} else {
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
res.end("404 Not Found");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Direct CLI execution handler
|
||||
const currentFilePath = fileURLToPath(import.meta.url);
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === currentFilePath) {
|
||||
const port = parseInt(process.env.PORT || "8080", 10);
|
||||
const host = process.env.HOST || "0.0.0.0";
|
||||
const siteDir = path.resolve(process.cwd(), "_site");
|
||||
|
||||
if (!fs.existsSync(siteDir)) {
|
||||
console.warn(`[Static Server] Warning: '${siteDir}' does not exist yet. Run 'npm run build' first.`);
|
||||
}
|
||||
|
||||
const server = createServer();
|
||||
server.listen(port, host, () => {
|
||||
console.log(`[Static Server] Serving '${siteDir}' on http://${host}:${port}`);
|
||||
});
|
||||
|
||||
// Graceful termination handling
|
||||
const shutdown = (signal) => {
|
||||
console.log(`\n[Static Server] Received ${signal}. Shutting down gracefully...`);
|
||||
server.close(() => {
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
// Load .env variables into process.env if SITE_DOMAIN is not already defined
|
||||
if (!process.env.SITE_DOMAIN) {
|
||||
try {
|
||||
const envPath = path.resolve(process.cwd(), ".env");
|
||||
if (fs.existsSync(envPath)) {
|
||||
const envContent = fs.readFileSync(envPath, "utf-8");
|
||||
for (const line of envContent.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith("#") && trimmed.includes("=")) {
|
||||
const [key, ...vals] = trimmed.split("=");
|
||||
const value = vals.join("=").trim().replace(/^["']|["']$/g, "");
|
||||
if (!process.env[key.trim()]) {
|
||||
process.env[key.trim()] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Silent fallback
|
||||
}
|
||||
}
|
||||
|
||||
const siteDomain = process.env.SITE_DOMAIN;
|
||||
if (!siteDomain) {
|
||||
throw new Error(
|
||||
"\n=======================================================================\n" +
|
||||
"FATAL CONFIGURATION ERROR: SITE_DOMAIN environment variable is not set.\n" +
|
||||
"The site domain must be explicitly defined in your .env file or environment.\n" +
|
||||
"Example in .env:\n" +
|
||||
" SITE_DOMAIN=riverrooks.dev\n" +
|
||||
"=======================================================================\n"
|
||||
);
|
||||
}
|
||||
const siteUrl = process.env.SITE_URL || `https://${siteDomain}`;
|
||||
|
||||
export default {
|
||||
domain: siteDomain,
|
||||
url: siteUrl,
|
||||
name: "River Rooks",
|
||||
title: "River Rooks | Homelab Gateway",
|
||||
description: "CTF Writeups, Techincal Publucations, Homelab Journey, and Development Projects.",
|
||||
status: "SYSTEMS NOMINAL",
|
||||
author: "River Rooks",
|
||||
nav: [
|
||||
{
|
||||
label: "Home",
|
||||
url: "/"
|
||||
},
|
||||
{
|
||||
label: "Dispatches",
|
||||
url: "/dispatches/"
|
||||
},
|
||||
{
|
||||
label: "Gitea",
|
||||
url: `https://git.${siteDomain}`,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: "RSS Feed",
|
||||
url: "/feed.xml"
|
||||
}
|
||||
],
|
||||
services: [
|
||||
{
|
||||
name: "Gitea",
|
||||
url: `https://git.${siteDomain}`,
|
||||
tag: `git.${siteDomain}:2222`,
|
||||
desc: "Self-hosted Git code repositories."
|
||||
},
|
||||
{
|
||||
name: "CyberChef",
|
||||
url: `https://chef.${siteDomain}`,
|
||||
tag: `chef.${siteDomain}:443`,
|
||||
desc: "Industry tool for analyzing and decoding data."
|
||||
},
|
||||
{
|
||||
name: "Aperisolve",
|
||||
url: `https://aperisolve.${siteDomain}`,
|
||||
tag: `aperisolve.${siteDomain}:443`,
|
||||
desc: "Basic image steganography suite."
|
||||
},
|
||||
{
|
||||
name: "Streams",
|
||||
url: `https://streams.${siteDomain}`,
|
||||
tag: `streams.${siteDomain}:443`,
|
||||
desc: "Self-hosted Steam aggregator for wathing live sports."
|
||||
},
|
||||
{
|
||||
name: "Calibre",
|
||||
url: `https://library.${siteDomain}`,
|
||||
tag: `books.${siteDomain}:443`,
|
||||
desc: `Calibre web frontend for managing and reading eBooks.`
|
||||
}
|
||||
],
|
||||
topics: [
|
||||
{
|
||||
title: "Systems Engineering",
|
||||
slug: "systems",
|
||||
desc: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
|
||||
badge: "TRACK: SYSTEMS"
|
||||
},
|
||||
{
|
||||
title: "Security Research",
|
||||
slug: "security",
|
||||
desc: "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
badge: "TRACK: SECURITY"
|
||||
},
|
||||
{
|
||||
title: "Homelab Infrastructure",
|
||||
slug: "homelab",
|
||||
desc: "Ut enim ad minim veniam, quis nostrud exercitation ullamco.",
|
||||
badge: "TRACK: INFRA"
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -1,74 +0,0 @@
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
/**
|
||||
* Custom Eleventy Filters for River Rooks Blog
|
||||
*/
|
||||
|
||||
export const postDate = (dateObj) => {
|
||||
if (!dateObj) return "";
|
||||
const date = typeof dateObj === "string" ? new Date(dateObj) : dateObj;
|
||||
const dt = DateTime.fromJSDate(date, { zone: "utc" });
|
||||
if (!dt.isValid) return "";
|
||||
return dt.toFormat("LLL dd, yyyy").toUpperCase();
|
||||
};
|
||||
|
||||
export const isoDate = (dateObj) => {
|
||||
if (!dateObj) return "";
|
||||
const date = typeof dateObj === "string" ? new Date(dateObj) : dateObj;
|
||||
const dt = DateTime.fromJSDate(date, { zone: "utc" });
|
||||
if (!dt.isValid) return "";
|
||||
return dt.toISODate() || "";
|
||||
};
|
||||
|
||||
export const limit = (arr, count) => {
|
||||
if (!Array.isArray(arr)) return [];
|
||||
if (count <= 0) return [];
|
||||
return arr.slice(0, count);
|
||||
};
|
||||
|
||||
export const filterTagList = (tags) => {
|
||||
const internalTags = ["all", "nav", "post", "posts"];
|
||||
if (!tags) return [];
|
||||
const tagArray = Array.isArray(tags) ? tags : [tags];
|
||||
return tagArray.filter((tag) => {
|
||||
if (typeof tag !== "string") return false;
|
||||
const clean = tag.replace(/^#/, "").toLowerCase();
|
||||
return !internalTags.includes(clean);
|
||||
});
|
||||
};
|
||||
|
||||
export const readingTime = (text, frontmatterTime) => {
|
||||
if (frontmatterTime) return frontmatterTime;
|
||||
if (!text || typeof text !== "string") return "1 MIN READ";
|
||||
const clean = text.replace(/<[^>]+>/g, "").trim();
|
||||
if (!clean) return "1 MIN READ";
|
||||
const words = clean.split(/\s+/).filter(Boolean).length;
|
||||
const minutes = Math.max(1, Math.ceil(words / 200));
|
||||
return `${minutes} MIN READ`;
|
||||
};
|
||||
|
||||
export const extractHeadings = (htmlContent) => {
|
||||
if (!htmlContent || typeof htmlContent !== "string") return [];
|
||||
const headingRegex = /<h([23])\s+id="([^"]+)"[^>]*>(.*?)<\/h\1>/gis;
|
||||
const headings = [];
|
||||
let match;
|
||||
while ((match = headingRegex.exec(htmlContent)) !== null) {
|
||||
headings.push({
|
||||
level: parseInt(match[1], 10),
|
||||
id: match[2],
|
||||
title: match[3].replace(/<[^>]+>/g, "").trim()
|
||||
});
|
||||
}
|
||||
return headings;
|
||||
};
|
||||
|
||||
export const filters = {
|
||||
postDate,
|
||||
isoDate,
|
||||
limit,
|
||||
filterTagList,
|
||||
readingTime,
|
||||
extractHeadings
|
||||
};
|
||||
|
||||
export default filters;
|
||||
@@ -1,34 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ title | default(site.title) }}</title>
|
||||
<meta name="description" content="{{ excerpt | default(site.description) }}">
|
||||
|
||||
<!-- Webfonts: IBM Plex Mono, JetBrains Mono, Inter -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,700;1,400&family=Inter:ital,wght@0,400;0,500;0,700;1,400&family=JetBrains+Mono:ital,wght@0,400;0,500;1,400&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Modular Bundled Stylesheet -->
|
||||
<link rel="stylesheet" href="/assets/css/main.css">
|
||||
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="tiling-layout">
|
||||
{% include "partials/global-bar.njk" %}
|
||||
|
||||
<main id="main-content">
|
||||
{% block content %}
|
||||
{{ content | safe }}
|
||||
{% endblock %}
|
||||
</main>
|
||||
|
||||
{% include "partials/footer.njk" %}
|
||||
</div>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,7 +0,0 @@
|
||||
{% extends "layouts/base.njk" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-container">
|
||||
{{ content | safe }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,93 +0,0 @@
|
||||
{% extends "layouts/base.njk" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="article-workspace-grid">
|
||||
<!-- Left Sticky Sidebar: Document Outline & Telemetry -->
|
||||
<aside class="article-sidebar" aria-label="Article Sidebar">
|
||||
<!-- Dynamic Table of Contents -->
|
||||
{% set headings = content | extractHeadings %}
|
||||
{% if headings.length > 0 %}
|
||||
<div class="sidebar-tile">
|
||||
<div class="sidebar-heading">
|
||||
<span>DOCUMENT OUTLINE</span>
|
||||
<span style="color: var(--accent-cyan);">[{{ headings.length }}]</span>
|
||||
</div>
|
||||
<nav class="toc-nav" aria-label="Table of Contents">
|
||||
{% for h in headings %}
|
||||
<a href="#{{ h.id }}" class="toc-link {% if h.level == 3 %}toc-sub{% endif %}">
|
||||
{{ h.title }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Article Telemetry Tile -->
|
||||
<div class="sidebar-tile">
|
||||
<div class="sidebar-heading">DISPATCH TELEMETRY</div>
|
||||
<div class="sidebar-stats-box" style="padding: 0; background: transparent; border: none;">
|
||||
<div class="stat-row">
|
||||
<span>PUBLISHED:</span>
|
||||
<span class="stat-val">{{ date | postDate }}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span>DURATION:</span>
|
||||
<span class="stat-val">{{ content | readingTime(readingTime) }}</span>
|
||||
</div>
|
||||
{% if difficulty %}
|
||||
<div class="stat-row">
|
||||
<span>DIFFICULTY:</span>
|
||||
<span class="stat-val" style="color: var(--accent-amber);">{{ difficulty }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div style="padding-top: 10px; border-top: var(--border-width) solid var(--border-color);">
|
||||
<div style="font-family: var(--font-mono); font-size: 0.72rem; color: var(--text-muted); margin-bottom: 6px;">TOPIC TAGS:</div>
|
||||
<div class="tag-cluster">
|
||||
{% for tag in tags | filterTagList %}
|
||||
<span class="tag-chip">#{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding-top: 10px; border-top: var(--border-width) solid var(--border-color);">
|
||||
<a href="/dispatches/" class="filter-tag-btn" style="width: 100%; justify-content: center;">
|
||||
← Return to Dispatches
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Right Main Article Workspace -->
|
||||
<article class="article-tile" aria-labelledby="article-title">
|
||||
<header class="article-header-block">
|
||||
<nav class="article-breadcrumbs" aria-label="Breadcrumb">
|
||||
<a href="/dispatches/">~/NOTES</a>
|
||||
<span>/</span>
|
||||
<span>{{ (tags | filterTagList)[0] | default('DISPATCHES') | upper }}</span>
|
||||
<span>/</span>
|
||||
<span style="color: var(--accent-cyan);">{{ page.fileSlug | upper }}.MD</span>
|
||||
</nav>
|
||||
|
||||
<h1 id="article-title" class="article-main-title">{{ title }}</h1>
|
||||
|
||||
<div class="article-meta-row">
|
||||
<span class="meta-item">
|
||||
<time datetime="{{ date | isoDate }}">{{ date | postDate }}</time>
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span class="meta-item">{{ content | readingTime(readingTime) }}</span>
|
||||
{% if difficulty %}
|
||||
<span>•</span>
|
||||
<span class="meta-item" style="color: var(--accent-amber);">DIFFICULTY: {{ difficulty }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="markdown-prose">
|
||||
{{ content | safe }}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,30 +0,0 @@
|
||||
{#
|
||||
Reusable Tile Container Macro (macros/tile.njk)
|
||||
Renders a terminal pane with header, title, meta pill, and custom body slot.
|
||||
Usage:
|
||||
{% import "macros/tile.njk" as ui %}
|
||||
{% call ui.tile(title="STATISTICS", meta="STATIC HTML") %}
|
||||
<p>Inside tile</p>
|
||||
{% endcall %}
|
||||
#}
|
||||
|
||||
{% macro tile(title="", meta="", class="", id="") %}
|
||||
<section class="tile {{ class }}" {% if id %}id="{{ id }}"{% endif %}>
|
||||
{% if title or meta %}
|
||||
<header class="tile-header">
|
||||
{% if title %}
|
||||
<div class="tile-title">
|
||||
<span class="status-indicator"></span>
|
||||
{{ title }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if meta %}
|
||||
<span class="tile-meta">{{ meta }}</span>
|
||||
{% endif %}
|
||||
</header>
|
||||
{% endif %}
|
||||
<div class="tile-content">
|
||||
{{ caller() }}
|
||||
</div>
|
||||
</section>
|
||||
{% endmacro %}
|
||||
@@ -1,61 +0,0 @@
|
||||
{% set featured = null %}
|
||||
{% for p in collections.posts %}
|
||||
{% if p.data.featured and not featured %}
|
||||
{% set featured = p %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if not featured and collections.posts.length > 0 %}
|
||||
{% set featured = collections.posts[0] %}
|
||||
{% endif %}
|
||||
|
||||
{% if featured %}
|
||||
<section class="tile" aria-label="Featured Technical Dispatch">
|
||||
<header class="tile-header">
|
||||
<div class="tile-title">
|
||||
<span class="status-indicator"></span>
|
||||
FEATURED DISPATCH
|
||||
</div>
|
||||
<span class="tile-meta">{{ (featured.data.tags | filterTagList)[0] | default('SYSTEMS') | upper }} • {{ featured.templateContent | readingTime(featured.data.readingTime) }}</span>
|
||||
</header>
|
||||
|
||||
<div class="featured-card-body">
|
||||
<div class="featured-badge-row">
|
||||
<span class="badge-pinned">[FEATURED DEEP DIVE]</span>
|
||||
<time datetime="{{ featured.date | isoDate }}">{{ featured.date | postDate }}</time>
|
||||
</div>
|
||||
|
||||
<h2 style="margin: 0; font-size: inherit;">
|
||||
<a href="{{ featured.url }}" class="featured-article-title">{{ featured.data.title }}</a>
|
||||
</h2>
|
||||
|
||||
<p class="featured-prose">
|
||||
{{ featured.data.excerpt }}
|
||||
</p>
|
||||
|
||||
<!-- Terminal Code Preview Snippet -->
|
||||
<div class="terminal-code-box">
|
||||
<div class="code-titlebar">
|
||||
<span class="code-title">
|
||||
<span style="color: var(--accent-cyan);">></span>
|
||||
<span>init_cluster.sh</span>
|
||||
</span>
|
||||
<span>POSIX sh</span>
|
||||
</div>
|
||||
<div class="code-content">
|
||||
<pre><code class="language-bash"># System initialization and node health check
|
||||
./cluster.sh --verify-nodes
|
||||
curl -s https://api.local/healthz | jq .status</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="featured-cta-row">
|
||||
<div class="tag-cluster">
|
||||
{% for tag in featured.data.tags | filterTagList %}
|
||||
<span class="tag-chip">#{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<a href="{{ featured.url }}" class="cta-button">READ FULL WRITEUP →</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
@@ -1,13 +0,0 @@
|
||||
<footer class="footer-tile" aria-label="Global Footer">
|
||||
<div class="footer-left">
|
||||
<span class="status-indicator"></span>
|
||||
<span>RUNTIME: ELEVENTY 3.x STATIC</span>
|
||||
<span>•</span>
|
||||
<span>THEME: NATIVE TERMINAL DARK</span>
|
||||
</div>
|
||||
|
||||
<div class="footer-right">
|
||||
<span>© {{ currentYear or "2026" }} {{ site.author }}</span>
|
||||
<a href="https://git.{{ site.domain }}" target="_blank" rel="noopener noreferrer" class="footer-link">Source Forge ↗</a>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -1,16 +0,0 @@
|
||||
<header class="global-bar">
|
||||
<a href="/" class="brand-group" aria-label="{{ site.name }} Home">
|
||||
<span class="status-indicator"></span>
|
||||
<span>{{ site.name }}</span>
|
||||
</a>
|
||||
|
||||
<nav class="bar-nav" aria-label="Quick Navigation">
|
||||
{% for item in site.nav %}
|
||||
<a href="{{ item.url }}"
|
||||
class="bar-link {% if item.external %}external{% endif %} {% if page.url == item.url or (item.url != '/' and page.url.startsWith(item.url)) %}active{% endif %}"
|
||||
{% if item.external %}target="_blank" rel="noopener noreferrer"{% endif %}>
|
||||
{% if page.url == item.url %}> {% endif %}{{ item.label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
</header>
|
||||
@@ -1,24 +0,0 @@
|
||||
<section class="hero-tile" aria-label="Blog Mission & Overview">
|
||||
<div class="hero-pretitle">TECHNICAL PUBLICATION REPOSITORY • ~/NOTES/INDEX.MD</div>
|
||||
<h1 class="hero-main-title">{{ heroTitle or site.description }}</h1>
|
||||
<p class="hero-landing-text">
|
||||
{{ heroText or "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation." }}
|
||||
</p>
|
||||
|
||||
<div class="filter-pills-row" aria-label="Topic Filters">
|
||||
<a href="/dispatches/" class="filter-tag-btn {% if not currentTag %}active{% endif %}">
|
||||
[All Dispatches ({{ collections.posts.length }})]
|
||||
</a>
|
||||
{% for tag in collections.tagList %}
|
||||
{% set tagCount = 0 %}
|
||||
{% for post in collections.posts %}
|
||||
{% if tag in (post.data.tags or []) %}
|
||||
{% set tagCount = tagCount + 1 %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<span class="filter-tag-btn {% if currentTag == tag %}active{% endif %}">
|
||||
[#{{ tag }}{% if tagCount > 0 %} ({{ tagCount }}){% endif %}]
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,27 +0,0 @@
|
||||
<article class="feed-card" aria-labelledby="post-title-{{ post.fileSlug }}">
|
||||
<div class="feed-meta-row">
|
||||
<time class="feed-date" datetime="{{ post.date | isoDate }}">
|
||||
{{ post.date | postDate }}
|
||||
</time>
|
||||
<span>{{ post.templateContent | readingTime(post.data.readingTime) }}</span>
|
||||
</div>
|
||||
|
||||
<h3 id="post-title-{{ post.fileSlug }}">
|
||||
<a href="{{ post.url }}" class="feed-heading">{{ post.data.title }}</a>
|
||||
</h3>
|
||||
|
||||
{% if post.data.excerpt %}
|
||||
<p class="feed-excerpt">{{ post.data.excerpt }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="feed-tags-row">
|
||||
<div class="tag-cluster">
|
||||
{% for tag in post.data.tags | filterTagList %}
|
||||
<span class="tag-chip">#{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if post.data.difficulty %}
|
||||
<span>DIFFICULTY: {{ post.data.difficulty }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
@@ -1,54 +0,0 @@
|
||||
<aside aria-label="Archive Statistics & Topics" style="display: flex; flex-direction: column; gap: var(--tile-gap);">
|
||||
<!-- Archive Statistics Tile -->
|
||||
<section class="tile" aria-label="Archive Telemetry">
|
||||
<header class="tile-header">
|
||||
<div class="tile-title">
|
||||
<span class="status-indicator"></span>
|
||||
ARCHIVE TELEMETRY
|
||||
</div>
|
||||
<span class="tile-meta">STATIC HTML</span>
|
||||
</header>
|
||||
|
||||
<div class="sidebar-stats-box">
|
||||
<div class="stat-row">
|
||||
<span>TOTAL DISPATCHES:</span>
|
||||
<span class="stat-val">{{ collections.posts.length }} PUBLISHED</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span>TOPIC DOMAINS:</span>
|
||||
<span class="stat-val">{{ collections.tagList.length }} CATEGORIES</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span>CORE SPECIFICATION:</span>
|
||||
<span class="stat-val">{{ site.systemSpec }}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span>OPERATIONAL STATUS:</span>
|
||||
<span class="stat-val" style="color: var(--accent-green);">{{ site.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Curated Topic Tracks Tile -->
|
||||
<section class="tile" aria-label="Curated Topic Tracks">
|
||||
<header class="tile-header">
|
||||
<div class="tile-title">
|
||||
<span class="status-indicator"></span>
|
||||
TOPIC TRACKS
|
||||
</div>
|
||||
<span class="tile-meta">CURATED PATHS</span>
|
||||
</header>
|
||||
|
||||
<div class="tile-content">
|
||||
{% for topic in site.topics %}
|
||||
<div class="topic-track-item">
|
||||
<div style="display: flex; justify-content: space-between; align-items: baseline;">
|
||||
<span class="topic-track-title">{{ topic.title }}</span>
|
||||
<span style="font-family: var(--font-mono); font-size: 0.68rem; color: var(--accent-cyan);">{{ topic.badge }}</span>
|
||||
</div>
|
||||
<p class="topic-track-desc">{{ topic.desc }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
@@ -1,298 +0,0 @@
|
||||
/* ==========================================================================
|
||||
Article Workspace & Markdown Prose (components/article.css)
|
||||
========================================================================== */
|
||||
|
||||
/* Left Sticky Sidebar */
|
||||
.article-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--tile-gap);
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
}
|
||||
|
||||
.sidebar-tile {
|
||||
background-color: var(--bg-surface);
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sidebar-heading {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: var(--border-width) solid var(--border-color);
|
||||
padding-bottom: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Document Table of Contents */
|
||||
.toc-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.toc-link {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
padding: 4px 6px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
line-height: 1.4;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.toc-link:hover {
|
||||
background-color: var(--bg-elevated);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.toc-link.active {
|
||||
color: var(--accent-cyan);
|
||||
background-color: var(--bg-canvas);
|
||||
border-left: 2px solid var(--accent-cyan);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.toc-sub {
|
||||
padding-left: 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
/* Main Article Reading Container */
|
||||
.article-tile {
|
||||
background-color: var(--bg-surface);
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 36px 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.article-tile {
|
||||
padding: 24px 18px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Article Header */
|
||||
.article-header-block {
|
||||
border-bottom: var(--border-width) solid var(--border-color);
|
||||
padding-bottom: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.article-breadcrumbs {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.article-breadcrumbs a {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.article-breadcrumbs a:hover {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.article-main-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 2.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.article-main-title {
|
||||
font-size: 1.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.article-meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Markdown Prose Hierarchy */
|
||||
.markdown-prose {
|
||||
max-width: var(--prose-max-width);
|
||||
font-size: 1.02rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.markdown-prose p {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.markdown-prose h2 {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.45rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 36px 0 16px 0;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: var(--border-width) solid var(--border-color);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.markdown-prose h3 {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin: 28px 0 12px 0;
|
||||
}
|
||||
|
||||
.markdown-prose a {
|
||||
color: var(--accent-cyan);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.markdown-prose a:hover {
|
||||
background-color: var(--accent-cyan);
|
||||
color: var(--bg-canvas);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Inline Code */
|
||||
.markdown-prose code:not(pre code) {
|
||||
font-family: var(--font-code);
|
||||
font-size: 0.88rem;
|
||||
background-color: var(--bg-canvas);
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Keyboard Shortcut Hints */
|
||||
kbd {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
background-color: var(--bg-surface);
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
color: var(--text-primary);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Markdown Callouts */
|
||||
.markdown-callout {
|
||||
margin: 20px 0;
|
||||
padding: 14px 18px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
background-color: var(--bg-canvas);
|
||||
font-size: 0.94rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.callout-note {
|
||||
border-left: 4px solid var(--accent-cyan);
|
||||
}
|
||||
|
||||
.callout-warning {
|
||||
border-left: 4px solid var(--accent-amber);
|
||||
}
|
||||
|
||||
.callout-title {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
font-size: 0.82rem;
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.callout-note .callout-title {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.callout-warning .callout-title {
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
/* Monospace Tables */
|
||||
.table-container {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
margin: 22px 0;
|
||||
}
|
||||
|
||||
.markdown-prose table,
|
||||
.markdown-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.86rem;
|
||||
font-family: var(--font-mono);
|
||||
text-align: left;
|
||||
margin: 20px 0;
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
}
|
||||
|
||||
.markdown-prose th,
|
||||
.markdown-table th {
|
||||
background-color: var(--bg-canvas);
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
padding: 8px 12px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.markdown-prose td,
|
||||
.markdown-table td {
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
padding: 8px 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.markdown-prose tr:hover,
|
||||
.markdown-table tr:hover {
|
||||
background-color: var(--bg-elevated);
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
.markdown-prose ul, .markdown-prose ol {
|
||||
margin: 16px 0 18px 24px;
|
||||
}
|
||||
|
||||
.markdown-prose li {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
/* ==========================================================================
|
||||
Cards & Feeds (components/cards.css)
|
||||
========================================================================== */
|
||||
|
||||
/* Featured Lead Article Card (Inside Tile) */
|
||||
.featured-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.featured-badge-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.76rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.badge-pinned {
|
||||
color: var(--accent-amber);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.featured-article-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.featured-article-title:hover {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.featured-prose {
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.featured-cta-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 10px;
|
||||
border-top: var(--border-width) solid var(--border-color);
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
background-color: var(--accent-cyan);
|
||||
color: var(--bg-canvas);
|
||||
padding: 6px 14px;
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
text-decoration: none;
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: var(--border-width) solid var(--accent-cyan);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
background-color: transparent;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Sidebar Stats / Telemetry Box */
|
||||
.sidebar-stats-box {
|
||||
background-color: var(--bg-canvas);
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.stat-val {
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Curated Topic Track Item */
|
||||
.topic-track-item {
|
||||
background-color: var(--bg-canvas);
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.topic-track-item:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
background-color: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.topic-track-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.topic-track-item:hover .topic-track-title {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.topic-track-desc {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Full-Width Chronological Article Feed */
|
||||
.feed-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.feed-card {
|
||||
background-color: var(--bg-canvas);
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: 16px 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.feed-card:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
background-color: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.feed-meta-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.feed-date {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.feed-heading {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.feed-card:hover .feed-heading {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.feed-excerpt {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.feed-tags-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 6px;
|
||||
border-top: var(--border-width) solid var(--border-color);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-muted);
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tag-cluster {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
background-color: var(--bg-surface);
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.tag-chip:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Featured Service Card (Homelab Gateway) */
|
||||
.blog-service-feature {
|
||||
background-color: var(--bg-canvas);
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.blog-feature-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.badge-core {
|
||||
background-color: var(--accent-cyan);
|
||||
color: var(--bg-canvas);
|
||||
padding: 2px 6px;
|
||||
font-weight: 700;
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.blog-feature-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.blog-feature-title:hover {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.blog-feature-desc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.blog-sublinks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
border-top: var(--border-width) solid var(--border-color);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.blog-sublink-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
padding: 4px 6px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.blog-sublink-item:hover {
|
||||
background-color: var(--bg-elevated);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
/* Homelab Services Mini-Grid */
|
||||
.services-mini-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.services-mini-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.service-tile-item {
|
||||
background-color: var(--bg-canvas);
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: 12px;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.service-tile-item:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
background-color: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.service-item-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.service-tile-item:hover .service-item-title {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.service-item-tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.service-item-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Infrastructure Node Telemetry */
|
||||
.node-stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
background-color: var(--bg-canvas);
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.node-name {
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.node-load {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
/* ==========================================================================
|
||||
Terminal Code Blocks & Syntax Highlighting (components/code-box.css)
|
||||
========================================================================== */
|
||||
|
||||
/* Terminal Code Box Component */
|
||||
.terminal-code-box,
|
||||
.code-block-container {
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--bg-canvas);
|
||||
overflow: hidden;
|
||||
font-family: var(--font-code);
|
||||
margin: 18px 0;
|
||||
}
|
||||
|
||||
.code-titlebar,
|
||||
.code-block-header {
|
||||
background-color: var(--bg-surface);
|
||||
border-bottom: var(--border-width) solid var(--border-color);
|
||||
padding: 6px 14px;
|
||||
font-size: 0.74rem;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.code-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.copy-code-btn {
|
||||
background: transparent;
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
color: var(--text-muted);
|
||||
padding: 2px 8px;
|
||||
font-family: inherit;
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.copy-code-btn:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.code-content,
|
||||
.code-block-container pre,
|
||||
.markdown-prose pre {
|
||||
padding: 14px 16px;
|
||||
font-size: 0.82rem;
|
||||
overflow-x: auto;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.55;
|
||||
margin: 18px 0;
|
||||
background-color: var(--bg-canvas);
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
font-family: var(--font-code);
|
||||
}
|
||||
|
||||
/* Syntax Highlighting Tokens (Prism / 11ty Syntax Highlight) */
|
||||
.token.comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: #5c6370;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.punctuation {
|
||||
color: #abb2bf;
|
||||
}
|
||||
|
||||
.token.property,
|
||||
.token.tag,
|
||||
.token.boolean,
|
||||
.token.number,
|
||||
.token.constant,
|
||||
.token.symbol,
|
||||
.token.deleted {
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
|
||||
.token.selector,
|
||||
.token.attr-name,
|
||||
.token.string,
|
||||
.token.char,
|
||||
.token.builtin,
|
||||
.token.inserted {
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.token.operator,
|
||||
.token.entity,
|
||||
.token.url,
|
||||
.language-css .token.string,
|
||||
.style .token.string {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.token.atrule,
|
||||
.token.attr-value,
|
||||
.token.keyword {
|
||||
color: #c678dd;
|
||||
}
|
||||
|
||||
.token.function,
|
||||
.token.class-name {
|
||||
color: #61afef;
|
||||
}
|
||||
|
||||
.token.regex,
|
||||
.token.important,
|
||||
.token.variable {
|
||||
color: var(--accent-amber);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
/* ==========================================================================
|
||||
Footer Tile (components/footer.css)
|
||||
========================================================================== */
|
||||
|
||||
.footer-tile {
|
||||
background-color: var(--bg-surface);
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 14px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-muted);
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.footer-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.footer-right {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer-link:hover {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
/* ==========================================================================
|
||||
Global Status Header Bar (components/global-bar.css)
|
||||
========================================================================== */
|
||||
|
||||
.global-bar {
|
||||
background-color: var(--bg-surface);
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 10px 18px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.brand-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.brand-group:hover {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: var(--accent-green);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.bar-nav {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bar-link {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.bar-link:hover {
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.bar-link.active {
|
||||
color: var(--text-primary);
|
||||
border-bottom: 1px solid var(--accent-cyan);
|
||||
}
|
||||
|
||||
.bar-link.external::after {
|
||||
content: " ↗";
|
||||
font-size: 0.75em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
/* ==========================================================================
|
||||
Tile Containers & Hero Tile (components/tiles.css)
|
||||
========================================================================== */
|
||||
|
||||
/* Standard Tile Container */
|
||||
.tile {
|
||||
background-color: var(--bg-surface);
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.tile-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
border-bottom: var(--border-width) solid var(--border-color);
|
||||
padding-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tile-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tile-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tile-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Hero Lander Tile */
|
||||
.hero-tile {
|
||||
background-color: var(--bg-surface);
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
border-left: 3px solid var(--accent-cyan);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 24px 28px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hero-pretitle {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
color: var(--accent-cyan);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-main-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 2.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.hero-main-title {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-landing-text {
|
||||
font-size: 0.98rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.65;
|
||||
max-width: 82ch;
|
||||
}
|
||||
|
||||
/* Filter Pills Row in Hero Tile */
|
||||
.filter-pills-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.filter-tag-btn {
|
||||
background-color: var(--bg-canvas);
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
color: var(--text-muted);
|
||||
padding: 3px 8px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-tag-btn:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.filter-tag-btn.active {
|
||||
background-color: var(--accent-cyan);
|
||||
color: var(--bg-canvas);
|
||||
border-color: var(--accent-cyan);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Directive Callout Box inside Hero */
|
||||
.hero-directive-box {
|
||||
background-color: var(--bg-canvas);
|
||||
border: var(--border-width) solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: 12px 16px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero-directive-box strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/* ==========================================================================
|
||||
Tiling Window Manager Grids & Layout (layout.css)
|
||||
========================================================================== */
|
||||
|
||||
/* Outer Shell Container */
|
||||
.tiling-layout {
|
||||
width: 100%;
|
||||
max-width: var(--site-max-width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--tile-gap);
|
||||
}
|
||||
|
||||
/* Asymmetric 2-Column Split Grid (Lander: Lead Article + Telemetry Sidebar) */
|
||||
.split-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.25fr 0.75fr;
|
||||
gap: var(--tile-gap);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.split-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Article Workspace Grid (Left Sidebar TOC + Main Reading Workspace) */
|
||||
.article-workspace-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 290px 1fr;
|
||||
gap: var(--tile-gap);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.article-workspace-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Generic Symmetrical Grids */
|
||||
.grid-2col {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--tile-gap);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid-2col {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
/* ==========================================================================
|
||||
Main Stylesheet (main.css)
|
||||
River Rooks Blog - Modular CSS Architecture
|
||||
========================================================================== */
|
||||
|
||||
/* 1. Design Tokens & CSS Custom Properties */
|
||||
@import "./tokens.css";
|
||||
|
||||
/* 2. Global Reset & Baseline Typography */
|
||||
@import "./reset.css";
|
||||
|
||||
/* 3. Tiling Window Manager Grids */
|
||||
@import "./layout.css";
|
||||
|
||||
/* 4. Component Styles */
|
||||
@import "./components/global-bar.css";
|
||||
@import "./components/tiles.css";
|
||||
@import "./components/cards.css";
|
||||
@import "./components/code-box.css";
|
||||
@import "./components/article.css";
|
||||
@import "./components/footer.css";
|
||||
@@ -1,52 +0,0 @@
|
||||
/* ==========================================================================
|
||||
CSS Reset & Baseline (reset.css)
|
||||
Strict adherence to developer-centric minimalism & instant transitions
|
||||
========================================================================== */
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-rendering: optimizeLegibility;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-canvas);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-prose);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 24px 16px 48px 16px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Hard anti-pattern enforcement: instant transitions across UI elements */
|
||||
a, button, input, select, textarea {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* High-contrast keyboard accessibility outline */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent-cyan);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
img, svg, video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Monospace font assignment for code & structural elements */
|
||||
code, pre, kbd, samp {
|
||||
font-family: var(--font-code);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/* ==========================================================================
|
||||
Design Tokens (tokens.css)
|
||||
River Rooks Blog - Native Dark Mode Architecture
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* Surface & Canvas Colors */
|
||||
--bg-canvas: #0d0f12; /* Deep slate/charcoal canvas */
|
||||
--bg-surface: #121417; /* Tile & card surface */
|
||||
--bg-elevated: #1c1f26; /* Hovered surface */
|
||||
|
||||
/* Borders & Dividers */
|
||||
--border-color: #2b303b; /* Crisp 1px border */
|
||||
--border-width: 1px;
|
||||
--border-radius: 4px; /* Strictly capped at 4px */
|
||||
--border-radius-sm: 2px;
|
||||
--border-radius-xs: 1px;
|
||||
|
||||
/* Typography */
|
||||
--font-mono: 'IBM Plex Mono', 'JetBrains Mono', monospace;
|
||||
--font-code: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
--font-prose: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
|
||||
/* Text Colors */
|
||||
--text-primary: #e0e6f0; /* Light off-white prose */
|
||||
--text-muted: #8a95a5; /* Monospace metadata */
|
||||
|
||||
/* High-Signal Terminal Accents */
|
||||
--accent-cyan: #00f0ff; /* Primary active accent */
|
||||
--accent-green: #00e676; /* Success / online indicator */
|
||||
--accent-amber: #ffb700; /* Warning / highlight accent */
|
||||
--accent-red: #ff5252; /* Danger / caution accent */
|
||||
|
||||
/* Structural Dimensions */
|
||||
--site-max-width: 1140px;
|
||||
--tile-gap: 16px;
|
||||
--prose-max-width: 68ch;
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
layout: layouts/base.njk
|
||||
title: "River Rooks | Engineering Notes & Systems Architecture"
|
||||
---
|
||||
|
||||
<!-- 1. Lander Hero Tile with Dynamic Filter Tag Buttons -->
|
||||
{% include "partials/hero-tile.njk" %}
|
||||
|
||||
<!-- 2. Asymmetric Split Grid: Featured Lead Post (Left) & Telemetry Sidebar (Right) -->
|
||||
<div class="split-grid">
|
||||
<div class="split-lead-col">
|
||||
{% include "partials/featured-card.njk" %}
|
||||
</div>
|
||||
|
||||
<div class="split-sidebar-col">
|
||||
{% include "partials/stats-sidebar.njk" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Full-Width Chronological Dispatches Feed Tile -->
|
||||
<section class="tile" aria-label="Dispatches Feed Catalog">
|
||||
<header class="tile-header">
|
||||
<div class="tile-title">
|
||||
<span class="status-indicator"></span>
|
||||
RECENT DISPATCHES
|
||||
</div>
|
||||
<span class="tile-meta">ORDER: CHRONOLOGICAL DESC • TOTAL: {{ collections.posts.length }}</span>
|
||||
</header>
|
||||
|
||||
<div class="feed-list">
|
||||
{% for post in collections.posts %}
|
||||
{% include "partials/post-card.njk" %}
|
||||
{% else %}
|
||||
<p style="color: var(--text-muted); font-family: var(--font-mono); font-size: 0.85rem; padding: 16px;">
|
||||
No published dispatches found in repository.
|
||||
</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
-115
@@ -1,115 +0,0 @@
|
||||
---
|
||||
layout: layouts/base.njk
|
||||
title: "River Rooks | Homelab Gateway"
|
||||
---
|
||||
|
||||
<!-- Main Hero Landing Tile -->
|
||||
<section class="hero-tile" aria-label="Gateway Declaration">
|
||||
<div class="hero-pretitle">SYSTEM DECLARATION • NODE GATEWAY</div>
|
||||
<h1 class="hero-main-title">Engineering Sovereign Infrastructure & Technical Notes</h1>
|
||||
|
||||
<div class="hero-landing-text">
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer nec odio. Praesent libero. Sed cursus ante dapibus diam. Sed nisi. Nulla quis sem at nibh elementum imperdiet. Duis sagittis ipsum.
|
||||
</p>
|
||||
<p>
|
||||
Praesent mauris. Fusce nec tellus sed augue semper porta. Mauris massa. Vestibulum lacinia arcu eget nulla. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="hero-directive-box">
|
||||
<strong>PRIMARY DESTINATIONS:</strong>
|
||||
<a href="/dispatches/" style="color: var(--accent-cyan); text-decoration: none; margin-left: 8px;">[1] Technical Blog ({{ collections.posts.length }} Articles)</a> •
|
||||
<a href="https://git.{{ site.domain }}" target="_blank" rel="noopener noreferrer" style="color: inherit; text-decoration: none;">[2] Gitea Repos</a> •
|
||||
<span>[3] Public Tools</span> •
|
||||
<span>[4] Cluster Telemetry</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Split Tiling Columns -->
|
||||
<div class="split-grid">
|
||||
<!-- Left Column: Featured Service (Blog) + Dynamic Recent Articles -->
|
||||
<div style="display: flex; flex-direction: column; gap: var(--tile-gap);">
|
||||
<section class="tile" aria-label="Featured Service: Technical Blog">
|
||||
<header class="tile-header">
|
||||
<div class="tile-title">
|
||||
<span class="status-indicator"></span>
|
||||
// PRIMARY SERVICE: THE TECHNICAL BLOG
|
||||
</div>
|
||||
<span class="tile-meta">~/notes/ • {{ collections.posts.length }} ARTICLES</span>
|
||||
</header>
|
||||
|
||||
<div class="blog-service-feature">
|
||||
<div class="blog-feature-top">
|
||||
<span class="badge-core">[CORE PUBLICATION SERVICE]</span>
|
||||
<span style="color: var(--text-muted);">{{ collections.posts[0].date | postDate }} LAST SYNC</span>
|
||||
</div>
|
||||
|
||||
<a href="/dispatches/" class="blog-feature-title">> Technical Notes & Engineering Dispatches</a>
|
||||
<p class="blog-feature-desc">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam.
|
||||
</p>
|
||||
|
||||
<div class="blog-sublinks">
|
||||
{% for post in collections.posts | limit(4) %}
|
||||
<a href="{{ post.url }}" class="blog-sublink-item">
|
||||
<span>{{ loop.index }}. {{ post.data.title }}</span>
|
||||
<span style="color: var(--accent-cyan);">[{{ post.templateContent | readingTime(post.data.readingTime) }}] →</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Homelab Hosted Services & Node Telemetry -->
|
||||
<div style="display: flex; flex-direction: column; gap: var(--tile-gap);">
|
||||
<section class="tile" aria-label="Homelab Services Directory">
|
||||
<header class="tile-header">
|
||||
<div class="tile-title">
|
||||
<span class="status-indicator"></span>
|
||||
// HOMELAB SERVICES ROUTER
|
||||
</div>
|
||||
<span class="tile-meta">{{ site.services.length }} PUBLIC TOOLS</span>
|
||||
</header>
|
||||
|
||||
<div class="services-mini-grid">
|
||||
{% for svc in site.services %}
|
||||
<a href="{{ svc.url }}" target="_blank" rel="noopener noreferrer" class="service-tile-item">
|
||||
<div>
|
||||
<div class="service-item-title">{{ svc.name }}</div>
|
||||
<div class="service-item-tag">{{ svc.tag }}</div>
|
||||
</div>
|
||||
<p class="service-item-desc">{{ svc.desc }}</p>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Infrastructure Telemetry Tile -->
|
||||
<section class="tile" aria-label="Infrastructure Nodes Telemetry">
|
||||
<header class="tile-header">
|
||||
<div class="tile-title">
|
||||
<span class="status-indicator"></span>
|
||||
// CLUSTER TELEMETRY
|
||||
</div>
|
||||
<span class="tile-meta">3 NODES ONLINE</span>
|
||||
</header>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||||
<div class="node-stat-row">
|
||||
<span class="node-name">NODE-01 (Primary Cluster)</span>
|
||||
<span class="node-load">ONLINE • LOAD: 0.18</span>
|
||||
</div>
|
||||
<div class="node-stat-row">
|
||||
<span class="node-name">NODE-02 (Storage Array)</span>
|
||||
<span class="node-load">ONLINE • POOL: 98% HEALTH</span>
|
||||
</div>
|
||||
<div class="node-stat-row">
|
||||
<span class="node-name">GATEWAY-01 (Edge Ingress)</span>
|
||||
<span class="node-load">ONLINE • ROUTING ACTIVE</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,52 +0,0 @@
|
||||
---
|
||||
layout: layouts/post.njk
|
||||
title: "Infrastructure Notes & Systems Deployment Guide"
|
||||
date: 2026-07-15
|
||||
tags:
|
||||
- homelab
|
||||
- networking
|
||||
- templates
|
||||
excerpt: "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident."
|
||||
readingTime: "4 MIN READ"
|
||||
featured: false
|
||||
difficulty: "BEGINNER"
|
||||
---
|
||||
|
||||
## 1.0 Topology Overview
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua:
|
||||
|
||||
```text
|
||||
[Gateway Ingress]
|
||||
|
|
||||
[Edge Router]
|
||||
|
|
||||
+-----+-----+
|
||||
| |
|
||||
[Node 01] [Node 02]
|
||||
```
|
||||
|
||||
## 2.0 Deployment Configuration
|
||||
|
||||
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
image: service:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:80"
|
||||
```
|
||||
|
||||
## 3.0 Verification Testing
|
||||
|
||||
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur:
|
||||
|
||||
```sh
|
||||
curl -s http://localhost:8080/health
|
||||
```
|
||||
|
||||
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||
@@ -1,78 +0,0 @@
|
||||
---
|
||||
layout: layouts/post.njk
|
||||
title: "Sample Technical Dispatch: Systems Architecture & Implementation"
|
||||
date: 2026-07-20
|
||||
tags:
|
||||
- systems
|
||||
- architecture
|
||||
- templates
|
||||
excerpt: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam."
|
||||
readingTime: "6 MIN READ"
|
||||
featured: true
|
||||
difficulty: "INTERMEDIATE"
|
||||
---
|
||||
|
||||
## 1.0 Executive Summary
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer nec odio. Praesent libero. Sed cursus ante dapibus diam. Sed nisi. Nulla quis sem at nibh elementum imperdiet. Duis sagittis ipsum. Praesent mauris. Fusce nec tellus sed augue semper porta. Mauris massa. Vestibulum lacinia arcu eget nulla.
|
||||
|
||||
Curabitur sodales ligula in libero. Sed dignissim lacinia nunc. Curabitur tortor. Pellentesque nibh. Aenean quam. In scelerisque sem at dolor. Maecenas mattis. Sed convallis tristique sem. Proin ut ligula vel nunc egestas porttitor. Morbi lectus risus, iaculis vel, suscipit quis, luctus non, massa.
|
||||
|
||||
<div class="markdown-callout callout-note">
|
||||
<div class="callout-title">[!NOTE] Operational Directives</div>
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer nec odio. Praesent libero. Sed cursus ante dapibus diam.</p>
|
||||
</div>
|
||||
|
||||
## 2.0 System Parameters & Specifications
|
||||
|
||||
Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Nam nec ante. Sed lacinia, urna non tincidunt mattis, tortor neque adipiscing diam, a cursus ipsum ante quis turpis:
|
||||
|
||||
### 2.1 Interface Configuration Matrix
|
||||
|
||||
| Parameter ID | Signal Name | Protocol Bus | Expected Value |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **0x01** | `SYS_CLOCK` | SPI Primary | 100 MHz |
|
||||
| **0x02** | `DATA_IN` | Serial Bus | Bidirectional |
|
||||
| **0x03** | `CONTROL_REG` | I2C Header | 0x3F Active |
|
||||
| **0x04** | `GROUND_REF` | Chassis GND | 0.0V Nominal |
|
||||
|
||||
<div class="markdown-callout callout-warning">
|
||||
<div class="callout-title">[!WARNING] Configuration Voltage Boundary</div>
|
||||
<p>Sed nisi. Nulla quis sem at nibh elementum imperdiet. Duis sagittis ipsum. Praesent mauris. Fusce nec tellus sed augue semper porta.</p>
|
||||
</div>
|
||||
|
||||
## 3.0 Execution Workflow
|
||||
|
||||
Nulla quis sem at nibh elementum imperdiet. Duis sagittis ipsum. Praesent mauris. Fusce nec tellus sed augue semper porta:
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
# Sample deployment and verification routine
|
||||
set -eu
|
||||
|
||||
echo "[+] Initializing subsystem..."
|
||||
check_environment() {
|
||||
test -f /etc/config.json && echo "Config verified."
|
||||
}
|
||||
|
||||
check_environment
|
||||
```
|
||||
|
||||
Press <kbd>Ctrl</kbd> + <kbd>C</kbd> to interrupt operations safely at any point.
|
||||
|
||||
## 4.0 Telemetry & Verification
|
||||
|
||||
Fusce nec tellus sed augue semper porta. Mauris massa. Vestibulum lacinia arcu eget nulla:
|
||||
|
||||
- **Metric A:** `1,048,576 ops/sec`
|
||||
- **Metric B:** `SHA256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`
|
||||
- **Latency:** `< 2.5ms` nominal
|
||||
|
||||
## 5.0 Audit Checklist
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit:
|
||||
|
||||
- [x] Baseline architectural parameters verified
|
||||
- [x] Initial configuration and unit tests passed
|
||||
- [x] System telemetry within expected boundaries
|
||||
- [ ] Production deployment and final review
|
||||
@@ -1,86 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const siteDir = path.resolve("_site");
|
||||
|
||||
function getAllHtmlFiles(dir) {
|
||||
let results = [];
|
||||
const list = fs.readdirSync(dir);
|
||||
for (const file of list) {
|
||||
const filePath = path.join(dir, file);
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat && stat.isDirectory()) {
|
||||
results = results.concat(getAllHtmlFiles(filePath));
|
||||
} else if (file.endsWith(".html")) {
|
||||
results.push(filePath);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
test("Build & Link Integrity: Output inspection and link verification", () => {
|
||||
// Ensure fresh build exists
|
||||
if (!fs.existsSync(path.join(siteDir, "index.html"))) {
|
||||
execSync("npm run build", {
|
||||
stdio: "pipe",
|
||||
env: { ...process.env, SITE_DOMAIN: process.env.SITE_DOMAIN || "riverrooks.dev" }
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Core Pages Exist
|
||||
assert.ok(fs.existsSync(path.join(siteDir, "index.html")), "Root _site/index.html must exist");
|
||||
assert.ok(fs.existsSync(path.join(siteDir, "dispatches/index.html")), "_site/dispatches/index.html must exist");
|
||||
assert.ok(fs.existsSync(path.join(siteDir, "assets/css/main.css")), "_site/assets/css/main.css must exist");
|
||||
|
||||
const htmlFiles = getAllHtmlFiles(siteDir);
|
||||
assert.ok(htmlFiles.length >= 4, "Must compile all core pages and articles");
|
||||
|
||||
for (const htmlFile of htmlFiles) {
|
||||
const relHtml = path.relative(siteDir, htmlFile);
|
||||
const content = fs.readFileSync(htmlFile, "utf-8");
|
||||
|
||||
// 2. HTML Boilerplate Invariants
|
||||
assert.match(content, /<!DOCTYPE html>/i, `${relHtml} must contain <!DOCTYPE html>`);
|
||||
assert.match(content, /<meta\s+name="viewport"/i, `${relHtml} must have viewport meta tag`);
|
||||
assert.match(content, /<link\s+rel="stylesheet"\s+href="\/assets\/css\/main\.css">/i, `${relHtml} must link to main.css`);
|
||||
|
||||
// 3. Internal Link Integrity (No 404s!)
|
||||
const internalLinks = [...content.matchAll(/href="(\/[^"#?]*)"/g)].map((m) => m[1]);
|
||||
for (const link of internalLinks) {
|
||||
if (link.startsWith("/feed.xml")) continue; // dynamic RSS route
|
||||
// Normalize link to file on disk: /dispatches/ -> _site/dispatches/index.html or _site/dispatches.html
|
||||
let targetPath;
|
||||
if (link.endsWith("/")) {
|
||||
targetPath = path.join(siteDir, link, "index.html");
|
||||
} else if (path.extname(link)) {
|
||||
targetPath = path.join(siteDir, link);
|
||||
} else {
|
||||
targetPath = path.join(siteDir, `${link}.html`);
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
targetPath = path.join(siteDir, link, "index.html");
|
||||
}
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
fs.existsSync(targetPath),
|
||||
`Broken internal link in ${relHtml}: href="${link}" resolves to missing file: ${targetPath}`
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Anchor Link Integrity (TOC anchor links match actual heading IDs)
|
||||
const anchorLinks = [...content.matchAll(/href="#([^"]+)"/g)].map((m) => m[1]);
|
||||
for (const anchor of anchorLinks) {
|
||||
// Ignore skip-to-content or standard body anchors
|
||||
if (anchor === "main-content") continue;
|
||||
const expectedIdRegex = new RegExp(`id="${anchor}"`, "i");
|
||||
assert.match(
|
||||
content,
|
||||
expectedIdRegex,
|
||||
`Broken TOC anchor link in ${relHtml}: href="#${anchor}" has no corresponding id="${anchor}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const cssDir = path.resolve("src/assets/css");
|
||||
|
||||
function getAllCssFiles(dir) {
|
||||
let results = [];
|
||||
const list = fs.readdirSync(dir);
|
||||
for (const file of list) {
|
||||
const filePath = path.join(dir, file);
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat && stat.isDirectory()) {
|
||||
results = results.concat(getAllCssFiles(filePath));
|
||||
} else if (file.endsWith(".css")) {
|
||||
results.push(filePath);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
test("Design Specification: Strict Anti-Pattern Regression Testing", () => {
|
||||
const cssFiles = getAllCssFiles(cssDir);
|
||||
assert.ok(cssFiles.length >= 5, "Must inspect all modular CSS files");
|
||||
|
||||
for (const file of cssFiles) {
|
||||
const relPath = path.relative(process.cwd(), file);
|
||||
const content = fs.readFileSync(file, "utf-8");
|
||||
|
||||
// 1. Anti-Pattern: NO Glassmorphism (backdrop-filter)
|
||||
assert.doesNotMatch(
|
||||
content,
|
||||
/backdrop-filter\s*:/i,
|
||||
`Violation in ${relPath}: Glassmorphism (backdrop-filter) is strictly forbidden`
|
||||
);
|
||||
|
||||
// 2. Anti-Pattern: NO Drop Shadows or Glows (box-shadow, text-shadow)
|
||||
// Allowed: box-shadow: none; or text-shadow: none;
|
||||
const shadowMatches = content.match(/(box-shadow|text-shadow)\s*:\s*([^;]+);/gi) || [];
|
||||
for (const match of shadowMatches) {
|
||||
assert.match(
|
||||
match,
|
||||
/(box-shadow|text-shadow)\s*:\s*none\s*;/i,
|
||||
`Violation in ${relPath}: Drop shadows and glows are forbidden (found: '${match}')`
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Anti-Pattern: NO Gradients (linear, radial, conic)
|
||||
assert.doesNotMatch(
|
||||
content,
|
||||
/(linear-gradient|radial-gradient|conic-gradient)/i,
|
||||
`Violation in ${relPath}: Gradients are strictly forbidden`
|
||||
);
|
||||
|
||||
// 4. Anti-Pattern: NO Rounded Bubbles (border-radius capped at 4px max)
|
||||
const radiusMatches = content.match(/border-radius\s*:\s*([^;]+);/gi) || [];
|
||||
for (const match of radiusMatches) {
|
||||
// Check for pixel values > 4px (e.g. 5px, 6px, 8px, 12px, 20px)
|
||||
const pxMatch = match.match(/(\d+)\s*px/i);
|
||||
if (pxMatch) {
|
||||
const px = parseInt(pxMatch[1], 10);
|
||||
assert.ok(
|
||||
px <= 4,
|
||||
`Violation in ${relPath}: Border radius must be 0px to 4px max, found: ${px}px in '${match}'`
|
||||
);
|
||||
}
|
||||
// Check for percentages (e.g. 50%)
|
||||
assert.doesNotMatch(
|
||||
match,
|
||||
/\d+%/,
|
||||
`Violation in ${relPath}: Rounded pill/circular percentages forbidden in '${match}'`
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Anti-Pattern: NO Float/Spring Transitions (Must be instant snaps)
|
||||
const transitionMatches = content.match(/transition\s*:\s*([^;]+);/gi) || [];
|
||||
for (const match of transitionMatches) {
|
||||
assert.match(
|
||||
match,
|
||||
/transition\s*:\s*(none(\s*!important)?|0s\s*);/i,
|
||||
`Violation in ${relPath}: Transitions must be instant (none or 0s), found: '${match}'`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
test("Docker Configuration: Dockerfile enforces Node runtime and excludes Nginx", () => {
|
||||
const dockerfilePath = path.resolve(process.cwd(), "Dockerfile");
|
||||
assert.ok(fs.existsSync(dockerfilePath), "Dockerfile must exist");
|
||||
|
||||
const content = fs.readFileSync(dockerfilePath, "utf-8");
|
||||
|
||||
// Must use Node 22 alpine
|
||||
assert.match(content, /FROM\s+node:22-alpine/i, "Dockerfile must use node:22-alpine base image");
|
||||
|
||||
// Must NOT contain nginx base images or references
|
||||
assert.doesNotMatch(content, /FROM\s+nginx/i, "Dockerfile must not use nginx base image");
|
||||
assert.doesNotMatch(content, /nginx/i, "Dockerfile must not contain nginx directives");
|
||||
|
||||
// Must build static site and serve with npm
|
||||
assert.match(content, /RUN\s+npm\s+run\s+build/, "Dockerfile must run npm build");
|
||||
assert.match(content, /ARG\s+PORT=8080/, "Dockerfile must define ARG PORT with default fallback");
|
||||
assert.match(content, /ENV\s+PORT=\$\{?PORT\}?/, "Dockerfile must define ENV PORT using variable");
|
||||
assert.match(content, /EXPOSE\s+\$\{?PORT\}?/, "Dockerfile must expose dynamic PORT variable");
|
||||
assert.match(content, /CMD\s*\[\s*["']npm["']\s*,\s*["']start["']\s*\]/, "Dockerfile must start via npm start");
|
||||
});
|
||||
|
||||
test("Docker Configuration: docker-compose configures Node service correctly", () => {
|
||||
const composePath = fs.existsSync(path.resolve(process.cwd(), "sample-docker-compose.yml"))
|
||||
? path.resolve(process.cwd(), "sample-docker-compose.yml")
|
||||
: path.resolve(process.cwd(), "docker-compose.yml");
|
||||
assert.ok(fs.existsSync(composePath), "Compose template file must exist");
|
||||
|
||||
const content = fs.readFileSync(composePath, "utf-8");
|
||||
|
||||
// Verifies dynamic port mapping and environment variables
|
||||
assert.match(content, /PORT:\s*"?\$\{PORT:-8080\}"?/, "docker-compose.yml must pass dynamic PORT build arg and env");
|
||||
assert.match(content, /:\$\{PORT:-8080\}/, "docker-compose.yml must map dynamic container PORT");
|
||||
|
||||
// Verifies domain argument enforcement
|
||||
assert.match(content, /SITE_DOMAIN/, "docker-compose.yml must require SITE_DOMAIN");
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
postDate,
|
||||
isoDate,
|
||||
limit,
|
||||
filterTagList,
|
||||
readingTime,
|
||||
extractHeadings
|
||||
} from "../src/_filters/index.js";
|
||||
|
||||
test("Filters - postDate: formats valid dates", () => {
|
||||
assert.equal(postDate("2026-07-20"), "JUL 20, 2026");
|
||||
assert.equal(postDate(new Date("2026-12-31T00:00:00Z")), "DEC 31, 2026");
|
||||
});
|
||||
|
||||
test("Filters - postDate: handles adversarial / invalid inputs gracefully", () => {
|
||||
assert.equal(postDate(null), "");
|
||||
assert.equal(postDate(undefined), "");
|
||||
assert.equal(postDate("invalid-date-string"), "");
|
||||
assert.equal(postDate(""), "");
|
||||
});
|
||||
|
||||
test("Filters - isoDate: formats ISO strings", () => {
|
||||
assert.equal(isoDate("2026-07-20"), "2026-07-20");
|
||||
assert.equal(isoDate(new Date("2026-05-15T00:00:00Z")), "2026-05-15");
|
||||
});
|
||||
|
||||
test("Filters - isoDate: handles adversarial / invalid inputs gracefully", () => {
|
||||
assert.equal(isoDate(null), "");
|
||||
assert.equal(isoDate(undefined), "");
|
||||
assert.equal(isoDate("not-a-date"), "");
|
||||
});
|
||||
|
||||
test("Filters - limit: slices arrays correctly", () => {
|
||||
assert.deepEqual(limit([1, 2, 3, 4, 5], 3), [1, 2, 3]);
|
||||
assert.deepEqual(limit([1, 2], 5), [1, 2]);
|
||||
});
|
||||
|
||||
test("Filters - limit: handles adversarial inputs (negative, 0, non-array)", () => {
|
||||
assert.deepEqual(limit([1, 2, 3], 0), []);
|
||||
assert.deepEqual(limit([1, 2, 3], -5), []);
|
||||
assert.deepEqual(limit(null, 3), []);
|
||||
assert.deepEqual(limit("not-an-array", 3), []);
|
||||
});
|
||||
|
||||
test("Filters - filterTagList: removes internal system tags", () => {
|
||||
const input = ["firmware", "posts", "security", "all", "nav", "post"];
|
||||
assert.deepEqual(filterTagList(input), ["firmware", "security"]);
|
||||
});
|
||||
|
||||
test("Filters - filterTagList: handles adversarial inputs (# prefix, casing, non-string)", () => {
|
||||
const input = ["#firmware", "#POSTS", "ALL", "#homelab", 42, null, undefined];
|
||||
assert.deepEqual(filterTagList(input), ["#firmware", "#homelab"]);
|
||||
assert.deepEqual(filterTagList(null), []);
|
||||
assert.deepEqual(filterTagList(undefined), []);
|
||||
});
|
||||
|
||||
test("Filters - readingTime: calculates reading time from word count", () => {
|
||||
// 199 words -> 1 min
|
||||
const shortText = "word ".repeat(199);
|
||||
assert.equal(readingTime(shortText), "1 MIN READ");
|
||||
|
||||
// 201 words -> 2 min
|
||||
const mediumText = "word ".repeat(201);
|
||||
assert.equal(readingTime(mediumText), "2 MIN READ");
|
||||
|
||||
// 1000 words -> 5 min
|
||||
const longText = "word ".repeat(1000);
|
||||
assert.equal(readingTime(longText), "5 MIN READ");
|
||||
});
|
||||
|
||||
test("Filters - readingTime: respects explicit frontmatter override", () => {
|
||||
assert.equal(readingTime("any text", "15 MIN READ"), "15 MIN READ");
|
||||
});
|
||||
|
||||
test("Filters - readingTime: handles adversarial HTML tags without inflating word count", () => {
|
||||
// HTML tags should be stripped before counting words
|
||||
const htmlText = "<div><span>word</span> <p>another</p></div>";
|
||||
assert.equal(readingTime(htmlText), "1 MIN READ");
|
||||
assert.equal(readingTime(""), "1 MIN READ");
|
||||
assert.equal(readingTime(null), "1 MIN READ");
|
||||
});
|
||||
|
||||
test("Filters - extractHeadings: extracts H2 and H3 with IDs and titles", () => {
|
||||
const html = `
|
||||
<h1>Title To Ignore</h1>
|
||||
<h2 id="section-1">1.0 First Section</h2>
|
||||
<p>Some text</p>
|
||||
<h3 id="sub-1">1.1 Sub Section</h3>
|
||||
<h4 id="h4-ignore">Ignore H4</h4>
|
||||
<h2 id="section-2">2.0 Second Section with <code>code</code> and <span class="tag">span</span></h2>
|
||||
`;
|
||||
|
||||
const headings = extractHeadings(html);
|
||||
assert.equal(headings.length, 3);
|
||||
assert.deepEqual(headings[0], { level: 2, id: "section-1", title: "1.0 First Section" });
|
||||
assert.deepEqual(headings[1], { level: 3, id: "sub-1", title: "1.1 Sub Section" });
|
||||
assert.deepEqual(headings[2], { level: 2, id: "section-2", title: "2.0 Second Section with code and span" });
|
||||
});
|
||||
|
||||
test("Filters - extractHeadings: handles adversarial HTML inputs gracefully", () => {
|
||||
assert.deepEqual(extractHeadings(""), []);
|
||||
assert.deepEqual(extractHeadings(null), []);
|
||||
assert.deepEqual(extractHeadings(undefined), []);
|
||||
assert.deepEqual(extractHeadings("<p>No headings here</p>"), []);
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const postsDir = path.resolve("src/posts");
|
||||
|
||||
test("Content Contract: All posts in src/posts/ satisfy frontmatter schema", () => {
|
||||
assert.ok(fs.existsSync(postsDir), "src/posts directory must exist");
|
||||
const postFiles = fs.readdirSync(postsDir).filter((f) => f.endsWith(".md"));
|
||||
|
||||
assert.ok(postFiles.length > 0, "There must be at least one published post");
|
||||
|
||||
for (const file of postFiles) {
|
||||
const filePath = path.join(postsDir, file);
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
|
||||
// 1. Filename Convention: YYYY-MM-DD-slug.md
|
||||
assert.match(
|
||||
file,
|
||||
/^\d{4}-\d{2}-\d{2}-[a-z0-9-]+\.md$/,
|
||||
`Post filename '${file}' must follow 'YYYY-MM-DD-slug.md' naming standard`
|
||||
);
|
||||
|
||||
// 2. YAML Frontmatter Boundary check
|
||||
assert.match(
|
||||
content,
|
||||
/^---\n([\s\S]*?)\n---/,
|
||||
`Post '${file}' must start with valid YAML frontmatter delimiters`
|
||||
);
|
||||
|
||||
// Extract frontmatter block
|
||||
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
const fmText = fmMatch[1];
|
||||
|
||||
// 3. Title Invariant
|
||||
assert.match(
|
||||
fmText,
|
||||
/title:\s*"[^"]{5,}"/,
|
||||
`Post '${file}' must contain a valid title of at least 5 characters`
|
||||
);
|
||||
|
||||
// 4. Date Invariant (ISO Format)
|
||||
assert.match(
|
||||
fmText,
|
||||
/date:\s*\d{4}-\d{2}-\d{2}/,
|
||||
`Post '${file}' must define an ISO date (YYYY-MM-DD)`
|
||||
);
|
||||
|
||||
// 5. Layout Invariant
|
||||
assert.match(
|
||||
fmText,
|
||||
/layout:\s*layouts\/post\.njk/,
|
||||
`Post '${file}' must specify 'layout: layouts/post.njk'`
|
||||
);
|
||||
|
||||
// 6. Tags Invariant
|
||||
assert.match(
|
||||
fmText,
|
||||
/tags:\s*\n(\s+-\s+[a-z0-9-]+\n)+/,
|
||||
`Post '${file}' must specify a non-empty YAML tags list`
|
||||
);
|
||||
|
||||
// 7. Excerpt Invariant
|
||||
assert.match(
|
||||
fmText,
|
||||
/excerpt:\s*"[^"]{20,}"/,
|
||||
`Post '${file}' must provide a substantive excerpt of at least 20 characters`
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
const port = process.env.PORT || 8080;
|
||||
|
||||
fetch(`http://127.0.0.1:${port}/healthz`)
|
||||
.then((res) => process.exit(res.ok ? 0 : 1))
|
||||
.catch(() => process.exit(1));
|
||||
@@ -1,79 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import http from "node:http";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
test("Healthcheck: test/healthcheck.js script exists and parses cleanly", () => {
|
||||
const scriptPath = path.resolve(process.cwd(), "test/healthcheck.js");
|
||||
assert.ok(fs.existsSync(scriptPath), "test/healthcheck.js must exist");
|
||||
|
||||
const content = fs.readFileSync(scriptPath, "utf-8");
|
||||
assert.match(content, /127\.0\.0\.1/, "Must target IPv4 loopback 127.0.0.1 to avoid Alpine IPv6 lookup failures");
|
||||
assert.match(content, /\/healthz/, "Must target the /healthz endpoint");
|
||||
});
|
||||
|
||||
test("Healthcheck: test/healthcheck.js succeeds when server returns 200 OK", async () => {
|
||||
const scriptPath = path.resolve(process.cwd(), "test/healthcheck.js");
|
||||
|
||||
// Start dummy HTTP server responding 200 on /healthz
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.url === "/healthz") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "ok" }));
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const port = server.address().port;
|
||||
|
||||
try {
|
||||
const exitCode = await new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [scriptPath], {
|
||||
env: { ...process.env, PORT: String(port) },
|
||||
});
|
||||
child.on("close", resolve);
|
||||
});
|
||||
|
||||
assert.strictEqual(exitCode, 0, "healthcheck.js must exit with code 0 on healthy endpoint");
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test("Healthcheck: test/healthcheck.js fails when connection is refused or server is 500", async () => {
|
||||
const scriptPath = path.resolve(process.cwd(), "test/healthcheck.js");
|
||||
|
||||
// Pick an unused high port
|
||||
const exitCodeDown = await new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [scriptPath], {
|
||||
env: { ...process.env, PORT: "59999" },
|
||||
});
|
||||
child.on("close", resolve);
|
||||
});
|
||||
assert.strictEqual(exitCodeDown, 1, "healthcheck.js must exit with code 1 when server is unreachable");
|
||||
|
||||
// Test 500 status code
|
||||
const server = http.createServer((req, res) => {
|
||||
res.writeHead(500);
|
||||
res.end();
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const port = server.address().port;
|
||||
|
||||
try {
|
||||
const exitCode500 = await new Promise((resolve) => {
|
||||
const child = spawn(process.execPath, [scriptPath], {
|
||||
env: { ...process.env, PORT: String(port) },
|
||||
});
|
||||
child.on("close", resolve);
|
||||
});
|
||||
assert.strictEqual(exitCode500, 1, "healthcheck.js must exit with code 1 on HTTP 500");
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
@@ -1,87 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { createServer } from "../server.js";
|
||||
|
||||
const TEST_PORT = 8999;
|
||||
const BASE_URL = `http://127.0.0.1:${TEST_PORT}`;
|
||||
|
||||
// Helper to perform HTTP GET requests
|
||||
function get(urlPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(`${BASE_URL}${urlPath}`, (res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => {
|
||||
resolve({
|
||||
statusCode: res.statusCode,
|
||||
headers: res.headers,
|
||||
body: data
|
||||
});
|
||||
});
|
||||
}).on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
test("Node Static Server: lifecycle, routing, and adversarial security tests", async (t) => {
|
||||
// Ensure _site exists before running server tests
|
||||
if (!fs.existsSync(path.resolve(process.cwd(), "_site/index.html"))) {
|
||||
execSync("npm run build", {
|
||||
stdio: "pipe",
|
||||
env: { ...process.env, SITE_DOMAIN: "riverrooks.dev" }
|
||||
});
|
||||
}
|
||||
|
||||
const server = createServer();
|
||||
await new Promise((resolve) => server.listen(TEST_PORT, "127.0.0.1", resolve));
|
||||
|
||||
t.after(() => {
|
||||
return new Promise((resolve) => server.close(resolve));
|
||||
});
|
||||
|
||||
await t.test("serves root index.html with 200 OK and text/html", async () => {
|
||||
const res = await get("/");
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.match(res.headers["content-type"], /text\/html/);
|
||||
assert.match(res.body, /<!DOCTYPE html>/i);
|
||||
// Verify security headers
|
||||
assert.equal(res.headers["x-content-type-options"], "nosniff");
|
||||
assert.equal(res.headers["x-frame-options"], "DENY");
|
||||
});
|
||||
|
||||
await t.test("serves clean directory URL (/dispatches/)", async () => {
|
||||
const res = await get("/dispatches/");
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.match(res.headers["content-type"], /text\/html/);
|
||||
});
|
||||
|
||||
await t.test("serves static assets with appropriate Content-Type", async () => {
|
||||
const res = await get("/assets/css/main.css");
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.match(res.headers["content-type"], /text\/css/);
|
||||
});
|
||||
|
||||
await t.test("responds with 200 OK JSON on /healthz", async () => {
|
||||
const res = await get("/healthz");
|
||||
assert.equal(res.statusCode, 200);
|
||||
assert.match(res.headers["content-type"], /application\/json/);
|
||||
const body = JSON.parse(res.body);
|
||||
assert.equal(body.status, "ok");
|
||||
});
|
||||
|
||||
await t.test("returns 404 Not Found for non-existent routes", async () => {
|
||||
const res = await get("/non-existent-file-xyz.html");
|
||||
assert.equal(res.statusCode, 404);
|
||||
});
|
||||
|
||||
await t.test("adversarial: blocks path traversal attempts", async () => {
|
||||
const res = await get("/../../../../etc/passwd");
|
||||
// Must either return 403 Forbidden or 404 Not Found, never 200
|
||||
assert.notEqual(res.statusCode, 200);
|
||||
assert.ok(res.statusCode === 403 || res.statusCode === 404);
|
||||
assert.doesNotMatch(res.body, /root:x:/);
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
test("Site Config: throws explicit error when SITE_DOMAIN is unset", () => {
|
||||
// Run isolated Node process with empty environment to verify error throw
|
||||
const cmd = `node -e '
|
||||
process.env.SITE_DOMAIN = "";
|
||||
delete process.env.SITE_DOMAIN;
|
||||
// mock empty cwd without .env
|
||||
process.chdir("/tmp");
|
||||
import("${process.cwd()}/src/_data/site.js")
|
||||
.then(() => process.exit(0))
|
||||
.catch(err => {
|
||||
if (err.message.includes("FATAL CONFIGURATION ERROR: SITE_DOMAIN")) {
|
||||
process.exit(42);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
'`;
|
||||
|
||||
try {
|
||||
execSync(cmd, { stdio: "pipe" });
|
||||
assert.fail("Should have thrown error when SITE_DOMAIN is missing");
|
||||
} catch (err) {
|
||||
assert.equal(err.status, 42, "Process exited with status 42 (expected error caught)");
|
||||
}
|
||||
});
|
||||
|
||||
test("Site Config: correctly parses SITE_DOMAIN and derives service subdomains", async () => {
|
||||
process.env.SITE_DOMAIN = "testcluster.internal";
|
||||
delete process.env.SITE_URL;
|
||||
|
||||
// Import site configuration dynamically
|
||||
const { default: site } = await import(`../src/_data/site.js?t=${Date.now()}`);
|
||||
|
||||
assert.equal(site.domain, "testcluster.internal");
|
||||
assert.equal(site.url, "https://testcluster.internal");
|
||||
|
||||
// Verify dynamic subdomains
|
||||
const gitService = site.services.find((s) => s.name === "Gitea");
|
||||
assert.ok(gitService, "Gitea service exists");
|
||||
assert.equal(gitService.url, "https://git.testcluster.internal");
|
||||
assert.equal(gitService.tag, "git.testcluster.internal:2222");
|
||||
|
||||
const chefService = site.services.find((s) => s.name === "CyberChef");
|
||||
assert.ok(chefService, "CyberChef service exists");
|
||||
assert.equal(chefService.url, "https://chef.testcluster.internal");
|
||||
|
||||
// Verify navigation links
|
||||
const navHome = site.nav.find((n) => n.label === "Home");
|
||||
assert.ok(navHome, "Nav Home link exists");
|
||||
assert.equal(navHome.url, "/");
|
||||
|
||||
const navGit = site.nav.find((n) => n.label === "Gitea");
|
||||
assert.ok(navGit, "Nav Gitea link exists");
|
||||
assert.equal(navGit.url, "https://git.testcluster.internal");
|
||||
});
|
||||
|
||||
test("Site Config: respects explicit SITE_URL override", async () => {
|
||||
process.env.SITE_DOMAIN = "riverrooks.dev";
|
||||
process.env.SITE_URL = "http://localhost:8080";
|
||||
|
||||
const { default: site } = await import(`../src/_data/site.js?t=${Date.now()}`);
|
||||
|
||||
assert.equal(site.domain, "riverrooks.dev");
|
||||
assert.equal(site.url, "http://localhost:8080");
|
||||
});
|
||||
Reference in New Issue
Block a user