Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ body:
value: |
Thanks for taking the time to report a bug.

**A specific program card** should use the Program card template, or Send feedback on that card's page.
**New program cards** belong under `releases/` via a pull request — see the [README](https://github.com/TomWhitwell/Workshop_Computer/blob/main/README.md). Do not use this template to propose a new card.
- type: input
id: summary
Expand Down
34 changes: 34 additions & 0 deletions .github/ISSUE_TEMPLATE/card_feedback.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Program card
description: Report a problem or suggest an improvement for a specific program card
title: "[Card]: "
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report this.

**Chat and designer questions** belong in [Discord](https://discord.com/channels/1210238368898879569/1484219323039092938).
**New program cards** belong under `releases/` via a pull request — see the [README](https://github.com/TomWhitwell/Workshop_Computer/blob/main/README.md).
**Catalogue / website issues** should use the Website feedback template.
- type: input
id: card
attributes:
label: Card
description: Which program card is this about? (prefilled from the card page)
placeholder: 03 Turing Machine
validations:
required: true
- type: input
id: summary
attributes:
label: Summary
description: A short description of the problem or idea
validations:
required: true
- type: textarea
id: details
attributes:
label: Details
description: What happened, or what should change? Include firmware version, steps, or screenshots if they help.
validations:
required: true
1 change: 1 addition & 0 deletions .github/ISSUE_TEMPLATE/feature_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ body:
value: |
Thanks for suggesting an improvement.

**A specific program card** should use the Program card template, or Send feedback on that card's page.
**New program cards** belong under `releases/` via a pull request — see the [README](https://github.com/TomWhitwell/Workshop_Computer/blob/main/README.md). Do not use this template to propose a new card.
- type: input
id: summary
Expand Down
33 changes: 33 additions & 0 deletions .github/ISSUE_TEMPLATE/website_feedback.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Website feedback
description: Report a problem or suggest an improvement for the program-card catalogue site
title: "[Website]: "
labels: ["Website"]
body:
- type: markdown
attributes:
value: |
Thanks for helping improve [computer.musicthing.co.uk](https://computer.musicthing.co.uk/).

**Chat and designer questions** belong in [Discord](https://discord.com/channels/1210238368898879569/1484219323039092938).
**A specific program card** should use the Program card template, or Send feedback on that card's page.
**New program cards** belong under `releases/` via a pull request — see the [README](https://github.com/TomWhitwell/Workshop_Computer/blob/main/README.md).
- type: input
id: summary
attributes:
label: Summary
description: A short description of the problem or idea
validations:
required: true
- type: input
id: page
attributes:
label: Page
description: Which page is this about? A URL is ideal.
placeholder: https://computer.musicthing.co.uk/
- type: textarea
id: details
attributes:
label: Details
description: What happened, or what should change? Include screenshots if they help.
validations:
required: true
7 changes: 4 additions & 3 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
name: Build and Deploy GitHub Pages

on:
push:
branches: [ "main" ]
workflow_run:
workflows: ["Synchronize repository metadata"]
types: [completed]
workflow_dispatch:

permissions:
Expand All @@ -16,7 +17,7 @@ concurrency:

jobs:
build:
if: github.event.repository.fork != true || github.event_name == 'workflow_dispatch'
if: github.event_name == 'workflow_dispatch' || (github.event.repository.fork != true && github.event.workflow_run.conclusion == 'success')
runs-on: ubuntu-latest
env:
# Fork previews retain their own github.io project URL. The upstream
Expand Down
16 changes: 12 additions & 4 deletions .github/workflows/sync-curation.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Synchronize site curation
name: Synchronize repository metadata

on:
push:
Expand All @@ -7,14 +7,15 @@ on:
workflow_dispatch:

concurrency:
group: sync-site-curation
group: sync-repository-metadata
cancel-in-progress: true

permissions:
contents: write
issues: write

jobs:
sync-curation:
sync-metadata:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
Expand Down Expand Up @@ -45,4 +46,11 @@ jobs:
git commit -m 'Synchronize site curation'
git push
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Synchronize program-card issue labels
if: github.repository == 'TomWhitwell/Workshop_Computer'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: npm run sync-card-labels
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"validate-info": "npm --prefix tools/sitegen run validate-info",
"check-curation": "npm --prefix tools/sitegen run check-curation",
"sync-curation": "npm --prefix tools/sitegen run sync-curation",
"sync-card-labels": "npm --prefix tools/sitegen run sync-card-labels",
"setup-panel-renderer": "npm --prefix tools/sitegen run setup-panel-renderer",
"hooks:install": "node tools/sitegen/src/validate/installHooks.js",
"validate-staged": "node tools/sitegen/src/validate/validateStaged.js",
Expand Down
1 change: 1 addition & 0 deletions tools/sitegen/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"validate-info": "node ./src/validate/cli.js",
"check-curation": "node ./src/curation/cli.js check",
"sync-curation": "node ./src/curation/cli.js sync",
"sync-card-labels": "node ./src/github/syncCardLabels.js",
"setup-panel-renderer": "playwright install --with-deps chromium",
"test": "npm run build && node --test"
},
Expand Down
2 changes: 1 addition & 1 deletion tools/sitegen/src/curation/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ function readDocument(file) {
return document;
}

function releaseCards() {
export function releaseCards() {
return fs.readdirSync(RELEASES_DIR, { withFileTypes: true })
.filter(entry => entry.isDirectory() && fs.existsSync(path.join(RELEASES_DIR, entry.name, 'info.yaml')))
.map(entry => {
Expand Down
87 changes: 87 additions & 0 deletions tools/sitegen/src/github/syncCardLabels.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { releaseCards } from '../curation/cli.js';
import {
NEW_CARD_LABEL_COLOR,
UPSTREAM_REPO,
cardIssueLabelsFromCards,
} from '../render/githubIssue.js';

const DEFAULT_DESCRIPTION = 'Program card';

export function missingCardIssueLabels(desired, existing) {
const have = new Set((existing || []).map(name => String(name)));
return (desired || []).filter(label => !have.has(label));
}

function gh(args, { token } = {}) {
const env = { ...process.env };
if (token) {
env.GH_TOKEN = token;
env.GITHUB_TOKEN = token;
}
const result = spawnSync('gh', args, { encoding: 'utf8', env });
if (result.error) throw result.error;
if (result.status !== 0) {
const detail = (result.stderr || result.stdout || '').trim() || `gh exited ${result.status}`;
throw new Error(detail);
}
return result.stdout;
}

function listExistingLabels(repo, token) {
const stdout = gh(['label', 'list', '--repo', repo, '--json', 'name', '--limit', '1000'], { token });
const parsed = JSON.parse(stdout || '[]');
return parsed.map(entry => entry.name).filter(Boolean);
}

function createLabel(repo, name, token) {
gh([
'label', 'create', name,
'--repo', repo,
'--color', NEW_CARD_LABEL_COLOR,
'--description', DEFAULT_DESCRIPTION,
], { token });
}

export function syncCardIssueLabels({
cards = releaseCards(),
repo = process.env.GH_REPO || UPSTREAM_REPO,
token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || '',
dryRun = process.argv.includes('--dry-run') || !token,
log = console,
} = {}) {
const desired = cardIssueLabelsFromCards(cards);
if (dryRun) {
log.log(`Would ensure ${desired.length} program-card label(s) on ${repo} (dry run).`);
for (const label of desired) log.log(` ${label}`);
return { desired, created: [], skipped: desired, dryRun: true };
}

const existing = listExistingLabels(repo, token);
const missing = missingCardIssueLabels(desired, existing);
const created = [];
for (const label of missing) {
try {
createLabel(repo, label, token);
created.push(label);
log.log(`Created label: ${label}`);
} catch (error) {
if (/already exists/i.test(error.message)) continue;
throw new Error(`Failed to create label "${label}": ${error.message}`);
}
}
if (!created.length) log.log(`Program-card labels are already synchronized (${desired.length} labels).`);
else log.log(`Created ${created.length} program-card label(s).`);
return { desired, created, skipped: missing.filter(label => !created.includes(label)), dryRun: false };
}

if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
syncCardIssueLabels();
} catch (error) {
console.error(`error: ${error.message}`);
process.exitCode = 1;
}
}
6 changes: 5 additions & 1 deletion tools/sitegen/src/render/cardPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { panelPositions } from './panelPositions.js';
import { renderMarkdownBlock, renderMarkdownInline, sanitizeAuthoredHtml } from '../utils/markdown.js';
import { instagramEmbedHtml } from '../utils/instagram.js';
import { cardFeedbackHostLabel, cardFeedbackUrl } from './githubIssue.js';
import { externalLinkArrow } from './icons.js';

const DEFAULT_DISCUSSION = 'https://discord.com/channels/1210238368898879569/1484219323039092938';
Expand Down Expand Up @@ -417,6 +418,8 @@ export function renderCardArticle({ card, panelImg, yamlUrl, uf2Url, extraDocs =
const hasPanel = !basic && hasPanelDefinition(card);
const panelRail = hasPanel ? renderPanelRail(card, panelImg) : '';
const discussionUrl = metadata.discussion_url || DEFAULT_DISCUSSION;
const feedbackUrl = cardFeedbackUrl(card);
const feedbackHost = cardFeedbackHostLabel(card);
const firstVideo = Array.isArray(card.videos) && card.videos[0];
const sourceLinkUrl = metadata.repository || sourceUrl;
const sourceLinkLabel = metadata.repository ? 'Upstream repository' : 'Release folder in the Workshop Computer repo';
Expand Down Expand Up @@ -468,7 +471,7 @@ export function renderCardArticle({ card, panelImg, yamlUrl, uf2Url, extraDocs =
${basic || !memoryMarkup ? '' : `<div class="program-card-hero__meta">${memoryMarkup}</div>`}
<div class="program-card-actions" aria-label="Card actions">${downloadActions}${editorAction}</div>
<div class="program-card-sha" data-sha-display role="status" aria-live="polite" hidden>SHA256: <code class="program-card-sha__value" data-sha-value></code> <button type="button" class="program-card-sha__verify" data-verify-open>How to verify</button></div>
<div class="program-card-hero__links" aria-label="Further card links">${documentation ? `<a href="#card-documentation">Read more</a>` : ''}<a href="${esc(discussionUrl)}">Support &amp; questions</a><button id="connectToggle" class="connect-toggle" type="button" role="switch" aria-checked="false" aria-label="Connect to RP2040 via WebUSB" title="Reboot computer into programming mode before connecting"><span class="c-status" aria-hidden="true"></span><span class="c-label">Connect workshop computer</span></button></div>
<div class="program-card-hero__links" aria-label="Further card links">${documentation ? `<a href="#card-documentation">Read more</a>` : ''}<a href="${esc(discussionUrl)}">Support &amp; questions</a><a href="${esc(feedbackUrl)}">Send feedback</a><button id="connectToggle" class="connect-toggle" type="button" role="switch" aria-checked="false" aria-label="Connect to RP2040 via WebUSB" title="Reboot computer into programming mode before connecting"><span class="c-status" aria-hidden="true"></span><span class="c-label">Connect workshop computer</span></button></div>
</div>
</header>`;

Expand Down Expand Up @@ -502,6 +505,7 @@ export function renderCardArticle({ card, panelImg, yamlUrl, uf2Url, extraDocs =
${readmeUrl ? `<div><dt>Read more</dt><dd><a href="${esc(readmeUrl)}">README in the Workshop Computer repo</a></dd></div>` : ''}
${sourceLinkUrl ? `<div><dt>Source</dt><dd><a href="${esc(sourceLinkUrl)}">${sourceLinkLabel}</a></dd></div>` : ''}
<div><dt>Support</dt><dd><a href="${esc(discussionUrl)}">Ask questions, contact the designer, or share feedback</a></dd></div>
<div><dt>Feedback</dt><dd><a href="${esc(feedbackUrl)}">Report an issue on ${esc(feedbackHost)}</a></dd></div>
</dl></details>
${notesMarkup}
${dataSources}
Expand Down
105 changes: 105 additions & 0 deletions tools/sitegen/src/render/githubIssue.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
export const UPSTREAM_REPO = 'TomWhitwell/Workshop_Computer';
export const UPSTREAM_ISSUES = `https://github.com/${UPSTREAM_REPO}`;
export const GITHUB_LABEL_MAX = 50;
export const NEW_CARD_LABEL_COLOR = 'c5def5';
export const SKIP_CARD_LABEL_IDS = new Set(['02_comingsoon', '77_Placeholder']);

export function cardFolderNumber(id) {
return String(id || '').split('_')[0].trim();
}

export function cardIssueLabel({ id, title } = {}) {
const number = cardFolderNumber(id);
const name = String(title || '').replace(/\s+/g, ' ').trim();
let label = !number ? name
: !name ? number
: (name === number || name.startsWith(`${number} `)) ? name
: `${number} ${name}`;
label = label.replace(/[\u0000-\u001f]/g, '').trim();
if (label.length > GITHUB_LABEL_MAX) label = label.slice(0, GITHUB_LABEL_MAX).trimEnd();
return label;
}

export function cardIssueLabelsFromCards(cards) {
const labels = [];
const seen = new Set();
for (const card of Array.isArray(cards) ? cards : []) {
if (!card || SKIP_CARD_LABEL_IDS.has(card.id)) continue;
const label = cardIssueLabel(card);
if (!label || seen.has(label)) continue;
seen.add(label);
labels.push(label);
}
return labels;
}

export function parseForgeRepository(raw) {
const text = String(raw || '').trim();
if (!text) return null;
let url;
try {
url = new URL(text);
} catch {
return null;
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
const host = url.hostname.replace(/^www\./i, '').toLowerCase();
const parts = url.pathname.replace(/\/+$/, '').split('/').filter(Boolean);
if (parts.length < 2) return null;
const owner = parts[0];
const repo = parts[1].replace(/\.git$/i, '');
if (!owner || !repo) return null;
if (/^(orgs|settings|login|marketplace|topics|features)$/i.test(owner)) return null;
const kind = host === 'github.com' ? 'github'
: host === 'gitlab.com' ? 'gitlab'
: 'forgejo';
return { host, owner, repo, origin: `${url.protocol}//${url.host}`, kind };
}

export function isCanonicalWorkshopRepo(parsed) {
return parsed?.kind === 'github'
&& String(parsed.owner).toLowerCase() === 'tomwhitwell'
&& String(parsed.repo).toLowerCase() === 'workshop_computer';
}

export function cardFeedbackHostLabel(card) {
const parsed = parseForgeRepository(card?.metadata?.repository);
if (parsed && !isCanonicalWorkshopRepo(parsed)) {
if (parsed.host === 'github.com') return 'GitHub';
if (parsed.host === 'codeberg.org') return 'Codeberg';
if (parsed.host === 'gitlab.com') return 'GitLab';
return parsed.host;
}
return 'GitHub';
}

export function websiteFeedbackUrl() {
return `${UPSTREAM_ISSUES}/issues/new?template=website_feedback.yml`;
}

function catalogueFeedbackUrl(card) {
const label = cardIssueLabel(card);
const params = new URLSearchParams({
template: 'card_feedback.yml',
labels: label,
card: label,
});
return `${UPSTREAM_ISSUES}/issues/new?${params}`;
}

function externalIssuesUrl(parsed, card) {
const title = cardIssueLabel(card);
if (parsed.kind === 'gitlab') {
const params = new URLSearchParams({ 'issue[title]': title ? `${title}: ` : '' });
return `${parsed.origin}/${parsed.owner}/${parsed.repo}/-/issues/new?${params}`;
}
const base = `${parsed.origin}/${parsed.owner}/${parsed.repo}/issues/new`;
if (!title) return base;
return `${base}?${new URLSearchParams({ title: `${title}: ` })}`;
}

export function cardFeedbackUrl(card) {
const parsed = parseForgeRepository(card?.metadata?.repository);
if (parsed && !isCanonicalWorkshopRepo(parsed)) return externalIssuesUrl(parsed, card);
return catalogueFeedbackUrl(card);
}
Loading
Loading