Catalog: apps, featured order, validator with tests, submission guide
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
15d6408811
13 files changed
+596
No files matched your search
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
@@ -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/<app-id>/`. 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/<app-id>/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.
|
||||
+176
@@ -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
|
||||
+21
@@ -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.
|
||||
@@ -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/<app-id>/`. 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.
|
||||
Whitespace-only changes.
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+24
@@ -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: {}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { read } from "./catalog.mjs";
|
||||
|
||||
const { apps, featured } = read(process.cwd());
|
||||
console.log(`catalog: ${apps.length} apps, ${featured.length} featured`);
|
||||
@@ -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\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 <img src=x onerror=alert(1)>\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": "\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);
|
||||
});
|
||||
Reference in new issue
Block a user