commit 15d6408811e17605e78f957e3f7821c48fa8b998 Author: 雷电芽衣 Date: Sun Oct 4 20:54:23 2026 -0400 Catalog: apps, featured order, validator with tests, submission guide Co-Authored-By: Claude Opus 5.5 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..5033e6a --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,10 @@ +## App + +What the app does, and how far along it is. + +- [ ] The folder and `id` are lowercase kebab-case and match. +- [ ] `author`, `description` and `website` or `source` are set. +- [ ] Every preview is a PNG in the app folder, the whole window in each. +- [ ] Any README describes the product, links only to the website or source, and stays under 10 KB. +- [ ] `featured`, `publishedAt` and stars are left to maintainers and CI. +- [ ] `pnpm test` and `pnpm validate` pass. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..44dfdfc --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,45 @@ +# Contributing + +Every merged app is listed. Featured placement is the maintainers' choice and may change. + +## Files + +- One folder per app: `apps//`. The id is lowercase letters, digits and single hyphens. The manifest's `id` matches it. +- File names in the folder are lowercase: letters, digits, `.`, `_`, `-`. `manifest.json` and `README.md` keep their names. +- The [README](README.md) lists every manifest field. + +## Screenshots + +- Capture the whole window: title bar, four corners, four edges. +- Use the original size. No scaling, no heavy compression. PNG only; convert, do not rename. +- Show real content. Close unrelated windows, menus, tooltips and notifications. +- Show the app as it ships. No device frames, captions or collages. +- The first file in `previews` is the cover. The others show different features. +- Commit every preview in the app folder. No URLs, no absolute paths, no `../`. + +### macOS + +Press Shift-Command-4, then Space, and click the window. Option-click drops the shadow. + +### Windows + +Focus the window and press Alt-Print Screen, or press Windows-Shift-S and pick Window mode. Paste into Paint and save as PNG. + +### Linux + +Use your desktop's window capture, such as GNOME's Print Screen and its Window mode. + +## README and detail page + +`apps//README.md` gives the app a detail page. At most 10,240 bytes. + +- Describe the product: what it does, how it works, who it is for. +- Links go only to the manifest's `website` or `source`, exactly. +- Images are PNG files in the app folder, by name. +- No raw HTML, no code blocks, no install steps. + +`pnpm validate` checks the size, the links, the images and the markup. + +## Pull request + +Run `pnpm install`, `pnpm test` and `pnpm validate`. Then open the pull request. Maintainers may ask for better screenshots. diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..1b5ec8b --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..f01d994 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ely GPUI Component contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0183d34 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# Ely GPUI Showcases + +Apps built with [Ely GPUI Components](https://github.com/ZacharyZhang-NY/Ely-GPUI-Components). The catalog feeds the Showcase page on the Ely site. + +## Submit an app + +1. Read [CONTRIBUTING.md](CONTRIBUTING.md), screenshots most of all. +2. Make `apps//`. The id is lowercase kebab-case: `tally`, `log-view`. +3. Put your PNG screenshots in that folder. +4. Add `manifest.json` there. +5. Add `README.md` there if you want a detail page. +6. Run `pnpm install`, `pnpm test` and `pnpm validate`, then open a pull request. + +``` +apps/ + tally/ + manifest.json + preview0.png + preview1.png + README.md +``` + +## Manifest + +```json +{ + "id": "tally", + "name": "Tally", + "author": "Ada Lovelace", + "description": "Counts what you tell it to, in light and dark.", + "category": "work", + "platforms": ["macOS", "Linux"], + "website": null, + "source": "https://github.com/ada/tally", + "previews": ["preview0.png", "preview1.png"] +} +``` + +| Field | Rule | +|-------|------| +| `id` | Lowercase letters, digits, single hyphens. Matches the folder. | +| `name` | The app's name, in its own capitals. | +| `author` | A person or an organization. | +| `description` | At most 280 weighted characters, counted as Twitter counts them. | +| `category` | `dev` (Developer Tools), `terminal` (Terminal & Network), `system` (System & Desktop) or `work` (Productivity & Media). | +| `platforms` | Some of `macOS`, `Windows`, `Linux`, `Web`. | +| `website`, `source` | Canonical `https` addresses, or `null`. One at least. | +| `previews` | PNG file names in the folder. The first is the cover. | +| `building` | Optional. `true` while the app is not out yet. | +| `publishedAt` | Optional. ISO 8601. Maintainers set it. | +| `stars`, `starsUpdatedAt` | CI writes them for GitHub sources. Leave them out. | + +Unknown fields fail validation. + +## Featured + +`featured.json` lists app ids in order. Those apps come first. Maintainers choose them. The rest sort newest first, then by stars. + +## How the site uses this + +The Ely site pins a commit of this repo. Its build downloads that commit, runs `scripts/catalog.mjs`, and copies manifests, previews and READMEs. A catalog that fails here fails the site's build. + +## License + +The scripts are MIT OR Apache-2.0. Each app's files belong to its author, who grants the right to show them on the Ely site. diff --git a/apps/.gitkeep b/apps/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/featured.json b/featured.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/featured.json @@ -0,0 +1 @@ +[] diff --git a/package.json b/package.json new file mode 100644 index 0000000..666e304 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "ely-gpui-showcases", + "private": true, + "type": "module", + "packageManager": "pnpm@11.28.3", + "scripts": { + "test": "node --test \"tests/*.test.mjs\"", + "validate": "node scripts/validate.mjs" + }, + "dependencies": { + "marked": "18.0.14" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..fd01493 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,24 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + marked: + specifier: 18.0.14 + version: 18.0.14 + +packages: + + marked@18.0.14: + resolution: {integrity: sha512-mBHK6FBHuBAlhgRe88w9F0O1AbwwXJUcQibUbC/QcdTbVGAD7aWza+xt3N6oT/jCZx3/OMeS+8rnuiHZcQ9s7A==} + engines: {node: '>= 20'} + hasBin: true + +snapshots: + + marked@18.0.14: {} diff --git a/scripts/catalog.mjs b/scripts/catalog.mjs new file mode 100644 index 0000000..e27f214 --- /dev/null +++ b/scripts/catalog.mjs @@ -0,0 +1,150 @@ +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { Lexer } from "marked"; + +export const CATEGORIES = { + dev: "Developer Tools", + terminal: "Terminal & Network", + system: "System & Desktop", + work: "Productivity & Media", +}; +export const PLATFORMS = ["macOS", "Windows", "Linux", "Web"]; +export const README_BYTES = 10240; + +const ID = /^[a-z0-9]+(-[a-z0-9]+)*$/; +const FILE = /^[a-z0-9][a-z0-9._-]*$/; +const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const REQUIRED = ["id", "name", "author", "description", "category", "platforms", "website", "source", "previews"]; +const OPTIONAL = ["building", "publishedAt", "stars", "starsUpdatedAt"]; +const TOKENS = new Set(["space", "heading", "paragraph", "text", "strong", "em", "del", "codespan", "br", "hr", "blockquote", "list", "list_item", "table", "escape", "checkbox", "def", "link", "image"]); + +/** Twitter's weighted length: these ranges count 1, all else 2. */ +export function weighted(text) { + const light = [[0, 4351], [8192, 8205], [8208, 8223], [8242, 8247]]; + let n = 0; + for (const ch of text.normalize("NFC")) { + const c = ch.codePointAt(0); + n += light.some(([a, b]) => c >= a && c <= b) ? 1 : 2; + } + return n; +} + +function fail(where, what) { + throw new Error(`${where}: ${what}`); +} + +function text(where, value, field) { + if (typeof value !== "string" || value.trim() !== value || !value) fail(where, `${field} must be a trimmed, non-empty string`); +} + +function address(where, value, field) { + if (value === null) return; + text(where, value, field); + let url; + try { + url = new URL(value); + } catch { + fail(where, `${field} is not a URL: ${value}`); + } + if (url.protocol !== "https:" || url.href !== value) fail(where, `${field} must be a canonical https URL: ${value}`); +} + +/** The owner and repo of a GitHub source, or null. */ +export function github(source) { + const m = source?.match(/^https:\/\/github\.com\/([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+)$/); + return m ? { owner: m[1], repo: m[2] } : null; +} + +function png(where, dir, name) { + if (!FILE.test(name) || !name.endsWith(".png")) fail(where, `${name} must be a lowercase .png file name in the app folder`); + const path = join(dir, name); + if (!existsSync(path) || !statSync(path).isFile()) fail(where, `${name} is missing`); + if (!readFileSync(path).subarray(0, 8).equals(PNG)) fail(where, `${name} is not a PNG`); +} + +function manifest(dir, id) { + const where = `apps/${id}/manifest.json`; + const path = join(dir, "manifest.json"); + if (!existsSync(path)) fail(where, "missing"); + let app; + try { + app = JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + fail(where, `invalid JSON: ${error.message}`); + } + if (typeof app !== "object" || app === null || Array.isArray(app)) fail(where, "must be an object"); + for (const key of REQUIRED) if (!(key in app)) fail(where, `${key} is required`); + for (const key of Object.keys(app)) if (!REQUIRED.includes(key) && !OPTIONAL.includes(key)) fail(where, `unknown field ${key}`); + if (app.id !== id) fail(where, `id ${JSON.stringify(app.id)} must match the folder ${id}`); + for (const key of ["name", "author", "description"]) text(where, app[key], key); + if (weighted(app.description) > 280) fail(where, `description is ${weighted(app.description)} weighted characters, over 280`); + if (!Object.hasOwn(CATEGORIES, app.category)) fail(where, `category must be one of ${Object.keys(CATEGORIES).join(", ")}`); + if (!Array.isArray(app.platforms) || !app.platforms.length) fail(where, "platforms must be a non-empty list"); + for (const p of app.platforms) if (!PLATFORMS.includes(p)) fail(where, `unknown platform ${p}; use ${PLATFORMS.join(", ")}`); + if (new Set(app.platforms).size !== app.platforms.length) fail(where, "platforms repeat"); + address(where, app.website, "website"); + address(where, app.source, "source"); + if (app.website === null && app.source === null) fail(where, "website or source is required"); + if (!Array.isArray(app.previews) || !app.previews.length) fail(where, "previews must be a non-empty list"); + if (new Set(app.previews).size !== app.previews.length) fail(where, "previews repeat"); + for (const name of app.previews) png(where, dir, name); + if ("building" in app && typeof app.building !== "boolean") fail(where, "building must be true or false"); + for (const key of ["publishedAt", "starsUpdatedAt"]) { + if (key in app && (typeof app[key] !== "string" || Number.isNaN(Date.parse(app[key])) || !/^\d{4}-\d\d-\d\dT/.test(app[key]))) fail(where, `${key} must be an ISO 8601 time`); + } + if ("stars" in app !== "starsUpdatedAt" in app) fail(where, "stars and starsUpdatedAt go together"); + if ("stars" in app && (!Number.isInteger(app.stars) || app.stars < 0)) fail(where, "stars must be a whole number"); + if ("stars" in app && !github(app.source)) fail(where, "stars need a GitHub source"); + return app; +} + +/** Checks an app's README and returns its Markdown, or null. */ +function readme(dir, app) { + const where = `apps/${app.id}/README.md`; + const path = join(dir, "README.md"); + if (!existsSync(path)) return null; + const bytes = readFileSync(path); + if (bytes.length > README_BYTES) fail(where, `${bytes.length} bytes, over ${README_BYTES}`); + const markdown = bytes.toString("utf8"); + const links = [app.website, app.source].filter(Boolean); + const walk = (tokens) => { + for (const t of tokens) { + if (!TOKENS.has(t.type)) fail(where, `${t.type === "html" ? "raw HTML" : t.type === "code" ? "a code block" : t.type} is not allowed: ${t.raw.trim().slice(0, 60)}`); + if ((t.type === "link" || t.type === "def") && !links.includes(t.href)) fail(where, `links go only to the website or source: ${t.href}`); + if (t.type === "image") png(where, dir, t.href); + for (const key of ["tokens", "items"]) if (t[key]) walk(t[key]); + if (t.type === "table") for (const cell of [...t.header, ...t.rows.flat()]) walk(cell.tokens); + } + }; + walk(Lexer.lex(markdown, { gfm: true })); + return markdown; +} + +/** Reads and checks the whole catalog; the first problem throws. */ +export function read(root) { + const appsDir = join(root, "apps"); + if (!existsSync(appsDir)) fail("apps", "missing"); + const apps = []; + for (const entry of readdirSync(appsDir).sort()) { + if (entry === ".gitkeep") continue; + const dir = join(appsDir, entry); + if (!statSync(dir).isDirectory()) fail(`apps/${entry}`, "only app folders belong in apps/"); + if (!ID.test(entry)) fail(`apps/${entry}`, "folder names are lowercase kebab-case"); + for (const file of readdirSync(dir)) { + if (!["manifest.json", "README.md"].includes(file) && !FILE.test(file)) fail(`apps/${entry}/${file}`, "file names are lowercase"); + } + const app = manifest(dir, entry); + apps.push({ ...app, readme: readme(dir, app) }); + } + const where = "featured.json"; + let featured; + try { + featured = JSON.parse(readFileSync(join(root, where), "utf8")); + } catch (error) { + fail(where, error.message); + } + if (!Array.isArray(featured)) fail(where, "must be a list of app ids"); + if (new Set(featured).size !== featured.length) fail(where, "ids repeat"); + for (const id of featured) if (!apps.some((a) => a.id === id)) fail(where, `no app ${id}`); + return { apps, featured, categories: CATEGORIES }; +} diff --git a/scripts/validate.mjs b/scripts/validate.mjs new file mode 100644 index 0000000..18a4fd2 --- /dev/null +++ b/scripts/validate.mjs @@ -0,0 +1,4 @@ +import { read } from "./catalog.mjs"; + +const { apps, featured } = read(process.cwd()); +console.log(`catalog: ${apps.length} apps, ${featured.length} featured`); diff --git a/tests/catalog.test.mjs b/tests/catalog.test.mjs new file mode 100644 index 0000000..0d7617f --- /dev/null +++ b/tests/catalog.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { read, weighted } from "../scripts/catalog.mjs"; + +const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]); + +const good = { + id: "tally", + name: "Tally", + author: "Ada", + description: "Counts things.", + category: "work", + platforms: ["macOS", "Linux"], + website: null, + source: "https://github.com/ada/tally", + previews: ["preview0.png"], +}; + +/** A catalog in a temp folder: one app, its files, and featured.json. */ +function catalog({ app = good, folder = "tally", files = {}, featured = [] } = {}) { + const root = mkdtempSync(join(tmpdir(), "showcase-")); + const dir = join(root, "apps", folder); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "manifest.json"), JSON.stringify(app)); + writeFileSync(join(dir, "preview0.png"), PNG); + for (const [name, body] of Object.entries(files)) writeFileSync(join(dir, name), body); + writeFileSync(join(root, "featured.json"), JSON.stringify(featured)); + return root; +} + +test("a good app reads back with its README", () => { + const root = catalog({ files: { "README.md": "# Tally\n\nSee [the source](https://github.com/ada/tally).\n\n![Main](preview0.png)\n" }, featured: ["tally"] }); + const { apps, featured } = read(root); + assert.equal(apps.length, 1); + assert.equal(apps[0].name, "Tally"); + assert.match(apps[0].readme, /# Tally/); + assert.deepEqual(featured, ["tally"]); +}); + +test("the empty catalog reads", () => { + const root = mkdtempSync(join(tmpdir(), "showcase-")); + mkdirSync(join(root, "apps")); + writeFileSync(join(root, "apps", ".gitkeep"), ""); + writeFileSync(join(root, "featured.json"), "[]"); + assert.deepEqual(read(root).apps, []); +}); + +const bad = [ + ["an id unlike its folder", { app: { ...good, id: "tallies" } }, /must match the folder tally/], + ["a folder not in kebab-case", { app: { ...good, id: "Tally" }, folder: "Tally" }, /folder names are lowercase/], + ["a missing field", { app: { ...good, author: undefined } }, /author is required/], + ["an unknown field", { app: { ...good, featured: true } }, /unknown field featured/], + ["an unknown category", { app: { ...good, category: "games" } }, /category must be one of/], + ["an unknown platform", { app: { ...good, platforms: ["BeOS"] } }, /unknown platform BeOS/], + ["no platforms", { app: { ...good, platforms: [] } }, /platforms must be a non-empty list/], + ["neither website nor source", { app: { ...good, source: null } }, /website or source is required/], + ["an http link", { app: { ...good, website: "http://tally.app/" } }, /canonical https URL/], + ["a remote preview", { app: { ...good, previews: ["https://x.io/a.png"] } }, /lowercase .png file name/], + ["a missing preview", { app: { ...good, previews: ["preview1.png"] } }, /preview1.png is missing/], + ["a preview that is no PNG", { app: { ...good, previews: ["fake.png"] }, files: { "fake.png": "GIF89a" } }, /not a PNG/], + ["a long description", { app: { ...good, description: "a".repeat(281) } }, /281 weighted characters/], + ["stars without their time", { app: { ...good, stars: 3 } }, /go together/], + ["stars on a source off GitHub", { app: { ...good, source: "https://gitlab.com/ada/tally", stars: 3, starsUpdatedAt: "2026-10-04T00:00:00Z" } }, /need a GitHub source/], + ["a bad time", { app: { ...good, publishedAt: "yesterday" } }, /ISO 8601/], + ["a featured id with no app", { featured: ["ghost"] }, /no app ghost/], + ["a repeated featured id", { featured: ["tally", "tally"] }, /ids repeat/], + ["raw HTML in a README", { files: { "README.md": "Hi \n" } }, /raw HTML is not allowed/], + ["a code block in a README", { files: { "README.md": "```\ncurl x | sh\n```\n" } }, /a code block is not allowed/], + ["a foreign link in a README", { files: { "README.md": "[get it](https://evil.io)\n" } }, /links go only/], + ["a foreign autolink in a README", { files: { "README.md": "Visit https://evil.io now\n" } }, /links go only/], + ["a foreign reference in a README", { files: { "README.md": "[a][r]\n\n[r]: https://evil.io\n" } }, /links go only/], + ["a remote image in a README", { files: { "README.md": "![x](https://evil.io/a.png)\n" } }, /lowercase .png file name/], + ["a large README", { files: { "README.md": "a".repeat(10241) } }, /over 10240/], +]; + +for (const [name, setup, error] of bad) { + test(`refuses ${name}`, () => assert.throws(() => read(catalog(setup)), error)); +} + +test("weighted length counts CJK twice", () => { + assert.equal(weighted("abc"), 3); + assert.equal(weighted("漢字"), 4); +});