Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions .github/workflows/website-pr.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# Website PR Guardrails
#
# Two protections for pull requests that touch website/:
#
# 1. Build check (all website PRs): the Eleventy site must build and the
# website unit tests must pass before merge. A broken post or template
# turns the PR red instead of reaching production.
#
# 2. Content-only guard (blog/* branches): branches named `blog/<anything>`
# are reserved for content publishing (e.g. by non-technical writers or
# agent sessions). Those PRs may ONLY touch files under
# website/src/blog/posts/. Any change outside that folder fails the check,
# so a writing session can never accidentally edit the homepage, templates,
# or product code.
#
# 3. Preview deploy (same-repo PRs, secrets permitting): deploys the built
# site to a Cloudflare Pages preview URL so the change can be reviewed
# live before merge. Skipped for forks (no secrets).
#
# Production deploys remain in website-deploy.yml (push to main only).

name: Website PR

on:
pull_request:
branches: [main]
paths:
- 'website/**'
- '.github/workflows/website-pr.yml'

concurrency:
group: website-pr-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
content-guard:
name: Blog branches touch only post content
if: startsWith(github.head_ref, 'blog/')
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0

- name: Verify changed files are all under website/src/blog/posts/
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
CHANGED=$(git diff --name-only "$BASE_SHA"..."$HEAD_SHA")
echo "Changed files:"
echo "$CHANGED"
VIOLATIONS=$(echo "$CHANGED" | grep -v '^website/src/blog/posts/' || true)
if [ -n "$VIOLATIONS" ]; then
echo ""
echo "::error::This is a blog/* branch, which may only change files under website/src/blog/posts/. Offending files:"
echo "$VIOLATIONS"
exit 1
fi
echo "OK: all changes are blog post content."

build:
name: Build site & run tests
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6

- uses: actions/setup-node@v5
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: website/package-lock.json

- name: Install dependencies
working-directory: website
run: npm ci

- name: Unit tests
working-directory: website
run: npm test

- name: Build site
working-directory: website
run: npx @11ty/eleventy
env:
# Same public build-time config as production. On forked PRs these
# are empty and env.js falls back to its committed defaults.
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }}
WAITLIST_SHEET_ENDPOINT: ${{ secrets.WAITLIST_SHEET_ENDPOINT }}

- name: Upload built site
uses: actions/upload-artifact@v4
with:
name: website-dist
path: website/_site
retention-days: 7

preview:
name: Deploy Cloudflare Pages preview
needs: build
# Forked PRs have no access to secrets; only same-repo branches preview.
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6

- uses: actions/download-artifact@v4
with:
name: website-dist
path: website/_site

- name: Deploy preview
id: deploy
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: website
command: pages deploy _site --project-name=houston-site --branch=${{ github.head_ref }}

- name: Preview URL in job summary
env:
DEPLOY_URL: ${{ steps.deploy.outputs.deployment-url }}
run: |
echo "### Website preview" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "$DEPLOY_URL" >> "$GITHUB_STEP_SUMMARY"
39 changes: 39 additions & 0 deletions website/BLOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Publishing a blog post

Posts are Markdown files in `website/src/blog/posts/`. One file = one post.
Everything else (index page, feed, sitemap, meta tags) updates automatically.

## Steps

1. Create a branch named `blog/<slug>`, e.g. `blog/agents-for-founders`.
CI enforces that `blog/*` branches only change files in
`website/src/blog/posts/`, so a writing session cannot touch the rest of
the site.
2. Add `website/src/blog/posts/<slug>.md`. The file name becomes the URL:
`<slug>.md` publishes at `https://gethouston.ai/blog/<slug>/`.
3. Front matter (all required unless marked optional):

```yaml
---
title: "Your post title"
description: "One or two sentences. Used in the index, meta tags, and feed."
author: "Full Name"
date: 2026-07-02
ogImage: /blog/my-post/cover.jpg # optional social card override
---
```

4. Write the body in plain Markdown. Headings start at `##` (the title is the
`#`). No em dashes in copy.
5. Open a PR to `main`. CI builds the site, runs tests, and posts a
Cloudflare Pages preview URL in the workflow summary. Review the preview.
6. Merge. The production deploy to gethouston.ai runs automatically.

## Notes

- Dates are UTC. A post dated today appears at the top of `/blog/`.
- The Atom feed lives at `/blog/feed.xml`; the sitemap picks up posts
automatically.
- Custom OG images belong in the post's own folder under
`website/src/blog/posts/` only if passthrough-copied; simpler: reuse the
default site card by omitting `ogImage`.
19 changes: 16 additions & 3 deletions website/eleventy.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { marked } from "marked";
import {
isoDate,
isoDateOnly,
readableDate,
readingTimeMinutes,
} from "./lib/blog.js";

marked.setOptions({ gfm: true, breaks: false });

Expand All @@ -10,6 +16,12 @@ export default function (eleventyConfig) {
return marked.parse(str);
});

// Blog filters (pure logic lives in lib/blog.js, unit-tested via `npm test`)
eleventyConfig.addFilter("readingTime", readingTimeMinutes);
eleventyConfig.addFilter("readableDate", readableDate);
eleventyConfig.addFilter("isoDate", isoDate);
eleventyConfig.addFilter("isoDateOnly", isoDateOnly);

// Pass through static assets unchanged
eleventyConfig.addPassthroughCopy("src/favicon.svg");
eleventyConfig.addPassthroughCopy("src/houston-black.svg");
Expand All @@ -19,15 +31,16 @@ export default function (eleventyConfig) {
eleventyConfig.addPassthroughCopy("src/og-image.jpg");
eleventyConfig.addPassthroughCopy("src/icons");
eleventyConfig.addPassthroughCopy("src/learn/style.css");
eleventyConfig.addPassthroughCopy("src/blog/blog.css");
eleventyConfig.addPassthroughCopy("src/slack");
eleventyConfig.addPassthroughCopy("src/auth");
eleventyConfig.addPassthroughCopy("src/_headers");
eleventyConfig.addPassthroughCopy("src/_redirects");
// SEO + AI-crawler files. Served verbatim at the site root (/robots.txt,
// /sitemap.xml, /llms.txt). The 404 page is a template with its own
// permalink, so it does not need a passthrough entry.
// /llms.txt). The sitemap is now a generated template (src/sitemap.njk) so
// blog posts can never be forgotten. The 404 page is a template with its
// own permalink, so it does not need a passthrough entry.
eleventyConfig.addPassthroughCopy("src/robots.txt");
eleventyConfig.addPassthroughCopy("src/sitemap.xml");
eleventyConfig.addPassthroughCopy("src/llms.txt");

return {
Expand Down
55 changes: 55 additions & 0 deletions website/lib/blog.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Pure helpers for the blog section. Kept dependency-free so they can be
// unit-tested with `node --test` and reused by the Eleventy config filters.

/**
* Estimated reading time in whole minutes for a piece of content.
* Strips HTML tags first so rendered post content can be passed directly.
* Uses the common 220 words-per-minute baseline and never returns 0.
*/
export function readingTimeMinutes(content) {
if (!content) return 1;
const text = String(content)
.replace(/<[^>]*>/g, " ")
.trim();
if (!text) return 1;
const words = text.split(/\s+/).length;
return Math.max(1, Math.round(words / 220));
}

/**
* Human-readable date, e.g. "July 2, 2026". Always formats in UTC so the
* build output does not depend on the build machine's timezone (Eleventy
* parses front-matter dates as UTC midnight).
*/
export function readableDate(date) {
const d = toValidDate(date);
return d.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
timeZone: "UTC",
});
}

/**
* ISO 8601 date-time string, e.g. "2026-07-02T00:00:00.000Z".
* Used by JSON-LD, the Atom feed, and the sitemap.
*/
export function isoDate(date) {
return toValidDate(date).toISOString();
}

/** ISO calendar date only, e.g. "2026-07-02". Used by sitemap lastmod. */
export function isoDateOnly(date) {
return isoDate(date).slice(0, 10);
}

function toValidDate(date) {
const d = date instanceof Date ? date : new Date(date);
if (Number.isNaN(d.getTime())) {
// Fail loudly at build time rather than emitting "Invalid Date" into
// meta tags, feeds, or the sitemap.
throw new Error(`blog helpers received an invalid date: ${date}`);
}
return d;
}
1 change: 1 addition & 0 deletions website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"scripts": {
"build": "npx @11ty/eleventy",
"dev": "npx @11ty/eleventy --serve",
"test": "node --test tests/*.test.js",
"deploy": "npm run build && npx wrangler pages deploy _site --project-name=houston-site --branch=main --commit-dirty=true"
},
"dependencies": {
Expand Down
10 changes: 9 additions & 1 deletion website/src/_includes/base.njk
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ title }}{% endblock %}</title>
<!-- Default social card (per-page blocks can override meta) -->
<!-- Social card. Pages can override the image via `ogImage` front matter
(site-relative path, e.g. /blog/my-post/cover.jpg); crawlers honor the
first og:image, so the default is only emitted when no override exists. -->
{% if ogImage %}
<meta property="og:image" content="https://gethouston.ai{{ ogImage }}">
<meta property="og:image:secure_url" content="https://gethouston.ai{{ ogImage }}">
<meta name="twitter:image" content="https://gethouston.ai{{ ogImage }}">
{% else %}
<meta property="og:image" content="https://gethouston.ai/og-image.jpg">
<meta property="og:image:secure_url" content="https://gethouston.ai/og-image.jpg">
<meta property="og:image:type" content="image/jpeg">
Expand All @@ -13,6 +20,7 @@
<meta property="og:image:alt" content="Houston: AI agents that actually do the work.">
<meta name="twitter:image" content="https://gethouston.ai/og-image.jpg">
<meta name="twitter:image:alt" content="Houston: AI agents that actually do the work.">
{% endif %}
{% block meta %}{% endblock %}
{% block favicon %}<link rel="icon" type="image/svg+xml" href="/favicon.svg">{% endblock %}
{% if noindex %}<meta name="robots" content="noindex,nofollow">{% endif %}
Expand Down
70 changes: 70 additions & 0 deletions website/src/_includes/blog-post.njk
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
{% extends "base.njk" %}
{% block title %}{{ title }} — Houston Blog{% endblock %}
{% block favicon %}<link rel="icon" type="image/svg+xml" href="/favicon.svg">{% endblock %}
{% block meta %}
<meta name="description" content="{{ description }}">
<meta property="og:type" content="article">
<meta property="og:url" content="https://gethouston.ai{{ page.url }}">
<meta property="og:title" content="{{ title }}">
<meta property="og:description" content="{{ description }}">
<meta property="og:site_name" content="Houston">
<meta property="article:published_time" content="{{ page.date | isoDate }}">
<meta property="article:author" content="{{ author }}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ title }}">
<meta name="twitter:description" content="{{ description }}">
<link rel="alternate" type="application/atom+xml" title="Houston Blog" href="/blog/feed.xml">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": {{ title | dump | safe }},
"description": {{ description | dump | safe }},
"datePublished": "{{ page.date | isoDate }}",
"dateModified": "{{ page.date | isoDate }}",
"url": "https://gethouston.ai{{ page.url }}",
"image": "https://gethouston.ai{{ ogImage or '/og-image.jpg' }}",
"author": { "@type": "Person", "name": {{ author | dump | safe }} },
"publisher": {
"@type": "Organization",
"name": "Houston",
"url": "https://gethouston.ai/",
"logo": { "@type": "ImageObject", "url": "https://gethouston.ai/houston-icon.png" }
},
"mainEntityOfPage": { "@type": "WebPage", "@id": "https://gethouston.ai{{ page.url }}" }
}
</script>
{% endblock %}
{% block stylesheets %}
<link rel="stylesheet" href="/learn/style.css?v=3">
<link rel="stylesheet" href="/blog/blog.css?v=1">
{% endblock %}
{% block body %}
{% include "nav-docs.njk" %}
<main class="post">
<div class="post-eyebrow"><a href="/blog/">Blog</a></div>
<h1>{{ title }}</h1>
<div class="post-byline">
<span class="post-byline-name">{{ author }}</span>
<span class="post-byline-sep">·</span>
<time datetime="{{ page.date | isoDateOnly }}">{{ page.date | readableDate }}</time>
<span class="post-byline-sep">·</span>
<span>{{ content | readingTime }} min read</span>
</div>
<article class="post-body">
{{ content | safe }}
</article>
<div class="post-back"><a href="/blog/">&larr; All posts</a></div>
</main>
{% include "footer-learn.njk" %}
<script>
// Nav border on scroll (mirrors learn pages)
(function () {
var nav = document.querySelector('nav');
if (!nav) return;
window.addEventListener('scroll', function () {
nav.classList.toggle('scrolled', window.scrollY > 8);
}, { passive: true });
})();
</script>
{% endblock %}
1 change: 1 addition & 0 deletions website/src/_includes/footer-learn.njk
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
bend.
</div>
<div style="margin-top: 12px; display: flex; gap: 16px; font-size: 12px; color: var(--gray-500); flex-wrap: wrap;">
<a href="/blog/" style="color: var(--gray-500);">Blog</a>
<a href="/changelog/" style="color: var(--gray-500);">Changelog</a>
<a href="/privacy/" style="color: var(--gray-500);">Privacy Policy</a>
<a href="/terms/" style="color: var(--gray-500);">Terms of Service</a>
Expand Down
Loading
Loading