From 337dca0a0603e91ceb6ed75d0cf427b70e6fb41a Mon Sep 17 00:00:00 2001 From: kecan0406 Date: Wed, 30 Sep 2026 10:35:04 +0900 Subject: [PATCH 1/2] feat(www): add a showcase section and page The landing page ends with a Showcase section that features the first site, and /showcase lists every site with a slot for the next one. Each site is one file in www/showcase/, checked at build time by a Vite plugin (virtual:showcase). A contributor who edits it on github.com learns from CI what to fix, and two pull requests never touch the same lines. "Add on GitHub" opens GitHub's new-file page with an entry template, which forks the repository and opens the pull request. hackers.pub is the first entry, with its cat motif (CC BY-SA 4.0, credited on the card). The header and footer link to the page. Co-Authored-By: Claude Sonnet 5.5 --- www/public/img/showcase/README.md | 13 + www/public/img/showcase/hackers-pub.svg | 6443 +++++++++++++++++++++++ www/showcase.ts | 107 + www/showcase/hackers-pub.json | 12 + www/src/app.css | 41 + www/src/components/CodeCard.tsx | 19 +- www/src/components/CodeFile.tsx | 26 + www/src/components/Footer.tsx | 26 + www/src/components/Header.tsx | 17 +- www/src/components/ImageCredit.tsx | 24 + www/src/components/ShowcaseCard.tsx | 48 + www/src/components/ShowcasePreview.tsx | 43 + www/src/components/ShowcaseSection.tsx | 63 + www/src/components/SiteHeading.tsx | 25 + www/src/globals.d.ts | 6 + www/src/highlight.tsx | 4 +- www/src/routes/index.tsx | 22 +- www/src/routes/showcase.tsx | 78 + www/src/showcase.ts | 56 + www/tsconfig.json | 2 +- www/vite.config.ts | 2 + 21 files changed, 7039 insertions(+), 38 deletions(-) create mode 100644 www/public/img/showcase/README.md create mode 100644 www/public/img/showcase/hackers-pub.svg create mode 100644 www/showcase.ts create mode 100644 www/showcase/hackers-pub.json create mode 100644 www/src/components/CodeFile.tsx create mode 100644 www/src/components/Footer.tsx create mode 100644 www/src/components/ImageCredit.tsx create mode 100644 www/src/components/ShowcaseCard.tsx create mode 100644 www/src/components/ShowcasePreview.tsx create mode 100644 www/src/components/ShowcaseSection.tsx create mode 100644 www/src/components/SiteHeading.tsx create mode 100644 www/src/routes/showcase.tsx create mode 100644 www/src/showcase.ts diff --git a/www/public/img/showcase/README.md b/www/public/img/showcase/README.md new file mode 100644 index 0000000..3d8e99d --- /dev/null +++ b/www/public/img/showcase/README.md @@ -0,0 +1,13 @@ +# Showcase images + +Each file is a site's mark, shown left of its name on the showcase card (`image` in +`www/showcase/.json`). Every image keeps its own license, so note it here. + +## hackers-pub.svg + +The cat motif from [hackers-pub/visual-identity](https://github.com/hackers-pub/visual-identity), +`exports/pubnyan-normal-border.svg`, unmodified. + +Visual Identity of Hackers' Pub © 2025 by Bak Eunji, licensed under +[CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/). The credit is on the card, from +`credit` in `www/showcase/hackers-pub.json`. The license grants no trademark rights. diff --git a/www/public/img/showcase/hackers-pub.svg b/www/public/img/showcase/hackers-pub.svg new file mode 100644 index 0000000..8806c7b --- /dev/null +++ b/www/public/img/showcase/hackers-pub.svg @@ -0,0 +1,6443 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/www/showcase.ts b/www/showcase.ts new file mode 100644 index 0000000..8a6f61c --- /dev/null +++ b/www/showcase.ts @@ -0,0 +1,107 @@ +import type { Plugin } from 'vite' +import type { ShowcaseEntry } from './src/showcase.ts' + +const dir = new URL('./showcase/', import.meta.url) +const pathOf = (url: URL) => decodeURIComponent(url.pathname) +const publicDir = new URL('./public/', import.meta.url) +const FIELDS = ['name', 'url', 'description', 'image', 'credit'] +const CREDIT_FIELDS = ['text', 'url', 'license', 'licenseUrl'] +/** Where a card's picture lives: a file in `public/img/showcase/`, referenced by its URL. */ +const IMAGE = /^\/img\/showcase\/[\w.-]+\.(?:svg|png|webp|jpe?g|avif)$/ + +const isUrl = (value: unknown) => { + try { + return typeof value === 'string' && new URL(value).protocol === 'https:' + } catch { + return false + } +} + +/** + * One `showcase/.json`, checked. An entry that does not fit fails the build with the file + * and the field, so a contributor who edited it on github.com learns what to change from CI. + */ +export function parseEntry(file: string, text: string): ShowcaseEntry { + const fail = (why: string) => new Error(`showcase/${file}: ${why}`) + let data: unknown + try { + data = JSON.parse(text) + } catch (error) { + // the template GitHub opens carries `//` comments that JSON cannot hold + if (/^\s*\/\//m.test(text)) throw fail('delete the // comment lines') + throw fail(`not valid JSON (${(error as Error).message})`) + } + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + throw fail('expected one JSON object') + } + const entry = data as Record + for (const key of Object.keys(entry)) { + if (!FIELDS.includes(key)) + throw fail(`unknown field "${key}" (the fields are ${FIELDS.join(', ')})`) + } + for (const key of ['name', 'description']) { + const value = entry[key] + if (typeof value !== 'string' || !value.trim()) + throw fail(`"${key}" must be a non-empty string`) + } + if (!isUrl(entry.url)) throw fail('"url" must be an https:// URL') + if (entry.image !== undefined && !(typeof entry.image === 'string' && IMAGE.test(entry.image))) { + throw fail('"image" must be a file in www/public/img/showcase/, like "/img/showcase/site.svg"') + } + if (entry.credit !== undefined) { + if (entry.image === undefined) throw fail('"credit" credits the "image", so it needs one') + const credit = entry.credit as Record | null + if (typeof credit !== 'object' || credit === null || Array.isArray(credit)) { + throw fail('"credit" must be an object') + } + for (const key of Object.keys(credit)) { + if (!CREDIT_FIELDS.includes(key)) { + throw fail(`unknown "credit" field "${key}" (the fields are ${CREDIT_FIELDS.join(', ')})`) + } + } + for (const key of ['text', 'license']) { + const value = credit[key] + if (typeof value !== 'string' || !value.trim()) { + throw fail(`"credit.${key}" must be a non-empty string`) + } + } + for (const key of ['url', 'licenseUrl']) { + if (!isUrl(credit[key])) throw fail(`"credit.${key}" must be an https:// URL`) + } + } + return entry as unknown as ShowcaseEntry +} + +/** + * `virtual:showcase`: the sites in `showcase/*.json`, one file per site, ordered by file name. + * A new site is a new file, so two pull requests never touch the same lines. + */ +export function showcase(): Plugin { + const id = 'virtual:showcase' + + return { + name: 'showcase', + resolveId: (source) => (source === id ? `\0${id}` : undefined), + async load(loaded) { + if (loaded !== `\0${id}`) return + this.addWatchFile(pathOf(dir)) + const files = (await this.fs.readdir(pathOf(dir))) + .filter((file) => file.endsWith('.json')) + .sort() + const entries = await Promise.all( + files.map(async (file) => { + const path = pathOf(new URL(file, dir)) + this.addWatchFile(path) + const entry = parseEntry(file, await this.fs.readFile(path, { encoding: 'utf8' })) + if (entry.image) { + await this.fs.stat(pathOf(new URL(`.${entry.image}`, publicDir))).catch(() => { + throw new Error(`showcase/${file}: "image" ${entry.image} is not in www/public`) + }) + } + return entry + }), + ) + return `export default ${JSON.stringify(entries)}` + }, + } +} diff --git a/www/showcase/hackers-pub.json b/www/showcase/hackers-pub.json new file mode 100644 index 0000000..65ad16c --- /dev/null +++ b/www/showcase/hackers-pub.json @@ -0,0 +1,12 @@ +{ + "name": "hackers.pub", + "url": "https://hackers.pub/", + "description": "ActivityPub-enabled social network for hackers.", + "image": "/img/showcase/hackers-pub.svg", + "credit": { + "text": "Hackers' Pub cat by Bak Eunji", + "url": "https://github.com/hackers-pub/visual-identity", + "license": "CC BY-SA 4.0", + "licenseUrl": "https://creativecommons.org/licenses/by-sa/4.0/" + } +} diff --git a/www/src/app.css b/www/src/app.css index d57cace..33227fc 100644 --- a/www/src/app.css +++ b/www/src/app.css @@ -119,6 +119,47 @@ } } +/* /showcase previews: a bar drifting like the library's — fast out of the gate, a long crawl, a + hop to the end, a fade. Plain CSS on `translate`; nothing here is driven by the package. */ +.showcase-bar::before { + content: ''; + position: absolute; + inset: 0; + background: var(--primary); + translate: -100% 0; + animation: showcase-drift 7s infinite; +} +@media (prefers-reduced-motion: reduce) { + .showcase-bar::before { + animation: none; + translate: -38% 0; + } +} +@keyframes showcase-drift { + 0% { + translate: -100% 0; + opacity: 1; + animation-timing-function: cubic-bezier(0.08, 0.6, 0.2, 1); + } + 78% { + translate: -9% 0; + opacity: 1; + animation-timing-function: ease-in; + } + 86% { + translate: 0 0; + opacity: 1; + } + 93% { + translate: 0 0; + opacity: 0; + } + 100% { + translate: -100% 0; + opacity: 0; + } +} + /* `--destructive` is sized for a filled button; a 3px bar at its dark value (L 0.396) is too dim to read as a failure, so the dark half is lifted. */ .ex-error[data-error] { diff --git a/www/src/components/CodeCard.tsx b/www/src/components/CodeCard.tsx index a64d4f7..64c7274 100644 --- a/www/src/components/CodeCard.tsx +++ b/www/src/components/CodeCard.tsx @@ -1,6 +1,7 @@ import { createSignal, Show } from 'solid-js' import { copyText } from '~/clipboard' -import { Highlight, plain } from '~/highlight' +import { CodeFile } from '~/components/CodeFile' +import { plain } from '~/highlight' import { usageText } from '~/playground' /** The code the playground produces. Every control lands in it, so one snippet is enough. */ @@ -14,9 +15,12 @@ export function CodeCard() { } return ( -
-
- app.tsx + -
-
-        
-      
-
+ } + /> ) } diff --git a/www/src/components/CodeFile.tsx b/www/src/components/CodeFile.tsx new file mode 100644 index 0000000..14e13ff --- /dev/null +++ b/www/src/components/CodeFile.tsx @@ -0,0 +1,26 @@ +import type { JSX } from 'solid-js' +import { Highlight, type Lang } from '~/highlight' + +/** + * A code card: the file's name in a header (with an `action` beside it), then the highlighted + * source. The landing's `CodeCard` and the showcase's entry sample share it. + */ +export function CodeFile(props: { + file: string + text: string + lang: Lang + action?: JSX.Element + class?: string +}) { + return ( +
+
+ {props.file} + {props.action} +
+
+        
+      
+
+ ) +} diff --git a/www/src/components/Footer.tsx b/www/src/components/Footer.tsx new file mode 100644 index 0000000..767a871 --- /dev/null +++ b/www/src/components/Footer.tsx @@ -0,0 +1,26 @@ +import { A } from '@solidjs/router' +import { REPO } from '~/links' + +const LINK = 'underline underline-offset-4 hover:text-foreground' + +/** The landing page's and /showcase's footer: license, the site's pages, and the version. */ +export function Footer() { + return ( + + ) +} diff --git a/www/src/components/Header.tsx b/www/src/components/Header.tsx index b48b098..398b8be 100644 --- a/www/src/components/Header.tsx +++ b/www/src/components/Header.tsx @@ -2,6 +2,9 @@ import { A } from '@solidjs/router' import { createSignal, onMount, Show } from 'solid-js' import { REPO } from '~/links' +const LINK = + 'rounded-lg px-3 py-2.5 text-[14px] font-medium text-muted-foreground hover:text-foreground' + const effectiveDark = () => document.documentElement.dataset.theme ? document.documentElement.dataset.theme === 'dark' @@ -32,17 +35,13 @@ export function Header() {