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
3 changes: 3 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Custom Domains affect DNS on deploy. A lead reviews this surface and its ownership.
/apps/website/wrangler.jsonc @dorelljames
/.github/CODEOWNERS @dorelljames
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7.0.1
# The pnpm version comes from "packageManager" in package.json.
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: pnpm/action-setup@v6.1.0
- uses: actions/setup-node@v7.1.0
with:
node-version: 24
cache: pnpm
Expand Down
6 changes: 5 additions & 1 deletion apps/website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,15 @@
"dev": "pnpm sync && astro dev",
"build": "pnpm sync && astro build && tsx scripts/check-dist.ts",
"preview": "astro preview",
"test": "vitest run"
"test": "pnpm check && vitest run",
"check": "astro sync && astro check && tsc --noEmit"
},
"devDependencies": {
"@astrojs/check": "^0.9.12",
"jsonc-parser": "^3.3.1",
"linkedom": "~0.18.14",
"tsx": "^4.23.15",
"typescript": "^6.0.3",
"vitest": "^5.0.5",
"wrangler": "^4.153.0",
"yaml": "^2.9.1"
Expand Down
2 changes: 2 additions & 0 deletions apps/website/public/_headers
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@
Access-Control-Allow-Origin: *
/brand/*
Access-Control-Allow-Origin: *
/brand/tokens.css
Content-Type: text/css; charset=utf-8
10 changes: 10 additions & 0 deletions apps/website/public/_redirects
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
# 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
/mission-vision/ /about/ 301
/members /about/#organizers 301
/members/ /about/#organizers 301
/iniatives /about/ 301
/iniatives/ /about/ 301
/initiatives /about/ 301
/initiatives/ /about/ 301
/official-links /links/ 301
/official-links/ /links/ 301
/community-projects /projects/ 301
/community-projects/ /projects/ 301
# Temporary: /events becomes a real page once events come from Cebby.
/events /#events 302
/events/ /#events 302
/promotional-materials /brand/ 301
/promotional-materials/ /brand/ 301
/annual-hacktoberfest https://hacktoberfest.jscebu.org/ 301
/annual-hacktoberfest/ https://hacktoberfest.jscebu.org/ 301
/volunteers-training-workshop /about/ 301
/volunteers-training-workshop/ /about/ 301
85 changes: 69 additions & 16 deletions apps/website/scripts/check-dist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { brand, lintHtml, type Brand } from '@jscebu/brand';
import { parseHTML } from 'linkedom';
import { brand, lintHtml, findColorLiteral, type Brand } from '@jscebu/brand';
import { DOMParser, parseHTML } from 'linkedom';

const SITE = 'https://www.jscebu.org';

Expand All @@ -35,12 +35,67 @@ export function findMissingLinks(text: string, fileExists: (path: string) => boo
...[...text.matchAll(/https:\/\/www\.jscebu\.org\/[^\s"'<>)\]|`]*/g)].map((m) => m[0].replace(/[.,;:]+$/, '')),
];
const paths = urls
.map((u) => (u.startsWith(SITE) ? u.slice(SITE.length) || '/' : u))
.map((u) => {
if (!u.startsWith('http')) return u;
try { const url = new URL(u); return url.origin === SITE ? url.pathname + url.search + url.hash : ''; }
catch { return ''; }
})
.filter((u) => u.startsWith('/') && !u.startsWith('//'))
.map((u) => u.replace(/[?#].*$/, ''));
return [...new Set(paths)].filter((p) => !fileExists(p));
}

/** Resolve canonical public URLs to physical files, without accepting redirect hops. */
export function resolveBuiltPath(urlPath: string, fileExists: (path: string) => boolean): string | undefined {
let path: string;
try { path = decodeURIComponent(urlPath); } catch { return undefined; }
if (!path.startsWith('/') || path.startsWith('//') || /[\\?#\0]/.test(path) || path.split('/').some((part) => part === '.' || part === '..')) return undefined;
if (/\.html?$/i.test(path)) return undefined;
const file = path.endsWith('/') ? `${path.slice(1)}index.html` : path.slice(1);
if (fileExists(file)) return file;
if (!path.endsWith('/') && !path.split('/').pop()!.includes('.') && fileExists(`${file}.html`)) return `${file}.html`;
return undefined;
}

export function findRedirectProblems(redirects: { from: string; to: string; status: number }[], readUrl: (path: string) => string | undefined): string[] {
return redirects.flatMap(({ from, to }) => {
const error = (reason: string) => [`_redirects sends ${from} to ${to}: ${reason}`];
let url: URL;
try { url = new URL(to, SITE); } catch { return error('invalid URL'); }
if (url.origin !== SITE) return [];
const html = readUrl(url.pathname);
if (html === undefined) return error('destination was not built at a canonical URL');
if (!url.hash) return [];
let id: string;
try { id = decodeURIComponent(url.hash.slice(1)); } catch { return error('malformed fragment encoding'); }
return parseHTML(html).document.getElementById(id) ? [] : error(`missing fragment #${id}`);
});
}

export function findSitemapProblems(xml: string, fileExists: (path: string) => boolean): string[] {
const doc = new DOMParser().parseFromString(xml, 'text/xml');
const entries = [...doc.querySelectorAll('url > loc')];
if (!entries.length) return ['sitemap.xml has no page URLs'];
return entries.flatMap((el) => {
const text = el.textContent?.trim() ?? '';
try {
const url = new URL(text);
if (url.origin === SITE && !url.search && !url.hash && fileExists(url.pathname)) return [];
} catch { /* Report invalid and noncanonical entries alike. */ }
return [`sitemap.xml links to ${text}, which is not a canonical built URL on ${SITE}`];
});
}

export function findTemplateLinkProblems(html: string): string[] {
const { document } = parseHTML(html);
return ['event-post', 'fb-cover'].flatMap((name) => {
const path = `/brand/templates/${name}`;
const link = document.querySelector(`#downloads a[href="${path}"]`);
if (!link) return [`/brand/ downloads is missing ${path}`];
return link.hasAttribute('download') ? [`/brand/ ${path} must open without a download attribute`] : [];
});
}

const normalize = (s: string) => s.replaceAll('`', '').replace(/\s+/g, ' ').trim();

export function findParityGaps(pageText: string, md: string, rules: string[]): string[] {
Expand All @@ -55,15 +110,13 @@ export function findParityGaps(pageText: string, md: string, rules: string[]): s
});
}

const HEX = /(?<!url\(\s*['"]?)#(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3,4})(?![\w-])/i;
const COLOR_FN = /\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch)\(/i;
const FONT_FAMILY = /font-family\s*:(?!\s*(?:var\(|(?:inherit|initial|unset)\b))\s*[^;}"]+/i;

export function findLiterals(srcFiles: { path: string; text: string }[]): string[] {
return srcFiles.flatMap(({ path, text }) =>
text.split('\n').flatMap((line, i) => {
const hit = line.match(HEX) ?? line.match(COLOR_FN) ?? line.match(FONT_FAMILY);
return hit ? [`${path}:${i + 1}: "${hit[0].trim()}" (use a --jc-* variable)`] : [];
const hit = findColorLiteral(line) ?? line.match(FONT_FAMILY)?.[0];
return hit ? [`${path}:${i + 1}: "${hit.trim()}" (use a --jc-* variable)`] : [];
}),
);
}
Expand Down Expand Up @@ -105,17 +158,17 @@ 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));
if (urlPath.endsWith('/')) return existsSync(join(p, 'index.html'));
return existsSync(p) && statSync(p).isFile();
const resolve = (path: string) => resolveBuiltPath(path, (p) => existsSync(join(dist, p)) && statSync(join(dist, p)).isFile());
const exists = (path: string) => resolve(path) !== undefined;
const readUrl = (path: string) => {
const file = resolve(path);
return file === undefined ? undefined : read(file);
};

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',
'brand/logo/sticker.svg', 'brand/templates/event-post.html', 'brand/templates/fb-cover.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');
Expand All @@ -136,9 +189,9 @@ function main(): void {
}),
...findMissingLinks(md, exists).map((p) => `brand.md links to ${p}, which was not built`),
...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`),
...findRedirectProblems(redirects, readUrl),
...findSitemapProblems(read('sitemap.xml'), exists),
...findTemplateLinkProblems(brandHtml),
...findParityGaps(parseHTML(brandHtml).document.body.textContent ?? '', md, parityRules(brand)),
...findLiterals(src),
];
Expand Down
17 changes: 17 additions & 0 deletions apps/website/scripts/deployment-scope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { deepStrictEqual } from 'node:assert/strict';

/** A testable guard against expanding the Custom Domain/DNS surface. */
export function assertDeploymentScope(config: unknown): void {
if (!config || typeof config !== 'object' || !('routes' in config)) throw new Error('Missing routes');
deepStrictEqual(config.routes, [{ pattern: 'www.jscebu.org', custom_domain: true }]);
const visit = (value: unknown, path: string): void => {
if (!value || typeof value !== 'object') return;
for (const [key, child] of Object.entries(value)) {
if ((key === 'route' || key === 'routes') && !(path === '' && key === 'routes')) {
throw new Error(`Unexpected deployment override: ${path}${key}`);
}
visit(child, `${path}${key}.`);
}
};
visit(config, '');
}
17 changes: 15 additions & 2 deletions apps/website/scripts/url-matrix.sh
Original file line number Diff line number Diff line change
Expand Up @@ -30,26 +30,39 @@ 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}"
if [ "$status" = 200 ] && [ -z "$location" ] && [[ "$type" == ${2:-}* ]]; then report ok "$1 200 ${type}"
else report fail "$1 200 ${2:-}" "got $status $type"; fi
}

redirect /mission-vision /about/ 301
redirect /mission-vision/ /about/ 301
redirect /members /about/#organizers 301
redirect /members/ /about/#organizers 301
redirect /iniatives /about/ 301
redirect /iniatives/ /about/ 301
redirect /initiatives /about/ 301
redirect /initiatives/ /about/ 301
redirect /official-links /links/ 301
redirect /official-links/ /links/ 301
redirect /community-projects /projects/ 301
redirect /community-projects/ /projects/ 301
redirect /events /#events 302
redirect /events/ /#events 302
redirect /promotional-materials /brand/ 301
redirect /promotional-materials/ /brand/ 301
redirect /annual-hacktoberfest https://hacktoberfest.jscebu.org/ 301
redirect /annual-hacktoberfest/ https://hacktoberfest.jscebu.org/ 301
redirect /volunteers-training-workshop /about/ 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/tokens.css 'text/css; charset=utf-8'
if printf '%s\n' "$headers" | grep -qi '^access-control-allow-origin: \*'; then report ok '/brand/tokens.css CORS *'; else report fail '/brand/tokens.css CORS *' 'header missing'; fi
page /brand/templates/event-post text/html
page /brand/templates/fb-cover text/html
page /brand/logo/sticker.svg image/svg+xml
page /sitemap.xml application/xml
page /robots.txt text/plain
Expand Down
4 changes: 2 additions & 2 deletions apps/website/src/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ 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 }),
projects: defineCollection({ loader: glob({ pattern: '**/*.md', base: './src/content/projects' }), schema: (context) => projectSchema(context) }),
organizers: defineCollection({ loader: file('src/content/organizers.json'), schema: (context) => organizerSchema(context) }),
links: defineCollection({ loader: file('src/content/links.json'), schema: linkSchema }),
pages: defineCollection({ loader: glob({ pattern: '**/*.md', base: './src/content/pages' }), schema: pageSchema }),
};
5 changes: 2 additions & 3 deletions apps/website/src/content/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
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<SchemaContext, 'image'>) =>
export const projectSchema = <T extends z.ZodType>({ image }: { image: () => T }) =>
z.object({
name: z.string(),
url: z.string().url(),
Expand All @@ -14,7 +13,7 @@ export const projectSchema = ({ image }: Pick<SchemaContext, 'image'>) =>
order: z.number(),
});

export const organizerSchema = ({ image }: Pick<SchemaContext, 'image'>) =>
export const organizerSchema = <T extends z.ZodType>({ image }: { image: () => T }) =>
z.object({
id: z.string(),
name: z.string(),
Expand Down
2 changes: 1 addition & 1 deletion apps/website/src/pages/brand/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ const sample = 'Aa Bb Cc 0123456789';
{
brand.assets.map((a) => (
<li>
<a class="jc-mono" href={a.path} download={a.path.endsWith('.html') ? undefined : ''}>↓ {a.path.replace('/brand/', '')}</a>
<a class="jc-mono" href={a.path} download={a.path.startsWith('/brand/templates/') ? undefined : ''}>↓ {a.path.replace('/brand/', '')}</a>
<span class="label">{a.label}</span>
<span class="use">{a.use}</span>
</li>
Expand Down
45 changes: 44 additions & 1 deletion apps/website/test/check-dist.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { brand } from '@jscebu/brand';
import { findCursorViolations, findLiterals, findMissingLinks, findParityGaps, isSourceText, parityRules } from '../scripts/check-dist.ts';
import { resolveBuiltPath, findRedirectProblems, findSitemapProblems, findTemplateLinkProblems, findCursorViolations, findLiterals, findMissingLinks, findParityGaps, isSourceText, parityRules } from '../scripts/check-dist.ts';

const cursor = '<span class="jc-cursor" aria-hidden="true"></span>';

Expand Down Expand Up @@ -83,3 +83,46 @@ describe('isSourceText', () => {
expect(['g.jpg', 'h.png', 'i.webp', 'j.svg', 'k.woff2'].map(isSourceText)).toEqual([false, false, false, false, false]);
});
});

describe('canonical built URLs', () => {
const files = new Set(['index.html', 'about/index.html', 'brand/templates/event-post.html', 'brand/tokens.css']);
it.each([
['/', 'index.html'], ['/about/', 'about/index.html'],
['/brand/templates/event-post', 'brand/templates/event-post.html'], ['/brand/tokens.css', 'brand/tokens.css'],
['/brand/templates/missing', undefined], ['/about', undefined], ['/about/index.html', undefined],
['/brand/templates/event-post.html', undefined], ['/%ZZ', undefined], ['/../index.html', undefined],
])('resolves %s without canonicalization hops', (path, expected) => {
expect(resolveBuiltPath(path, (p) => files.has(p))).toBe(expected);
});
});

describe('redirect destinations', () => {
const read = (path: string) => path === '/about/' ? '<html><body><h2 id="organizers">Team</h2><h2 id="café">Café</h2></body></html>' : undefined;
it.each(['/about/#organizers', '/about/#caf%C3%A9', 'https://www.jscebu.org/about/#organizers', 'https://elsewhere.example/#missing'])('accepts %s', (to) => {
expect(findRedirectProblems([{ from: '/members', to, status: 301 }], read)).toEqual([]);
});
it.each(['/about/#missing', '/missing/#organizers', '/about/#%ZZ'])('reports source and bad destination %s', (to) => {
const errors = findRedirectProblems([{ from: '/members', to, status: 301 }], read);
expect(errors).toHaveLength(1);
expect(errors[0]).toContain('/members');
expect(errors[0]).toContain(to);
});
});

describe('sitemap destinations', () => {
const exists = (path: string) => path === '/about/';
const xml = (url: string) => `<?xml version="1.0"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>${url}</loc></url></urlset>`;
it('accepts a canonical same-site page', () => {
expect(findSitemapProblems(xml('https://www.jscebu.org/about/'), exists)).toEqual([]);
});
it.each(['https://www.jscebu.org/missing/', 'https://www.jscebu.org/members', 'https://www.jscebu.org/about', 'https://elsewhere.example/about/'])('rejects %s', (url) => {
expect(findSitemapProblems(xml(url), exists)).toHaveLength(1);
});
});

describe('brand template navigation', () => {
const html = (attr = '') => `<html><body><section id="downloads"><a href="/brand/templates/event-post" ${attr}>Post</a><a href="/brand/templates/fb-cover">Cover</a></section></body></html>`;
it('accepts canonical template links without download', () => expect(findTemplateLinkProblems(html())).toEqual([]));
it('rejects even an empty download attribute', () => expect(findTemplateLinkProblems(html('download=""'))).toHaveLength(1));
it('requires both links in downloads', () => expect(findTemplateLinkProblems('<html></html>')).toHaveLength(2));
});
14 changes: 6 additions & 8 deletions apps/website/test/content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,12 @@ describe('site content', () => {

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',
]),
);
expect(links.map((l) => l.url)).toEqual([
'https://www.facebook.com/JavaScriptCebu/',
'https://twitter.com/javascriptcebu',
'https://github.com/javascriptcebu/',
'mailto:hello@jscebu.org',
]);
});

it('features Cebby as the only project', () => {
Expand Down
Loading
Loading