From 2fcd3b0c8b6bb5564f99eb41631d737a18b07cf4 Mon Sep 17 00:00:00 2001 From: humanbydefinition Date: Thu, 30 Jul 2026 00:47:43 +0200 Subject: [PATCH 1/6] docs: centralize installation guidance --- docs/installation.md | 235 ++++++++++++++++++++++++++++--------------- 1 file changed, 152 insertions(+), 83 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index 46d6c0ef..82cb2766 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,142 +1,211 @@ --- title: Installation -description: Guide to installing textmode.js, a creative-coding library for real-time ASCII and textmode graphics in the browser. +description: Install textmode.js and its official add-ons with npm or browser-ready UMD bundles. --- # Installation -Getting started with `textmode.js` is straightforward. This guide will walk you through the installation process for different environments and provide you with everything you need to begin creating ASCII art in the browser. (ง •̀\_•́)ง +Install `textmode.js` with npm for a modern JavaScript or TypeScript project, or load its UMD bundle directly +in the browser. Official add-ons use the same plugin setup in either environment. (ง •̀\_•́)ง ## Try it online first -Before installing anything locally, you can try `textmode.js` directly in your browser using our dedicated web editor: +Want to experiment before setting up a project? Open +[editor.textmode.art](https://editor.textmode.art) to write, preview, save, and share `textmode.js` sketches +directly in your browser. -🌐 **[editor.textmode.art](https://editor.textmode.art)** +The editor includes examples and the official add-ons, so it is a quick way to learn the API or prototype an +idea without installing anything. -The web editor is specifically designed for `textmode.js` and provides: +## Requirements -- **Zero setup required** - Start coding immediately -- **Live preview** - See your creations in real-time -- **Save & share** - Export your sketches and share with others -- **Built-in examples** - Learn from interactive examples -- **Full API access** - All `textmode.js` features available +- A modern browser with [`WebGL2`](https://developer.mozilla.org/en-US/docs/Web/API/WebGL2RenderingContext) + support +- [Node.js 20.8.1 or newer](https://nodejs.org/) and npm when using the package-manager workflow -The web editor is perfect for learning, prototyping, or creating quick experiments without any local setup! +You do not need to create a `` element yourself. Unless you provide one, `textmode.js` creates and +mounts a canvas after the document body is available. -## Prerequisites - -To get started with `textmode.js`, you'll need: - -- A **modern web browser** with `WebGL2` support _(Chrome, Firefox, Safari, Edge, etc.)_ -- A [``](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/canvas) in your project _(optional, otherwise the library will create one for you)_ -- [Node.js 20.8.1+](https://nodejs.org/) and `npm` _(optional, for ESM installation)_ - -:::warning -`textmode.js` is currently fully dependent on `WebGL2`. Ensure your target browsers support it. You can check compatibility on [caniuse.com](https://caniuse.com/webgl2). +::: warning WebGL2 is required +`textmode.js` does not currently provide a WebGL1 or Canvas 2D fallback. Check your target browsers on +[Can I Use](https://caniuse.com/webgl2). ::: -## Importing `textmode.js` +## Install `textmode.js` -### UMD +### npm and ESM -To use `textmode.js` in a UMD environment, download the latest `umd` build from the [**GitHub releases page**](https://github.com/humanbydefinition/textmode.js/releases/) or import it directly from a CDN like [**jsDelivr**](https://www.jsdelivr.com/package/npm/textmode.js). The library is distributed as a single JavaScript file, which you can include in your project by adding the following script tag to your HTML file: +For projects built with tools such as Vite, install the package from npm: -```html - - - - - textmode.js sketch - - - - - - - - +```bash +npm install textmode.js ``` -```javascript -// sketch.js +Import the `textmode` entry point in your JavaScript or TypeScript: + +```js +import { textmode } from "textmode.js"; + const t = textmode.create({ width: window.innerWidth, height: window.innerHeight, fontSize: 16, - frameRate: 60, -}); - -t.setup(() => { - // Optional setup code here (e.g., load fonts/shaders, initialize variables that access 't') }); t.draw(() => { - t.background(32); // Dark gray background - - t.char("A"); - t.charColor(255, 0, 0); // Cover the top-left quarter of the grid with a rectangle of red 'A's - t.rect(t.grid.cols / 2, t.grid.rows / 2); - - // ...add your drawing code here! -}); - -t.windowResized(() => { - t.resizeCanvas(window.innerWidth, window.innerHeight); + t.background(18); + t.char("@"); + t.charColor(255); + t.rect(12, 8); }); ``` -### ESM +Your bundler will include the ESM build and its TypeScript declarations automatically. Continue with +[First Sketch](/docs/first-sketch) for the complete draw, setup, and resize pattern. -To use `textmode.js` in an ESM environment, you can install it via `npm`: +### CDN and UMD -```bash -npm install textmode.js -``` - -Then, you can import it in your JavaScript or TypeScript files: +For a browser project without a package manager or bundler, load the UMD build from +[jsDelivr](https://www.jsdelivr.com/package/npm/textmode.js): ```html - - + textmode.js sketch - + + ``` -```js -// sketch.js +The UMD bundle exposes the library as the global `textmode` object. Load it before any script that calls +`textmode.create()`. + +## Install add-ons + +Official add-ons are npm packages with `textmode.js` as a peer dependency. Each package exports a plugin that +you pass to `textmode.create()` through the `plugins` array: + +| Package | Plugin / UMD global | +| ----------------------------------------------- | ------------------- | +| [`textmode.export.js`](/api/textmode.export.js/) | `ExportPlugin` | +| [`textmode.filters.js`](/api/textmode.filters.js/) | `FiltersPlugin` | +| [`textmode.figlet.js`](/api/textmode.figlet.js/) | `FigletPlugin` | +| [`textmode.synth.js`](/api/textmode.synth.js/) | `SynthPlugin` | + +The corresponding UMD files are `dist/textmode.export.umd.js`, `dist/textmode.filters.umd.js`, +`dist/textmode.figlet.umd.js`, and `dist/textmode.synth.umd.js`. + +### npm and ESM -// Import textmode.js +Install the core library and the add-on together. For example, to use `textmode.filters.js`: + +```bash +npm install textmode.js textmode.filters.js +``` + +Import the plugin and register it when you create the `Textmodifier` instance: + +```js import { textmode } from "textmode.js"; +import { FiltersPlugin } from "textmode.filters.js"; const t = textmode.create({ width: window.innerWidth, height: window.innerHeight, - fontSize: 16, - frameRate: 60, + plugins: [FiltersPlugin], }); +``` -t.setup(() => { - // Optional setup code here (e.g., load fonts/shaders, initialize variables that access 't') -}); +Importing the add-on also makes its TypeScript declarations and any `Textmodifier` augmentations available to +your project. -t.draw(() => { - t.background(32); // Dark gray background +### CDN and UMD - t.char("A"); - t.charColor(255, 0, 0); // Cover the top-left quarter of the grid with a rectangle of red 'A's - t.rect(t.grid.cols / 2, t.grid.rows / 2); +Load the core UMD bundle first, followed by the add-on bundle, and then create your sketch with the add-on's +global plugin: - // ...add your drawing code here! -}); +```html + + + +``` + +Use the package, bundle, and plugin names from the table above to install another add-on with the same pattern. -t.windowResized(() => { - t.resizeCanvas(window.innerWidth, window.innerHeight); +### Use multiple add-ons + +Install every package your sketch needs in one command: + +```bash +npm install textmode.js textmode.export.js textmode.filters.js +``` + +Then import and register the plugins together: + +```js +import { textmode } from "textmode.js"; +import { ExportPlugin } from "textmode.export.js"; +import { FiltersPlugin } from "textmode.filters.js"; + +const t = textmode.create({ + width: window.innerWidth, + height: window.innerHeight, + plugins: [ExportPlugin, FiltersPlugin], }); ``` + +For UMD projects, follow the same order: load `textmode.js`, load each add-on bundle, and then run the sketch. + +### Version compatibility + +Each add-on declares its compatible `textmode.js` versions through `peerDependencies`. Keep the core library +and add-ons up to date together, and resolve any peer-dependency warning reported by npm before running your +project. + +::: tip Pin CDN versions for production +The CDN examples use `@latest` for convenient experimentation. For a reproducible production build, replace +`latest` with tested versions for both `textmode.js` and every add-on. +::: + +## Common setup issues + +- **`textmode is not defined`**: load the core UMD bundle before your sketch, or import `textmode` in your ESM + module. +- **A plugin global is not defined**: confirm that the matching add-on UMD bundle loaded before your sketch + and that you used the plugin name from the table above. +- **npm reports a missing or incompatible peer dependency**: install a compatible `textmode.js` version + alongside the add-on. +- **The canvas is blank or WebGL initialization fails**: confirm that WebGL2 is enabled and supported by the + browser and device. + +## Next steps + +- [Create your first sketch](/docs/first-sketch) +- [Learn how plugins work](/docs/plugins) +- [Export sketches with `textmode.export.js`](/docs/exporting) +- [Apply add-on filters](/docs/filters#add-on-filter-package) +- [Browse the complete API reference](/api/) From 036a3e7a73b255c4d44e298410ac40cdf9cdcec2 Mon Sep 17 00:00:00 2001 From: humanbydefinition Date: Thu, 30 Jul 2026 01:02:36 +0200 Subject: [PATCH 2/6] docs: update online editor guidance --- docs/installation.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/installation.md b/docs/installation.md index 82cb2766..fcbef7a8 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -10,12 +10,18 @@ in the browser. Official add-ons use the same plugin setup in either environment ## Try it online first -Want to experiment before setting up a project? Open -[editor.textmode.art](https://editor.textmode.art) to write, preview, save, and share `textmode.js` sketches -directly in your browser. +Open [editor.textmode.art](https://editor.textmode.art/), a browser-based live-coding environment for the +complete official `textmode.js` ecosystem. Sketches run as you edit, with no local toolchain required. -The editor includes examples and the official add-ons, so it is a quick way to learn the API or prototype an -idea without installing anything. +The editor includes `textmode.js` and all four official add-ons: `textmode.export.js`, `textmode.filters.js`, +`textmode.figlet.js`, and `textmode.synth.js`. + +- Write with Monaco-powered completions, hover documentation, and diagnostics. +- Start with a blank sketch, an included example, or a community gallery sketch. +- Keep code and preferences saved in the browser, then share sketches through URL-based links. +- Use microphone or line-input analysis for audio-reactive work, and create on desktop or mobile. + +Use it to learn the ecosystem, prototype an idea, or decide which packages your local project needs. ## Requirements From d50073114d66fba6119c656c814bb0ca93b070b4 Mon Sep 17 00:00:00 2001 From: humanbydefinition Date: Fri, 31 Jul 2026 18:24:44 +0200 Subject: [PATCH 3/6] refactor: update contributor recognition process and improve documentation --- .all-contributorsrc | 44 -- .github/workflows/contributors.yml | 86 ++++ .github/workflows/deploy.yml | 10 +- .github/workflows/dispatch-contributors.yml | 85 ++++ .github/workflows/reconcile-contributors.yml | 230 ++++++++++ .vitepress/data/contribution-types.json | 204 +++++++++ .../data/contribution-types.schema.json | 48 ++ .vitepress/data/contributors.json | 67 ++- .vitepress/data/contributors.schema.json | 83 ++++ .vitepress/theme/composables/contributors.ts | 205 +++++---- CONTRIBUTING.md | 15 +- README.md | 43 +- docs/contributing/getting-started.md | 22 +- docs/contributing/index.md | 13 +- docs/contributing/submit-a-sketch.md | 13 +- docs/contributors.md | 6 +- package.json | 9 +- scripts/contributors.mjs | 421 ++++++++++++++++++ scripts/contributors.test.mjs | 221 +++++++++ 19 files changed, 1608 insertions(+), 217 deletions(-) delete mode 100644 .all-contributorsrc create mode 100644 .github/workflows/contributors.yml create mode 100644 .github/workflows/dispatch-contributors.yml create mode 100644 .github/workflows/reconcile-contributors.yml create mode 100644 .vitepress/data/contribution-types.json create mode 100644 .vitepress/data/contribution-types.schema.json create mode 100644 .vitepress/data/contributors.schema.json create mode 100644 scripts/contributors.mjs create mode 100644 scripts/contributors.test.mjs diff --git a/.all-contributorsrc b/.all-contributorsrc deleted file mode 100644 index daa407a2..00000000 --- a/.all-contributorsrc +++ /dev/null @@ -1,44 +0,0 @@ -{ - "projectName": "code.textmode.art", - "projectOwner": "humanbydefinition", - "repoType": "github", - "repoHost": "https://github.com", - "files": [ - "README.md" - ], - "imageSize": 100, - "contributorsPerLine": 7, - "commitConvention": "angular", - "commitType": "docs", - "skipCi": true, - "contributors": [ - { - "login": "humanbydefinition", - "name": "humanbydefinition", - "avatar_url": "https://www.github.com/humanbydefinition.png", - "profile": "https://github.com/humanbydefinition", - "contributions": [ - "code", - "doc", - "design", - "example", - "ideas", - "maintenance", - "infra", - "tool", - "plugin", - "review" - ] - }, - { - "login": "trintlermint", - "name": "trintlermint", - "avatar_url": "https://www.github.com/trintlermint.png", - "profile": "https://github.com/trintlermint", - "contributions": [ - "design", - "example" - ] - } - ] -} diff --git a/.github/workflows/contributors.yml b/.github/workflows/contributors.yml new file mode 100644 index 00000000..8e125de9 --- /dev/null +++ b/.github/workflows/contributors.yml @@ -0,0 +1,86 @@ +name: Contributors + +on: + pull_request: + branches: [dev, main] + push: + branches: [dev, main] + repository_dispatch: + types: [contributors-source-updated] + schedule: + - cron: "23 3 * * *" + workflow_dispatch: + inputs: + target-branch: + description: Branch to reconcile + required: true + default: dev + type: choice + options: + - dev + - main + dry-run: + description: Render and report without creating a pull request + required: true + default: true + type: boolean + +permissions: + contents: read + +jobs: + check: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Checkout pull request + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: Verify canonical contributor output + run: npm run check:contributors + + reconcile-push: + if: github.event_name == 'push' && vars.CONTRIBUTOR_SYNC_ENABLED == 'true' + uses: ./.github/workflows/reconcile-contributors.yml + with: + mode: reconcile + target-branch: ${{ github.ref_name }} + source-ref: ${{ github.ref_name }} + app-client-id: ${{ vars.CONTRIBUTOR_SYNC_APP_CLIENT_ID }} + secrets: + app-private-key: ${{ secrets.CONTRIBUTOR_SYNC_APP_PRIVATE_KEY }} + + reconcile-dev: + if: >- + (github.event_name == 'workflow_dispatch' && + inputs.target-branch == 'dev' && + (inputs.dry-run || vars.CONTRIBUTOR_SYNC_ENABLED == 'true')) || + (vars.CONTRIBUTOR_SYNC_ENABLED == 'true' && + (github.event_name == 'schedule' || github.event_name == 'repository_dispatch')) + uses: ./.github/workflows/reconcile-contributors.yml + with: + mode: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run && 'dry-run' || 'reconcile' }} + target-branch: dev + source-ref: ${{ github.event.client_payload.source_sha || 'main' }} + app-client-id: ${{ vars.CONTRIBUTOR_SYNC_APP_CLIENT_ID }} + secrets: + app-private-key: ${{ secrets.CONTRIBUTOR_SYNC_APP_PRIVATE_KEY }} + + reconcile-main: + if: >- + (github.event_name == 'workflow_dispatch' && + inputs.target-branch == 'main' && + (inputs.dry-run || vars.CONTRIBUTOR_SYNC_ENABLED == 'true')) || + (vars.CONTRIBUTOR_SYNC_ENABLED == 'true' && + (github.event_name == 'schedule' || github.event_name == 'repository_dispatch')) + uses: ./.github/workflows/reconcile-contributors.yml + with: + mode: ${{ github.event_name == 'workflow_dispatch' && inputs.dry-run && 'dry-run' || 'reconcile' }} + target-branch: main + source-ref: ${{ github.event.client_payload.source_sha || 'main' }} + app-client-id: ${{ vars.CONTRIBUTOR_SYNC_APP_CLIENT_ID }} + secrets: + app-private-key: ${{ secrets.CONTRIBUTOR_SYNC_APP_PRIVATE_KEY }} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index cc5bffc3..37c41b07 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -19,18 +19,18 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 20 cache: npm - name: Setup Pages - uses: actions/configure-pages@v4 + uses: actions/configure-pages@1f0c5cde4bc74cd7e1254d0cb4de8d49e9068c7d # v4 - name: Install dependencies run: npm install --frozen-lockfile @@ -39,7 +39,7 @@ jobs: run: npm run build - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 with: path: .vitepress/dist @@ -53,4 +53,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 diff --git a/.github/workflows/dispatch-contributors.yml b/.github/workflows/dispatch-contributors.yml new file mode 100644 index 00000000..b6fd5e54 --- /dev/null +++ b/.github/workflows/dispatch-contributors.yml @@ -0,0 +1,85 @@ +name: Dispatch contributor synchronization + +on: + push: + branches: [main] + paths: + - .vitepress/data/contribution-types.json + - .vitepress/data/contribution-types.schema.json + - .vitepress/data/contributors.json + - .vitepress/data/contributors.schema.json + - scripts/contributors.mjs + - .github/workflows/reconcile-contributors.yml + schedule: + - cron: "41 3 * * *" + workflow_dispatch: + inputs: + dry-run: + description: Resolve targets without sending repository dispatch events + required: true + default: true + type: boolean + +permissions: + contents: read + +jobs: + dispatch: + if: vars.CONTRIBUTOR_SYNC_ENABLED == 'true' + runs-on: ubuntu-latest + + steps: + - name: Checkout canonical main branch + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: main + persist-credentials: false + + - name: Resolve canonical source + id: source + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Create cross-repository GitHub App token + if: github.event_name != 'workflow_dispatch' || !inputs.dry-run + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + client-id: ${{ vars.CONTRIBUTOR_SYNC_APP_CLIENT_ID }} + private-key: ${{ secrets.CONTRIBUTOR_SYNC_APP_PRIVATE_KEY }} + owner: humanbydefinition + permission-contents: write + repositories: | + textmode.js-dev + textmode.synth.js + textmode.export.js + textmode.figlet.js + textmode.filters.js + + - name: Dispatch synchronization + if: github.event_name != 'workflow_dispatch' || !inputs.dry-run + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + SOURCE_SHA: ${{ steps.source.outputs.sha }} + run: | + for repository in \ + textmode.js-dev \ + textmode.synth.js \ + textmode.export.js \ + textmode.figlet.js \ + textmode.filters.js + do + gh api \ + --method POST \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2026-03-10" \ + "/repos/humanbydefinition/$repository/dispatches" \ + -f event_type=contributors-source-updated \ + -F "client_payload[source_sha]=$SOURCE_SHA" \ + -F "client_payload[schema_version]=1" + done + + - name: Report dry run + if: github.event_name == 'workflow_dispatch' && inputs.dry-run + run: | + echo "Would dispatch code.textmode.art@${{ steps.source.outputs.sha }} to:" + echo "textmode.js-dev textmode.synth.js textmode.export.js textmode.figlet.js textmode.filters.js" diff --git a/.github/workflows/reconcile-contributors.yml b/.github/workflows/reconcile-contributors.yml new file mode 100644 index 00000000..2aa0a566 --- /dev/null +++ b/.github/workflows/reconcile-contributors.yml @@ -0,0 +1,230 @@ +name: Reconcile contributor README + +on: + workflow_call: + inputs: + mode: + description: Operation to perform (check, dry-run, or reconcile) + required: true + type: string + target-branch: + description: Branch that owns the README and receives the synchronization PR + required: true + type: string + checkout-ref: + description: Optional commit to inspect instead of the target branch + required: false + default: "" + type: string + source-ref: + description: Exact code.textmode.art commit, main, or dev for local reconciliation + required: false + default: main + type: string + app-client-id: + description: Client ID for textmode-contributors-bot + required: false + default: "" + type: string + secrets: + app-private-key: + description: Private key for textmode-contributors-bot + required: false + +permissions: + contents: read + +jobs: + reconcile: + name: ${{ inputs.mode }} ${{ inputs.target-branch }} + runs-on: ubuntu-latest + concurrency: + group: contributors-${{ github.repository }}-${{ inputs.target-branch }} + cancel-in-progress: false + + steps: + - name: Validate workflow inputs + env: + MODE: ${{ inputs.mode }} + TARGET_BRANCH: ${{ inputs.target-branch }} + SOURCE_REF: ${{ inputs.source-ref }} + APP_CLIENT_ID: ${{ inputs.app-client-id }} + APP_PRIVATE_KEY: ${{ secrets.app-private-key }} + run: | + case "$MODE" in + check|dry-run|reconcile) ;; + *) echo "::error::Unsupported reconciliation mode: $MODE"; exit 1 ;; + esac + + case "$TARGET_BRANCH" in + dev|beta|main) ;; + *) echo "::error::Unsupported target branch: $TARGET_BRANCH"; exit 1 ;; + esac + + if [[ "$SOURCE_REF" != "main" && "$SOURCE_REF" != "dev" && ! "$SOURCE_REF" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::source-ref must be main, dev, or a full commit SHA" + exit 1 + fi + + if [[ "$MODE" == "reconcile" && (-z "$APP_CLIENT_ID" || -z "$APP_PRIVATE_KEY") ]]; then + echo "::error::Contributor synchronization GitHub App credentials are not configured" + exit 1 + fi + + - name: Create short-lived GitHub App token + if: inputs.mode == 'reconcile' + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 + with: + client-id: ${{ inputs.app-client-id }} + private-key: ${{ secrets.app-private-key }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-pull-requests: write + + - name: Check whether the target branch exists + id: target + env: + GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }} + TARGET_BRANCH: ${{ inputs.target-branch }} + run: | + if RESPONSE="$(gh api "repos/$GITHUB_REPOSITORY/branches/$TARGET_BRANCH" 2>&1)"; then + echo "exists=true" >> "$GITHUB_OUTPUT" + elif [[ "$RESPONSE" == *"(HTTP 404)"* ]]; then + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "::notice::Skipping missing branch $TARGET_BRANCH" + else + printf '%s\n' "$RESPONSE" + exit 1 + fi + + - name: Checkout target repository + if: steps.target.outputs.exists == 'true' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ inputs.checkout-ref || inputs.target-branch }} + fetch-depth: 0 + path: target + token: ${{ steps.app-token.outputs.token || github.token }} + + - name: Checkout canonical contributor source + if: steps.target.outputs.exists == 'true' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: humanbydefinition/code.textmode.art + ref: ${{ inputs.source-ref }} + sparse-checkout: | + .vitepress/data/contribution-types.json + .vitepress/data/contributors.json + scripts/contributors.mjs + sparse-checkout-cone-mode: false + path: contributor-source + persist-credentials: false + + - name: Resolve and validate canonical source + if: steps.target.outputs.exists == 'true' + id: source + working-directory: contributor-source + run: | + SOURCE_SHA="$(git rev-parse HEAD)" + if [[ ! "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Canonical source did not resolve to a full commit SHA" + exit 1 + fi + echo "sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT" + + - name: Render canonical section + if: steps.target.outputs.exists == 'true' + run: | + node contributor-source/scripts/contributors.mjs render \ + --registry contributor-source/.vitepress/data/contributors.json \ + --types contributor-source/.vitepress/data/contribution-types.json \ + --readme target/README.md + + - name: Inspect generated diff + if: steps.target.outputs.exists == 'true' + id: diff + working-directory: target + run: | + if git diff --quiet -- README.md; then + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + CHANGED_FILES="$(git diff --name-only)" + if [[ "$CHANGED_FILES" != "README.md" ]]; then + echo "::error::Contributor renderer changed files other than README.md" + git diff --name-status + exit 1 + fi + + echo "changed=true" >> "$GITHUB_OUTPUT" + + - name: Fail when pull request content is stale + if: inputs.mode == 'check' && steps.diff.outputs.changed == 'true' + working-directory: target + run: | + git diff -- README.md + echo "::error::README.md does not contain the canonical Contributors section" + exit 1 + + - name: Show dry-run diff + if: inputs.mode == 'dry-run' && steps.diff.outputs.changed == 'true' + working-directory: target + run: git diff -- README.md + + - name: Create or refresh synchronization pull request + if: inputs.mode == 'reconcile' && steps.diff.outputs.changed == 'true' + working-directory: target + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + APP_SLUG: ${{ steps.app-token.outputs.app-slug }} + SOURCE_SHA: ${{ steps.source.outputs.sha }} + TARGET_BRANCH: ${{ inputs.target-branch }} + WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + SYNC_BRANCH="automation/sync-contributors/$TARGET_BRANCH" + BOT_USER_ID="$(gh api "/users/$APP_SLUG[bot]" --jq .id)" + + git config user.name "$APP_SLUG[bot]" + git config user.email "$BOT_USER_ID+$APP_SLUG[bot]@users.noreply.github.com" + git switch -C "$SYNC_BRANCH" + git add README.md + git commit -m "docs: sync ecosystem contributors" + + if git ls-remote --exit-code --heads origin "$SYNC_BRANCH" >/dev/null 2>&1; then + git fetch origin "refs/heads/$SYNC_BRANCH:refs/remotes/origin/$SYNC_BRANCH" + fi + git push --force-with-lease origin "HEAD:refs/heads/$SYNC_BRANCH" + + PR_NUMBER="$(gh pr list \ + --base "$TARGET_BRANCH" \ + --head "$SYNC_BRANCH" \ + --state open \ + --json number \ + --jq '.[0].number // empty')" + + printf -v PR_BODY \ + 'Generated from humanbydefinition/code.textmode.art@%s.\n\nThis pull request changes only the canonical README Contributors section.\n\nReconciliation run: %s' \ + "$SOURCE_SHA" \ + "$WORKFLOW_URL" + + if [[ -z "$PR_NUMBER" ]]; then + PR_URL="$(gh pr create \ + --base "$TARGET_BRANCH" \ + --head "$SYNC_BRANCH" \ + --title "docs: sync ecosystem contributors" \ + --body "$PR_BODY")" + PR_NUMBER="${PR_URL##*/}" + else + gh pr edit "$PR_NUMBER" \ + --title "docs: sync ecosystem contributors" \ + --body "$PR_BODY" + fi + + gh pr merge "$PR_NUMBER" --auto --squash + + - name: Report no-op + if: steps.target.outputs.exists == 'true' && steps.diff.outputs.changed == 'false' + run: echo "README.md already matches code.textmode.art@${{ steps.source.outputs.sha }}" diff --git a/.vitepress/data/contribution-types.json b/.vitepress/data/contribution-types.json new file mode 100644 index 00000000..cc284168 --- /dev/null +++ b/.vitepress/data/contribution-types.json @@ -0,0 +1,204 @@ +{ + "$schema": "./contribution-types.schema.json", + "schemaVersion": 1, + "contributionTypes": [ + { + "key": "code", + "emoji": "💻", + "label": "Code", + "description": "Commits and pull requests" + }, + { + "key": "doc", + "emoji": "📖", + "label": "Documentation", + "description": "README, guides, and API documentation" + }, + { + "key": "design", + "emoji": "🎨", + "label": "Design", + "description": "User experience, branding, and visual design" + }, + { + "key": "example", + "emoji": "💡", + "label": "Examples", + "description": "Usage examples and creative sketches" + }, + { + "key": "ideas", + "emoji": "🤔", + "label": "Ideas and planning", + "description": "Feature proposals, planning, and feedback" + }, + { + "key": "maintenance", + "emoji": "🚧", + "label": "Maintenance", + "description": "Refactoring and project upkeep" + }, + { + "key": "infra", + "emoji": "🚇", + "label": "Infrastructure", + "description": "Continuous integration, hosting, and build systems" + }, + { + "key": "tool", + "emoji": "🔧", + "label": "Tools", + "description": "Developer and community tooling" + }, + { + "key": "plugin", + "emoji": "🔌", + "label": "Plugins and libraries", + "description": "Plugin and utility library development" + }, + { + "key": "review", + "emoji": "👀", + "label": "Code review", + "description": "Reviewing pull requests" + }, + { + "key": "audio", + "emoji": "🔊", + "label": "Audio", + "description": "Podcasts, music, and sound effects" + }, + { + "key": "a11y", + "emoji": "♿️", + "label": "Accessibility", + "description": "Accessibility improvements and audits" + }, + { + "key": "bug", + "emoji": "🐛", + "label": "Bug reports", + "description": "Reporting reproducible bugs" + }, + { + "key": "blog", + "emoji": "📝", + "label": "Blog posts", + "description": "Writing about the project" + }, + { + "key": "business", + "emoji": "💼", + "label": "Business development", + "description": "Business strategy and partnerships" + }, + { + "key": "content", + "emoji": "🖋", + "label": "Content", + "description": "Website copy and written material" + }, + { + "key": "data", + "emoji": "🔣", + "label": "Data", + "description": "Datasets and test data" + }, + { + "key": "eventOrganizing", + "emoji": "📋", + "label": "Event organizing", + "description": "Organizing project events" + }, + { + "key": "financial", + "emoji": "💵", + "label": "Financial support", + "description": "Funding and donations" + }, + { + "key": "fundingFinding", + "emoji": "🔍", + "label": "Funding research", + "description": "Identifying funding and grant opportunities" + }, + { + "key": "mentoring", + "emoji": "🧑‍🏫", + "label": "Mentoring", + "description": "Supporting contributors" + }, + { + "key": "platform", + "emoji": "📦", + "label": "Packaging", + "description": "Porting and packaging for new platforms" + }, + { + "key": "projectManagement", + "emoji": "📆", + "label": "Project management", + "description": "Planning and coordination" + }, + { + "key": "promotion", + "emoji": "📣", + "label": "Promotion", + "description": "Community outreach and social sharing" + }, + { + "key": "question", + "emoji": "💬", + "label": "Community support", + "description": "Answering questions" + }, + { + "key": "research", + "emoji": "🔬", + "label": "Research", + "description": "Technical and creative research" + }, + { + "key": "security", + "emoji": "🛡️", + "label": "Security", + "description": "Security and privacy improvements" + }, + { + "key": "translation", + "emoji": "🌍", + "label": "Translation", + "description": "Translating project content" + }, + { + "key": "test", + "emoji": "⚠️", + "label": "Tests", + "description": "Writing and improving tests" + }, + { + "key": "tutorial", + "emoji": "✅", + "label": "Tutorials", + "description": "Educational content" + }, + { + "key": "talk", + "emoji": "📢", + "label": "Talks", + "description": "Presentations and talks" + }, + { + "key": "userTesting", + "emoji": "📓", + "label": "User testing", + "description": "Testing workflows with users" + }, + { + "key": "video", + "emoji": "📹", + "label": "Videos", + "description": "Creating video content" + } + ] +} diff --git a/.vitepress/data/contribution-types.schema.json b/.vitepress/data/contribution-types.schema.json new file mode 100644 index 00000000..c53d0de0 --- /dev/null +++ b/.vitepress/data/contribution-types.schema.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://code.textmode.art/schemas/contribution-types.schema.json", + "title": "textmode.js ecosystem contribution types", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "contributionTypes"], + "properties": { + "$schema": { + "type": "string" + }, + "schemaVersion": { + "const": 1 + }, + "contributionTypes": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/contributionType" + } + } + }, + "$defs": { + "contributionType": { + "type": "object", + "additionalProperties": false, + "required": ["key", "emoji", "label", "description"], + "properties": { + "key": { + "type": "string", + "pattern": "^[A-Za-z][A-Za-z0-9]*$" + }, + "emoji": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string", + "minLength": 1 + } + } + } + } +} diff --git a/.vitepress/data/contributors.json b/.vitepress/data/contributors.json index 9b2135f8..35185210 100644 --- a/.vitepress/data/contributors.json +++ b/.vitepress/data/contributors.json @@ -1,26 +1,45 @@ { - "trintlermint": { - "links": [ - { - "icon": "website", - "link": "https://trintler.me" - } - ] - }, - "humanbydefinition": { - "links": [ - { - "icon": "instagram", - "link": "https://www.instagram.com/humanbydefinition/" - }, - { - "icon": "mastodon", - "link": "https://mastodon.social/@humanbydefinition" - }, - { - "icon": "twitter", - "link": "https://twitter.com/textmode_art" - } - ] - } + "$schema": "./contributors.schema.json", + "schemaVersion": 1, + "contributors": [ + { + "login": "humanbydefinition", + "contributions": [ + "code", + "doc", + "design", + "example", + "ideas", + "maintenance", + "infra", + "tool", + "plugin", + "review" + ], + "links": [ + { + "icon": "instagram", + "url": "https://www.instagram.com/humanbydefinition/" + }, + { + "icon": "twitter", + "url": "https://twitter.com/textmode_art" + }, + { + "icon": "mastodon", + "url": "https://mastodon.social/@humanbydefinition" + } + ] + }, + { + "login": "trintlermint", + "contributions": ["design", "example"], + "links": [ + { + "icon": "website", + "url": "https://trintler.me" + } + ] + } + ] } diff --git a/.vitepress/data/contributors.schema.json b/.vitepress/data/contributors.schema.json new file mode 100644 index 00000000..367da7d9 --- /dev/null +++ b/.vitepress/data/contributors.schema.json @@ -0,0 +1,83 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://code.textmode.art/schemas/contributors.schema.json", + "title": "textmode.js ecosystem contributors", + "type": "object", + "additionalProperties": false, + "required": ["schemaVersion", "contributors"], + "properties": { + "$schema": { + "type": "string" + }, + "schemaVersion": { + "const": 1 + }, + "contributors": { + "type": "array", + "items": { + "$ref": "#/$defs/contributor" + } + } + }, + "$defs": { + "httpsUrl": { + "type": "string", + "format": "uri", + "pattern": "^https://" + }, + "contributor": { + "type": "object", + "additionalProperties": false, + "required": ["login", "contributions"], + "properties": { + "login": { + "type": "string", + "pattern": "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "profileUrl": { + "$ref": "#/$defs/httpsUrl" + }, + "avatarUrl": { + "$ref": "#/$defs/httpsUrl" + }, + "contributions": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "links": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["icon", "url"], + "properties": { + "icon": { + "enum": [ + "website", + "github", + "instagram", + "twitter", + "mastodon", + "bluesky", + "discord" + ] + }, + "url": { + "$ref": "#/$defs/httpsUrl" + } + } + } + } + } + } + } +} diff --git a/.vitepress/theme/composables/contributors.ts b/.vitepress/theme/composables/contributors.ts index fcf08392..19350d96 100644 --- a/.vitepress/theme/composables/contributors.ts +++ b/.vitepress/theme/composables/contributors.ts @@ -1,135 +1,148 @@ -import allContributorsRaw from '../../../.all-contributorsrc?raw' -import contributorMetadataRaw from '../../data/contributors.json?raw' +import contributionTypesCatalog from "../../data/contribution-types.json"; +import contributorsRegistry from "../../data/contributors.json"; export interface ContributorLink { - icon: string - link: string + icon: string; + link: string; } -interface AllContributorsEntry { - login: string - name: string - avatar_url: string - profile: string - contributions: string[] +interface ContributionTypeDefinition { + key: string; + emoji: string; + label: string; + description: string; } -interface AllContributorsConfig { - contributors: AllContributorsEntry[] +interface ContributorRegistryEntry { + login: string; + name?: string; + profileUrl?: string; + avatarUrl?: string; + contributions: string[]; + links?: Array<{ + icon: string; + url: string; + }>; } -interface ContributorMetadataEntry { - links?: ContributorLink[] +interface ContributorsRegistry { + schemaVersion: 1; + contributors: ContributorRegistryEntry[]; } -type ContributorMetadata = Record +interface ContributionTypesCatalog { + schemaVersion: 1; + contributionTypes: ContributionTypeDefinition[]; +} -const linkOrder = ['website', 'github', 'instagram', 'twitter', 'mastodon', 'bluesky', 'discord'] as const +const linkOrder = [ + "website", + "github", + "instagram", + "twitter", + "mastodon", + "bluesky", + "discord", +] as const; interface Contribution { - type: string - emoji: string - name: string - description: string + type: string; + emoji: string; + name: string; + description: string; } export interface Contributor { - login: string - name: string - avatar: string - profile: string - contributions: Contribution[] - links: ContributorLink[] + login: string; + name: string; + avatar: string; + profile: string; + contributions: Contribution[]; + links: ContributorLink[]; } -const contributionTypeMap: Record> = { - audio: { emoji: '🔊', name: 'Audio', description: 'Podcasts, background music, sound effects' }, - a11y: { emoji: '♿️', name: 'Accessibility', description: 'Accessibility improvements or audits' }, - bug: { emoji: '🐛', name: 'Bug Reports', description: 'Reporting issues' }, - blog: { emoji: '📝', name: 'Blogposts', description: 'Writing blog posts about the project' }, - business: { emoji: '💼', name: 'Business Development', description: 'Business strategy or partnerships' }, - code: { emoji: '💻', name: 'Code', description: 'Commits and pull requests' }, - content: { emoji: '🖋', name: 'Content', description: 'Website copy or written material' }, - data: { emoji: '🔣', name: 'Data', description: 'Contributed datasets or test data' }, - doc: { emoji: '📖', name: 'Documentation', description: 'README, Wiki, API docs' }, - design: { emoji: '🎨', name: 'Design', description: 'UI/UX, branding, visuals' }, - example: { emoji: '💡', name: 'Examples', description: 'Usage examples' }, - eventOrganizing: { emoji: '📋', name: 'Event Organizing', description: 'Organizing project events' }, - financial: { emoji: '💵', name: 'Financial Support', description: 'Funding or donations' }, - fundingFinding: { emoji: '🔍', name: 'Funding/Grant Finding', description: 'Identifying funding sources' }, - ideas: { emoji: '🤔', name: 'Ideas & Planning', description: 'Feature proposals' }, - infra: { emoji: '🚇', name: 'Infrastructure', description: 'CI, hosting, build systems' }, - maintenance: { emoji: '🚧', name: 'Maintenance', description: 'Refactoring, upkeep' }, - mentoring: { emoji: '🧑‍🏫', name: 'Mentoring', description: 'Supporting contributors' }, - platform: { emoji: '📦', name: 'Packaging', description: 'Porting to new platforms' }, - plugin: { emoji: '🔌', name: 'Plugin/Utility Libraries', description: 'Plugin development' }, - projectManagement: { emoji: '📆', name: 'Project Management', description: 'Planning and coordination' }, - promotion: { emoji: '📣', name: 'Promotion', description: 'Social sharing' }, - question: { emoji: '💬', name: 'Answering Questions', description: 'Community support' }, - research: { emoji: '🔬', name: 'Research', description: 'Literature reviews' }, - review: { emoji: '👀', name: 'Code Review', description: 'Reviewing pull requests' }, - security: { emoji: '🛡️', name: 'Security', description: 'Privacy and security improvements' }, - tool: { emoji: '🔧', name: 'Tools', description: 'Tooling contributions' }, - translation: { emoji: '🌍', name: 'Translation', description: 'Language translations' }, - test: { emoji: '⚠️', name: 'Tests', description: 'Writing test cases' }, - tutorial: { emoji: '✅', name: 'Tutorials', description: 'Educational content' }, - talk: { emoji: '📢', name: 'Talks', description: 'Presentations and talks' }, - userTesting: { emoji: '📓', name: 'User Testing', description: 'Conducting user testing' }, - video: { emoji: '📹', name: 'Videos', description: 'Creating video content' }, +const registry = contributorsRegistry as ContributorsRegistry; +const catalog = contributionTypesCatalog as ContributionTypesCatalog; +const contributionTypes = new Map( + catalog.contributionTypes.map((contribution) => [ + contribution.key, + contribution, + ]), +); + +function getProfile(entry: ContributorRegistryEntry): string { + return entry.profileUrl ?? `https://github.com/${entry.login}`; } -const allContributors = JSON.parse(allContributorsRaw) as AllContributorsConfig -const contributorMetadata = JSON.parse(contributorMetadataRaw) as ContributorMetadata +function getAvatar(entry: ContributorRegistryEntry): string { + const url = new URL( + entry.avatarUrl ?? `https://github.com/${entry.login}.png`, + ); + url.searchParams.set("s", "160"); + return url.toString(); +} function getContribution(type: string): Contribution { - const contribution = contributionTypeMap[type] + const contribution = contributionTypes.get(type); if (!contribution) { - return { - type, - emoji: '✨', - name: type, - description: 'Community contribution', - } + throw new Error(`Unknown contributor type: ${type}`); } return { type, - ...contribution, - } + emoji: contribution.emoji, + name: contribution.label, + description: contribution.description, + }; } -function getLinks(entry: AllContributorsEntry, metadata: ContributorMetadataEntry | undefined): ContributorLink[] { - const links: ContributorLink[] = [{ icon: 'github', link: entry.profile }] - - for (const link of metadata?.links ?? []) { - if (!links.some(existing => existing.icon === link.icon && existing.link === link.link)) { - links.push(link) +function getLinks(entry: ContributorRegistryEntry): ContributorLink[] { + const links: ContributorLink[] = [ + { icon: "github", link: getProfile(entry) }, + ]; + + for (const link of entry.links ?? []) { + if ( + !links.some( + (existing) => existing.icon === link.icon && existing.link === link.url, + ) + ) { + links.push({ icon: link.icon, link: link.url }); } } return links.sort((left, right) => { - const leftIndex = linkOrder.indexOf(left.icon as (typeof linkOrder)[number]) - const rightIndex = linkOrder.indexOf(right.icon as (typeof linkOrder)[number]) - - const normalizedLeftIndex = leftIndex === -1 ? linkOrder.length : leftIndex - const normalizedRightIndex = rightIndex === -1 ? linkOrder.length : rightIndex - - return normalizedLeftIndex - normalizedRightIndex - }) + const leftIndex = linkOrder.indexOf( + left.icon as (typeof linkOrder)[number], + ); + const rightIndex = linkOrder.indexOf( + right.icon as (typeof linkOrder)[number], + ); + + const normalizedLeftIndex = leftIndex === -1 ? linkOrder.length : leftIndex; + const normalizedRightIndex = + rightIndex === -1 ? linkOrder.length : rightIndex; + + return normalizedLeftIndex - normalizedRightIndex; + }); } -export const contributors: Contributor[] = allContributors.contributors.map((entry) => ({ - login: entry.login, - name: entry.name, - avatar: `${entry.avatar_url}?s=160`, - profile: entry.profile, - contributions: entry.contributions.map(getContribution), - links: getLinks(entry, contributorMetadata[entry.login]), -})) +export const contributors: Contributor[] = registry.contributors.map( + (entry) => ({ + login: entry.login, + name: entry.name ?? entry.login, + avatar: getAvatar(entry), + profile: getProfile(entry), + contributions: entry.contributions.map(getContribution), + links: getLinks(entry), + }), +); export function findContributorByName(name: string): Contributor | null { - return contributors.find( - contributor => contributor.name === name || contributor.login === name, - ) ?? null + return ( + contributors.find( + (contributor) => contributor.name === name || contributor.login === name, + ) ?? null + ); } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c52e7fc2..6586cb00 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,16 +21,19 @@ npm run dev ## Contributor credit -This project uses the [All Contributors](https://allcontributors.org/) specification. - -Maintainers update contributor recognition with: +The textmode.js ecosystem keeps contributor profiles in +[`.vitepress/data/contributors.json`](./.vitepress/data/contributors.json) and the ordered contribution-type catalog +in [`.vitepress/data/contribution-types.json`](./.vitepress/data/contribution-types.json). Maintainers update the +relevant file and run: ```bash -npm run contributors:add -- -npm run contributors:generate +npm run contributors:validate +npm run contributors:render +npm run check:contributors ``` -Optional website-only profile links live in [`.vitepress/data/contributors.json`](./.vitepress/data/contributors.json). +The documentation site reads both canonical files directly. Automation synchronizes the same generated Contributors +section to every official textmode.js library README. ## Need help? diff --git a/README.md b/README.md index b5cebbf5..7baf7ca2 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ textmodejs_banner -| [![All Contributors](https://img.shields.io/github/all-contributors/humanbydefinition/code.textmode.art?color=ee8449&style=flat-square)](#contributors) | [![VitePress](https://img.shields.io/badge/VitePress-646CFF?logo=vitepress&logoColor=white)](https://vitepress.dev/) [![GitHub Pages](https://img.shields.io/badge/GitHub%20Pages-222?logo=github&logoColor=white)](https://pages.github.com/) | [![Discord](https://img.shields.io/discord/1357070706181017691?color=5865F2&label=Discord&logo=discord&logoColor=white)](https://discord.gg/sjrw8QXNks) [![ko-fi](https://shields.io/badge/ko--fi-donate-ff5f5f?logo=ko-fi)](https://ko-fi.com/V7V8JG2FY) | -|:-------------|:-------------|:-------------| +| [![Contributors](https://img.shields.io/badge/contributors-community-ee8449?style=flat-square)](#contributors) | [![VitePress](https://img.shields.io/badge/VitePress-646CFF?logo=vitepress&logoColor=white)](https://vitepress.dev/) [![GitHub Pages](https://img.shields.io/badge/GitHub%20Pages-222?logo=github&logoColor=white)](https://pages.github.com/) | [![Discord](https://img.shields.io/discord/1357070706181017691?color=5865F2&label=Discord&logo=discord&logoColor=white)](https://discord.gg/sjrw8QXNks) [![ko-fi](https://shields.io/badge/ko--fi-donate-ff5f5f?logo=ko-fi)](https://ko-fi.com/V7V8JG2FY) | +| :------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -47,33 +47,42 @@ We welcome contributions! There are many ways to help: - **Improve docs** - Better explanations, code examples, or tutorials - **Report issues** - Found a bug or typo? Let us know -All contributors are credited in this README and on the site. See [CONTRIBUTING.md](CONTRIBUTING.md) for details. +Ecosystem contributors are credited in this README and on the site. See [CONTRIBUTING.md](CONTRIBUTING.md) for details. + + + ## Contributors -Thanks go to these wonderful people ([emoji key](https://allcontributors.org/emoji-key)): +Thanks to the people who contribute code, documentation, design, examples, ideas, infrastructure, and care +across the textmode.js ecosystem. - - - + - - + +
humanbydefinition
humanbydefinition

💻 📖 🎨 💡 🤔 🚧 🚇 🔧 🔌 👀
trintlermint
trintlermint

🎨 💡
+ + humanbydefinition avatar +
humanbydefinition +
+
💻 📖 🎨 💡 🤔 🚧 🚇 🔧 🔌 👀 +
+ + trintlermint avatar +
trintlermint +
+
🎨 💡 +
+ - +Contribution details and profile links are maintained on the [textmode.js contributors page](https://code.textmode.art/docs/contributors). - - - -This project follows the [all-contributors](https://allcontributors.org) specification. -Contributions of any kind are welcome. -Maintainers can update this section with `npm run contributors:add -- ` -and `npm run contributors:generate`. + ## Tech stack diff --git a/docs/contributing/getting-started.md b/docs/contributing/getting-started.md index 23f74c9a..009dd7a1 100644 --- a/docs/contributing/getting-started.md +++ b/docs/contributing/getting-started.md @@ -68,8 +68,9 @@ Understanding the project layout helps you find what you need: .vitepress/ ├── config.mts # VitePress configuration ├── data/ -│ ├── sketches.json # Example sketch metadata -│ └── contributors.json # Optional contributor profile metadata +│ ├── sketches.json # Example sketch metadata +│ ├── contribution-types.json # Ordered contribution-type catalog +│ └── contributors.json # Canonical contributor profiles └── theme/ # Custom theme components docs/ # Documentation pages (you'll mostly edit here) @@ -86,8 +87,8 @@ public/ # Static assets (images, SVGs) - **[`.vitepress/config.mts`](https://github.com/humanbydefinition/code.textmode.art/blob/main/.vitepress/config.mts)** - VitePress configuration - **[`.vitepress/data/sketches.json`](https://github.com/humanbydefinition/code.textmode.art/blob/main/.vitepress/data/sketches.json)** - Example sketch metadata used by the docs -- **[`.all-contributorsrc`](https://github.com/humanbydefinition/code.textmode.art/blob/main/.all-contributorsrc)** - Canonical contributor registry -- **[`.vitepress/data/contributors.json`](https://github.com/humanbydefinition/code.textmode.art/blob/main/.vitepress/data/contributors.json)** - Optional social/profile metadata for the website +- **[`.vitepress/data/contribution-types.json`](https://github.com/humanbydefinition/code.textmode.art/blob/main/.vitepress/data/contribution-types.json)** - Ordered catalog of supported contribution keys, labels, descriptions, and emoji +- **[`.vitepress/data/contributors.json`](https://github.com/humanbydefinition/code.textmode.art/blob/main/.vitepress/data/contributors.json)** - Canonical ecosystem contributor profiles, contribution keys, and optional links ## Making changes @@ -159,14 +160,19 @@ When submitting a pull request: ## Getting credit -To be credited as a contributor, maintainers update [`.all-contributorsrc`](https://github.com/humanbydefinition/code.textmode.art/blob/main/.all-contributorsrc) with: +To credit a contributor, maintainers update the canonical +[contributors registry](https://github.com/humanbydefinition/code.textmode.art/blob/main/.vitepress/data/contributors.json) +and run: ```bash -npm run contributors:add -- your-github-username doc -npm run contributors:generate +npm run contributors:validate +npm run contributors:render +npm run check:contributors ``` -If you want additional social links to appear on the site, add them to [`.vitepress/data/contributors.json`](https://github.com/humanbydefinition/code.textmode.art/blob/main/.vitepress/data/contributors.json). +The registry stores optional profile links and references keys from the separate +[contribution-type catalog](https://github.com/humanbydefinition/code.textmode.art/blob/main/.vitepress/data/contribution-types.json). +Together they drive every official textmode.js Contributors section. You'll appear on the [Contributors page](/docs/contributors) when your PR is merged! diff --git a/docs/contributing/index.md b/docs/contributing/index.md index 09709e88..f44511cc 100644 --- a/docs/contributing/index.md +++ b/docs/contributing/index.md @@ -40,10 +40,15 @@ Ready to contribute? Check out our [Getting Started guide](/docs/contributing/ge ## Credits -All contributors are recognized on the [Contributors page](/docs/contributors) and in the [README](https://github.com/humanbydefinition/code.textmode.art#contributors) using the [All Contributors](https://allcontributors.org/) specification. - -Maintainers update contributor recognition with `npm run contributors:add -- ` and `npm run contributors:generate`. -If you want extra social links on the website, add them to [`.vitepress/data/contributors.json`](https://github.com/humanbydefinition/code.textmode.art/blob/main/.vitepress/data/contributors.json). +All ecosystem contributors are recognized on the [Contributors page](/docs/contributors) and in the +[README](https://github.com/humanbydefinition/code.textmode.art#contributors). + +Maintainers update names and optional profile links in the canonical +[contributors registry](https://github.com/humanbydefinition/code.textmode.art/blob/main/.vitepress/data/contributors.json). +The supported labels and their display order live in the separate +[contribution-type catalog](https://github.com/humanbydefinition/code.textmode.art/blob/main/.vitepress/data/contribution-types.json). +Both files are validated and rendered with `npm run check:contributors`, and GitHub automation synchronizes the +generated section to the official textmode.js library READMEs. ## Questions? diff --git a/docs/contributing/submit-a-sketch.md b/docs/contributing/submit-a-sketch.md index 52a50240..65c57e7c 100644 --- a/docs/contributing/submit-a-sketch.md +++ b/docs/contributing/submit-a-sketch.md @@ -62,10 +62,8 @@ If your sketch is for the public docs showcase: The current showcase metadata lives in [`.vitepress/data/sketches.json`](https://github.com/humanbydefinition/code.textmode.art/blob/main/.vitepress/data/sketches.json). -Contributor recognition on the site is also tied to: - -- [`.all-contributorsrc`](https://github.com/humanbydefinition/code.textmode.art/blob/main/.all-contributorsrc) -- [`.vitepress/data/contributors.json`](https://github.com/humanbydefinition/code.textmode.art/blob/main/.vitepress/data/contributors.json) +Contributor recognition on the site comes from the canonical +[contributors registry](https://github.com/humanbydefinition/code.textmode.art/blob/main/.vitepress/data/contributors.json). ## Submitting an API example sketch @@ -77,10 +75,9 @@ If your sketch is for a generated API reference example: 4. Add or update the sketch for the relevant API entry 5. Submit a pull request explaining which API entry the example improves -If you want your contribution metadata and optional profile links reflected on the `code.textmode.art` site as well, you may also need a companion PR in [`code.textmode.art`](https://github.com/humanbydefinition/code.textmode.art) touching: - -- [`.all-contributorsrc`](https://github.com/humanbydefinition/code.textmode.art/blob/main/.all-contributorsrc) -- [`.vitepress/data/contributors.json`](https://github.com/humanbydefinition/code.textmode.art/blob/main/.vitepress/data/contributors.json) +If you want your contribution metadata and optional profile links reflected on the `code.textmode.art` site as well, +you may also need a companion PR updating the canonical +[contributors registry](https://github.com/humanbydefinition/code.textmode.art/blob/main/.vitepress/data/contributors.json). If you are not sure which repository owns a given API example, open an issue first or ask in Discord before starting the implementation. diff --git a/docs/contributors.md b/docs/contributors.md index 5948d367..84ebeca9 100644 --- a/docs/contributors.md +++ b/docs/contributors.md @@ -13,7 +13,8 @@ import { contributors } from '../.vitepress/theme/composables/contributors' `textmode.js` is built with passion and maintained by creative minds who believe in making textmode graphics accessible to everyone. (人´∀`).☆.。.:\*・° :::info About this page -This page follows the [**All Contributors**](https://allcontributors.org/) specification and uses the same contributor data as the project README. +This page and every official textmode.js README use the canonical contributor registry and contribution-type catalog +maintained by `code.textmode.art`. ::: :::tip Help us grow! @@ -26,4 +27,5 @@ We welcome contributions of all sizes! Whether it's fixing a typo, adding a feat ## About the emoji key -This page uses the standard All Contributors contribution types. For the complete reference, visit the [All Contributors Emoji Key](https://allcontributors.org/emoji-key). +Each emoji describes a contribution type defined in the canonical contribution-type catalog. Hover or focus an emoji +to see its label and description. diff --git a/package.json b/package.json index c48c8ff7..0776d7e8 100644 --- a/package.json +++ b/package.json @@ -12,13 +12,16 @@ }, "scripts": { "dev": "vitepress dev --host --port 4175", - "prebuild": "npm run check:api-sandpack", + "prebuild": "npm run check:contributors && npm run check:api-sandpack", "build": "vitepress build", "preview": "vitepress preview", "api:sandpack": "node scripts/api-sandpack-examples.mjs", "check:api-sandpack": "node scripts/api-sandpack-examples.mjs --check", - "contributors:add": "npx all-contributors-cli add", - "contributors:generate": "npx all-contributors-cli generate" + "contributors:validate": "node scripts/contributors.mjs validate", + "contributors:render": "node scripts/contributors.mjs render", + "contributors:check": "node scripts/contributors.mjs check", + "test:contributors": "node --test scripts/contributors.test.mjs", + "check:contributors": "npm run test:contributors && npm run contributors:check" }, "dependencies": { "@iconify/vue": "^5.0.0", diff --git a/scripts/contributors.mjs b/scripts/contributors.mjs new file mode 100644 index 00000000..93d1aec5 --- /dev/null +++ b/scripts/contributors.mjs @@ -0,0 +1,421 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +export const START_MARKER = ""; +export const END_MARKER = ""; + +const CONTRIBUTORS_REGISTRY_URL = + "https://github.com/humanbydefinition/code.textmode.art/blob/main/.vitepress/data/contributors.json"; +const CONTRIBUTION_TYPES_URL = + "https://github.com/humanbydefinition/code.textmode.art/blob/main/.vitepress/data/contribution-types.json"; +const CONTRIBUTORS_URL = "https://code.textmode.art/docs/contributors"; +const ALLOWED_LINK_ICONS = [ + "website", + "github", + "instagram", + "twitter", + "mastodon", + "bluesky", + "discord", +]; + +function assert(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +function assertObject(value, label) { + assert( + value !== null && typeof value === "object" && !Array.isArray(value), + `${label} must be an object`, + ); +} + +function assertNonEmptyString(value, label) { + assert( + typeof value === "string" && value.trim().length > 0, + `${label} must be a non-empty string`, + ); +} + +function assertHttpsUrl(value, label) { + assertNonEmptyString(value, label); + + let url; + try { + url = new URL(value); + } catch { + throw new Error(`${label} must be a valid URL`); + } + + assert(url.protocol === "https:", `${label} must use HTTPS`); + assert( + url.username === "" && url.password === "", + `${label} must not contain credentials`, + ); +} + +function assertKnownKeys(object, allowedKeys, label) { + for (const key of Object.keys(object)) { + assert( + allowedKeys.includes(key), + `${label} contains unknown property "${key}"`, + ); + } +} + +export function validateContributionTypes(catalog) { + assertObject(catalog, "contribution type catalog"); + assertKnownKeys( + catalog, + ["$schema", "schemaVersion", "contributionTypes"], + "contribution type catalog", + ); + assert( + catalog.schemaVersion === 1, + `unsupported contribution types schema version: ${catalog.schemaVersion}`, + ); + + assert( + Array.isArray(catalog.contributionTypes) && + catalog.contributionTypes.length > 0, + "contributionTypes must be a non-empty array", + ); + const seenKeys = new Set(); + + for (const [index, definition] of catalog.contributionTypes.entries()) { + const label = `contributionTypes[${index}]`; + assertObject(definition, label); + assertKnownKeys( + definition, + ["key", "emoji", "label", "description"], + label, + ); + assertNonEmptyString(definition.key, `${label}.key`); + assert( + /^[A-Za-z][A-Za-z0-9]*$/.test(definition.key), + `${label}.key is invalid`, + ); + const normalizedKey = definition.key.toLowerCase(); + assert( + !seenKeys.has(normalizedKey), + `duplicate contribution type key "${definition.key}"`, + ); + seenKeys.add(normalizedKey); + assertNonEmptyString(definition.emoji, `${label}.emoji`); + assertNonEmptyString(definition.label, `${label}.label`); + assertNonEmptyString(definition.description, `${label}.description`); + } + + return catalog; +} + +export function validateRegistry(registry, catalog) { + validateContributionTypes(catalog); + assertObject(registry, "registry"); + assertKnownKeys( + registry, + ["$schema", "schemaVersion", "contributors"], + "registry", + ); + assert( + registry.schemaVersion === 1, + `unsupported contributors schema version: ${registry.schemaVersion}`, + ); + + const contributionTypeIds = catalog.contributionTypes.map( + (definition) => definition.key, + ); + const contributionTypeKeys = new Set(contributionTypeIds); + + assert(Array.isArray(registry.contributors), "contributors must be an array"); + const seenLogins = new Set(); + + for (const [index, contributor] of registry.contributors.entries()) { + const label = `contributors[${index}]`; + assertObject(contributor, label); + assertKnownKeys( + contributor, + ["login", "name", "profileUrl", "avatarUrl", "contributions", "links"], + label, + ); + assertNonEmptyString(contributor.login, `${label}.login`); + assert( + /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(contributor.login), + `${label}.login is not a valid GitHub login`, + ); + + const normalizedLogin = contributor.login.toLowerCase(); + assert( + !seenLogins.has(normalizedLogin), + `duplicate contributor login "${contributor.login}"`, + ); + seenLogins.add(normalizedLogin); + + if (contributor.name !== undefined) { + assertNonEmptyString(contributor.name, `${label}.name`); + } + if (contributor.profileUrl !== undefined) { + assertHttpsUrl(contributor.profileUrl, `${label}.profileUrl`); + } + if (contributor.avatarUrl !== undefined) { + assertHttpsUrl(contributor.avatarUrl, `${label}.avatarUrl`); + } + + assert( + Array.isArray(contributor.contributions) && + contributor.contributions.length > 0, + `${label}.contributions must be a non-empty array`, + ); + + const seenContributions = new Set(); + let previousContributionIndex = -1; + for (const contribution of contributor.contributions) { + assertNonEmptyString(contribution, `${label}.contributions entry`); + assert( + contributionTypeKeys.has(contribution), + `${label} references unknown contribution type "${contribution}"`, + ); + assert( + !seenContributions.has(contribution), + `${label} repeats contribution type "${contribution}"`, + ); + seenContributions.add(contribution); + + const contributionIndex = contributionTypeIds.indexOf(contribution); + assert( + contributionIndex > previousContributionIndex, + `${label}.contributions must follow contribution type catalog order`, + ); + previousContributionIndex = contributionIndex; + } + + if (contributor.links !== undefined) { + assert( + Array.isArray(contributor.links), + `${label}.links must be an array`, + ); + const seenLinks = new Set(); + let previousLinkIndex = -1; + + for (const [linkIndex, link] of contributor.links.entries()) { + const linkLabel = `${label}.links[${linkIndex}]`; + assertObject(link, linkLabel); + assertKnownKeys(link, ["icon", "url"], linkLabel); + assert( + ALLOWED_LINK_ICONS.includes(link.icon), + `${linkLabel}.icon is unsupported`, + ); + assertHttpsUrl(link.url, `${linkLabel}.url`); + + const identity = `${link.icon}\u0000${link.url}`; + assert(!seenLinks.has(identity), `${label}.links contains a duplicate`); + seenLinks.add(identity); + + const currentLinkIndex = ALLOWED_LINK_ICONS.indexOf(link.icon); + assert( + currentLinkIndex > previousLinkIndex, + `${label}.links must follow the supported icon order`, + ); + previousLinkIndex = currentLinkIndex; + } + } + } + + return registry; +} + +export function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function getProfileUrl(contributor) { + return contributor.profileUrl ?? `https://github.com/${contributor.login}`; +} + +function getAvatarUrl(contributor, size) { + const url = new URL( + contributor.avatarUrl ?? `https://github.com/${contributor.login}.png`, + ); + url.searchParams.set("s", String(size)); + return url.toString(); +} + +export function renderContributorsSection(registry, catalog) { + validateRegistry(registry, catalog); + const contributionTypes = new Map( + catalog.contributionTypes.map((definition) => [definition.key, definition]), + ); + + const rows = []; + for (let index = 0; index < registry.contributors.length; index += 7) { + const cells = registry.contributors + .slice(index, index + 7) + .map((contributor) => { + const name = contributor.name ?? contributor.login; + const profileUrl = getProfileUrl(contributor); + const contributions = contributor.contributions + .map((type) => { + const definition = contributionTypes.get(type); + const title = `${definition.label}: ${definition.description}`; + return `${escapeHtml(definition.emoji)}`; + }) + .join(" "); + + return [ + ' ', + ` `, + ` ${escapeHtml(name)} avatar`, + `
${escapeHtml(name)}`, + "
", + `
${contributions}`, + " ", + ].join("\n"); + }); + + rows.push([" ", ...cells, " "].join("\n")); + } + + return [ + START_MARKER, + "", + ``, + "## Contributors", + "", + "Thanks to the people who contribute code, documentation, design, examples, ideas, infrastructure, and care", + "across the textmode.js ecosystem.", + "", + "", + "", + " ", + ...rows, + " ", + "
", + "", + "", + `Contribution details and profile links are maintained on the [textmode.js contributors page](${CONTRIBUTORS_URL}).`, + "", + END_MARKER, + ].join("\n"); +} + +export function replaceContributorsSection(readme, renderedSection) { + const startCount = readme.split(START_MARKER).length - 1; + const endCount = readme.split(END_MARKER).length - 1; + assert( + startCount === 1, + `README must contain exactly one ${START_MARKER} marker`, + ); + assert( + endCount === 1, + `README must contain exactly one ${END_MARKER} marker`, + ); + + const startIndex = readme.indexOf(START_MARKER); + const endIndex = readme.indexOf(END_MARKER); + assert(startIndex < endIndex, "README contributor markers are out of order"); + + const afterEnd = endIndex + END_MARKER.length; + return `${readme.slice(0, startIndex)}${renderedSection}${readme.slice(afterEnd)}`; +} + +async function loadJson(jsonPath, label) { + const source = await readFile(jsonPath, "utf8"); + try { + return JSON.parse(source); + } catch (error) { + throw new Error(`invalid ${label} JSON: ${error.message}`); + } +} + +export async function loadContributorData(registryPath, contributionTypesPath) { + const [registry, catalog] = await Promise.all([ + loadJson(registryPath, "contributor registry"), + loadJson(contributionTypesPath, "contribution type catalog"), + ]); + validateRegistry(registry, catalog); + return { registry, catalog }; +} + +function parseArguments(argv) { + const [command, ...rest] = argv; + const options = {}; + + for (let index = 0; index < rest.length; index += 1) { + const argument = rest[index]; + assert(argument.startsWith("--"), `unexpected argument "${argument}"`); + const name = argument.slice(2); + const value = rest[index + 1]; + assert(value && !value.startsWith("--"), `missing value for --${name}`); + options[name] = value; + index += 1; + } + + return { command, options }; +} + +export async function runCli(argv) { + const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); + const projectRoot = path.resolve(scriptDirectory, ".."); + const { command, options } = parseArguments(argv); + const registryPath = path.resolve( + options.registry ?? + path.join(projectRoot, ".vitepress/data/contributors.json"), + ); + const contributionTypesPath = path.resolve( + options.types ?? + path.join(projectRoot, ".vitepress/data/contribution-types.json"), + ); + const readmePath = path.resolve( + options.readme ?? path.join(projectRoot, "README.md"), + ); + const { registry, catalog } = await loadContributorData( + registryPath, + contributionTypesPath, + ); + + if (command === "validate") { + return; + } + + assert( + command === "render" || command === "check", + "usage: contributors.mjs [--registry path] [--types path] [--readme path]", + ); + const readme = await readFile(readmePath, "utf8"); + const rendered = replaceContributorsSection( + readme, + renderContributorsSection(registry, catalog), + ); + + if (command === "check") { + assert( + rendered === readme, + `${readmePath} contributor section is out of date`, + ); + return; + } + + if (rendered !== readme) { + await writeFile(readmePath, rendered); + } +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + runCli(process.argv.slice(2)).catch((error) => { + console.error(`contributors: ${error.message}`); + process.exitCode = 1; + }); +} diff --git a/scripts/contributors.test.mjs b/scripts/contributors.test.mjs new file mode 100644 index 00000000..4ab351b9 --- /dev/null +++ b/scripts/contributors.test.mjs @@ -0,0 +1,221 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + END_MARKER, + START_MARKER, + escapeHtml, + renderContributorsSection, + replaceContributorsSection, + validateContributionTypes, + validateRegistry, +} from "./contributors.mjs"; + +function createCatalog(overrides = {}) { + return { + schemaVersion: 1, + contributionTypes: [ + { + key: "code", + emoji: "💻", + label: "Code", + description: "Commits and pull requests", + }, + { + key: "doc", + emoji: "📖", + label: "Documentation", + description: "Documentation improvements", + }, + ], + ...overrides, + }; +} + +function createRegistry(overrides = {}) { + return { + schemaVersion: 1, + contributors: [ + { + login: "octocat", + name: "Octo Cat", + contributions: ["code", "doc"], + links: [{ icon: "website", url: "https://example.com" }], + }, + ], + ...overrides, + }; +} + +test("renders deterministically and is idempotent", () => { + const registry = createRegistry(); + const catalog = createCatalog(); + const section = renderContributorsSection(registry, catalog); + const readme = `# Example\n\n${START_MARKER}\nold\n${END_MARKER}\n`; + const rendered = replaceContributorsSection(readme, section); + + assert.equal(replaceContributorsSection(rendered, section), rendered); + assert.match(rendered, /Octo Cat/); + assert.match(rendered, /Code: Commits and pull requests/); +}); + +test("wraps contributor cards after seven entries in registry order", () => { + const contributors = Array.from({ length: 8 }, (_, index) => ({ + login: `person-${index}`, + name: `Person ${index}`, + contributions: ["code"], + })); + const section = renderContributorsSection( + createRegistry({ contributors }), + createCatalog(), + ); + + assert.equal(section.match(//g)?.length, 2); + assert.ok(section.indexOf("Person 0") < section.indexOf("Person 7")); +}); + +test("rejects duplicate logins regardless of case", () => { + const registry = createRegistry({ + contributors: [ + { login: "octocat", contributions: ["code"] }, + { login: "OctoCat", contributions: ["code"] }, + ], + }); + + assert.throws( + () => validateRegistry(registry, createCatalog()), + /duplicate contributor login/, + ); +}); + +test("rejects unknown and out-of-order contribution types", () => { + assert.throws( + () => + validateRegistry( + createRegistry({ + contributors: [{ login: "octocat", contributions: ["unknown"] }], + }), + createCatalog(), + ), + /unknown contribution type/, + ); + assert.throws( + () => + validateRegistry( + createRegistry({ + contributors: [{ login: "octocat", contributions: ["doc", "code"] }], + }), + createCatalog(), + ), + /catalog order/, + ); +}); + +test("rejects unsupported schema versions, insecure URLs, and unsupported link icons", () => { + assert.throws( + () => + validateRegistry(createRegistry({ schemaVersion: 2 }), createCatalog()), + /unsupported/, + ); + assert.throws( + () => + validateRegistry(createRegistry(), createCatalog({ schemaVersion: 2 })), + /unsupported/, + ); + assert.throws( + () => + validateRegistry( + createRegistry({ + contributors: [ + { + login: "octocat", + contributions: ["code"], + links: [{ icon: "website", url: "http://example.com" }], + }, + ], + }), + createCatalog(), + ), + /must use HTTPS/, + ); + assert.throws( + () => + validateRegistry( + createRegistry({ + contributors: [ + { + login: "octocat", + contributions: ["code"], + links: [{ icon: "untrusted", url: "https://example.com" }], + }, + ], + }), + createCatalog(), + ), + /icon is unsupported/, + ); +}); + +test("rejects duplicate contribution type keys regardless of case", () => { + const catalog = createCatalog({ + contributionTypes: [ + { + key: "code", + emoji: "💻", + label: "Code", + description: "Code", + }, + { + key: "Code", + emoji: "✨", + label: "Other code", + description: "Other code", + }, + ], + }); + + assert.throws( + () => validateContributionTypes(catalog), + /duplicate contribution type key/, + ); +}); + +test("fails closed for absent, duplicate, and reversed markers", () => { + const section = renderContributorsSection(createRegistry(), createCatalog()); + assert.throws( + () => replaceContributorsSection("# Missing\n", section), + /exactly one/, + ); + assert.throws( + () => + replaceContributorsSection( + `${START_MARKER}\n${START_MARKER}\n${END_MARKER}`, + section, + ), + /exactly one/, + ); + assert.throws( + () => replaceContributorsSection(`${END_MARKER}\n${START_MARKER}`, section), + /out of order/, + ); +}); + +test("escapes HTML-sensitive contributor content", () => { + const registry = createRegistry({ + contributors: [ + { + login: "octocat", + name: 'Miyuki ', + contributions: ["code"], + }, + ], + }); + const section = renderContributorsSection(registry, createCatalog()); + + assert.doesNotMatch(section, /