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
12 changes: 12 additions & 0 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ try {
/** @type {string[]} */
const SKIP_PATTERNS = ['/write', '/search'];

// Paginated listing pages (/blog/2, /topics/x/2, /tags/x/2, /authors/x/articles/2)
// are secondary — keep them below their first page and below real articles.
/**
* @param {string} path
* @returns {boolean}
*/
function isPaginatedListing(path) {
return /\/\d+$/.test(path);
}

/**
* @param {string} path
* @returns {number}
Expand All @@ -64,6 +74,7 @@ function priorityFor(path) {
if (path === '/blog' || path === '/topics') return 0.9;
if (path === '/playground' || path === '/community' || path === '/contribute' || path === '/why')
return 0.8;
if (isPaginatedListing(path)) return 0.4;
if (path.startsWith('/blog/')) return 0.7;
if (path.startsWith('/topics/') || path.startsWith('/tags/')) return 0.6;
if (path.startsWith('/authors/')) return 0.6;
Expand All @@ -76,6 +87,7 @@ function priorityFor(path) {
*/
function changefreqFor(path) {
if (path === '/' || path === '/blog' || path === '/topics') return 'daily';
if (isPaginatedListing(path)) return 'weekly';
if (path.startsWith('/blog/') || path.startsWith('/authors/')) return 'monthly';
return 'weekly';
}
Expand Down
105 changes: 89 additions & 16 deletions functions/api/create-pr.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
// Cloudflare Pages Function: opens a pull request on the repo directly via github app

interface KVStore {
get(key: string): Promise<string | null>;
put(key: string, value: string, opts?: { expirationTtl?: number }): Promise<void>;
}

type Env = {
GH_APP_ID?: string;
GH_APP_INSTALLATION_ID?: string;
GH_APP_PRIVATE_KEY?: string;
GH_REPO?: string;
ALLOWED_ORIGIN?: string;
// Optional KV namespace; when bound, submissions are throttled per IP.
RATE_LIMIT?: KVStore;
};

type PostFile = { path: string; content: string; encoding: 'utf-8' | 'base64' };
Expand All @@ -15,6 +22,30 @@ const DEFAULT_ORIGIN = 'https://mlsystems.dev';
const CONTACT_EMAIL = 'admin@mlsystems.dev';
const UA = 'mlsystems-write';

const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const FILE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
const AUTHOR_PATH_RE = /^src\/content\/authors\/[a-z0-9]+(?:-[a-z0-9]+)*\.json$/;
const MAX_FILES = 40;
const MAX_FILE_CHARS = 8_000_000;
const MAX_TOTAL_CHARS = 24_000_000;
const MAX_SUBMISSIONS_PER_HOUR = 5;

// Every file must live in the post's own folder (flat, safe names) — except a
// single new-author profile. Anything else could overwrite arbitrary repo files.
function invalidPath(files: PostFile[], slug: string): string | null {
const postDir = `src/content/posts/${slug}/`;
let authorFiles = 0;
for (const f of files) {
if (AUTHOR_PATH_RE.test(f.path)) {
if (++authorFiles > 1) return f.path;
continue;
}
if (!f.path.startsWith(postDir)) return f.path;
if (!FILE_NAME_RE.test(f.path.slice(postDir.length))) return f.path;
}
return null;
}

function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
Expand Down Expand Up @@ -107,10 +138,6 @@ export async function onRequestPost(context: { request: Request; env: Env }): Pr
const origin = request.headers.get('origin');
if (origin && origin !== allowed) return json({ error: 'Forbidden origin.' }, 403);

if (!env.GH_APP_ID || !env.GH_APP_INSTALLATION_ID || !env.GH_APP_PRIVATE_KEY) {
return json({ error: 'Publishing is not configured on the server yet.' }, 500);
}

let payload: {
title?: string;
slug?: string;
Expand All @@ -124,32 +151,71 @@ export async function onRequestPost(context: { request: Request; env: Env }): Pr
return json({ error: 'Invalid request body.' }, 400);
}
const slug = (payload.slug ?? '').trim();
const title = (payload.title ?? '').trim() || slug;
const summary = (payload.summary ?? '').trim();
const title = (payload.title ?? '').trim().slice(0, 200) || slug;
const summary = (payload.summary ?? '').trim().slice(0, 500);
const files = payload.files ?? [];
const isEdit = payload.isEdit === true;
if (!slug || files.length === 0) return json({ error: 'Missing post data.' }, 400);
if (!SLUG_RE.test(slug) || slug.length > 80) {
return json({ error: 'Invalid URL slug.' }, 400);
}
if (files.length > MAX_FILES) return json({ error: 'Too many files in this post.' }, 400);
let totalChars = 0;
for (const f of files) {
if (typeof f.path !== 'string' || typeof f.content !== 'string') {
return json({ error: 'Invalid file entry.' }, 400);
}
totalChars += f.content.length;
if (f.content.length > MAX_FILE_CHARS || totalChars > MAX_TOTAL_CHARS) {
return json({ error: 'This post is too large to submit — reduce image sizes.' }, 413);
}
}
const badPath = invalidPath(files, slug);
if (badPath) return json({ error: `File path not allowed: ${badPath}` }, 400);

const appId = env.GH_APP_ID;
const installationId = env.GH_APP_INSTALLATION_ID;
const privateKey = env.GH_APP_PRIVATE_KEY;
if (!appId || !installationId || !privateKey) {
return json({ error: 'Publishing is not configured on the server yet.' }, 500);
}

if (env.RATE_LIMIT) {
const ip = request.headers.get('cf-connecting-ip') ?? 'unknown';
const key = `create-pr:${ip}`;
const count = Number((await env.RATE_LIMIT.get(key)) ?? '0');
if (count >= MAX_SUBMISSIONS_PER_HOUR) {
return json({ error: 'Too many submissions — please try again in an hour.' }, 429);
}
await env.RATE_LIMIT.put(key, String(count + 1), { expirationTtl: 3600 });
}

const [owner, name] = (env.GH_REPO || DEFAULT_REPO).split('/');

try {
const jwt = await appJwt(env.GH_APP_ID, env.GH_APP_PRIVATE_KEY);
const inst = (await gh(`/app/installations/${env.GH_APP_INSTALLATION_ID}/access_tokens`, jwt, {
const jwt = await appJwt(appId, privateKey);
const inst = (await gh(`/app/installations/${installationId}/access_tokens`, jwt, {
method: 'POST',
})) as { token: string };
const token = inst.token;

// A brand-new post must not silently overwrite an existing one at the same slug.
// Edits (loaded via the portal's "Open existing post") are meant to, so skip then.
if (
!isEdit &&
(await fileExistsOnMain(owner, name, `src/content/posts/${slug}/index.mdx`, token))
) {
// Edits (loaded via the portal's "Open existing post") are meant to. isEdit is
// client-supplied, so the server checks reality itself and labels updates loudly —
// maintainer review of the PR is the trust boundary for overwrites.
const postExists = await fileExistsOnMain(
owner,
name,
`src/content/posts/${slug}/index.mdx`,
token,
);
if (!isEdit && postExists) {
return json(
{ error: 'A post with this URL already exists. Change the URL slug and try again.' },
409,
);
}
const isUpdate = isEdit && postExists;

// A newly registered author must not overwrite an existing profile at the same handle.
const authorFile = files.find((f) => f.path.startsWith('src/content/authors/'));
Expand Down Expand Up @@ -188,7 +254,7 @@ export async function onRequestPost(context: { request: Request; env: Env }): Pr
const commit = (await gh(`/repos/${owner}/${name}/git/commits`, token, {
method: 'POST',
body: JSON.stringify({
message: `Add post: ${title}`,
message: `${isUpdate ? 'Update' : 'Add'} post: ${title}`,
tree: newTree.sha,
parents: [baseSha],
}),
Expand Down Expand Up @@ -265,15 +331,22 @@ export async function onRequestPost(context: { request: Request; env: Env }): Pr

const pr = (await gh(`/repos/${owner}/${name}/pulls`, token, {
method: 'POST',
body: JSON.stringify({ title: `New post: ${title}`, head: branch, base: 'main', body }),
body: JSON.stringify({
title: `${isUpdate ? 'Update post' : 'New post'}: ${title}`,
head: branch,
base: 'main',
body,
}),
})) as { html_url: string; number: number };

// Best-effort label for triage. Needs Issues: write on the App + the label to
// exist; ignore failures so a missing permission never blocks the submission.
try {
await gh(`/repos/${owner}/${name}/issues/${pr.number}/labels`, token, {
method: 'POST',
body: JSON.stringify({ labels: ['blog-submission'] }),
body: JSON.stringify({
labels: ['blog-submission', ...(isUpdate ? ['post-update'] : [])],
}),
});
} catch {
// labeling is optional
Expand Down
45 changes: 37 additions & 8 deletions src/components/HeroFigure.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
'use client';

import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo, useRef } from 'react';

function useAnimationFrame() {
function useAnimationFrame(active: boolean) {
const [t, setT] = useState(0);
const tRef = useRef(0);
useEffect(() => {
if (!active) return;
let raf: number;
const start = performance.now();
const start = performance.now() - tRef.current * 1000;
const tick = (now: number) => {
setT((now - start) / 1000);
tRef.current = (now - start) / 1000;
setT(tRef.current);
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, []);
}, [active]);
return t;
}

Expand Down Expand Up @@ -556,19 +559,45 @@ export default function HeroFigure() {
];

const [idx, setIdx] = useState(0);
const rootRef = useRef<HTMLDivElement>(null);
const [reducedMotion] = useState(
() =>
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
);
const [pageVisible, setPageVisible] = useState(true);
const [onScreen, setOnScreen] = useState(true);

useEffect(() => {
const onVisibility = () => setPageVisible(!document.hidden);
onVisibility();
document.addEventListener('visibilitychange', onVisibility);
return () => document.removeEventListener('visibilitychange', onVisibility);
}, []);

useEffect(() => {
const el = rootRef.current;
if (!el) return;
const observer = new IntersectionObserver(([entry]) => setOnScreen(entry.isIntersecting));
observer.observe(el);
return () => observer.disconnect();
}, []);

const active = !reducedMotion && pageVisible && onScreen;

useEffect(() => {
if (!active) return;
const cycleMs = 9000;
const interval = setInterval(() => setIdx((i) => (i + 1) % FIGS.length), cycleMs);
return () => clearInterval(interval);
}, [FIGS.length]);
}, [active, FIGS.length]);

const t = useAnimationFrame();
const t = useAnimationFrame(active);
const current = FIGS[idx];
const Comp = current.Comp;

return (
<div className="figure">
<div className="figure" ref={rootRef}>
<div className="figure-head">
<div className="figure-head-tabs">
{FIGS.map((f, i) => (
Expand Down
69 changes: 14 additions & 55 deletions src/components/SearchInline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,48 +2,17 @@

import { useEffect, useMemo, useRef, useState } from 'react';
import { loadPagefind, type PagefindResultData } from '@/lib/pagefind';
import {
GROUP_LABEL,
buildMeta,
classifyUrl,
sortRows,
topicMatches as matchTopics,
type SearchRow,
} from '@/lib/search';

type Topic = { id: string; name: string; desc: string };

type Group = 'topic' | 'article' | 'tool' | 'author' | 'page';

type RowItem = {
group: Group;
url: string;
title: string;
excerpt?: string;
meta?: string;
};

function classifyUrl(url: string): Group {
if (url.startsWith('/blog/')) return 'article';
if (url.startsWith('/playground/')) return 'tool';
if (url.startsWith('/authors/')) return 'author';
return 'page';
}

const GROUP_LABEL: Record<Group, string> = {
topic: 'Topics',
article: 'Articles',
tool: 'Tools',
author: 'Authors',
page: 'Pages',
};

function buildMeta(group: Group, meta: PagefindResultData['meta']): string {
const parts: string[] = [];
if (group === 'article') {
if (meta.topic) parts.push(meta.topic);
if (meta.read) parts.push(meta.read);
if (meta.authors) parts.push(meta.authors);
} else if (group === 'tool') {
parts.push('Tool');
} else if (group === 'author') {
parts.push('Contributor');
}
return parts.join(' · ');
}

export default function SearchInline({ topics }: { topics: Topic[] }) {
const [query, setQuery] = useState('');
const [pageResults, setPageResults] = useState<PagefindResultData[]>([]);
Expand All @@ -67,22 +36,14 @@ export default function SearchInline({ topics }: { topics: Topic[] }) {
window.history.replaceState({}, '', url.toString());
}, [query]);

const topicMatches = useMemo<RowItem[]>(() => {
const topicRows = useMemo<SearchRow[]>(() => {
const q = query.trim().toLowerCase();
if (!q) return [];
return topics
.filter((t) => t.name.toLowerCase().includes(q) || t.desc.toLowerCase().includes(q))
.slice(0, 3)
.map((t) => ({
group: 'topic' as const,
url: `/topics/${t.id}`,
title: t.name,
excerpt: t.desc,
}));
return matchTopics(topics, q);
}, [query, topics]);

const rows = useMemo<RowItem[]>(() => {
const fromPagefind: RowItem[] = pageResults.map((r) => {
const rows = useMemo<SearchRow[]>(() => {
const fromPagefind: SearchRow[] = pageResults.map((r) => {
const g = classifyUrl(r.url);
return {
group: g,
Expand All @@ -92,10 +53,8 @@ export default function SearchInline({ topics }: { topics: Topic[] }) {
meta: buildMeta(g, r.meta),
};
});
const merged = [...topicMatches, ...fromPagefind];
const order: Group[] = ['topic', 'article', 'tool', 'author', 'page'];
return merged.sort((a, b) => order.indexOf(a.group) - order.indexOf(b.group));
}, [topicMatches, pageResults]);
return sortRows([...topicRows, ...fromPagefind]);
}, [topicRows, pageResults]);

useEffect(() => {
let cancelled = false;
Expand Down
Loading
Loading