From c5a30418849972f3ec36b684281ae886c563eea8 Mon Sep 17 00:00:00 2001 From: Dorell James Date: Wed, 28 Oct 2026 08:40:57 +0800 Subject: [PATCH 01/12] docs: add jscebu.org v1 design Landing page plus the pages the old Notion site really had (about, links, projects with Cebby), redirects for every old URL, and swapping www to the jscebu-org Worker as a custom domain. Events from Cebby come in a later release. --- .../specs/2026-10-28-jscebu-site-v1-design.md | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-28-jscebu-site-v1-design.md diff --git a/docs/superpowers/specs/2026-10-28-jscebu-site-v1-design.md b/docs/superpowers/specs/2026-10-28-jscebu-site-v1-design.md new file mode 100644 index 0000000..b997135 --- /dev/null +++ b/docs/superpowers/specs/2026-10-28-jscebu-site-v1-design.md @@ -0,0 +1,162 @@ +# jscebu.org v1: Design + +- **Date:** 2026-10-28 +- **Owner:** Dorell James +- **Status:** design for approval +- **Scope:** project 2 of 3 (see the design-system spec, "Project order"), first release +- **Builds on:** `docs/superpowers/specs/2026-10-27-jscebu-design-system-design.md` (the brand, `apps/website`, `/brand`) + +Labels: **[Decided]** Dorell's call · **[Proposed]** Claude's call, change freely · **[Builder]** left to whoever builds it. + +## 1. Intent + +Move www.jscebu.org off Notion and onto the new Astro site now, with what exists today, and add events once Cebby can serve them. + +- **[Decided]** "Ship the landing page for now and what's available." +- **[Decided]** Build the site first, then swap all of www.jscebu.org to it. + - This **supersedes Task 8 of the design-system plan**, which was a `/brand`-only route cutover. + - There is no partial cutover. + +## 2. Goals, non-goals, anti-goals + +**Goals** +- A landing page and the few pages the old site really had, all built on the design system. +- www.jscebu.org served by the `jscebu-org` Cloudflare Worker. +- Every old URL lands somewhere sensible. + +**Non-goals for v1** (later releases) +- `/events` pages and the Cebby endpoint (§6). +- A Hacktoberfest page (it has its own site). +- Event recaps, RSS, a blog. +- Moving the apex domain off Vercel. +- Auto-deploy from git. + +**Anti-goals** +- An old link that now 404s without a reason. +- An API token reaching the browser or the repo. +- Hand-typed colors or fonts. The design-system rules and `check-dist` apply to every page. + +## 3. Pages + +All pages use the **Terminal** personality and share one nav and footer. +- **Nav:** the dark flat lockup, then Projects, About, Brand. +- **Footer:** links, the code license and the brand-assets note. + +| Path | Content | Source | +|---|---|---| +| `/` | **Hero:** "Build things. Ship them. Together" with the blinking cursor, the sticker, and a one-line intro. **What we do:** meetups, workshops, Hacktoberfest Cebu. **Upcoming events** (`#events`): buttons to Cebby and to JS Cebu's Facebook events. **Featured project:** Cebby. **Community photo:** real and unfiltered. **Join:** Facebook, GitHub, X, email. | site content (§4) | +| `/projects` | Project cards. Cebby only in v1, as **[Decided]** "Cebby first". | `projects` collection | +| `/about` | Mission and vision, plus "how we measure success" (moved verbatim from `/mission-vision`). History: GitHub org 2018, launched 2019 from Laravel Cebu and Vue.js Cebu, with the Angular, React and Vue Cebu sub-communities as history. Organizers (`#organizers`): the four leads from `/members`, as listed today. | `about.md` + `organizers` collection | +| `/links` | The official channels: Facebook, X, GitHub, website, hello@jscebu.org. | `links` collection | +| `/brand`, `/brand.md`, `/brand/*`, `/llms.txt`, `/404` | Already built. `/brand` switches to the shared nav, keeping its section links. | unchanged | +| `/sitemap-index.xml`, `/robots.txt` | **[Proposed]** For search engines. The old sitemap returns 500. | build | + +- **[Proposed]** No Bisaya on these pages until Dorell approves the phrases. Approval is part of the design-system PR review. +- **[Builder]** Final copy, written in the brand voice. Dorell reviews it on the preview. + +## 4. Content model + +- **[Decided]** Markdown in the repo, using Astro content collections ("or any new one today"). + - **[Builder]** Use Astro 7's current content-layer API (loaders) and check its docs at build time. +- **[Proposed]** Collections, with schemas validated at build: + - **`projects`:** one Markdown file per project. Fields: + - `name`, `url`, `repo?`, `summary` + - `image` (a local logo or screenshot) + - `status` (`active` | `archived`) + - `featured: boolean` + - `order` + - Body: a longer description. + - **`organizers`:** one data file. Fields: `name`, `role`, `community?`, `order`. + - **`links`:** one data file. Fields: `label`, `url`, `handle?`, `order`. +- `about.md` is a single Markdown page. +- The landing-page copy lives in the page itself. +- **Cebby's card content:** "Cebby is a public discovery layer for Cebu tech events". It links to https://www.getcebby.com and github.com/getcebby/cebby, with its icon copied from the Cebby repo. + +## 5. Old URLs + +Redirects ship as a `_redirects` file served by Workers static assets. **[Builder]** Confirm the file format against the current Cloudflare docs. + +| Old URL (today) | v1 | Status | +|---|---|---| +| `/mission-vision` | `/about` | 301 | +| `/members` | `/about#organizers` | 301 | +| `/iniatives` (sic), `/initiatives` | `/about` | 301 | +| `/official-links` | `/links` | 301 | +| `/community-projects` (a 2023 Hacktoberfest repo list) | `/projects` | 301 | +| `/events` | `/#events` | **302**, because `/events` becomes a real page later | +| `/promotional-materials` (Notion "page not found" today) | `/brand` | 301 | +| `/annual-hacktoberfest` (Notion "page not found" today) | `https://hacktoberfest.jscebu.org` | 301 | +| `/volunteers-training-workshop` (Notion "page not found" today) | `/about` | 301 | +| `/feed`, `/sitemap.xml` (500 today) | 404 | None. v1 has no feed. The sitemap moves to `/sitemap-index.xml`. | +| anything else | the branded 404 page | | + +## 6. Later: events from Cebby + +Recorded so v1 doesn't paint it into a corner. This gets built in its own release. + +- **[Decided]** + - Events come from Cebby's API. + - Cebby gains an endpoint that returns JS Cebu's events, protected by an API token. + - jscebu.org pulls it server-side, so the token never reaches the browser or the repo. +- **What exists today in Cebby** (`getcebby/cebby`, `apps/pwa`): + - Astro 5 on Cloudflare Workers, with server-side rendering. + - Supabase tables `organizations`, `accounts`, `events`, `event_organizers`. + - JS Cebu is in the data (`javascript-cebu`). + - Existing routes live under `src/pages/api/`. +- **[Builder, then]** Fetch at build time with a content loader, or per request with a live collection, which needs the Cloudflare adapter. Decide when building it. + +## 7. Going live + +**Before the swap** +- Confirm the jscebu.org zone is in the Cloudflare account wrangler uses (`@dorelljames`). +- Deploy the Worker to workers.dev and check every page and redirect there. +- **[Proposed]** These are the go/no-go gate. + +**The swap: [Proposed]** +- Record the current DNS records. +- Delete the `www` CNAME to `cname.vercel-dns.com`. +- Attach `www.jscebu.org` to the `jscebu-org` Worker as a **Workers Custom Domain**. Cloudflare creates the DNS record and the certificate, and there is no origin, so the zone's SSL mode doesn't matter. +- Expect a gap of seconds to minutes between the delete and the attach. +- Needs Dorell's explicit "go". + +**After the swap** +- Run the §5 URL matrix against www, plus `/brand.md` headers. + +**Rollback** +- Remove the custom domain. +- Recreate the `www` CNAME to `cname.vercel-dns.com`, DNS-only. + +**Apex** +- `jscebu.org` keeps its working Vercel `308 → www` for now. +- Retiring Vercel is a follow-up. + +**Deploys** +- By hand with `wrangler deploy` for v1. +- **[Proposed]** Connecting Workers Builds for auto-deploy from `main` is a follow-up. + +**Branches** +- This work is on `feat/site-v1`, stacked on the design-system PR (#1). +- Its PR targets `feat/brand-v0.1` until #1 merges. + +## 8. Acceptance + +1. Every page in §3 builds. +2. `check-dist` passes on all pages: + - It covers cursor wrapping, internal links (now every path, since the whole site is ours), parity on `/brand`, and no literals. + - The redirect table is tested against `_redirects`. +3. The pages hold at 320px with no horizontal scroll, and the cursor is still under reduced motion. +4. On workers.dev and then on www: + - Every §5 row returns its listed status and target. + - `/brand.md` returns `text/markdown; charset=utf-8`. +5. Dorell looks at the preview and says ship it. +6. After launch, the design-system plan's Task 9 runs against the live `https://www.jscebu.org/brand`: a fresh agent designs a landing page and a social graphic. + +## 9. Left to the builder + +- Final copy. +- The section layout within the Terminal recipe. +- The project card design. +- The Astro collections API details. +- The `_redirects` syntax. +- The sitemap integration. +- Cebby's icon file. From 69d1560c001932a2167ad6dfcc6227bf366cf2e6 Mon Sep 17 00:00:00 2001 From: Dorell James Date: Wed, 28 Oct 2026 08:43:31 +0800 Subject: [PATCH 02/12] docs: add jscebu.org v1 plan Six tasks: content collections, shared layout with about/links/projects, the landing page, redirects plus all-page checks, the workers.dev preview with Dorell's review, and the www custom-domain swap on an explicit go. The spec now uses a small sitemap endpoint instead of an integration. --- .../plans/2026-10-28-jscebu-site-v1.md | 199 ++++++++++++++++++ .../specs/2026-10-28-jscebu-site-v1-design.md | 7 +- 2 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-28-jscebu-site-v1.md diff --git a/docs/superpowers/plans/2026-10-28-jscebu-site-v1.md b/docs/superpowers/plans/2026-10-28-jscebu-site-v1.md new file mode 100644 index 0000000..366d897 --- /dev/null +++ b/docs/superpowers/plans/2026-10-28-jscebu-site-v1.md @@ -0,0 +1,199 @@ +# jscebu.org v1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the jscebu.org landing page plus `/projects` (Cebby), `/about` and `/links` on the existing Astro app, redirect every old Notion URL, and swap www.jscebu.org to the `jscebu-org` Worker. + +**Architecture:** +- The app is the static Astro 7 site in `apps/website`. +- Content lives in Markdown/JSON content collections, validated with zod schemas. +- All pages share a `Site` layout (nav and footer) on top of the existing `Terminal` layout. +- Old URLs are redirected by a `_redirects` file that Workers static assets applies before asset matching. +- `check-dist` grows to cover every built page. +- The swap attaches `www.jscebu.org` to the Worker as a Custom Domain. + +**Tech Stack:** Astro 7.4 (content layer: `glob` and `file` loaders from `astro/loaders`, zod from `astro/zod`), Vitest 5, Wrangler 4, linkedom. + +**Spec:** `docs/superpowers/specs/2026-10-28-jscebu-site-v1-design.md`. It builds on `docs/superpowers/specs/2026-10-27-jscebu-design-system-design.md`. + +## Global Constraints + +- Every page uses the Terminal personality. There must be no hex, `rgb()`, `hsl()` or font-family literals in `apps/website/src`; `check-dist` enforces this. +- The primary headline on each page ends with ``, wrapped in `.jc-nw`. There is one cursor per page, and only the landing hero's cursor blinks. +- No Bisaya copy until Dorell approves the phrases. +- Content comes from the current site: + - Mission and vision, verbatim from `/mission-vision`. + - The four leads, from `/members`. + - The channels, from `/official-links`. + - Cebby's description: "Cebby is a public discovery layer for Cebu tech events", linking https://www.getcebby.com and https://github.com/getcebby/cebby. +- The upcoming-events links are https://www.getcebby.com and https://www.facebook.com/JavaScriptCebu/events. +- There is no blog link, because blog.jscebu.org returns 403. +- Work happens on branch `feat/site-v1`, stacked on `feat/brand-v0.1`. The PR targets `feat/brand-v0.1` until PR #1 merges. +- Commit messages: an imperative subject of about 50 characters; the body has one line per paragraph with no hard wrapping. +- **Outward actions:** + - The workers.dev preview deploy is approved. + - Deleting the www DNS record and attaching the custom domain each need Dorell's explicit "go". + +## Review Focus + +1. **Someone following an old link** (`/members`, `/iniatives`, `/events`, the three Notion "page not found" pages): they must land on the target in spec §5 with its status. A redirect into a 404 is a failure. Owners: Task 4 (`_redirects` table test) and Tasks 5–6 (live URL matrix). +2. **A visitor on a 320px phone:** no horizontal scroll on `/`, `/projects`, `/about`, `/links`, `/brand`. Owner: Task 5 (iframe harness, since headless Chrome won't lay out below 500px). +3. **A nav or footer link to a path that doesn't exist:** the build must fail. Owner: Task 4 (`findMissingLinks` over every page, with no "ours" filter, and redirect sources counting as missing). +4. **An agent fetching `/brand.md` after the swap:** it must still get `text/markdown; charset=utf-8` with CORS. Owner: Task 6. +5. **The swap gap:** between deleting the CNAME and attaching the domain, www is down. The rollback commands must be written down and tested for syntax before the "go". Owner: Task 6. + +--- + +## File Structure + +``` +apps/website/ + src/content.config.ts collections: projects, organizers, links, pages + src/content/schemas.ts zod schemas (shared by config and tests) + src/content/projects/cebby.md src/content/organizers.json src/content/links.json + src/content/pages/about.md + src/assets/projects/cebby.png Cebby icon (copied from getcebby/cebby) + src/layouts/Site.astro Terminal + SiteNav + slot + SiteFooter + src/components/SiteNav.astro SiteFooter.astro ProjectCard.astro + src/pages/index.astro projects.astro about.astro links.astro sitemap.xml.ts robots.txt.ts + public/_redirects + scripts/check-dist.ts all pages; parseRedirects + scripts/url-matrix.sh old/new URL checks against a base URL + test/content.test.ts test/redirects.test.ts +``` + +--- + +### Task 1: Content collections and data + +**Files:** Create `src/content.config.ts`, `src/content/schemas.ts`, the content files above and `src/assets/projects/cebby.png`. Test: `test/content.test.ts`. + +**Interfaces (produces):** +- `projectSchema({ image })`: + - `name: string`, `url: url`, `repo?: url`, `summary: string` + - `image: image()` + - `status: 'active' | 'archived'`, `featured: boolean`, `order: number` +- `organizerSchema`: `{ id, name, role, community?, order }`. The `file()` loader needs an `id` per entry. +- `linkSchema`: `{ id, label, url, handle?, order }`. +- `pageSchema`: `{ title, description }`. +- `collections = { projects: glob('**/*.md', base: 'src/content/projects'), organizers: file('src/content/organizers.json'), links: file('src/content/links.json'), pages: glob('**/*.md', base: 'src/content/pages') }`. + +- [ ] **Step 1: Write the failing test.** `content.test.ts` reads the JSON files and the frontmatter of each Markdown file. It parses them with the schemas; for the image field, a stub `z.string()` stands in. Assert: + - The organizers are exactly Cyrus Hiyas, Dorell James, Harlequin Doyon and Sieg Balona, with roles Lead, Lead, Lead, Co-Lead, in that order. + - The links include `https://www.facebook.com/JavaScriptCebu/`, `https://twitter.com/javascriptcebu`, `https://github.com/javascriptcebu/` and `mailto:hello@jscebu.org`. + - The only project is Cebby (`featured: true`, url `https://www.getcebby.com`). + - `about.md` contains the mission's first sentence verbatim: "JavaScript Cebu aims to empower and unite developers". +- [ ] **Step 2: Run** `pnpm --filter website test`. Expected: FAIL (the schemas module is missing). +- [ ] **Step 3: Implement** the schemas, config and data. + - `about.md` holds the Mission, Vision and "How we measure success" sections verbatim, plus a short History section (spec §3). + - Copy Cebby's icon from `~/Projects/cebby/apps/pwa/public/` (choose the largest PNG app icon). +- [ ] **Step 4: Run** the tests, then `pnpm --filter website build`. Expected: PASS, and Astro reports the 4 collections. +- [ ] **Step 5: Commit:** `feat(website): add content collections for the site`. + +### Task 2: Shared layout, and the /projects, /about and /links pages + +**Files:** Create `src/layouts/Site.astro`, `src/components/{SiteNav,SiteFooter,ProjectCard}.astro`, and `src/pages/{projects,about,links}.astro`. Modify `src/pages/brand/index.astro` and `src/pages/404.astro` to use `Site`. + +**Interfaces:** +- `Site.astro` props: `{ title: string; description: string; current?: 'home' | 'projects' | 'about' | 'links' | 'brand' }`. + - It renders `Terminal`, then `SiteNav`, a `
` slot, and `SiteFooter`. +- `SiteNav`: + - The lockup-dark logo links to `/`. Then the links Projects, About, Brand. + - `aria-current="page"` on the current link. + - It collapses to the logo plus links on one line from 320px up; no hamburger menu. +- `/brand` keeps its section links as a second row under `SiteNav`, and keeps its `brand.md` link. +- `SiteFooter`: + - Links to Projects, About, Links, Brand, `/brand.md`, the GitHub repo and `/llms.txt`. + - The license sentence from `/brand`'s footer. +- `ProjectCard` props: `{ entry: CollectionEntry<'projects'> }`. +- Each page's H1 ends with `` (non-blinking): + - `/projects` "Community projects" + - `/about` "About JS Cebu" + - `/links` "Find JS Cebu" + +- [ ] **Step 1:** Build the layout and pages from the collections (`getCollection`, `render`). +- [ ] **Step 2: Run** `pnpm --filter website build`. Expected: `check-dist` passes, including `/brand` parity. +- [ ] **Step 3: Commit:** `feat(website): add site layout and about, links, projects`. + +### Task 3: The landing page + +**Files:** Create `src/pages/index.astro`. + +- [ ] **Step 1:** Build `/` with `Site` (`current: 'home'`). Sections, in order, with these ids: + - `hero`: + - The label `// cebu's javascript community`. + - The H1 ``. + - One line: "Meetups, workshops and Hacktoberfest, run by developers in Cebu." + - Buttons: "Upcoming events ↓" (`#events`) and "Join us" (`#join`). + - `` on the right on desktop, in the flow on mobile, as on `/brand`. + - `what-we-do`: three cards (Meetups, Workshops, Hacktoberfest Cebu), each one line. Hacktoberfest links to `https://hacktoberfest.jscebu.org`. + - `events`: a heading, one line saying events are listed on Cebby, and buttons to Cebby (primary) and Facebook events. + - `projects`: the featured project's `ProjectCard`, plus a link to `/projects`. + - `community`: the real community photo (`.jc-photo`, unfiltered, `/brand/photos/community-meetup.jpg`), with a mono caption. + - `join`: the `links` collection as buttons. +- [ ] **Step 2: Run** `pnpm --filter website build`. Expected: PASS. +- [ ] **Step 3: Commit:** `feat(website): add the landing page`. + +### Task 4: Every page checked, redirects, sitemap + +**Files:** Modify `scripts/check-dist.ts`. Create `public/_redirects`, `src/pages/{sitemap.xml,robots.txt}.ts` and `test/redirects.test.ts`. Modify `test/check-dist.test.ts`. + +**Interfaces:** +- `parseRedirects(text: string): { from: string; to: string; status: number }[]`. Comments and blank lines are skipped; the status defaults to 302. +- `findMissingLinks(text, fileExists)` checks every root-relative and `https://www.jscebu.org/…` path; the "ours" filter is removed. Redirect sources are not files, so linking to one counts as missing. +- `check-dist` runs the cursor and link checks over every `dist/**/*.html`, `brand.md` and `llms.txt`. Parity stays on `/brand`, and the literal check stays on `src/**`. It also checks that every redirect target that is a path on this site exists. + +- [ ] **Step 1: Write the failing tests.** + - `redirects.test.ts` reads `public/_redirects` and asserts it equals spec §5, row for row: + - `/mission-vision` → `/about` 301 + - `/members` → `/about#organizers` 301 + - `/iniatives` → `/about` 301 + - `/initiatives` → `/about` 301 + - `/official-links` → `/links` 301 + - `/community-projects` → `/projects` 301 + - `/events` → `/#events` 302 + - `/promotional-materials` → `/brand` 301 + - `/annual-hacktoberfest` → `https://hacktoberfest.jscebu.org` 301 + - `/volunteers-training-workshop` → `/about` 301 + - In `check-dist.test.ts`, replace the "ignores pages the legacy site serves" case with: `/events` is reported missing when it isn't a file. +- [ ] **Step 2: Run** `pnpm --filter website test`. Expected: FAIL. +- [ ] **Step 3: Implement.** + - `sitemap.xml` lists `/`, `/projects`, `/about`, `/links` and `/brand/` as absolute URLs. + - `robots.txt` allows everything and points to the sitemap. +- [ ] **Step 4: Run** `pnpm test && pnpm --filter website build`. Expected: PASS. +- [ ] **Step 5: Commit:** `feat(website): redirect old URLs and check every page`. + +### Task 5: Preview on workers.dev (CHECKPOINT: Dorell reviews) + +**Files:** Create `scripts/url-matrix.sh`. + +- [ ] **Step 1:** Write `scripts/url-matrix.sh BASE`. It curls each row of spec §5 without following redirects and checks the status and `Location`. It also checks: + - `/`, `/projects`, `/about`, `/links` and `/brand/` return 200. + - `/brand.md` returns 200 with `text/markdown; charset=utf-8` and `access-control-allow-origin: *`. + - `/nope` returns 404. + - It prints one line per check and exits 1 on any mismatch. +- [ ] **Step 2: Run** it against `npx wrangler dev --local`. Expected: all pass. +- [ ] **Step 3: Deploy the preview.** Run `pnpm --filter website build && pnpm --filter website exec wrangler deploy`. Then run `scripts/url-matrix.sh https://jscebu-org..workers.dev`. Expected: all pass. +- [ ] **Step 4:** Screenshot `/`, `/projects`, `/about` and `/links` at 1280px, and at a true 320px through the iframe harness, with reduced motion forced. Check for no horizontal scroll and a visible cursor. +- [ ] **Step 5:** Push, then open a PR from `feat/site-v1` into `feat/brand-v0.1`. +- [ ] **CHECKPOINT:** share the preview URL. Dorell approves the copy and the pages, or asks for changes. + +### Task 6: Swap www.jscebu.org (CHECKPOINT: explicit "go") + +- [ ] **Step 1: Pre-flight (read-only).** + - `npx wrangler whoami` shows the account. + - Confirm the `jscebu.org` zone is in it, using the Cloudflare dashboard or an API zone listing with that account. + - Record the current DNS records for `www` and the apex in the ledger. + - Write the rollback commands into the ledger. +- [ ] **Step 2: CHECKPOINT.** Get Dorell's "go" for the swap window. +- [ ] **Step 3: Delete the www CNAME** (`cname.vercel-dns.com`). Use the API if wrangler's token has DNS edit; otherwise Dorell deletes it in the dashboard. +- [ ] **Step 4: Attach the domain.** Add `"routes": [{ "pattern": "www.jscebu.org", "custom_domain": true }]` to `wrangler.jsonc` and replace the "no routes yet" comment. Then run `wrangler deploy`. +- [ ] **Step 5: Verify.** Run `scripts/url-matrix.sh https://www.jscebu.org` until it passes. Certificate issuance can take a few minutes. + - Also check that `https://jscebu.org` still returns `308 → https://www.jscebu.org/` from Vercel. + - If www isn't serving within 15 minutes, roll back. +- [ ] **Step 6: Commit:** `feat(website): serve www.jscebu.org from the Worker`. Update project memory with the launch date and the follow-ups: + - Retire Vercel and Notion, and move the apex redirect to Cloudflare. + - Remove www from the Vercel project. + - Workers Builds auto-deploy. + - The Cebby events endpoint. + - Design-system Task 9 against the live `/brand`. diff --git a/docs/superpowers/specs/2026-10-28-jscebu-site-v1-design.md b/docs/superpowers/specs/2026-10-28-jscebu-site-v1-design.md index b997135..6443fd2 100644 --- a/docs/superpowers/specs/2026-10-28-jscebu-site-v1-design.md +++ b/docs/superpowers/specs/2026-10-28-jscebu-site-v1-design.md @@ -49,7 +49,7 @@ All pages use the **Terminal** personality and share one nav and footer. | `/about` | Mission and vision, plus "how we measure success" (moved verbatim from `/mission-vision`). History: GitHub org 2018, launched 2019 from Laravel Cebu and Vue.js Cebu, with the Angular, React and Vue Cebu sub-communities as history. Organizers (`#organizers`): the four leads from `/members`, as listed today. | `about.md` + `organizers` collection | | `/links` | The official channels: Facebook, X, GitHub, website, hello@jscebu.org. | `links` collection | | `/brand`, `/brand.md`, `/brand/*`, `/llms.txt`, `/404` | Already built. `/brand` switches to the shared nav, keeping its section links. | unchanged | -| `/sitemap-index.xml`, `/robots.txt` | **[Proposed]** For search engines. The old sitemap returns 500. | build | +| `/sitemap.xml`, `/robots.txt` | **[Proposed]** For search engines, generated by a small endpoint rather than an integration. The old sitemap returns 500. | build | - **[Proposed]** No Bisaya on these pages until Dorell approves the phrases. Approval is part of the design-system PR review. - **[Builder]** Final copy, written in the brand voice. Dorell reviews it on the preview. @@ -87,7 +87,8 @@ Redirects ship as a `_redirects` file served by Workers static assets. **[Builde | `/promotional-materials` (Notion "page not found" today) | `/brand` | 301 | | `/annual-hacktoberfest` (Notion "page not found" today) | `https://hacktoberfest.jscebu.org` | 301 | | `/volunteers-training-workshop` (Notion "page not found" today) | `/about` | 301 | -| `/feed`, `/sitemap.xml` (500 today) | 404 | None. v1 has no feed. The sitemap moves to `/sitemap-index.xml`. | +| `/feed` (500 today) | 404 | None. v1 has no feed. | +| `/sitemap.xml` (500 today) | the new sitemap | | | anything else | the branded 404 page | | ## 6. Later: events from Cebby @@ -158,5 +159,5 @@ Recorded so v1 doesn't paint it into a corner. This gets built in its own releas - The project card design. - The Astro collections API details. - The `_redirects` syntax. -- The sitemap integration. +- The sitemap endpoint. - Cebby's icon file. From 451597a5d37099ae8925f0710d2efdd6e589da51 Mon Sep 17 00:00:00 2001 From: Dorell James Date: Wed, 28 Oct 2026 08:51:01 +0800 Subject: [PATCH 03/12] docs: record the events browsing requirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dorell doesn't want events as one long list. The Facebook history (about 50 events since 2019, with series, Hacktoberfest stages and several hosts) is noted in spec §6 as input for the events release. --- .../superpowers/specs/2026-10-28-jscebu-site-v1-design.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/superpowers/specs/2026-10-28-jscebu-site-v1-design.md b/docs/superpowers/specs/2026-10-28-jscebu-site-v1-design.md index 6443fd2..f0bc0af 100644 --- a/docs/superpowers/specs/2026-10-28-jscebu-site-v1-design.md +++ b/docs/superpowers/specs/2026-10-28-jscebu-site-v1-design.md @@ -105,6 +105,14 @@ Recorded so v1 doesn't paint it into a corner. This gets built in its own releas - JS Cebu is in the data (`javascript-cebu`). - Existing routes live under `src/pages/api/`. - **[Builder, then]** Fetch at build time with a content loader, or per request with a live collection, which needs the Cloudflare adapter. Decide when building it. +- **[Decided, input for that release]** "We've got lots of events and I don't want to build it in a way that's one list of top to bottom." + - Dorell shared the Facebook past-events page on 2026-10-28. It holds about 50 events, from Jan 2019 to Oct 2026. + - **Series:** React Series: Zero to Hero 1–5 plus a hackathon (2019); six AI4Devs Workshops (Jun 2025); Automated Testing workshops (2024–2025). + - **Hacktoberfest Cebu stages, per edition:** kickoff or opening, online midpoint, deployment day, awarding. Editions ran 2020, 2021, 2023, 2024, 2025 and 2026. + - **Hosts besides JS Cebu:** React Cebu, Vue.js Cebu, DEVCON Cebu, enspace Cebu, PWA Pilipinas, Cebu Tech Communities. + - **Recurring venues:** DOST Cebu PSTO, enspace Cebu, LegalMatch, Zero-Ten Park. + - **Facebook lists some events twice.** + - The browse design (grouping by year, series or edition, filters, highlights) is decided in that release, not v1. ## 7. Going live From 96bf38d9415cf7d1dd3097e190864b323ed78005 Mon Sep 17 00:00:00 2001 From: Dorell James Date: Wed, 28 Oct 2026 08:56:08 +0800 Subject: [PATCH 04/12] feat(website): add content collections for the site projects (Cebby), organizers (the four leads from /members), links (the official channels) and pages (about, with the mission and vision carried over verbatim) are Markdown and JSON collections validated by zod schemas. A test checks the content matches the current site. --- apps/website/package.json | 3 +- apps/website/src/assets/projects/cebby.png | Bin 0 -> 28167 bytes apps/website/src/content.config.ts | 10 +++++ apps/website/src/content/links.json | 6 +++ apps/website/src/content/organizers.json | 6 +++ apps/website/src/content/pages/about.md | 27 +++++++++++++ apps/website/src/content/projects/cebby.md | 12 ++++++ apps/website/src/content/schemas.ts | 36 +++++++++++++++++ apps/website/test/content.test.ts | 44 +++++++++++++++++++++ pnpm-lock.yaml | 3 ++ 10 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 apps/website/src/assets/projects/cebby.png create mode 100644 apps/website/src/content.config.ts create mode 100644 apps/website/src/content/links.json create mode 100644 apps/website/src/content/organizers.json create mode 100644 apps/website/src/content/pages/about.md create mode 100644 apps/website/src/content/projects/cebby.md create mode 100644 apps/website/src/content/schemas.ts create mode 100644 apps/website/test/content.test.ts diff --git a/apps/website/package.json b/apps/website/package.json index c87a454..e052f09 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -14,7 +14,8 @@ "linkedom": "~0.18.14", "tsx": "^4.23.15", "vitest": "^5.0.5", - "wrangler": "^4.153.0" + "wrangler": "^4.153.0", + "yaml": "^2.9.1" }, "dependencies": { "@fontsource-variable/archivo": "^5.3.0", diff --git a/apps/website/src/assets/projects/cebby.png b/apps/website/src/assets/projects/cebby.png new file mode 100644 index 0000000000000000000000000000000000000000..3dcbb74851ca9707fde0611ef2c03d390877e838 GIT binary patch literal 28167 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 literal 0 HcmV?d00001 diff --git a/apps/website/src/content.config.ts b/apps/website/src/content.config.ts new file mode 100644 index 0000000..3801b7c --- /dev/null +++ b/apps/website/src/content.config.ts @@ -0,0 +1,10 @@ +import { defineCollection } from 'astro:content'; +import { file, glob } from 'astro/loaders'; +import { linkSchema, organizerSchema, pageSchema, projectSchema } from './content/schemas'; + +export const collections = { + projects: defineCollection({ loader: glob({ pattern: '**/*.md', base: './src/content/projects' }), schema: projectSchema }), + organizers: defineCollection({ loader: file('src/content/organizers.json'), schema: organizerSchema }), + links: defineCollection({ loader: file('src/content/links.json'), schema: linkSchema }), + pages: defineCollection({ loader: glob({ pattern: '**/*.md', base: './src/content/pages' }), schema: pageSchema }), +}; diff --git a/apps/website/src/content/links.json b/apps/website/src/content/links.json new file mode 100644 index 0000000..21f8a6a --- /dev/null +++ b/apps/website/src/content/links.json @@ -0,0 +1,6 @@ +[ + { "id": "facebook", "label": "Facebook", "url": "https://www.facebook.com/JavaScriptCebu/", "handle": "JavaScriptCebu", "order": 1 }, + { "id": "x", "label": "X (Twitter)", "url": "https://twitter.com/javascriptcebu", "handle": "@JavaScriptCebu", "order": 2 }, + { "id": "github", "label": "GitHub", "url": "https://github.com/javascriptcebu/", "handle": "javascriptcebu", "order": 3 }, + { "id": "email", "label": "Email", "url": "mailto:hello@jscebu.org", "handle": "hello@jscebu.org", "order": 4 } +] diff --git a/apps/website/src/content/organizers.json b/apps/website/src/content/organizers.json new file mode 100644 index 0000000..0b76a49 --- /dev/null +++ b/apps/website/src/content/organizers.json @@ -0,0 +1,6 @@ +[ + { "id": "cyrus-hiyas", "name": "Cyrus Hiyas", "role": "Lead", "community": "Angular Cebu", "order": 1 }, + { "id": "dorell-james", "name": "Dorell James", "role": "Lead", "community": "React Cebu", "order": 2 }, + { "id": "harlequin-doyon", "name": "Harlequin Doyon", "role": "Lead", "community": "Vue.js Cebu", "order": 3 }, + { "id": "sieg-balona", "name": "Sieg Balona", "role": "Co-Lead", "community": "React Cebu", "order": 4 } +] diff --git a/apps/website/src/content/pages/about.md b/apps/website/src/content/pages/about.md new file mode 100644 index 0000000..09685f2 --- /dev/null +++ b/apps/website/src/content/pages/about.md @@ -0,0 +1,27 @@ +--- +title: About JS Cebu +description: JavaScript Cebu's mission, vision, history and organizers. +--- + +## Our mission + +JavaScript Cebu aims to empower and unite developers by providing a collaborative platform for learning, sharing, and innovation in JavaScript and related technologies. We strive to enhance the technological landscape of Cebu through community-driven events such as meetups, codecamps, and other educational gatherings. + +## Our vision + +To be the leading catalyst for technological advancement and community collaboration in Cebu, fostering a vibrant ecosystem where developers can grow, innovate, and contribute to the global tech scene. + +## How we measure success + +We use the following metrics time to time to see how we're doing: + +- **Growth in Membership:** Increasing the number of active participants and registered members attending our events. +- **Event Frequency and Diversity:** Successfully hosting a variety of events regularly, including meetups, codecamps, and special tech talks. +- **Community Engagement:** High levels of engagement in community discussions, projects, and collaborations as evidenced by feedback, social media interactions, and community-driven projects. +- **Partnerships and Sponsorships:** Establishing strong partnerships with local and international tech companies and securing sponsorships that support the sustainability and expansion of our events. +- **Success Stories:** Documenting and showcasing success stories of members who have advanced in their careers or launched successful projects as a result of our community support. +- **Positive Remarks:** We always aim to be the type of organization for the people and by the people. + +## History + +JavaScript Cebu's GitHub organization was created in December 2018, and the community launched in 2019, sprouting from Laravel Cebu and Vue.js Cebu. For years it was home to three sub-communities, Angular Cebu, React Cebu and Vue.js Cebu, which ran their own meetups and workshops under the JS Cebu banner. Today JS Cebu runs as one community for everyone building with JavaScript in Cebu, and it co-presents Hacktoberfest Cebu every October. diff --git a/apps/website/src/content/projects/cebby.md b/apps/website/src/content/projects/cebby.md new file mode 100644 index 0000000..49f4abc --- /dev/null +++ b/apps/website/src/content/projects/cebby.md @@ -0,0 +1,12 @@ +--- +name: Cebby +url: https://www.getcebby.com +repo: https://github.com/getcebby/cebby +summary: Cebby is a public discovery layer for Cebu tech events. +image: ../../assets/projects/cebby.png +status: active +featured: true +order: 1 +--- + +Cebby gathers Cebu tech events from Luma, Eventbrite, Meetup and Facebook into one calm, glanceable feed, so you can see what's happening this week without following every page. It's built by the community and open source. diff --git a/apps/website/src/content/schemas.ts b/apps/website/src/content/schemas.ts new file mode 100644 index 0000000..30a3a83 --- /dev/null +++ b/apps/website/src/content/schemas.ts @@ -0,0 +1,36 @@ +import { z } from 'astro/zod'; +import type { SchemaContext } from 'astro:content'; + +/** Takes Astro's schema context so `image()` resolves local images; tests pass a stand-in. */ +export const projectSchema = ({ image }: Pick) => + z.object({ + name: z.string(), + url: z.string().url(), + repo: z.string().url().optional(), + summary: z.string(), + image: image(), + status: z.enum(['active', 'archived']), + featured: z.boolean(), + order: z.number(), + }); + +export const organizerSchema = z.object({ + id: z.string(), + name: z.string(), + role: z.string(), + community: z.string().optional(), + order: z.number(), +}); + +export const linkSchema = z.object({ + id: z.string(), + label: z.string(), + url: z.string(), + handle: z.string().optional(), + order: z.number(), +}); + +export const pageSchema = z.object({ + title: z.string(), + description: z.string(), +}); diff --git a/apps/website/test/content.test.ts b/apps/website/test/content.test.ts new file mode 100644 index 0000000..d08b4f4 --- /dev/null +++ b/apps/website/test/content.test.ts @@ -0,0 +1,44 @@ +import { readFileSync } from 'node:fs'; +import { z } from 'astro/zod'; +import { parse } from 'yaml'; +import { describe, expect, it } from 'vitest'; +import { linkSchema, organizerSchema, pageSchema, projectSchema } from '../src/content/schemas.ts'; + +const content = new URL('../src/content/', import.meta.url); +const read = (path: string) => readFileSync(new URL(path, content), 'utf8'); +const frontmatter = (md: string) => parse(md.match(/^---\n([\s\S]*?)\n---/)![1]); + +describe('site content', () => { + it('lists the four organizers from /members, in order', () => { + const organizers = z.array(organizerSchema).parse(JSON.parse(read('organizers.json'))); + expect(organizers.map((o) => [o.name, o.role])).toEqual([ + ['Cyrus Hiyas', 'Lead'], + ['Dorell James', 'Lead'], + ['Harlequin Doyon', 'Lead'], + ['Sieg Balona', 'Co-Lead'], + ]); + }); + + it('lists the official channels from /official-links', () => { + const links = z.array(linkSchema).parse(JSON.parse(read('links.json'))); + expect(links.map((l) => l.url)).toEqual( + expect.arrayContaining([ + 'https://www.facebook.com/JavaScriptCebu/', + 'https://twitter.com/javascriptcebu', + 'https://github.com/javascriptcebu/', + 'mailto:hello@jscebu.org', + ]), + ); + }); + + it('features Cebby as the only project', () => { + const cebby = projectSchema({ image: () => z.string() }).parse(frontmatter(read('projects/cebby.md'))); + expect(cebby).toMatchObject({ name: 'Cebby', url: 'https://www.getcebby.com', featured: true, status: 'active' }); + }); + + it('keeps the mission verbatim on the about page', () => { + const about = read('pages/about.md'); + pageSchema.parse(frontmatter(about)); + expect(about).toContain('JavaScript Cebu aims to empower and unite developers'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eefa212..e56631f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,9 @@ importers: wrangler: specifier: ^4.153.0 version: 4.153.0(@types/node@26.6.5) + yaml: + specifier: ^2.9.1 + version: 2.9.1 packages/brand: dependencies: From 6608e3ec576a0e69422a30a7900a838e3b286199 Mon Sep 17 00:00:00 2001 From: Dorell James Date: Wed, 28 Oct 2026 08:58:17 +0800 Subject: [PATCH 05/12] feat(website): add site layout and about, links, projects A shared Site layout gives every page the same nav (lockup on wide screens, the mark alone below 480px per the 120px lockup minimum) and footer. /about renders the mission, vision, history and organizers from the collections, /links the official channels, /projects the project cards (Cebby). /brand and the 404 page now use the shared layout; /brand keeps its section links as a second nav row. --- apps/website/src/components/ProjectCard.astro | 76 ++++++++++++++ apps/website/src/components/Section.astro | 2 +- apps/website/src/components/SiteFooter.astro | 60 +++++++++++ apps/website/src/components/SiteNav.astro | 90 +++++++++++++++++ apps/website/src/layouts/Site.astro | 59 +++++++++++ apps/website/src/pages/404.astro | 25 ++--- apps/website/src/pages/about.astro | 99 +++++++++++++++++++ apps/website/src/pages/brand/index.astro | 99 ++++++------------- apps/website/src/pages/links.astro | 69 +++++++++++++ apps/website/src/pages/projects.astro | 30 ++++++ 10 files changed, 520 insertions(+), 89 deletions(-) create mode 100644 apps/website/src/components/ProjectCard.astro create mode 100644 apps/website/src/components/SiteFooter.astro create mode 100644 apps/website/src/components/SiteNav.astro create mode 100644 apps/website/src/layouts/Site.astro create mode 100644 apps/website/src/pages/about.astro create mode 100644 apps/website/src/pages/links.astro create mode 100644 apps/website/src/pages/projects.astro diff --git a/apps/website/src/components/ProjectCard.astro b/apps/website/src/components/ProjectCard.astro new file mode 100644 index 0000000..eeda270 --- /dev/null +++ b/apps/website/src/components/ProjectCard.astro @@ -0,0 +1,76 @@ +--- +import { render, type CollectionEntry } from 'astro:content'; + +interface Props { + entry: CollectionEntry<'projects'>; + /** Show the longer description from the Markdown body, not just the summary. */ + full?: boolean; +} + +const { entry, full = false } = Astro.props; +const { name, url, repo, summary, image, status } = entry.data; +const { Content } = await render(entry); +const host = new URL(url).hostname.replace(/^www\./, ''); +--- + +
+ +
+
+

{name}

+ {status === 'archived' && archived} +
+

{summary}

+ {full &&
} +

+ {host} ↗ + {repo && source ↗} +

+
+
+ + diff --git a/apps/website/src/components/Section.astro b/apps/website/src/components/Section.astro index 97cb915..06c40c5 100644 --- a/apps/website/src/components/Section.astro +++ b/apps/website/src/components/Section.astro @@ -21,7 +21,7 @@ const { id, number, title, tag } = Astro.props; .section { padding: 56px 0; border-top: 1px solid var(--jc-line); - scroll-margin-top: 72px; + scroll-margin-top: 112px; } .head { display: flex; diff --git a/apps/website/src/components/SiteFooter.astro b/apps/website/src/components/SiteFooter.astro new file mode 100644 index 0000000..2307bae --- /dev/null +++ b/apps/website/src/components/SiteFooter.astro @@ -0,0 +1,60 @@ +--- +import { brand } from '@jscebu/brand'; + +const links = [ + ['/projects', 'Projects'], + ['/about', 'About'], + ['/links', 'Links'], + ['/brand/', 'Brand'], + ['/brand.md', 'brand.md'], + ['/llms.txt', 'llms.txt'], + ['https://github.com/javascriptcebu/jscebu.org', 'GitHub'], +]; +const mark = brand.assets.find((a) => a.id === 'mark-color')!.path; +--- + +
+
+ JavaScript Cebu +
    + {links.map(([href, label]) =>
  • {label}
  • )} +
+ +
+
+ + diff --git a/apps/website/src/components/SiteNav.astro b/apps/website/src/components/SiteNav.astro new file mode 100644 index 0000000..280858b --- /dev/null +++ b/apps/website/src/components/SiteNav.astro @@ -0,0 +1,90 @@ +--- +import { brand } from '@jscebu/brand'; + +interface Props { + current?: 'home' | 'projects' | 'about' | 'links' | 'brand'; +} + +const { current } = Astro.props; +const items = [ + { href: '/projects', label: 'Projects', key: 'projects' }, + { href: '/about', label: 'About', key: 'about' }, + { href: '/brand/', label: 'Brand', key: 'brand' }, +] as const; +const lockup = brand.assets.find((a) => a.id === 'lockup-dark')!.path; +const mark = brand.assets.find((a) => a.id === 'mark-color')!.path; +--- + + + + diff --git a/apps/website/src/layouts/Site.astro b/apps/website/src/layouts/Site.astro new file mode 100644 index 0000000..1114cad --- /dev/null +++ b/apps/website/src/layouts/Site.astro @@ -0,0 +1,59 @@ +--- +import SiteFooter from '../components/SiteFooter.astro'; +import SiteNav from '../components/SiteNav.astro'; +import Terminal from './Terminal.astro'; + +interface Props { + title: string; + description: string; + current?: 'home' | 'projects' | 'about' | 'links' | 'brand'; +} + +const { title, description, current } = Astro.props; +--- + + + +
+ +
+ +
+ + diff --git a/apps/website/src/pages/404.astro b/apps/website/src/pages/404.astro index 7847826..c933297 100644 --- a/apps/website/src/pages/404.astro +++ b/apps/website/src/pages/404.astro @@ -1,27 +1,18 @@ --- import Cursor from '../components/Cursor.astro'; -import Terminal from '../layouts/Terminal.astro'; +import Site from '../layouts/Site.astro'; --- - -
+ +

// 404

-

That page doesn't exist. The brand guidelines are at /brand.

-
-
+

That page doesn't exist. Try the home page or the brand guidelines.

+ + diff --git a/apps/website/src/pages/about.astro b/apps/website/src/pages/about.astro new file mode 100644 index 0000000..f8317d6 --- /dev/null +++ b/apps/website/src/pages/about.astro @@ -0,0 +1,99 @@ +--- +import { getCollection, getEntry, render } from 'astro:content'; +import Cursor from '../components/Cursor.astro'; +import Site from '../layouts/Site.astro'; + +const page = (await getEntry('pages', 'about'))!; +const { Content } = await render(page); +const organizers = (await getCollection('organizers')).sort((a, b) => a.data.order - b.data.order); +--- + + +
+
+

// about

+

+
+
+ +
+
+

Organizers

+
    + { + organizers.map(({ data }) => ( +
  • + {data.name} + + {data.role} + {data.community && ` · ${data.community}`} + +
  • + )) + } +
+
+
+
+ + diff --git a/apps/website/src/pages/brand/index.astro b/apps/website/src/pages/brand/index.astro index 0da0615..402ddf6 100644 --- a/apps/website/src/pages/brand/index.astro +++ b/apps/website/src/pages/brand/index.astro @@ -7,7 +7,7 @@ import RuleList from '../../components/RuleList.astro'; import Section from '../../components/Section.astro'; import Sticker from '../../components/Sticker.astro'; import Swatch from '../../components/Swatch.astro'; -import Terminal from '../../layouts/Terminal.astro'; +import Site from '../../layouts/Site.astro'; import { asset, fontVar } from '../../lib/view'; const agentUrl = `${brand.site}/brand.md`; @@ -36,18 +36,17 @@ const nav = [ const sample = 'Aa Bb Cc 0123456789'; --- - - + -
+

// brand · v{brand.version}

@@ -216,13 +215,8 @@ const sample = 'Aa Bb Cc 0123456789'; )) }
-
- - -
+ + diff --git a/apps/website/src/pages/links.astro b/apps/website/src/pages/links.astro new file mode 100644 index 0000000..528638a --- /dev/null +++ b/apps/website/src/pages/links.astro @@ -0,0 +1,69 @@ +--- +import { getCollection } from 'astro:content'; +import Cursor from '../components/Cursor.astro'; +import Site from '../layouts/Site.astro'; + +const links = (await getCollection('links')).sort((a, b) => a.data.order - b.data.order); +--- + + +
+
+

// official links

+

+
+ +
+
+ + diff --git a/apps/website/src/pages/projects.astro b/apps/website/src/pages/projects.astro new file mode 100644 index 0000000..11f3f94 --- /dev/null +++ b/apps/website/src/pages/projects.astro @@ -0,0 +1,30 @@ +--- +import { getCollection } from 'astro:content'; +import Cursor from '../components/Cursor.astro'; +import ProjectCard from '../components/ProjectCard.astro'; +import Site from '../layouts/Site.astro'; + +const projects = (await getCollection('projects')).sort((a, b) => a.data.order - b.data.order); +--- + + +
+
+

// projects

+

+

Things the JS Cebu community builds and runs, in the open.

+
+
+ {projects.map((entry) => )} +
+
+
+ + From 82ed425a5728107b76a19b1e8beda7dc03981e46 Mon Sep 17 00:00:00 2001 From: Dorell James Date: Wed, 28 Oct 2026 08:59:32 +0800 Subject: [PATCH 06/12] feat(website): add the landing page The home page carries the hero with the blinking cursor and sticker, what JS Cebu does (meetups, workshops, Hacktoberfest Cebu), upcoming events linking to Cebby and Facebook until the Cebby endpoint exists, Cebby as the featured project, the real community photo, and the official channels. Every page now has a canonical URL and Open Graph tags with the community photo, so shared jscebu.org links get a proper preview card. --- apps/website/src/layouts/Terminal.astro | 10 + apps/website/src/pages/index.astro | 235 ++++++++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 apps/website/src/pages/index.astro diff --git a/apps/website/src/layouts/Terminal.astro b/apps/website/src/layouts/Terminal.astro index 9a11aaa..4396198 100644 --- a/apps/website/src/layouts/Terminal.astro +++ b/apps/website/src/layouts/Terminal.astro @@ -11,6 +11,8 @@ interface Props { } const { title, description } = Astro.props; +const canonical = new URL(Astro.url.pathname, Astro.site); +const shareImage = new URL(brand.assets.find((a) => a.id === 'photo')!.path, Astro.site); --- @@ -20,6 +22,14 @@ const { title, description } = Astro.props; {title} + + + + + + + + diff --git a/apps/website/src/pages/index.astro b/apps/website/src/pages/index.astro new file mode 100644 index 0000000..01fd830 --- /dev/null +++ b/apps/website/src/pages/index.astro @@ -0,0 +1,235 @@ +--- +import { brand } from '@jscebu/brand'; +import { getCollection } from 'astro:content'; +import Cursor from '../components/Cursor.astro'; +import ProjectCard from '../components/ProjectCard.astro'; +import Sticker from '../components/Sticker.astro'; +import Site from '../layouts/Site.astro'; + +const featured = (await getCollection('projects', ({ data }) => data.featured)).sort((a, b) => a.data.order - b.data.order); +const links = (await getCollection('links')).sort((a, b) => a.data.order - b.data.order); +const photo = brand.assets.find((a) => a.id === 'photo')!.path; + +const work = [ + { title: 'Meetups', text: 'Talks, demos and lightning talks from developers building in Cebu.' }, + { title: 'Workshops', text: 'Hands-on sessions, from automated testing to building with AI.' }, + { title: 'Hacktoberfest Cebu', text: 'A month of open source every October, with partner communities.', href: 'https://hacktoberfest.jscebu.org' }, +]; +--- + + +
+
+
+

// cebu's javascript community

+

+

Meetups, workshops and Hacktoberfest, run by developers in Cebu.

+ +
+ +
+ +
+

What we do

+ +
+ +
+

Upcoming events

+

Our next meetups and workshops, along with every other Cebu tech event, are on Cebby.

+ +
+ +
+

Featured project

+
+ {featured.map((entry) => )} +
+ All projects → +
+ +
+
+ The JS Cebu community at a meetup +
The JS Cebu community at a meetup
+
+
+ +
+

Join us

+

Come to a meetup, follow along online, or say hello.

+ +
+
+
+ + From c97d522cc4a7d56feae73c12d28a045423e0a534 Mon Sep 17 00:00:00 2001 From: Dorell James Date: Wed, 28 Oct 2026 09:01:25 +0800 Subject: [PATCH 07/12] feat(website): redirect old URLs and check every page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit public/_redirects sends every Notion-era URL to its new home (spec §5), including the three that were soft 404s; /events is a temporary 302 to the landing page's events section. Adds /sitemap.xml and /robots.txt. check-dist now checks the cursor and every internal link on all built pages, brand.md and llms.txt, and that each on-site redirect target exists. A link to a folder without its trailing slash counts as missing, because Workers would answer it with a redirect; links, redirect targets, brand.md and llms.txt now use /brand/, /about/ and so on. The 404 page is noindex with no canonical URL. --- apps/website/public/_redirects | 13 +++++ apps/website/scripts/check-dist.ts | 54 ++++++++++++++----- apps/website/src/components/SiteFooter.astro | 6 +-- apps/website/src/components/SiteNav.astro | 4 +- apps/website/src/layouts/Site.astro | 5 +- apps/website/src/layouts/Terminal.astro | 8 +-- apps/website/src/pages/404.astro | 2 +- apps/website/src/pages/index.astro | 2 +- apps/website/src/pages/robots.txt.ts | 6 +++ apps/website/src/pages/sitemap.xml.ts | 10 ++++ apps/website/test/check-dist.test.ts | 8 ++- apps/website/test/redirects.test.ts | 30 +++++++++++ .../specs/2026-10-28-jscebu-site-v1-design.md | 16 +++--- packages/brand/src/generate/brand-md.ts | 2 +- packages/brand/src/generate/llms-txt.ts | 2 +- packages/brand/test/generate.test.ts | 9 ++++ 16 files changed, 140 insertions(+), 37 deletions(-) create mode 100644 apps/website/public/_redirects create mode 100644 apps/website/src/pages/robots.txt.ts create mode 100644 apps/website/src/pages/sitemap.xml.ts create mode 100644 apps/website/test/redirects.test.ts diff --git a/apps/website/public/_redirects b/apps/website/public/_redirects new file mode 100644 index 0000000..5ebd4d4 --- /dev/null +++ b/apps/website/public/_redirects @@ -0,0 +1,13 @@ +# Old Notion-era jscebu.org URLs (spec: docs/superpowers/specs/2026-10-28-jscebu-site-v1-design.md §5). +# Targets end in / because folders are served at /name/; a bare /name would redirect twice. +/mission-vision /about/ 301 +/members /about/#organizers 301 +/iniatives /about/ 301 +/initiatives /about/ 301 +/official-links /links/ 301 +/community-projects /projects/ 301 +# Temporary: /events becomes a real page once events come from Cebby. +/events /#events 302 +/promotional-materials /brand/ 301 +/annual-hacktoberfest https://hacktoberfest.jscebu.org 301 +/volunteers-training-workshop /about/ 301 diff --git a/apps/website/scripts/check-dist.ts b/apps/website/scripts/check-dist.ts index 91e6ca4..497fb70 100644 --- a/apps/website/scripts/check-dist.ts +++ b/apps/website/scripts/check-dist.ts @@ -1,7 +1,8 @@ /** * Post-build checks over dist/ and src/. Run after `astro build`; exits 1 with a list on any failure. * - The cursor never wraps alone (and HTML compression didn't eat the space before its word). - * - Every /brand link on the page and in brand.md resolves to a built file. + * - Every link to this site, on every page and in brand.md and llms.txt, resolves to a built file. + * - Every redirect in _redirects that points at this site lands on a built file. * - Every rule appears on both the page and brand.md ("one source, two readers"). * - No color or font-family literals in src/: everything comes from --jc-* variables. */ @@ -12,8 +13,6 @@ import { brand, lintHtml, type Brand } from '@jscebu/brand'; import { parseHTML } from 'linkedom'; const SITE = 'https://www.jscebu.org'; -/** Paths this app serves; everything else still belongs to the legacy site. */ -const OURS = /^\/(?:brand(?:[/.]|$)|llms\.txt$|_astro\/)/; export function findCursorViolations(html: string): string[] { const out = lintHtml(html) @@ -38,8 +37,7 @@ export function findMissingLinks(text: string, fileExists: (path: string) => boo const paths = urls .map((u) => (u.startsWith(SITE) ? u.slice(SITE.length) || '/' : u)) .filter((u) => u.startsWith('/') && !u.startsWith('//')) - .map((u) => u.replace(/[?#].*$/, '')) - .filter((p) => OURS.test(p)); + .map((u) => u.replace(/[?#].*$/, '')); return [...new Set(paths)].filter((p) => !fileExists(p)); } @@ -81,6 +79,18 @@ export function parityRules(b: Brand): string[] { ]; } +/** Parse a Cloudflare `_redirects` file: `source destination [status]`, status defaulting to 302. */ +export function parseRedirects(text: string): { from: string; to: string; status: number }[] { + return text + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')) + .map((line) => { + const [from, to, status] = line.split(/\s+/); + return { from, to, status: status ? Number(status) : 302 }; + }); +} + function walk(dir: string): string[] { return readdirSync(dir).flatMap((name) => { const p = join(dir, name); @@ -92,23 +102,39 @@ function main(): void { const app = fileURLToPath(new URL('..', import.meta.url)); const dist = join(app, 'dist'); const read = (p: string) => readFileSync(join(dist, p), 'utf8'); + /** A URL path is built when it names a file, or ends in / over a folder with index.html. A bare folder path would cost a redirect. */ const exists = (urlPath: string) => { const p = join(dist, decodeURIComponent(urlPath)); - return urlPath.endsWith('/') ? existsSync(join(p, 'index.html')) : existsSync(p) || existsSync(join(p, 'index.html')); + if (urlPath.endsWith('/')) return existsSync(join(p, 'index.html')); + return existsSync(p) && statSync(p).isFile(); }; - const required = ['brand/index.html', 'brand.md', 'brand/tokens.css', 'llms.txt', '404.html', 'brand/logo/sticker.svg', 'brand/templates/event-post.html']; - const html = read('brand/index.html'); + const required = [ + 'index.html', 'projects/index.html', 'about/index.html', 'links/index.html', '404.html', + 'brand/index.html', 'brand.md', 'brand/tokens.css', 'llms.txt', 'sitemap.xml', 'robots.txt', '_redirects', + 'brand/logo/sticker.svg', 'brand/templates/event-post.html', + ]; + const pages = walk(dist).filter((p) => p.endsWith('.html') && !p.startsWith(join(dist, 'brand', 'templates'))).map((p) => relative(dist, p)); + const brandHtml = read('brand/index.html'); const md = read('brand.md'); - const { document } = parseHTML(html); + const redirects = existsSync(join(dist, '_redirects')) ? parseRedirects(read('_redirects')) : []; const src = walk(join(app, 'src')).map((p) => ({ path: relative(app, p), text: readFileSync(p, 'utf8') })); const errors = [ ...required.filter((f) => !existsSync(join(dist, f))).map((f) => `dist/${f} was not built`), - ...findCursorViolations(html).map((m) => `/brand cursor: ${m}`), - ...findMissingLinks(html, exists).map((p) => `/brand links to ${p}, which was not built`), + ...pages.flatMap((page) => { + const html = read(page); + return [ + ...findCursorViolations(html).map((m) => `${page} cursor: ${m}`), + ...findMissingLinks(html, exists).map((p) => `${page} links to ${p}, which was not built`), + ]; + }), ...findMissingLinks(md, exists).map((p) => `brand.md links to ${p}, which was not built`), - ...findParityGaps(document.body.textContent ?? '', md, parityRules(brand)), + ...findMissingLinks(read('llms.txt'), exists).map((p) => `llms.txt links to ${p}, which was not built`), + ...redirects + .filter((r) => r.to.startsWith('/') && !exists(r.to.replace(/#.*$/, ''))) + .map((r) => `_redirects sends ${r.from} to ${r.to}, which was not built`), + ...findParityGaps(parseHTML(brandHtml).document.body.textContent ?? '', md, parityRules(brand)), ...findLiterals(src), ]; @@ -116,7 +142,9 @@ function main(): void { console.error(`check-dist: ${errors.length} problem(s)\n${errors.map((e) => ` - ${e}`).join('\n')}`); process.exit(1); } - console.log(`check-dist: ok (${required.length} files, cursor, links, parity, ${src.length} source files free of literals)`); + console.log( + `check-dist: ok (${required.length} files, ${pages.length} pages: cursor and links; ${redirects.length} redirects; /brand parity; ${src.length} source files free of literals)`, + ); } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) main(); diff --git a/apps/website/src/components/SiteFooter.astro b/apps/website/src/components/SiteFooter.astro index 2307bae..60b134c 100644 --- a/apps/website/src/components/SiteFooter.astro +++ b/apps/website/src/components/SiteFooter.astro @@ -2,9 +2,9 @@ import { brand } from '@jscebu/brand'; const links = [ - ['/projects', 'Projects'], - ['/about', 'About'], - ['/links', 'Links'], + ['/projects/', 'Projects'], + ['/about/', 'About'], + ['/links/', 'Links'], ['/brand/', 'Brand'], ['/brand.md', 'brand.md'], ['/llms.txt', 'llms.txt'], diff --git a/apps/website/src/components/SiteNav.astro b/apps/website/src/components/SiteNav.astro index 280858b..40e38e9 100644 --- a/apps/website/src/components/SiteNav.astro +++ b/apps/website/src/components/SiteNav.astro @@ -7,8 +7,8 @@ interface Props { const { current } = Astro.props; const items = [ - { href: '/projects', label: 'Projects', key: 'projects' }, - { href: '/about', label: 'About', key: 'about' }, + { href: '/projects/', label: 'Projects', key: 'projects' }, + { href: '/about/', label: 'About', key: 'about' }, { href: '/brand/', label: 'Brand', key: 'brand' }, ] as const; const lockup = brand.assets.find((a) => a.id === 'lockup-dark')!.path; diff --git a/apps/website/src/layouts/Site.astro b/apps/website/src/layouts/Site.astro index 1114cad..bab7d1b 100644 --- a/apps/website/src/layouts/Site.astro +++ b/apps/website/src/layouts/Site.astro @@ -7,12 +7,13 @@ interface Props { title: string; description: string; current?: 'home' | 'projects' | 'about' | 'links' | 'brand'; + index?: boolean; } -const { title, description, current } = Astro.props; +const { title, description, current, index } = Astro.props; --- - +
diff --git a/apps/website/src/layouts/Terminal.astro b/apps/website/src/layouts/Terminal.astro index 4396198..d333b68 100644 --- a/apps/website/src/layouts/Terminal.astro +++ b/apps/website/src/layouts/Terminal.astro @@ -8,9 +8,11 @@ import { brand, tokensCss } from '@jscebu/brand'; interface Props { title: string; description: string; + /** False for pages search engines shouldn't list, like the 404 page. */ + index?: boolean; } -const { title, description } = Astro.props; +const { title, description, index = true } = Astro.props; const canonical = new URL(Astro.url.pathname, Astro.site); const shareImage = new URL(brand.assets.find((a) => a.id === 'photo')!.path, Astro.site); --- @@ -22,12 +24,12 @@ const shareImage = new URL(brand.assets.find((a) => a.id === 'photo')!.path, Ast {title} - + {index ? : } - + {index && } diff --git a/apps/website/src/pages/404.astro b/apps/website/src/pages/404.astro index c933297..6c19187 100644 --- a/apps/website/src/pages/404.astro +++ b/apps/website/src/pages/404.astro @@ -3,7 +3,7 @@ import Cursor from '../components/Cursor.astro'; import Site from '../layouts/Site.astro'; --- - +

// 404

diff --git a/apps/website/src/pages/index.astro b/apps/website/src/pages/index.astro index 01fd830..5051580 100644 --- a/apps/website/src/pages/index.astro +++ b/apps/website/src/pages/index.astro @@ -73,7 +73,7 @@ const work = [
{featured.map((entry) => )}
- All projects → + All projects →
diff --git a/apps/website/src/pages/robots.txt.ts b/apps/website/src/pages/robots.txt.ts new file mode 100644 index 0000000..6c12151 --- /dev/null +++ b/apps/website/src/pages/robots.txt.ts @@ -0,0 +1,6 @@ +import type { APIRoute } from 'astro'; + +export const GET: APIRoute = ({ site }) => + new Response(`User-agent: *\nAllow: /\n\nSitemap: ${new URL('/sitemap.xml', site)}\n`, { + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + }); diff --git a/apps/website/src/pages/sitemap.xml.ts b/apps/website/src/pages/sitemap.xml.ts new file mode 100644 index 0000000..bc01f7c --- /dev/null +++ b/apps/website/src/pages/sitemap.xml.ts @@ -0,0 +1,10 @@ +import type { APIRoute } from 'astro'; + +const pages = ['/', '/projects/', '/about/', '/links/', '/brand/']; + +export const GET: APIRoute = ({ site }) => { + const urls = pages.map((p) => ` ${new URL(p, site)}`).join('\n'); + return new Response(`\n\n${urls}\n\n`, { + headers: { 'Content-Type': 'application/xml; charset=utf-8' }, + }); +}; diff --git a/apps/website/test/check-dist.test.ts b/apps/website/test/check-dist.test.ts index 04d51e5..21e470a 100644 --- a/apps/website/test/check-dist.test.ts +++ b/apps/website/test/check-dist.test.ts @@ -26,8 +26,12 @@ describe('findMissingLinks', () => { expect(findMissingLinks('See https://www.jscebu.org/brand/logo/missing.svg.', exists)).toEqual(['/brand/logo/missing.svg']); }); - it('ignores pages the legacy site still serves and other origins', () => { - expect(findMissingLinks('xy', () => false)).toEqual([]); + it('checks every path on this site, so a link to a redirect source counts as missing', () => { + expect(findMissingLinks('xy', (p) => p === '/about/')).toEqual(['/events']); + }); + + it('ignores other origins, mailto links and in-page anchors', () => { + expect(findMissingLinks('ymj', () => false)).toEqual([]); }); }); diff --git a/apps/website/test/redirects.test.ts b/apps/website/test/redirects.test.ts new file mode 100644 index 0000000..7a8ec3c --- /dev/null +++ b/apps/website/test/redirects.test.ts @@ -0,0 +1,30 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { parseRedirects } from '../scripts/check-dist.ts'; + +describe('parseRedirects', () => { + it('skips comments and blank lines and defaults the status to 302', () => { + expect(parseRedirects('# old pages\n\n/a /b\n/c https://x.example 301\n')).toEqual([ + { from: '/a', to: '/b', status: 302 }, + { from: '/c', to: 'https://x.example', status: 301 }, + ]); + }); +}); + +describe('public/_redirects', () => { + it('sends every old jscebu.org URL where the v1 spec says (§5)', () => { + const rules = parseRedirects(readFileSync(new URL('../public/_redirects', import.meta.url), 'utf8')); + expect(rules).toEqual([ + { from: '/mission-vision', to: '/about/', status: 301 }, + { from: '/members', to: '/about/#organizers', status: 301 }, + { from: '/iniatives', to: '/about/', status: 301 }, + { from: '/initiatives', to: '/about/', status: 301 }, + { from: '/official-links', to: '/links/', status: 301 }, + { from: '/community-projects', to: '/projects/', status: 301 }, + { from: '/events', to: '/#events', status: 302 }, + { from: '/promotional-materials', to: '/brand/', status: 301 }, + { from: '/annual-hacktoberfest', to: 'https://hacktoberfest.jscebu.org', status: 301 }, + { from: '/volunteers-training-workshop', to: '/about/', status: 301 }, + ]); + }); +}); diff --git a/docs/superpowers/specs/2026-10-28-jscebu-site-v1-design.md b/docs/superpowers/specs/2026-10-28-jscebu-site-v1-design.md index f0bc0af..e94a4cc 100644 --- a/docs/superpowers/specs/2026-10-28-jscebu-site-v1-design.md +++ b/docs/superpowers/specs/2026-10-28-jscebu-site-v1-design.md @@ -74,19 +74,19 @@ All pages use the **Terminal** personality and share one nav and footer. ## 5. Old URLs -Redirects ship as a `_redirects` file served by Workers static assets. **[Builder]** Confirm the file format against the current Cloudflare docs. +Redirects ship as a `_redirects` file served by Workers static assets. Targets end in `/` because Workers serves `about/index.html` at `/about/` and 307s `/about` to it; a slash-less target would redirect twice. **[Builder]** Confirm the file format against the current Cloudflare docs. | Old URL (today) | v1 | Status | |---|---|---| -| `/mission-vision` | `/about` | 301 | -| `/members` | `/about#organizers` | 301 | -| `/iniatives` (sic), `/initiatives` | `/about` | 301 | -| `/official-links` | `/links` | 301 | -| `/community-projects` (a 2023 Hacktoberfest repo list) | `/projects` | 301 | +| `/mission-vision` | `/about/` | 301 | +| `/members` | `/about/#organizers` | 301 | +| `/iniatives` (sic), `/initiatives` | `/about/` | 301 | +| `/official-links` | `/links/` | 301 | +| `/community-projects` (a 2023 Hacktoberfest repo list) | `/projects/` | 301 | | `/events` | `/#events` | **302**, because `/events` becomes a real page later | -| `/promotional-materials` (Notion "page not found" today) | `/brand` | 301 | +| `/promotional-materials` (Notion "page not found" today) | `/brand/` | 301 | | `/annual-hacktoberfest` (Notion "page not found" today) | `https://hacktoberfest.jscebu.org` | 301 | -| `/volunteers-training-workshop` (Notion "page not found" today) | `/about` | 301 | +| `/volunteers-training-workshop` (Notion "page not found" today) | `/about/` | 301 | | `/feed` (500 today) | 404 | None. v1 has no feed. | | `/sitemap.xml` (500 today) | the new sitemap | | | anything else | the branded 404 page | | diff --git a/packages/brand/src/generate/brand-md.ts b/packages/brand/src/generate/brand-md.ts index 0604acc..593e544 100644 --- a/packages/brand/src/generate/brand-md.ts +++ b/packages/brand/src/generate/brand-md.ts @@ -29,7 +29,7 @@ export function brandMd(b: Brand): string { sections.push(`# ${b.name} brand · v${b.version} -> Rules for anyone, person or AI agent, making something for ${b.name}: event graphics, slides, pages, recaps. Follow the MUST checklist exactly and check your work against it before sharing. The same rules, with pictures, are at ${url('/brand')}. +> Rules for anyone, person or AI agent, making something for ${b.name}: event graphics, slides, pages, recaps. Follow the MUST checklist exactly and check your work against it before sharing. The same rules, with pictures, are at ${url('/brand/')}. Start here: diff --git a/packages/brand/src/generate/llms-txt.ts b/packages/brand/src/generate/llms-txt.ts index b8a4a12..828dbe2 100644 --- a/packages/brand/src/generate/llms-txt.ts +++ b/packages/brand/src/generate/llms-txt.ts @@ -9,7 +9,7 @@ export function llmsTxt(b: Brand): string { ## Brand - [Brand rules for agents](${b.site}/brand.md): the MUST checklist, exact tokens, a recipe per personality, voice and asset URLs. Read this before making anything for ${b.name}. -- [Brand guidelines](${b.site}/brand): the same rules for people, with logo downloads. +- [Brand guidelines](${b.site}/brand/): the same rules for people, with logo downloads. - [tokens.css](${b.site}/brand/tokens.css): CSS custom properties and the cursor, sticker and highlighter classes. `; } diff --git a/packages/brand/test/generate.test.ts b/packages/brand/test/generate.test.ts index c8a57a7..572a4b6 100644 --- a/packages/brand/test/generate.test.ts +++ b/packages/brand/test/generate.test.ts @@ -73,6 +73,15 @@ describe('brandMd', () => { }); }); +describe('links to the /brand page', () => { + it('use the trailing slash the site serves it at, so agents skip a redirect', () => { + for (const text of [brandMd(brand), llmsTxt(brand)]) { + expect(text).toContain('https://www.jscebu.org/brand/'); + expect(text).not.toMatch(/https:\/\/www\.jscebu\.org\/brand(?![/.])/); + } + }); +}); + describe('llmsTxt', () => { it('points agents to brand.md', () => { const txt = llmsTxt(brand); From 08bc05f7cb6acca5be43ac406086561b31ac85cd Mon Sep 17 00:00:00 2001 From: Dorell James Date: Wed, 28 Oct 2026 09:02:18 +0800 Subject: [PATCH 08/12] feat(website): add a URL matrix for previews and the swap scripts/url-matrix.sh checks a base URL against the v1 spec: every old Notion-era URL redirects with the right status and target, the pages return 200, brand.md keeps text/markdown with CORS, and unknown paths return 404. It passes against wrangler dev. The Hacktoberfest redirect target is written as https://hacktoberfest.jscebu.org/, the form Workers sends. --- apps/website/public/_redirects | 2 +- apps/website/scripts/url-matrix.sh | 60 +++++++++++++++++++ apps/website/test/redirects.test.ts | 2 +- .../specs/2026-10-28-jscebu-site-v1-design.md | 2 +- 4 files changed, 63 insertions(+), 3 deletions(-) create mode 100755 apps/website/scripts/url-matrix.sh diff --git a/apps/website/public/_redirects b/apps/website/public/_redirects index 5ebd4d4..c3013be 100644 --- a/apps/website/public/_redirects +++ b/apps/website/public/_redirects @@ -9,5 +9,5 @@ # Temporary: /events becomes a real page once events come from Cebby. /events /#events 302 /promotional-materials /brand/ 301 -/annual-hacktoberfest https://hacktoberfest.jscebu.org 301 +/annual-hacktoberfest https://hacktoberfest.jscebu.org/ 301 /volunteers-training-workshop /about/ 301 diff --git a/apps/website/scripts/url-matrix.sh b/apps/website/scripts/url-matrix.sh new file mode 100755 index 0000000..21f7e09 --- /dev/null +++ b/apps/website/scripts/url-matrix.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Check a deployed (or `wrangler dev`) jscebu.org against the v1 spec: old Notion-era URLs +# redirect per §5, pages return 200, brand.md keeps its agent-facing headers, misses 404. +# Usage: scripts/url-matrix.sh https://www.jscebu.org +set -uo pipefail + +base=${1:?usage: url-matrix.sh BASE_URL} +base=${base%/} +host=$(printf '%s' "$base" | sed -E 's#^https?://([^/]+).*#\1#') +fail=0 + +# fetch PATH → sets status, location (host-relative when on the same host) and headers +fetch() { + headers=$(curl -s -o /dev/null -D - --max-time 30 "$base$1" | tr -d '\r') + status=$(printf '%s\n' "$headers" | head -1 | awk '{print $2}') + location=$(printf '%s\n' "$headers" | grep -i '^location:' | head -1 | sed -E 's/^[Ll]ocation: *//; s#^https?://'"$host"'##') +} + +report() { # ok? label detail + if [ "$1" = ok ]; then printf 'ok %s\n' "$2"; else printf 'FAIL %s (%s)\n' "$2" "$3"; fail=1; fi +} + +redirect() { # path target status + fetch "$1" + if [ "$status" = "$3" ] && [ "$location" = "$2" ]; then report ok "$1 → $3 $2" + else report fail "$1 → $3 $2" "got $status ${location:-no location}"; fi +} + +page() { # path [content-type prefix] + fetch "$1" + local type + type=$(printf '%s\n' "$headers" | grep -i '^content-type:' | head -1 | sed -E 's/^[Cc]ontent-[Tt]ype: *//') + if [ "$status" = 200 ] && [[ "$type" == ${2:-}* ]]; then report ok "$1 200 ${type}" + else report fail "$1 200 ${2:-}" "got $status $type"; fi +} + +redirect /mission-vision /about/ 301 +redirect /members /about/#organizers 301 +redirect /iniatives /about/ 301 +redirect /initiatives /about/ 301 +redirect /official-links /links/ 301 +redirect /community-projects /projects/ 301 +redirect /events /#events 302 +redirect /promotional-materials /brand/ 301 +redirect /annual-hacktoberfest https://hacktoberfest.jscebu.org/ 301 +redirect /volunteers-training-workshop /about/ 301 + +for p in / /projects/ /about/ /links/ /brand/; do page "$p" text/html; done +page /brand.md 'text/markdown; charset=utf-8' +if printf '%s\n' "$headers" | grep -qi '^access-control-allow-origin: \*'; then report ok '/brand.md CORS *'; else report fail '/brand.md CORS *' 'header missing'; fi +page /llms.txt 'text/plain; charset=utf-8' +page /brand/tokens.css text/css +page /brand/logo/sticker.svg image/svg+xml +page /sitemap.xml application/xml +page /robots.txt text/plain + +fetch /this-page-does-not-exist +if [ "$status" = 404 ]; then report ok '/this-page-does-not-exist 404'; else report fail '/this-page-does-not-exist 404' "got $status"; fi + +exit $fail diff --git a/apps/website/test/redirects.test.ts b/apps/website/test/redirects.test.ts index 7a8ec3c..27b0f49 100644 --- a/apps/website/test/redirects.test.ts +++ b/apps/website/test/redirects.test.ts @@ -23,7 +23,7 @@ describe('public/_redirects', () => { { from: '/community-projects', to: '/projects/', status: 301 }, { from: '/events', to: '/#events', status: 302 }, { from: '/promotional-materials', to: '/brand/', status: 301 }, - { from: '/annual-hacktoberfest', to: 'https://hacktoberfest.jscebu.org', status: 301 }, + { from: '/annual-hacktoberfest', to: 'https://hacktoberfest.jscebu.org/', status: 301 }, { from: '/volunteers-training-workshop', to: '/about/', status: 301 }, ]); }); diff --git a/docs/superpowers/specs/2026-10-28-jscebu-site-v1-design.md b/docs/superpowers/specs/2026-10-28-jscebu-site-v1-design.md index e94a4cc..89721a1 100644 --- a/docs/superpowers/specs/2026-10-28-jscebu-site-v1-design.md +++ b/docs/superpowers/specs/2026-10-28-jscebu-site-v1-design.md @@ -85,7 +85,7 @@ Redirects ship as a `_redirects` file served by Workers static assets. Targets e | `/community-projects` (a 2023 Hacktoberfest repo list) | `/projects/` | 301 | | `/events` | `/#events` | **302**, because `/events` becomes a real page later | | `/promotional-materials` (Notion "page not found" today) | `/brand/` | 301 | -| `/annual-hacktoberfest` (Notion "page not found" today) | `https://hacktoberfest.jscebu.org` | 301 | +| `/annual-hacktoberfest` (Notion "page not found" today) | `https://hacktoberfest.jscebu.org/` | 301 | | `/volunteers-training-workshop` (Notion "page not found" today) | `/about/` | 301 | | `/feed` (500 today) | 404 | None. v1 has no feed. | | `/sitemap.xml` (500 today) | the new sitemap | | From 51b0522ad98d4b0e0d740e3158812263bc87b899 Mon Sep 17 00:00:00 2001 From: Dorell James Date: Wed, 28 Oct 2026 09:22:19 +0800 Subject: [PATCH 09/12] feat(website): add a little motion Headline words rise in like terminal output with the cursor already waiting at the end, the sticker gets slapped on as the page loads and peels a little on hover, sections and cards fade up as they scroll in, and buttons and link cards respond to hover and press. Everything is transform and opacity with decelerating easing, switched off entirely under prefers-reduced-motion, and nothing needs JavaScript to be visible: only the reveal script adds the class that hides content until it scrolls in. Adds .impeccable.md, the design context (audience, use cases, personality, motion) for design work on this repo, distilled from the approved specs. --- .impeccable.md | 39 ++++++++++ apps/website/src/components/Cursor.astro | 28 ++++++-- apps/website/src/components/SiteNav.astro | 1 + apps/website/src/components/Sticker.astro | 43 ++++++++++-- apps/website/src/layouts/Site.astro | 86 +++++++++++++++++++++++ apps/website/src/pages/404.astro | 2 +- apps/website/src/pages/about.astro | 6 +- apps/website/src/pages/brand/index.astro | 4 +- apps/website/src/pages/index.astro | 32 ++++++--- apps/website/src/pages/links.astro | 8 +-- apps/website/src/pages/projects.astro | 4 +- 11 files changed, 221 insertions(+), 32 deletions(-) create mode 100644 .impeccable.md diff --git a/.impeccable.md b/.impeccable.md new file mode 100644 index 0000000..5f08573 --- /dev/null +++ b/.impeccable.md @@ -0,0 +1,39 @@ +# Design Context: jscebu.org + +Distilled from the approved specs: +- `docs/superpowers/specs/2026-10-27-jscebu-design-system-design.md` +- `docs/superpowers/specs/2026-10-28-jscebu-site-v1-design.md` + +The brand rules themselves live in `packages/brand` and at https://www.jscebu.org/brand.md. This file covers who the site is for and how it should feel. + +## Audience + +- **Developers in Cebu:** students, juniors and working devs who come to meetups, workshops and Hacktoberfest Cebu. +- **Volunteers, co-leads and partner communities:** they make things for JS Cebu. +- **AI agents:** they make on-brand graphics and pages, given only the brand link. + +## Use cases + +- Find the next event. For now that means Cebby and Facebook. +- See that the community is real and active: real photos, real projects. +- Find where to join. +- Get the logos and brand rules. + +## Personality + +- **The website uses the Terminal personality:** ink background, a faint grid, yellow light. +- **The two signatures carry recognition:** the block cursor at the end of the primary headline, and the logo as a tilted die-cut sticker. +- **Voice:** + - English first, friendly and direct, like a fellow developer. + - Bisaya only lightly, and only approved phrases. +- **Photos:** real and never filtered. +- **No hexagon motifs** beyond the logo itself. + +## Motion + +Added 2026-10-28, because Dorell said "a little bit of animations. right now, it's zero". + +- **A little, and purposeful.** Motion follows the brand's two signatures: text arriving like terminal output, and the sticker being slapped on. +- **Easing and timing:** decelerating curves (ease-out-expo or quart), never bounce. Entrances run 400–700ms; feedback runs 100–250ms. Animate only transform and opacity. +- **One hero moment per page,** plus quiet reveals as sections scroll in and small hover and press feedback on things you can click. +- **Under `prefers-reduced-motion: reduce`, nothing moves,** and the cursor doesn't blink. Content never depends on JavaScript to become visible. diff --git a/apps/website/src/components/Cursor.astro b/apps/website/src/components/Cursor.astro index b5a9f0e..7dc00e4 100644 --- a/apps/website/src/components/Cursor.astro +++ b/apps/website/src/components/Cursor.astro @@ -2,17 +2,33 @@ /** * A headline's text with the brand cursor after the last word. The last word and * the cursor share a .jc-nw wrapper so the cursor can never wrap onto its own line. + * With `animate`, each word rises in turn (styles in Site.astro); the cursor is there from the start. */ interface Props { text: string; blink?: boolean; + animate?: boolean; } -const { text, blink = false } = Astro.props; -const trimmed = text.trim(); -const split = trimmed.lastIndexOf(' '); -const rest = split === -1 ? '' : trimmed.slice(0, split + 1); -const last = trimmed.slice(split + 1); +const { text, blink = false, animate = false } = Astro.props; +const words = text.trim().split(/\s+/); +const last = words.pop()!; +const cursorClass = ['jc-cursor', { 'jc-cursor--blink': blink }]; --- -{rest}{last} +{ + animate ? ( + <> + {words.map((word, i) => ( + <> + {word}{' '} + + ))} + {last} + + ) : ( + <> + {words.length ? `${words.join(' ')} ` : ''}{last} + + ) +} diff --git a/apps/website/src/components/SiteNav.astro b/apps/website/src/components/SiteNav.astro index 40e38e9..7e241de 100644 --- a/apps/website/src/components/SiteNav.astro +++ b/apps/website/src/components/SiteNav.astro @@ -82,6 +82,7 @@ const mark = brand.assets.find((a) => a.id === 'mark-color')!.path; .links a { color: var(--jc-muted); text-decoration: none; + transition: color 150ms; } .links a:hover, .links a[aria-current='page'] { diff --git a/apps/website/src/components/Sticker.astro b/apps/website/src/components/Sticker.astro index 99e7c2a..255646c 100644 --- a/apps/website/src/components/Sticker.astro +++ b/apps/website/src/components/Sticker.astro @@ -1,23 +1,58 @@ --- -/** The logo as a tilted die-cut sticker. The brand allows 6–10° either way. */ +/** The logo as a tilted die-cut sticker. The brand allows 6–10° either way. `slap` sticks it on as the page loads. */ interface Props { size: string; tilt?: number; alt?: string; class?: string; + slap?: boolean; + /** When the slap starts, so it can land after the headline. */ + delay?: number; } -const { size, tilt = -8, alt = '', class: className } = Astro.props; +const { size, tilt = -8, alt = '', class: className, slap = false, delay = 600 } = Astro.props; if (Math.abs(tilt) < 6 || Math.abs(tilt) > 10) { throw new Error(`Sticker tilt must be 6–10° either way, got ${tilt}`); } --- {alt} + + diff --git a/apps/website/src/layouts/Site.astro b/apps/website/src/layouts/Site.astro index bab7d1b..d0de708 100644 --- a/apps/website/src/layouts/Site.astro +++ b/apps/website/src/layouts/Site.astro @@ -21,7 +21,93 @@ const { title, description, current, index } = Astro.props; + +