Skip to content
Merged
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
25 changes: 16 additions & 9 deletions site/scripts/generate-doc-redirects.test.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,42 @@
import { describe, test, expect } from 'bun:test'
import { redirectHtml, computeRedirects } from './generate-doc-redirects'
import { HUB_DOCS } from '../src/data/hub'

describe('redirectHtml', () => {
test('emits meta-refresh + canonical', () => {
const html = redirectHtml('/fledge/docs/lanes')
expect(html).toContain('<meta http-equiv="refresh" content="0; url=/fledge/docs/lanes">')
const html = redirectHtml(HUB_DOCS)
expect(html).toContain(`<meta http-equiv="refresh" content="0; url=${HUB_DOCS}">`)
expect(html).toContain('canonical')
expect(html).toContain('location.replace')
})

test('includes a visible "moved to CorvidLabs" link for no-JS users', () => {
const html = redirectHtml(HUB_DOCS)
expect(html).toContain('This site has moved to CorvidLabs')
expect(html).toContain(`href="${HUB_DOCS}"`)
})
})

describe('computeRedirects', () => {
test('maps top-level mdBook pages to new docs paths', () => {
test('maps every legacy mdBook page to the hub docs index', () => {
const mapped = computeRedirects(['lanes.md', 'pillars.md', 'getting-started/installation.md'])
expect(mapped['lanes.html']).toBe('/fledge/docs/lanes')
expect(mapped['pillars.html']).toBe('/fledge/docs/pillars')
expect(mapped['getting-started/installation.html']).toBe('/fledge/docs/getting-started/installation')
expect(mapped['lanes.html']).toBe(HUB_DOCS)
expect(mapped['pillars.html']).toBe(HUB_DOCS)
expect(mapped['getting-started/installation.html']).toBe(HUB_DOCS)
})

test('skips top-level .md whose stem collides with an Astro page dir', () => {
// plugins.md would emit public/plugins.html, which on GitHub Pages
// shadows the Astro-built plugins/index.html (the registry). Skip it.
// shadows the Astro-built plugins/index.html. Skip it.
const mapped = computeRedirects(['plugins.md', 'lanes.md'], new Set(['plugins']))
expect(mapped['plugins.html']).toBeUndefined()
expect(mapped['lanes.html']).toBe('/fledge/docs/lanes')
expect(mapped['lanes.html']).toBe(HUB_DOCS)
})

test('skip only applies to top-level files, not nested ones', () => {
// getting-started/plugins.md emits getting-started/plugins.html and can't
// collide with the top-level plugins/ dir, so it stays.
const mapped = computeRedirects(['getting-started/plugins.md'], new Set(['plugins']))
expect(mapped['getting-started/plugins.html']).toBe('/fledge/docs/getting-started/plugins')
expect(mapped['getting-started/plugins.html']).toBe(HUB_DOCS)
})
})
21 changes: 14 additions & 7 deletions site/scripts/generate-doc-redirects.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
import { readdirSync, statSync, mkdirSync, writeFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { HUB_DOCS } from '../src/data/hub'

const __dirname = dirname(fileURLToPath(import.meta.url))
const PUBLIC_DIR = join(__dirname, '..', 'public')
const BASE = '/fledge/'

// The standalone site is retired: every legacy mdBook `.html` path now
// redirects straight to the CorvidLabs hub docs index (no internal hop).
const TARGET = HUB_DOCS

export function redirectHtml(target: string): string {
return `<!DOCTYPE html>
<html><head>
<html lang="en"><head>
<meta charset="utf-8">
<title>Redirecting…</title>
<title>Moved to CorvidLabs</title>
<link rel="canonical" href="${target}">
<meta http-equiv="refresh" content="0; url=${target}">
<meta name="robots" content="noindex">
<script>location.replace(${JSON.stringify(target)})</script>
</head><body>
<p>This page has moved. <a href="${target}">Continue →</a></p>
<p>This page has moved. <a href="${target}">This site has moved to CorvidLabs →</a></p>
</body></html>
`
}
Expand All @@ -33,8 +38,7 @@ export function computeRedirects(
const stem = f.replace(/\.md$/, '')
if (isTopLevel && skipStems.has(stem)) continue
const html = f.replace(/\.md$/, '.html')
const newPath = `${BASE}docs/${stem}`.replace(/\/+/g, '/')
out[html] = newPath
out[html] = TARGET
}
return out
}
Expand Down Expand Up @@ -66,7 +70,10 @@ function main() {
// Source-of-truth = the migrated docs/ tree under site/src/content/docs
const docsSrc = join(__dirname, '..', 'src', 'content', 'docs')
const mdFiles = walk(docsSrc)
const skip = topLevelPageDirs()
// index.md would emit public/index.html and shadow the root redirect page
// (src/pages/index.astro → the hub marketing URL). Skip it so the root keeps
// pointing at marketing rather than the docs index.
const skip = topLevelPageDirs().add('index')
const mapped = computeRedirects(mdFiles, skip)
for (const [oldPath, newPath] of Object.entries(mapped)) {
const dest = join(PUBLIC_DIR, oldPath)
Expand Down
74 changes: 74 additions & 0 deletions site/src/components/Redirect.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
---
/**
* Full-page redirect to the CorvidLabs hub.
*
* The standalone fledge site has been retired — both its marketing pages and
* its docs now live on the CorvidLabs hub. Every route on this site renders
* this component so that, however a visitor (or crawler) arrives, they land on
* the canonical hub URL.
*
* Emits, per the retirement plan:
* - <meta http-equiv="refresh"> for immediate browser redirect
* - <link rel="canonical"> so search engines fold ranking into the hub URL
* - a visible "This site has moved to CorvidLabs →" link for no-JS users
*/
interface Props {
/** Absolute hub URL to redirect to. */
to: string
}

const { to } = Astro.props as Props
---

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Moved to CorvidLabs</title>
<link rel="canonical" href={to} />
<meta http-equiv="refresh" content={`0; url=${to}`} />
<meta name="robots" content="noindex" />
<script is:inline define:vars={{ to }}>
location.replace(to)
</script>
<style>
:root {
color-scheme: dark;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: #0a0a0a;
color: #ededed;
font-family:
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
text-align: center;
padding: 24px;
}
main {
max-width: 32rem;
}
a {
color: #fb923c;
font-weight: 600;
text-decoration: underline;
text-underline-offset: 3px;
}
p {
color: #a3a3a3;
line-height: 1.6;
}
</style>
</head>
<body>
<main>
<p>This page has moved.</p>
<p>
<a href={to}>This site has moved to CorvidLabs &rarr;</a>
</p>
</main>
</body>
</html>
9 changes: 9 additions & 0 deletions site/src/data/hub.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Canonical CorvidLabs hub URLs.
*
* The standalone fledge site has been retired; its marketing and docs are fully
* migrated to the CorvidLabs hub. These constants are the redirect targets used
* by every route on this (now redirect-only) site.
*/
export const HUB_MARKETING = 'https://corvidlabs.github.io/corvidlabs-site/fledge/'
export const HUB_DOCS = 'https://corvidlabs.github.io/corvidlabs-site/fledge/docs/'
19 changes: 3 additions & 16 deletions site/src/pages/404.astro
Original file line number Diff line number Diff line change
@@ -1,18 +1,5 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import Button from '../components/Button.astro'
const base = import.meta.env.BASE_URL
import Redirect from '../components/Redirect.astro'
import { HUB_MARKETING } from '../data/hub'
---
<BaseLayout title="404 — fledge">
<main id="main" class="container" style="padding: 120px 0 80px; text-align: center;">
<p style="color: var(--accent-bright); font-family: var(--serif); font-style: italic; font-size: 1.25rem; margin-bottom: 8px;">404</p>
<h1 style="font-size: 2.4rem; letter-spacing: -0.02em; margin-bottom: 12px;">This page took flight without us.</h1>
<p style="color: var(--text-muted); font-size: 1.05rem; margin: 0 auto 28px; max-width: 480px;">
The page you're looking for doesn't exist (any more). Try the docs or the plugin registry.
</p>
<div style="display: flex; gap: 12px; justify-content: center; flex-wrap: wrap;">
<Button href={`${base}/docs`} variant="primary">Read the docs</Button>
<Button href={`${base}/plugins`} variant="secondary">Browse plugins</Button>
</div>
</main>
</BaseLayout>
<Redirect to={HUB_MARKETING} />
19 changes: 4 additions & 15 deletions site/src/pages/blog/[...slug].astro
Original file line number Diff line number Diff line change
@@ -1,22 +1,11 @@
---
import { getCollection } from 'astro:content'
import ArticleLayout from '../../layouts/ArticleLayout.astro'
import Redirect from '../../components/Redirect.astro'
import { HUB_MARKETING } from '../../data/hub'

export async function getStaticPaths() {
const entries = await getCollection('blog', p => !p.data.draft)
return entries.map(entry => ({ params: { slug: entry.slug }, props: { entry } }))
return entries.map(entry => ({ params: { slug: entry.slug } }))
}

const { entry } = Astro.props
const { Content } = await entry.render()
const date = entry.data.date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
const meta = `${date} · ${entry.data.author} · ${entry.data.readTime} min read`
---
<ArticleLayout
title={entry.data.title}
description={entry.data.description}
eyebrow={entry.data.category}
meta={meta}
>
<Content />
</ArticleLayout>
<Redirect to={HUB_MARKETING} />
98 changes: 3 additions & 95 deletions site/src/pages/blog/index.astro
Original file line number Diff line number Diff line change
@@ -1,97 +1,5 @@
---
import { getCollection } from 'astro:content'
import BaseLayout from '../../layouts/BaseLayout.astro'
import PostCard from '../../components/PostCard.astro'
import CategoryTag from '../../components/CategoryTag.astro'
import plugins from '../../data/plugins.json' with { type: 'json' }
const base = import.meta.env.BASE_URL
const posts = (await getCollection('blog', p => !p.data.draft))
.sort((a, b) => +b.data.date - +a.data.date)
const featured = posts.find(p => p.data.featured)
const rest = posts.filter(p => p !== featured)
const fmt = (d: Date) => d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
import Redirect from '../../components/Redirect.astro'
import { HUB_MARKETING } from '../../data/hub'
---
<BaseLayout title="fledge blog">
<main id="main">

<section class="page-head">
<div class="container head-row">
<div>
<p class="eyebrow">The fledge blog</p>
<h1>Updates, plugins, and <em>field notes.</em></h1>
<p class="lede">Release notes, plugin spotlights, workflow deep-dives, and the occasional design rant.</p>
</div>
<div class="rss-row">
<a href={`${base}/rss.xml`} class="rss"><span aria-hidden="true">⌗</span> RSS</a>
</div>
</div>
</section>

{featured && (
<section class="featured">
<div class="container">
<a href={`${base}/blog/${featured.slug}`} class="feat-card">
<div class="feat-meta">
<CategoryTag category={featured.data.category} />
<span class="feat-date">{fmt(featured.data.date)} · {featured.data.readTime} min read</span>
</div>
<h2>{featured.data.title}</h2>
<p class="dek">{featured.data.description}</p>
<p class="byline">by {featured.data.author}</p>
</a>
</div>
</section>
)}

<section class="post-list">
<div class="container">
<div class="list-head">
<h2>All posts</h2>
<span class="count">{posts.length} posts</span>
</div>
<ul class="post-grid">
{rest.map(p => <PostCard post={p} />)}
</ul>
</div>
</section>

<section class="read-more">
<div class="container">
<a href={`${base}/plugins`} class="rm-card">Browse {plugins.length} plugins <span aria-hidden="true">→</span></a>
<a href={`${base}/docs`} class="rm-card">Read the docs <span aria-hidden="true">→</span></a>
<a href="https://github.com/CorvidLabs/fledge" class="rm-card">Star on GitHub <span aria-hidden="true">↗</span></a>
</div>
</section>

</main>
</BaseLayout>

<style>
.page-head { padding: 60px 0 36px; background-image: radial-gradient(ellipse 1200px 400px at 50% -100px, rgba(234,88,12,0.10), transparent 70%); border-bottom: 1px solid var(--border); }
.head-row { display: flex; align-items: end; justify-content: space-between; gap: 24px; flex-wrap: wrap; }
.eyebrow { color: var(--accent-bright); font-size: 0.875rem; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; margin-bottom: 14px; }
.page-head h1 { font-size: clamp(2.2rem, 4vw, 3rem); letter-spacing: -0.025em; line-height: 1.1; margin-bottom: 10px; }
.page-head h1 em { font-style: italic; font-family: var(--serif); color: var(--accent-bright); font-weight: 400; }
.lede { color: var(--text-muted); font-size: 1.125rem; max-width: 580px; }
.rss { padding: 10px 14px; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: 8px; color: var(--text-muted); font-size: 0.875rem; display: inline-flex; gap: 6px; align-items: center; }
.rss:hover { color: var(--accent-bright); border-color: var(--accent); }
.featured { padding: 40px 0 20px; }
.feat-card { display: block; padding: 40px; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius); color: inherit; text-decoration: none; }
.feat-card:hover { border-color: var(--accent); }
.feat-meta { display: flex; gap: 12px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; }
.feat-date { color: var(--text-dim); font-size: 0.875rem; font-family: var(--mono); }
.feat-card h2 { font-size: clamp(1.6rem, 3vw, 2.1rem); line-height: 1.15; margin-bottom: 14px; }
.feat-card .dek { color: var(--text-muted); font-size: 1.0625rem; line-height: 1.55; margin-bottom: 12px; }
.feat-card .byline { color: var(--text-dim); font-size: 0.9375rem; }
.post-list { padding: 40px 0 60px; }
.list-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.list-head h2 { font-size: 1.5rem; }
.list-head .count { color: var(--text-dim); font-size: 0.9375rem; }
.post-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
.read-more { padding: 40px 0 80px; }
.read-more .container { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.rm-card { display: block; padding: 22px; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); font-weight: 500; text-decoration: none; transition: border-color .15s; }
.rm-card:hover { border-color: var(--accent); color: var(--accent-bright); }
@media (max-width: 1024px) { .post-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 760px) { .post-grid, .read-more .container { grid-template-columns: 1fr; } }
</style>
<Redirect to={HUB_MARKETING} />
14 changes: 6 additions & 8 deletions site/src/pages/docs/[...slug].astro
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
---
import { getCollection } from 'astro:content'
import DocsLayout from '../../layouts/DocsLayout.astro'
import Redirect from '../../components/Redirect.astro'
import { HUB_DOCS } from '../../data/hub'

// Each retired doc URL still builds and redirects to the hub docs index.
// Deep per-page mapping is intentionally collapsed to the docs index.
export async function getStaticPaths() {
const entries = await getCollection('docs')
return entries
.filter(e => e.slug !== 'index')
.map(entry => ({ params: { slug: entry.slug }, props: { entry } }))
.map(entry => ({ params: { slug: entry.slug } }))
}

const { entry } = Astro.props
const { Content } = await entry.render()
---
<DocsLayout title={entry.data.title} description={entry.data.description} section={entry.data.section}>
<Content />
</DocsLayout>
<Redirect to={HUB_DOCS} />
11 changes: 3 additions & 8 deletions site/src/pages/docs/index.astro
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
---
import { getEntry } from 'astro:content'
import DocsLayout from '../../layouts/DocsLayout.astro'
const entry = await getEntry('docs', 'index')
if (!entry) throw new Error('docs/index not found')
const { Content } = await entry.render()
import Redirect from '../../components/Redirect.astro'
import { HUB_DOCS } from '../../data/hub'
---
<DocsLayout title={entry.data.title} description={entry.data.description} section={entry.data.section}>
<Content />
</DocsLayout>
<Redirect to={HUB_DOCS} />
Loading
Loading