From 9bc2be721bf541efcb618298ec73b4999d8092ff Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 6 Oct 2026 18:13:39 -0500 Subject: [PATCH] feat: changelog.md release notes, v tags, plugin release notes and videos --- .github/workflows/electron.yml | 22 +- .github/workflows/release.yml | 53 +--- CHANGELOG.md | 15 ++ Casks/termix.rb | 2 +- RELEASE_NOTES.md | 58 ---- electron/main.cjs | 8 + package-lock.json | 2 +- package.json | 2 +- packages/plugin-sdk/README.md | 1 + .../plugin-sdk/cli/commands/changelog.mjs | 25 ++ packages/plugin-sdk/cli/commands/validate.mjs | 40 ++- packages/plugin-sdk/cli/index.mjs | 3 + packages/plugin-sdk/cli/lib/changelog.mjs | 126 --------- packages/plugin-sdk/cli/lib/tmxplug.mjs | 2 +- packages/plugin-sdk/package.json | 7 +- .../plugin-sdk/schema/changelog.schema.json | 67 ----- .../plugin-sdk/schema/manifest.schema.json | 5 + packages/plugin-sdk/src/changelog.ts | 249 ++++++++++++++++++ packages/plugin-sdk/src/manifest.ts | 45 ++++ packaging/flatpak/com.karmaa.termix.yml | 4 +- scripts/check-changelog.cjs | 19 ++ scripts/generate-appstore-notes.cjs | 48 +--- scripts/generate-appstore-notes.test.ts | 94 +++---- scripts/generate-release-body.cjs | 109 +++----- scripts/generate-release-body.test.ts | 46 ++++ scripts/lib/bundled-plugins.cjs | 2 +- scripts/lib/changelog-section.cjs | 24 ++ scripts/plugin-changelog.test.ts | 246 ++++++++++------- scripts/publish-youtube.cjs | 121 --------- scripts/publish-youtube.test.ts | 55 ---- src/backend/database/routes/plugins.ts | 47 ++++ src/backend/plugins/manage.ts | 44 ++++ src/backend/plugins/manifest.ts | 1 + src/backend/plugins/registry-index.ts | 12 +- .../tests/database/routes/plugins.test.ts | 17 ++ src/backend/tests/plugins/manage.test.ts | 37 +++ src/backend/tests/plugins/manifest.test.ts | 45 ++++ .../tests/plugins/registry-index.test.ts | 22 ++ src/ui/api/plugins-api.ts | 15 ++ src/ui/locales/en.json | 14 +- src/ui/plugins/PluginDetail.tsx | 52 +--- src/ui/plugins/PluginReleaseNotes.tsx | 197 ++++++++++++++ src/ui/plugins/plugin-model.ts | 80 ++++++ .../tests/plugins/PluginReleaseNotes.test.tsx | 107 ++++++++ src/ui/tests/plugins/plugin-model.test.ts | 88 +++++++ 45 files changed, 1462 insertions(+), 816 deletions(-) create mode 100644 CHANGELOG.md delete mode 100644 RELEASE_NOTES.md create mode 100644 packages/plugin-sdk/cli/commands/changelog.mjs delete mode 100644 packages/plugin-sdk/cli/lib/changelog.mjs delete mode 100644 packages/plugin-sdk/schema/changelog.schema.json create mode 100644 packages/plugin-sdk/src/changelog.ts create mode 100644 scripts/check-changelog.cjs create mode 100644 scripts/generate-release-body.test.ts create mode 100644 scripts/lib/changelog-section.cjs delete mode 100644 scripts/publish-youtube.cjs delete mode 100644 scripts/publish-youtube.test.ts create mode 100644 src/ui/plugins/PluginReleaseNotes.tsx create mode 100644 src/ui/tests/plugins/PluginReleaseNotes.test.tsx diff --git a/.github/workflows/electron.yml b/.github/workflows/electron.yml index 576af8533..9dba3910e 100644 --- a/.github/workflows/electron.yml +++ b/.github/workflows/electron.yml @@ -355,8 +355,8 @@ jobs: convert public/icon.png -resize 128x128 flatpak-build/icon-128.png cd flatpak-build - sed -i "s|https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_x64_appimage.AppImage|file://$(realpath ../release/termix_linux_x64_appimage.AppImage)|g" com.karmaa.termix.yml - sed -i "s|https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_arm64_appimage.AppImage|file://$(realpath ../release/termix_linux_arm64_appimage.AppImage)|g" com.karmaa.termix.yml + sed -i "s|https://github.com/Termix-SSH/Termix/releases/download/vVERSION_PLACEHOLDER/termix_linux_x64_appimage.AppImage|file://$(realpath ../release/termix_linux_x64_appimage.AppImage)|g" com.karmaa.termix.yml + sed -i "s|https://github.com/Termix-SSH/Termix/releases/download/vVERSION_PLACEHOLDER/termix_linux_arm64_appimage.AppImage|file://$(realpath ../release/termix_linux_arm64_appimage.AppImage)|g" com.karmaa.termix.yml sed -i "s/CHECKSUM_X64_PLACEHOLDER/$CHECKSUM_X64/g" com.karmaa.termix.yml sed -i "s/CHECKSUM_ARM64_PLACEHOLDER/$CHECKSUM_ARM64/g" com.karmaa.termix.yml sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" com.karmaa.termix.metainfo.xml @@ -382,7 +382,7 @@ jobs: run: | VERSION="${{ steps.flatpak-version.outputs.version }}" cp packaging/flatpak/com.karmaa.termix.flatpakref release/ - sed -i "s|VERSION_PLACEHOLDER|release-${VERSION}-tag|g" release/com.karmaa.termix.flatpakref + sed -i "s|VERSION_PLACEHOLDER|v${VERSION}|g" release/com.karmaa.termix.flatpakref - name: Upload Flatpak bundle uses: actions/upload-artifact@v7 @@ -618,7 +618,7 @@ jobs: sed -i '' "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-generated/termix.rb sed -i '' "s|version \".*\"|version \"$VERSION\"|g" homebrew-generated/termix.rb sed -i '' "s|sha256 \".*\"|sha256 \"$CHECKSUM\"|g" homebrew-generated/termix.rb - sed -i '' "s|release-[0-9.]*-tag|release-$VERSION-tag|g" homebrew-generated/termix.rb + sed -i '' "s|download/[^/]*/termix_macos|download/v$VERSION/termix_macos|g" homebrew-generated/termix.rb - name: Upload Homebrew Cask as artifact uses: actions/upload-artifact@v7 @@ -634,7 +634,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | VERSION="${{ steps.homebrew-version.outputs.version }}" - RELEASE_TAG="release-$VERSION-tag" + RELEASE_TAG="v$VERSION" gh release list --repo ${{ github.repository }} --limit 100 | grep -q "$RELEASE_TAG" || { echo "Release $RELEASE_TAG not found" @@ -673,7 +673,7 @@ jobs: run: | $VERSION = "${{ steps.package-version.outputs.version }}" $MSI_NAME = "termix_windows_x64_msi.msi" - $DOWNLOAD_URL = "https://github.com/Termix-SSH/Termix/releases/download/release-$($VERSION)-tag/$($MSI_NAME)" + $DOWNLOAD_URL = "https://github.com/Termix-SSH/Termix/releases/download/v$($VERSION)/$($MSI_NAME)" Write-Host "Downloading from $DOWNLOAD_URL" New-Item -ItemType Directory -Force -Path "release_asset" @@ -696,7 +696,7 @@ jobs: $CHECKSUM = "${{ steps.msi-info.outputs.checksum }}" $MSI_NAME = "${{ steps.msi-info.outputs.msi_name }}" - $DOWNLOAD_URL = "https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$MSI_NAME" + $DOWNLOAD_URL = "https://github.com/Termix-SSH/Termix/releases/download/v$VERSION/$MSI_NAME" New-Item -ItemType Directory -Force -Path "choco-build" Copy-Item -Path "packaging\chocolatey\*" -Destination "choco-build" -Recurse -Force @@ -784,7 +784,7 @@ jobs: mkdir -p release_assets APPIMAGE_X64_NAME="termix_linux_x64_appimage.AppImage" - URL_X64="https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$APPIMAGE_X64_NAME" + URL_X64="https://github.com/Termix-SSH/Termix/releases/download/v$VERSION/$APPIMAGE_X64_NAME" PATH_X64="release_assets/$APPIMAGE_X64_NAME" echo "Downloading x64 AppImage from $URL_X64" curl -L -o "$PATH_X64" "$URL_X64" @@ -792,7 +792,7 @@ jobs: CHECKSUM_X64=$(sha256sum "$PATH_X64" | awk '{print $1}') APPIMAGE_ARM64_NAME="termix_linux_arm64_appimage.AppImage" - URL_ARM64="https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$APPIMAGE_ARM64_NAME" + URL_ARM64="https://github.com/Termix-SSH/Termix/releases/download/v$VERSION/$APPIMAGE_ARM64_NAME" PATH_ARM64="release_assets/$APPIMAGE_ARM64_NAME" echo "Downloading arm64 AppImage from $URL_ARM64" curl -L -o "$PATH_ARM64" "$URL_ARM64" @@ -918,7 +918,7 @@ jobs: run: | VERSION="${{ steps.package-version.outputs.version }}" DMG_NAME="termix_macos_universal_dmg.dmg" - URL="https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$DMG_NAME" + URL="https://github.com/Termix-SSH/Termix/releases/download/v$VERSION/$DMG_NAME" mkdir -p release_asset DOWNLOAD_PATH="release_asset/$DMG_NAME" @@ -1191,7 +1191,7 @@ jobs: META_DIR="$RUNNER_TEMP/asc_metadata" rm -rf "$META_DIR" node scripts/generate-appstore-notes.cjs \ - --notes RELEASE_NOTES.md \ + --changelog CHANGELOG.md \ --out-dir "$META_DIR" \ --locales "en-US" echo "metadata_path=$META_DIR" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d269e47b7..58bdca430 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,6 +24,7 @@ jobs: version: ${{ steps.info.outputs.version }} release_tag: ${{ steps.info.outputs.release_tag }} mobile_version: ${{ steps.info.outputs.mobile_version }} + mobile_tag: ${{ steps.info.outputs.mobile_tag }} dev_branch: ${{ steps.info.outputs.dev_branch }} steps: - name: Guard branch (release modes) @@ -62,22 +63,23 @@ jobs: fi echo "dev_branch=$DEV_BRANCH" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "release_tag=release-$VERSION-tag" >> "$GITHUB_OUTPUT" + echo "release_tag=v$VERSION" >> "$GITHUB_OUTPUT" MOBILE_RAW=$(gh release view -R Termix-SSH/Mobile --json tagName -q .tagName) - MOBILE_VERSION=$(echo "$MOBILE_RAW" | sed -E 's/^release-//; s/-tag$//') + MOBILE_VERSION=$(echo "$MOBILE_RAW" | sed -E 's/^release-//; s/-tag$//; s/^v//') if [ -z "$MOBILE_VERSION" ]; then echo "Failed to resolve the latest Termix-SSH/Mobile release version." exit 1 fi echo "mobile_version=$MOBILE_VERSION" >> "$GITHUB_OUTPUT" + echo "mobile_tag=$MOBILE_RAW" >> "$GITHUB_OUTPUT" - - name: Validate release notes + - name: Check CHANGELOG.md has notes for this version run: | node scripts/generate-release-body.cjs \ --version "${{ steps.info.outputs.version }}" \ --mobile-version "${{ steps.info.outputs.mobile_version }}" \ - --notes RELEASE_NOTES.md > /dev/null + --mobile-tag "${{ steps.info.outputs.mobile_tag }}" > /dev/null normalize: needs: [prep] @@ -242,7 +244,7 @@ jobs: node scripts/generate-release-body.cjs \ --version "${{ needs.prep.outputs.version }}" \ --mobile-version "${{ needs.prep.outputs.mobile_version }}" \ - --notes RELEASE_NOTES.md > PR_BODY.md + --mobile-tag "${{ needs.prep.outputs.mobile_tag }}" > PR_BODY.md - name: Open and squash-merge PR to main id: merge @@ -250,7 +252,7 @@ jobs: GH_TOKEN: ${{ secrets.TERMIX_PAT }} run: | DEV_BRANCH="${{ needs.prep.outputs.dev_branch }}" - TITLE="release-${{ needs.prep.outputs.version }}" + TITLE="v${{ needs.prep.outputs.version }}" if [ "${{ inputs.mode }}" = "Overwrite release" ]; then echo "OVERWRITE: no merge; building from main as-is." @@ -336,7 +338,7 @@ jobs: node scripts/generate-release-body.cjs \ --version "${{ needs.prep.outputs.version }}" \ --mobile-version "${{ needs.prep.outputs.mobile_version }}" \ - --notes RELEASE_NOTES.md > RELEASE_BODY.md + --mobile-tag "${{ needs.prep.outputs.mobile_tag }}" > RELEASE_BODY.md - name: Create or update GitHub release if: ${{ inputs.mode != 'Overwrite release' }} @@ -344,7 +346,7 @@ jobs: GH_TOKEN: ${{ secrets.TERMIX_PAT }} run: | TAG="${{ needs.prep.outputs.release_tag }}" - TITLE="release-${{ needs.prep.outputs.version }}" + TITLE="v${{ needs.prep.outputs.version }}" if gh release view "$TAG" --repo ${{ github.repository }} >/dev/null 2>&1; then gh release edit "$TAG" --repo ${{ github.repository }} --title "$TITLE" --notes-file RELEASE_BODY.md else @@ -504,7 +506,7 @@ jobs: if [ -z "$PR_NUMBER" ]; then PR_URL=$(gh pr create --repo Termix-SSH/Docs \ --base main --head "$BRANCH" \ - --title "release-${{ needs.prep.outputs.version }}" \ + --title "v${{ needs.prep.outputs.version }}" \ --body "API docs for ${{ needs.prep.outputs.version }}") PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$') fi @@ -516,39 +518,8 @@ jobs: gh pr merge "$PR_NUMBER" --repo Termix-SSH/Docs --squash --admin - publish-youtube: - needs: [prep, electron-release] - if: ${{ always() && inputs.mode != 'Dry run' && inputs.mode != 'Overwrite release' && needs.electron-release.result == 'success' }} - runs-on: blacksmith-2vcpu-ubuntu-2404 - steps: - - name: Checkout main - uses: actions/checkout@v7 - with: - ref: main - fetch-depth: 1 - - - name: Setup Node.js - uses: actions/setup-node@v7 - with: - node-version-file: ".nvmrc" - - - name: Set release video to public - env: - YOUTUBE_CLIENT_ID: ${{ secrets.YOUTUBE_CLIENT_ID }} - YOUTUBE_CLIENT_SECRET: ${{ secrets.YOUTUBE_CLIENT_SECRET }} - YOUTUBE_REFRESH_TOKEN: ${{ secrets.YOUTUBE_REFRESH_TOKEN }} - run: node scripts/publish-youtube.cjs - cleanup: - needs: - [ - prep, - merge-to-main, - electron-release, - cask-commit-back, - docs, - publish-youtube, - ] + needs: [prep, merge-to-main, electron-release, cask-commit-back, docs] if: ${{ always() && (inputs.mode == 'Everything' || inputs.mode == 'Skip submit') && needs.merge-to-main.result == 'success' && needs.docs.result == 'success' }} runs-on: blacksmith-2vcpu-ubuntu-2404 steps: diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..22d01a88d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +## 26.10.0 + +### Added + +- + +### Changed + +- + +### Removed + +- Downgrading to 2.8 is no longer supported. A 2.8 install has to start 2.9 once before upgrading diff --git a/Casks/termix.rb b/Casks/termix.rb index 862fab450..496a9edb2 100644 --- a/Casks/termix.rb +++ b/Casks/termix.rb @@ -2,7 +2,7 @@ cask "termix" do version "2.9.1" sha256 "6d8847e18508314db8ed4380c63bf0dd31437102a288b8e861879c92ba25a254" - url "https://github.com/Termix-SSH/Termix/releases/download/release-#{version}-tag/termix_macos_universal_dmg.dmg" + url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg" name "Termix" desc "Web-based server management platform with SSH terminal, tunneling, and file editing" homepage "https://github.com/Termix-SSH/Termix" diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md deleted file mode 100644 index 3bdca37e2..000000000 --- a/RELEASE_NOTES.md +++ /dev/null @@ -1,58 +0,0 @@ - - -A patch for 2.9.0 that fixes OIDC sign-in after upgrading, Docker and Host Metrics logins, memory and CPU use, file downloads, tmux sessions, user data export and more. Also adds predefined host tags. - - - - - -https://youtu.be/lngaePO96tM - - - - - -- Added predefined host tags that admins can set, with suggestions in the host editor -- Kept compact host row actions on the same line as the host name -- Added a reload button when a sign-in verification method fails to load -- Admins can now merge a duplicate SSO account made by 2.9.0 back into the original account -- Hosts behind the same jump hosts now share one connection for status checks -- Simplified host status to just online or offline, removing the "last login failed" state -- Host status now updates every 15 seconds without a page refresh, and status checks run every 30 seconds by default -- Completed the Simplified Chinese translation -- Documented cookie and API key sign-in in the API docs - - - - - -- OIDC accounts from 2.8 not being able to sign in, or getting a new empty account, after upgrading -- Downloading a file in the file manager turning the whole screen black -- tmux sessions not resuming and new sessions being created instead -- Connections through two or more jump hosts failing -- Docker and Host Metrics failing to sign in while the terminal worked -- Fleets, Proxmox and automations not getting the host sudo password -- Server running out of memory on start and when importing hosts -- High CPU use after the app was first opened -- User data export freezing the server -- 1Password and other secret references being rejected as SSH keys -- Slow file deletes in the file manager -- Mac App Store app failing to start its local server -- Snippets failing to sync on older databases -- GitHub sign in failing -- RDP showing scrollbars and jittering near the edges -- Status checks triggering Fail2Ban bans while a session was open -- Too many database writes from session activity -- Host editor tabs being hidden when they did not fit -- SSH host keys being accepted without a check when the host was missing from the database -- Host status only updating after a full page refresh -- Every host showing offline right after signing in -- Host status flipping between online and offline on refresh or after one dropped packet -- Hosts showing "last login failed" when they connected fine -- Hosts behind a jump host staying offline after the jump connection dropped -- Host status being wiped in the desktop app when the host list failed to load -- Tunnels, remote desktop logins and other host settings missing from shared hosts in the desktop app -- SSO and LDAP provider dialogs overflowing the screen and using mismatched toggles -- Plugin audit log entries failing to save when no user was signed in - - diff --git a/electron/main.cjs b/electron/main.cjs index a5ed24361..26ab6d422 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -1462,6 +1462,14 @@ function createWindow() { details.requestHeaders["User-Agent"] = customUserAgent; + // YouTube refuses embeds without a Referer, which file:// never sends. + if ( + details.url.startsWith("https://www.youtube-nocookie.com/embed/") && + !details.requestHeaders.Referer + ) { + details.requestHeaders.Referer = "https://termix.site/"; + } + const rememberedJwt = getRememberedElectronAuthCookie("jwt", details.url); if (rememberedJwt) { setCookieHeaderValue( diff --git a/package-lock.json b/package-lock.json index af6e4a1aa..a5afcd00e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14955,7 +14955,7 @@ }, "packages/plugin-sdk": { "name": "@termix-ssh/plugin-sdk", - "version": "1.1.0", + "version": "1.2.0", "license": "Apache-2.0", "dependencies": { "@tailwindcss/node": "^4.2.4", diff --git a/package.json b/package.json index 5bf418712..4ddd68abc 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs", "prepare": "husky || true", "prebuild": "node scripts/write-electron-build-info.cjs", - "lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/generate-settings-search-index.cjs --check && node scripts/check-test-locations.cjs", + "lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/generate-settings-search-index.cjs --check && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs", "lint:fix": "eslint --fix .", "type-check": "npm run build:sdk && tsc -b --force", "test": "vitest run", diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md index 60e65b05b..f9f1f13e0 100644 --- a/packages/plugin-sdk/README.md +++ b/packages/plugin-sdk/README.md @@ -32,6 +32,7 @@ npx termix-plugin test # run the plugin's Vitest suite npx termix-plugin validate # check manifest.json and the files it names npx termix-plugin migrations # write migrations from the table definitions npx termix-plugin pack # write -.tmxplug +npx termix-plugin changelog # print this version's notes from CHANGELOG.md npx termix-plugin sign # sign it, needs TERMIX_PLUGIN_SIGNING_KEY npx termix-plugin verify --key # check a signature npx termix-plugin keygen --out # make a new signing key pair diff --git a/packages/plugin-sdk/cli/commands/changelog.mjs b/packages/plugin-sdk/cli/commands/changelog.mjs new file mode 100644 index 000000000..f5042a78a --- /dev/null +++ b/packages/plugin-sdk/cli/commands/changelog.mjs @@ -0,0 +1,25 @@ +import fs from "node:fs"; +import path from "node:path"; +import { readManifest } from "../lib/plugin-dir.mjs"; +import { loadSdkModule } from "./validate.mjs"; + +/** + * Prints one version's notes from CHANGELOG.md, the manifest version unless + * --version says otherwise. The release workflow uses it as the release body. + */ +export async function changelog({ cwd, args = [] }) { + const index = args.indexOf("--version"); + const version = + index === -1 ? readManifest(cwd).version : args[index + 1] || null; + if (!version) throw new Error("--version needs a value"); + + const file = path.join(cwd, "CHANGELOG.md"); + if (!fs.existsSync(file)) throw new Error("There is no CHANGELOG.md"); + + const { changelogSection } = await loadSdkModule("changelog"); + const section = changelogSection(fs.readFileSync(file, "utf8"), version); + if (!section) { + throw new Error(`CHANGELOG.md has no notes for ${version}`); + } + process.stdout.write(`${section}\n`); +} diff --git a/packages/plugin-sdk/cli/commands/validate.mjs b/packages/plugin-sdk/cli/commands/validate.mjs index 028bd0daf..223156978 100644 --- a/packages/plugin-sdk/cli/commands/validate.mjs +++ b/packages/plugin-sdk/cli/commands/validate.mjs @@ -2,24 +2,22 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { readManifest } from "../lib/plugin-dir.mjs"; -import { validateChangelog } from "../lib/changelog.mjs"; /** * The manifest rules live in src/manifest.ts, which the server uses too, so * there is one implementation rather than a copy here that drifts. */ -async function loadParseManifest() { - const entry = new URL("../../dist/manifest.js", import.meta.url); +export async function loadSdkModule(name) { + const entry = new URL(`../../dist/${name}.js`, import.meta.url); if (!fs.existsSync(fileURLToPath(entry))) { throw new Error("The plugin SDK is not built yet. Run: npm run build:sdk"); } - const mod = await import(entry.href); - return mod.parseManifest; + return import(entry.href); } export async function validate({ cwd }) { const raw = readManifest(cwd); - const parseManifest = await loadParseManifest(); + const { parseManifest } = await loadSdkModule("manifest"); const { errors } = parseManifest(raw); const problems = [...errors]; @@ -41,7 +39,7 @@ export async function validate({ cwd }) { ); problems.push(...validateNativeDependencies(cwd, raw)); problems.push(...validatePackage(cwd, raw)); - problems.push(...validateChangelogFile(cwd, raw)); + problems.push(...(await validateChangelogFile(cwd, raw))); if (problems.length > 0) { for (const problem of problems) console.error(` ${problem}`); @@ -82,26 +80,20 @@ function validatePackage(cwd, raw) { } /** - * CHANGELOG.json is optional, but when it is there it has to parse and its + * CHANGELOG.md is optional, but when it is there it has to parse and its * newest release has to match the manifest version. */ -function validateChangelogFile(cwd, raw) { - const jsonPath = path.join(cwd, "CHANGELOG.json"); - if (!fs.existsSync(jsonPath)) { - return fs.existsSync(path.join(cwd, "CHANGELOG.md")) - ? [ - "CHANGELOG.md is no longer shipped; move the release notes to CHANGELOG.json", - ] - : []; +async function validateChangelogFile(cwd, raw) { + if (fs.existsSync(path.join(cwd, "CHANGELOG.json"))) { + return [ + "CHANGELOG.json is no longer read; move the release notes to CHANGELOG.md", + ]; } - let changelog; - try { - changelog = JSON.parse(fs.readFileSync(jsonPath, "utf8")); - } catch (error) { - return [`CHANGELOG.json is not valid JSON: ${error.message}`]; - } - return validateChangelog(changelog, raw.version).map( - (problem) => `CHANGELOG.json: ${problem}`, + const mdPath = path.join(cwd, "CHANGELOG.md"); + if (!fs.existsSync(mdPath)) return []; + const { validateChangelog } = await loadSdkModule("changelog"); + return validateChangelog(fs.readFileSync(mdPath, "utf8"), raw.version).map( + (problem) => `CHANGELOG.md: ${problem}`, ); } diff --git a/packages/plugin-sdk/cli/index.mjs b/packages/plugin-sdk/cli/index.mjs index 61cf4be76..c5fe1ac6a 100755 --- a/packages/plugin-sdk/cli/index.mjs +++ b/packages/plugin-sdk/cli/index.mjs @@ -14,6 +14,7 @@ import { pack } from "./commands/pack.mjs"; import { sign, verify, keygen } from "./commands/sign.mjs"; import { migrations } from "./commands/migrations.mjs"; import { dev } from "./commands/dev.mjs"; +import { changelog } from "./commands/changelog.mjs"; const COMMANDS = { build, @@ -30,6 +31,7 @@ const COMMANDS = { keygen, migrations, dev, + changelog, }; const [command, ...args] = process.argv.slice(2); @@ -48,6 +50,7 @@ if (!command || command === "--help" || command === "-h") { " verify Check a .tmxplug's .sig --key [,...]", " keygen Write a new signing key pair [--out dir]", " migrations Generate migrations from the plugin's table definitions", + " changelog Print a version's notes from CHANGELOG.md [--version x.y.z]", " dev Build, pack and install on a running server, again on every change", " [--server url] [--key tmx_...] [--once]", " (or TERMIX_SERVER_URL and TERMIX_API_KEY; needs developer mode)", diff --git a/packages/plugin-sdk/cli/lib/changelog.mjs b/packages/plugin-sdk/cli/lib/changelog.mjs deleted file mode 100644 index 6eae9e149..000000000 --- a/packages/plugin-sdk/cli/lib/changelog.mjs +++ /dev/null @@ -1,126 +0,0 @@ -export const CHANGE_TYPES = [ - "added", - "changed", - "fixed", - "removed", - "deprecated", - "security", -]; - -const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/; -const DATE = /^\d{4}-\d{2}-\d{2}$/; -const TOP_KEYS = ["$schema", "unreleased", "releases"]; -const RELEASE_KEYS = ["version", "date", "notes", "changes"]; -const CHANGE_KEYS = ["type", "text"]; - -function compareVersions(a, b) { - const pa = SEMVER.exec(a); - const pb = SEMVER.exec(b); - for (let i = 1; i <= 3; i++) { - const diff = Number(pa[i]) - Number(pb[i]); - if (diff !== 0) return diff; - } - if (pa[4] === pb[4]) return 0; - if (!pa[4]) return 1; - if (!pb[4]) return -1; - return pa[4] < pb[4] ? -1 : 1; -} - -function unknownKeys(value, allowed, where, problems) { - for (const key of Object.keys(value)) { - if (!allowed.includes(key)) - problems.push(`${where} has unknown key "${key}"`); - } -} - -function checkChanges(changes, where, problems) { - if (!Array.isArray(changes)) { - problems.push(`${where} must be an array`); - return; - } - changes.forEach((change, i) => { - const at = `${where}[${i}]`; - if (!change || typeof change !== "object" || Array.isArray(change)) { - problems.push(`${at} must be an object`); - return; - } - unknownKeys(change, CHANGE_KEYS, at, problems); - if (!CHANGE_TYPES.includes(change.type)) { - problems.push(`${at}.type must be one of ${CHANGE_TYPES.join(", ")}`); - } - if (typeof change.text !== "string" || change.text.trim() === "") { - problems.push(`${at}.text must be a non-empty string`); - } - }); -} - -/** - * Checks a parsed CHANGELOG.json. The newest release has to be the version - * in the manifest, so a release never ships without its notes. - */ -export function validateChangelog(changelog, manifestVersion) { - const problems = []; - if (!changelog || typeof changelog !== "object" || Array.isArray(changelog)) { - return ["CHANGELOG.json must be an object"]; - } - unknownKeys(changelog, TOP_KEYS, "CHANGELOG.json", problems); - - if (changelog.unreleased !== undefined) { - checkChanges(changelog.unreleased, "unreleased", problems); - } - - const releases = changelog.releases; - if (!Array.isArray(releases) || releases.length === 0) { - problems.push("releases must be a non-empty array"); - return problems; - } - - const seen = new Set(); - releases.forEach((release, i) => { - const at = `releases[${i}]`; - if (!release || typeof release !== "object" || Array.isArray(release)) { - problems.push(`${at} must be an object`); - return; - } - unknownKeys(release, RELEASE_KEYS, at, problems); - if (typeof release.version !== "string" || !SEMVER.test(release.version)) { - problems.push(`${at}.version must be a version like 1.2.3`); - } else if (seen.has(release.version)) { - problems.push(`${at}.version ${release.version} is listed twice`); - } else { - seen.add(release.version); - } - if ( - release.date !== undefined && - (typeof release.date !== "string" || !DATE.test(release.date)) - ) { - problems.push(`${at}.date must be a date like 2026-10-01`); - } - if (release.notes !== undefined && typeof release.notes !== "string") { - problems.push(`${at}.notes must be a string`); - } - checkChanges(release.changes, `${at}.changes`, problems); - if (Array.isArray(release.changes) && release.changes.length === 0) { - problems.push(`${at}.changes must list at least one change`); - } - }); - - const versions = releases - .map((release) => release?.version) - .filter((v) => typeof v === "string" && SEMVER.test(v)); - for (let i = 1; i < versions.length; i++) { - if (compareVersions(versions[i - 1], versions[i]) < 0) { - problems.push("releases must be listed newest first"); - break; - } - } - - const newest = releases[0]?.version; - if (manifestVersion && newest !== manifestVersion) { - problems.push( - `the newest release is ${newest}, but manifest version is ${manifestVersion}`, - ); - } - - return problems; -} diff --git a/packages/plugin-sdk/cli/lib/tmxplug.mjs b/packages/plugin-sdk/cli/lib/tmxplug.mjs index 065098f6b..d44d44fc6 100644 --- a/packages/plugin-sdk/cli/lib/tmxplug.mjs +++ b/packages/plugin-sdk/cli/lib/tmxplug.mjs @@ -9,7 +9,7 @@ export const TMXPLUG_CONTENTS = [ "locales", "migrations", "README.md", - "CHANGELOG.json", + "CHANGELOG.md", "icon.svg", ]; diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 12155d39e..76e32bf4e 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@termix-ssh/plugin-sdk", - "version": "1.1.0", + "version": "1.2.0", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "license": "Apache-2.0", "repository": { @@ -49,6 +49,10 @@ "types": "./dist/capabilities.d.ts", "default": "./dist/capabilities.js" }, + "./changelog": { + "types": "./dist/changelog.d.ts", + "default": "./dist/changelog.js" + }, "./settings": { "types": "./dist/settings.d.ts", "default": "./dist/settings.js" @@ -75,7 +79,6 @@ }, "./tsconfig.plugin.json": "./tsconfig.plugin.json", "./schema/manifest.schema.json": "./schema/manifest.schema.json", - "./schema/changelog.schema.json": "./schema/changelog.schema.json", "./ui": { "types": "./dist/host/ui.d.ts", "default": "./dist/host/ui.js" diff --git a/packages/plugin-sdk/schema/changelog.schema.json b/packages/plugin-sdk/schema/changelog.schema.json deleted file mode 100644 index 66a6119f1..000000000 --- a/packages/plugin-sdk/schema/changelog.schema.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/Termix-SSH/Termix/main/packages/plugin-sdk/schema/changelog.schema.json", - "title": "Termix plugin changelog", - "description": "Every release of a plugin, newest first. The newest release must match the manifest version.", - "type": "object", - "additionalProperties": false, - "required": ["releases"], - "properties": { - "$schema": { "type": "string" }, - "unreleased": { - "description": "Changes that are merged but not released yet.", - "type": "array", - "items": { "$ref": "#/$defs/change" } - }, - "releases": { - "type": "array", - "minItems": 1, - "items": { "$ref": "#/$defs/release" } - } - }, - "$defs": { - "release": { - "type": "object", - "additionalProperties": false, - "required": ["version", "changes"], - "properties": { - "version": { - "type": "string", - "pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?$" - }, - "date": { - "description": "When the version was released. Leave it out until it ships.", - "type": "string", - "pattern": "^\\d{4}-\\d{2}-\\d{2}$" - }, - "notes": { - "description": "A short line shown above the list of changes.", - "type": "string" - }, - "changes": { - "type": "array", - "minItems": 1, - "items": { "$ref": "#/$defs/change" } - } - } - }, - "change": { - "type": "object", - "additionalProperties": false, - "required": ["type", "text"], - "properties": { - "type": { - "enum": [ - "added", - "changed", - "fixed", - "removed", - "deprecated", - "security" - ] - }, - "text": { "type": "string", "minLength": 1 } - } - } - } -} diff --git a/packages/plugin-sdk/schema/manifest.schema.json b/packages/plugin-sdk/schema/manifest.schema.json index 4259f1881..220d1611a 100644 --- a/packages/plugin-sdk/schema/manifest.schema.json +++ b/packages/plugin-sdk/schema/manifest.schema.json @@ -74,6 +74,11 @@ "minLength": 1, "description": "A Lucide icon name, like \"Play\"." }, + "video": { + "type": "string", + "pattern": "^https://(www\\.|m\\.)?(youtube\\.com|youtu\\.be|youtube-nocookie\\.com)/", + "description": "A YouTube link shown at the top of the plugin's page." + }, "engine": { "type": "object", "additionalProperties": false, diff --git a/packages/plugin-sdk/src/changelog.ts b/packages/plugin-sdk/src/changelog.ts new file mode 100644 index 000000000..3597127ee --- /dev/null +++ b/packages/plugin-sdk/src/changelog.ts @@ -0,0 +1,249 @@ +/** + * CHANGELOG.md: every release of a plugin (or of core) in one Markdown file. + * + * # Changelog + * + * ## Unreleased + * + * ### Added + * - Something merged but not shipped yet + * + * ## 1.1.0 - 2026-10-06 + * + * An optional summary paragraph. + * + * ### Added + * - Something new + * + * ### Fixed + * - Something broken + * + * Releases are listed newest first, and the newest one has to be the + * manifest version. The date is optional until the version ships. + */ + +import semver from "semver"; + +export const CHANGE_TYPES = [ + "added", + "changed", + "fixed", + "removed", + "deprecated", + "security", +] as const; + +export type ChangeType = (typeof CHANGE_TYPES)[number]; + +export interface ChangelogChange { + type: ChangeType; + text: string; +} + +export interface ChangelogRelease { + version: string; + date?: string; + summary?: string; + changes: ChangelogChange[]; +} + +export interface Changelog { + unreleased: ChangelogChange[]; + /** Newest first, as written. */ + releases: ChangelogRelease[]; +} + +export interface ReleaseNotes { + summary?: string; + changes: ChangelogChange[]; +} + +const RELEASE_HEADING = + /^##\s+\[?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\]?(?:\s+-\s+(.+?))?\s*$/; +const UNRELEASED_HEADING = /^##\s+\[?unreleased\]?\s*$/i; +const DATE = /^\d{4}-\d{2}-\d{2}$/; +const BULLET = /^[-*]\s+(.*)$/; +const COMMENT = /^$/; + +/** + * Parses the body under one release heading: an optional summary, then + * `### Added` style sections of bullets. Indented lines continue the bullet + * above them. + */ +export function parseReleaseNotes( + body: string, + where = "release", +): ReleaseNotes & { problems: string[] } { + const problems: string[] = []; + const summary: string[] = []; + const changes: ChangelogChange[] = []; + let section: ChangeType | null = null; + let current: ChangelogChange | null = null; + let fence = false; + + for (const raw of body.replace(/\r/g, "").split("\n")) { + const line = raw.trimEnd(); + const trimmed = line.trim(); + if (trimmed.startsWith("```")) fence = !fence; + + if (!fence && trimmed.startsWith("### ")) { + const name = trimmed.slice(4).trim().toLowerCase(); + if ((CHANGE_TYPES as readonly string[]).includes(name)) { + section = name as ChangeType; + } else { + problems.push( + `${where} has an unknown section "${trimmed.slice(4).trim()}", use one of ${CHANGE_TYPES.map(title).join(", ")}`, + ); + section = null; + } + current = null; + continue; + } + + if (!section) { + if (trimmed || summary.length > 0) summary.push(line); + continue; + } + + if (!trimmed || COMMENT.test(trimmed)) continue; + const bullet = fence ? null : BULLET.exec(line); + if (bullet) { + current = { type: section, text: bullet[1].trim() }; + changes.push(current); + } else if (current && /^\s/.test(line)) { + current.text += `\n${trimmed}`; + } else { + problems.push( + `${where} has text under "### ${title(section)}" that is not a list item: ${trimmed}`, + ); + } + } + + for (const change of changes) { + if (!change.text) { + problems.push(`${where} has an empty list item`); + } + } + + const text = summary.join("\n").trim(); + return { ...(text ? { summary: text } : {}), changes, problems }; +} + +/** Parses a whole CHANGELOG.md. Problems are reported, never thrown. */ +export function parseChangelog(markdown: string): { + changelog: Changelog; + problems: string[]; +} { + const problems: string[] = []; + const changelog: Changelog = { unreleased: [], releases: [] }; + const lines = markdown.replace(/\r/g, "").split("\n"); + + type Block = { heading: string; line: number; body: string[] }; + const blocks: Block[] = []; + let fence = false; + lines.forEach((line, i) => { + if (line.trim().startsWith("```")) fence = !fence; + if (!fence && /^##\s/.test(line)) { + blocks.push({ heading: line.trim(), line: i + 1, body: [] }); + } else if (blocks.length > 0) { + blocks[blocks.length - 1].body.push(line); + } + }); + + const seen = new Set(); + blocks.forEach((block, i) => { + if (UNRELEASED_HEADING.test(block.heading)) { + if (i !== 0) problems.push(`"## Unreleased" has to come first`); + const notes = parseReleaseNotes(block.body.join("\n"), "Unreleased"); + problems.push(...notes.problems); + changelog.unreleased = notes.changes; + return; + } + + const match = RELEASE_HEADING.exec(block.heading); + if (!match) { + problems.push( + `line ${block.line}: "${block.heading}" should look like "## 1.2.0 - 2026-10-06"`, + ); + return; + } + const [, version, date] = match; + if (date !== undefined && !DATE.test(date)) { + problems.push(`${version} has a date "${date}", use YYYY-MM-DD`); + } + if (seen.has(version)) problems.push(`${version} is listed twice`); + seen.add(version); + + const notes = parseReleaseNotes(block.body.join("\n"), version); + problems.push(...notes.problems); + if (notes.changes.length === 0) { + problems.push(`${version} has no changes listed`); + } + changelog.releases.push({ + version, + ...(date && DATE.test(date) ? { date } : {}), + ...(notes.summary ? { summary: notes.summary } : {}), + changes: notes.changes, + }); + }); + + const versions = changelog.releases.map((r) => r.version); + for (let i = 1; i < versions.length; i++) { + if (semver.lt(versions[i - 1], versions[i])) { + problems.push("releases must be listed newest first"); + break; + } + } + + return { changelog, problems }; +} + +/** + * Checks a CHANGELOG.md. With a manifest version, the newest release has to + * be that version, so a release never ships without its notes. + */ +export function validateChangelog( + markdown: string, + manifestVersion?: string, +): string[] { + const { changelog, problems } = parseChangelog(markdown); + if (changelog.releases.length === 0) { + problems.push('no releases found, add a "## 1.0.0" heading'); + return problems; + } + const newest = changelog.releases[0].version; + if (manifestVersion && newest !== manifestVersion) { + problems.push( + `the newest release is ${newest}, but the manifest version is ${manifestVersion}`, + ); + } + return problems; +} + +/** + * The Markdown under one version's heading, as written, for a release body. + * Null when the version is not in the file. + */ +export function changelogSection( + markdown: string, + version: string, +): string | null { + const lines = markdown.replace(/\r/g, "").split("\n"); + let start = -1; + let fence = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line.trim().startsWith("```")) fence = !fence; + if (fence || !/^##\s/.test(line)) continue; + if (start >= 0) { + return lines.slice(start, i).join("\n").trim(); + } + const match = RELEASE_HEADING.exec(line.trim()); + if (match && match[1] === version) start = i + 1; + } + return start >= 0 ? lines.slice(start).join("\n").trim() : null; +} + +function title(type: string): string { + return type.charAt(0).toUpperCase() + type.slice(1); +} diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts index 153133b48..aa94b6e03 100644 --- a/packages/plugin-sdk/src/manifest.ts +++ b/packages/plugin-sdk/src/manifest.ts @@ -49,6 +49,43 @@ export function isTermixCompatible( return semver.satisfies(semver.coerce(coreVersion)!.version, range); } +const YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/; +const YOUTUBE_HOSTS = new Set([ + "youtube.com", + "www.youtube.com", + "m.youtube.com", + "youtu.be", + "youtube-nocookie.com", + "www.youtube-nocookie.com", +]); + +/** + * The video id of a YouTube link, or null for anything else. Only the id is + * ever used, so a manifest can never point the embed at another site. + */ +export function youtubeVideoId(value: unknown): string | null { + if (typeof value !== "string") return null; + let url: URL; + try { + url = new URL(value.trim()); + } catch { + return null; + } + if (url.protocol !== "https:" || url.username || url.password) return null; + const host = url.hostname.toLowerCase(); + if (!YOUTUBE_HOSTS.has(host)) return null; + + let id: string | null | undefined; + if (host === "youtu.be") { + id = url.pathname.slice(1); + } else if (url.pathname === "/watch") { + id = url.searchParams.get("v"); + } else { + id = /^\/(?:embed|shorts|live)\/([^/]+)\/?$/.exec(url.pathname)?.[1]; + } + return id && YOUTUBE_ID.test(id) ? id : null; +} + export const PLUGIN_CATEGORIES = [ "Terminal", "Files & Transfer", @@ -540,6 +577,8 @@ export interface PluginManifest { repository?: string; category: string; icon?: string; + /** A YouTube link shown at the top of the plugin's page. */ + video?: string; engine: PluginEngine; /** Catalog capability ids. */ capabilities: string[]; @@ -591,6 +630,7 @@ const ALLOWED_TOP_LEVEL = new Set([ ...REQUIRED_TOP_LEVEL, "repository", "icon", + "video", "dependencies", "optionalDependencies", "provides", @@ -747,6 +787,11 @@ export function validateManifest(manifest: unknown): string[] { if ("repository" in m) requireString(m.repository, "repository", errors); if ("icon" in m) requireString(m.icon, "icon", errors); + if ("video" in m && youtubeVideoId(m.video) === null) { + errors.push( + `Field "video" must be a YouTube link such as https://www.youtube.com/watch?v=..., got: ${JSON.stringify(m.video)}`, + ); + } for (const field of ["backend", "frontend", "locales"] as const) { if (field in m && requireString(m[field], field, errors)) { requireRelativePath(m[field] as string, field, errors); diff --git a/packaging/flatpak/com.karmaa.termix.yml b/packaging/flatpak/com.karmaa.termix.yml index b0cde5341..ba57e6eb0 100644 --- a/packaging/flatpak/com.karmaa.termix.yml +++ b/packaging/flatpak/com.karmaa.termix.yml @@ -59,14 +59,14 @@ modules: sources: - type: file - url: https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_x64_appimage.AppImage + url: https://github.com/Termix-SSH/Termix/releases/download/vVERSION_PLACEHOLDER/termix_linux_x64_appimage.AppImage sha256: CHECKSUM_X64_PLACEHOLDER dest-filename: termix.AppImage only-arches: - x86_64 - type: file - url: https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_arm64_appimage.AppImage + url: https://github.com/Termix-SSH/Termix/releases/download/vVERSION_PLACEHOLDER/termix_linux_arm64_appimage.AppImage sha256: CHECKSUM_ARM64_PLACEHOLDER dest-filename: termix.AppImage only-arches: diff --git a/scripts/check-changelog.cjs b/scripts/check-changelog.cjs new file mode 100644 index 000000000..3caf88211 --- /dev/null +++ b/scripts/check-changelog.cjs @@ -0,0 +1,19 @@ +const fs = require("fs"); +const path = require("path"); + +const root = path.resolve(__dirname, ".."); +const sdk = path.join(root, "packages/plugin-sdk/dist/changelog.js"); + +if (!fs.existsSync(sdk)) { + console.error("check-changelog: build the SDK first (npm run build:sdk)"); + process.exit(1); +} + +const { parseChangelog } = require(sdk); +const { problems } = parseChangelog( + fs.readFileSync(path.join(root, "CHANGELOG.md"), "utf8"), +); +if (problems.length > 0) { + for (const problem of problems) console.error(` CHANGELOG.md: ${problem}`); + process.exit(1); +} diff --git a/scripts/generate-appstore-notes.cjs b/scripts/generate-appstore-notes.cjs index 778297c95..48c65f038 100644 --- a/scripts/generate-appstore-notes.cjs +++ b/scripts/generate-appstore-notes.cjs @@ -1,5 +1,6 @@ const fs = require("fs"); const path = require("path"); +const { changelogSection } = require("./lib/changelog-section.cjs"); // Apple caps the "What's New in This Version" field at 4000 characters. const MAX_LENGTH = 4000; @@ -26,18 +27,6 @@ function fail(message) { process.exit(1); } -function extractSection(notes, name, { required = true } = {}) { - const pattern = new RegExp( - `([\\s\\S]*?)`, - ); - const match = notes.match(pattern); - if (!match) { - if (required) fail(`missing section in release notes`); - return ""; - } - return match[1].trim(); -} - // Strip markdown that reads badly as plain text in App Store Connect. function toPlainText(markdown) { return markdown @@ -50,7 +39,7 @@ function toPlainText(markdown) { text = text.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1"); text = text.replace(/`([^`]+)`/g, "$1"); text = text.replace(/\*\*([^*]+)\*\*/g, "$1"); - text = text.replace(/^#+\s*/, ""); + if (/^#+\s/.test(text)) text = `${text.replace(/^#+\s*/, "")}:`; return text; }) .join("\n") @@ -78,28 +67,21 @@ function truncate(text, limit) { return lines.join("\n").trim(); } -function buildNotes(notesFile) { - const summary = extractSection(notesFile, "SUMMARY"); - const updateLog = extractSection(notesFile, "UPDATE_LOG", { - required: false, - }); - const bugFixes = extractSection(notesFile, "BUG_FIXES", { required: false }); - - const parts = [toPlainText(summary)]; - if (updateLog) parts.push("", "Update Log:", toPlainText(updateLog)); - if (bugFixes) parts.push("", "Bug Fixes:", toPlainText(bugFixes)); - - const body = parts - .join("\n") - .replace(/\n{3,}/g, "\n\n") - .trim(); +function buildNotes(changelog, version) { + const notes = changelogSection(changelog, version); + if (!notes) fail(`CHANGELOG.md has no notes for ${version}`); + const body = toPlainText(notes); if (!body) fail("release notes produced no text"); return truncate(body, MAX_LENGTH); } function main() { const args = parseArgs(process.argv.slice(2)); - const notesPath = args.notes || "RELEASE_NOTES.md"; + const changelogPath = args.changelog || "CHANGELOG.md"; + const version = + typeof args.version === "string" + ? args.version + : require(path.resolve("package.json")).version; const outDir = args["out-dir"]; const locales = String(args.locales || "en-US") .split(",") @@ -109,12 +91,10 @@ function main() { if (!outDir || outDir === true) fail("--out-dir is required"); if (locales.length === 0) fail("--locales resolved to no locales"); - const resolvedNotes = path.resolve(notesPath); - if (!fs.existsSync(resolvedNotes)) { - fail(`release notes file not found: ${resolvedNotes}`); - } + const resolved = path.resolve(changelogPath); + if (!fs.existsSync(resolved)) fail(`changelog not found: ${resolved}`); - const notes = buildNotes(fs.readFileSync(resolvedNotes, "utf8")); + const notes = buildNotes(fs.readFileSync(resolved, "utf8"), version); for (const locale of locales) { const localeDir = path.join(path.resolve(outDir), locale); diff --git a/scripts/generate-appstore-notes.test.ts b/scripts/generate-appstore-notes.test.ts index 7e5484fce..ea6656f56 100644 --- a/scripts/generate-appstore-notes.test.ts +++ b/scripts/generate-appstore-notes.test.ts @@ -9,10 +9,20 @@ const { MAX_LENGTH, } = require("./generate-appstore-notes.cjs"); -function notesFile(sections: Record) { - return Object.entries(sections) - .map(([name, body]) => `\n${body}\n`) - .join("\n\n"); +function changelog(body: string, version = "26.10.0") { + return [ + "# Changelog", + "", + `## ${version}`, + "", + body, + "", + "## 26.9.0", + "", + "### Added", + "- Older", + "", + ].join("\n"); } describe("toPlainText", () => { @@ -36,6 +46,10 @@ describe("toPlainText", () => { expect(toPlainText("* One\n* Two")).toBe("- One\n- Two"); }); + it("turns headings into labels", () => { + expect(toPlainText("### Added\n- One")).toBe("Added:\n- One"); + }); + it("collapses runs of blank lines", () => { expect(toPlainText("- One\n\n\n\n- Two")).toBe("- One\n\n- Two"); }); @@ -52,74 +66,40 @@ describe("truncate", () => { }); it("drops a section header left with no bullets under it", () => { - const result = truncate("- One\n\nBug Fixes:\n- Two", 18); + const result = truncate("- One\n\nFixed:\n- Two", 14); expect(result).toBe("- One"); }); }); describe("buildNotes", () => { - it("includes the summary, update log, and bug fixes", () => { + it("uses only the version's section", () => { const notes = buildNotes( - notesFile({ - SUMMARY: "A big release.", - UPDATE_LOG: "- Added a thing", - BUG_FIXES: "- Fixed a thing", - }), + changelog("A summary.\n\n### Added\n- A thing\n\n### Fixed\n- A bug"), + "26.10.0", ); - - expect(notes).toContain("A big release."); - expect(notes).toContain("Update Log:"); - expect(notes).toContain("- Added a thing"); - expect(notes).toContain("Bug Fixes:"); - expect(notes).toContain("- Fixed a thing"); + expect(notes).toBe("A summary.\n\nAdded:\n- A thing\n\nFixed:\n- A bug"); + expect(notes).not.toContain("Older"); }); - it("omits optional sections that are absent", () => { - const notes = buildNotes(notesFile({ SUMMARY: "Small release." })); - - expect(notes).toBe("Small release."); - expect(notes).not.toContain("Update Log:"); - expect(notes).not.toContain("Bug Fixes:"); - }); - - it("stays within the App Store character limit", () => { + it("stays within the App Store limit and never ends mid-bullet", () => { + const bullets = Array.from( + { length: 400 }, + (_, i) => `- Added feature number ${i}`, + ); const notes = buildNotes( - notesFile({ - SUMMARY: "Big release.", - UPDATE_LOG: Array.from( - { length: 400 }, - (_, i) => `- Added feature number ${i}`, - ).join("\n"), - BUG_FIXES: Array.from( - { length: 400 }, - (_, i) => `- Fixed bug number ${i}`, - ).join("\n"), - }), + changelog(`### Added\n${bullets.join("\n")}`), + "26.10.0", ); - expect(notes.length).toBeLessThanOrEqual(MAX_LENGTH); - expect(notes.length).toBeGreaterThan(0); + expect(notes.split("\n").at(-1)).toMatch(/^- Added feature number \d+$/); }); - it("never ends mid-bullet when truncating", () => { - const notes = buildNotes( - notesFile({ - SUMMARY: "Big release.", - UPDATE_LOG: Array.from( - { length: 400 }, - (_, i) => `- Added feature number ${i}`, - ).join("\n"), - }), - ); - - const lines = notes.split("\n"); - expect(lines[lines.length - 1]).toMatch(/^- Added feature number \d+$/); - }); - - it("produces non-empty notes for the real release notes file", () => { + it("produces notes for the real changelog", () => { const fs = require("fs"); - const notes = buildNotes(fs.readFileSync("RELEASE_NOTES.md", "utf8")); - + const notes = buildNotes( + fs.readFileSync("CHANGELOG.md", "utf8"), + require("../package.json").version, + ); expect(notes.length).toBeGreaterThan(0); expect(notes.length).toBeLessThanOrEqual(MAX_LENGTH); }); diff --git a/scripts/generate-release-body.cjs b/scripts/generate-release-body.cjs index a7e425fb7..36e571e03 100644 --- a/scripts/generate-release-body.cjs +++ b/scripts/generate-release-body.cjs @@ -1,5 +1,6 @@ const fs = require("fs"); const path = require("path"); +const { changelogSection } = require("./lib/changelog-section.cjs"); function parseArgs(argv) { const args = {}; @@ -24,37 +25,10 @@ function fail(message) { process.exit(1); } -function extractSection(notes, name) { - const pattern = new RegExp( - `([\\s\\S]*?)`, - ); - const match = notes.match(pattern); - if (!match) { - fail(`missing section in release notes`); - } - const value = match[1].trim(); - if (!value) { - fail(`empty section in release notes`); - } - return value; -} - -function youtubeId(raw) { - const value = raw.trim(); - let match = value.match(/[?&]v=([A-Za-z0-9_-]+)/); - if (match) return match[1]; - match = value.match(/youtu\.be\/([A-Za-z0-9_-]+)/); - if (match) return match[1]; - match = value.match(/embed\/([A-Za-z0-9_-]+)/); - if (match) return match[1]; - if (/^[A-Za-z0-9_-]+$/.test(value)) return value; - fail(`could not parse a YouTube video id from "${value}"`); -} - -function buildTable(version, mobileVersion) { - const tag = `release-${version}-tag`; +function buildTable(version, mobileVersion, mobileTag) { + const tag = `v${version}`; const base = `https://github.com/Termix-SSH/Termix/releases/download/${tag}`; - const mobileBase = `https://github.com/Termix-SSH/Mobile/releases/download/release-${mobileVersion}-tag`; + const mobileBase = `https://github.com/Termix-SSH/Mobile/releases/download/${mobileTag || `release-${mobileVersion}-tag`}`; const win = (file) => `${base}/${file}`; const linux = (file) => `${base}/${file}`; @@ -71,58 +45,49 @@ function buildTable(version, mobileVersion) { ].join("\n"); } -function main() { - const args = parseArgs(process.argv.slice(2)); - const version = args.version; - const mobileVersion = args["mobile-version"]; - const notesPath = args.notes || "RELEASE_NOTES.md"; - - if (!version || version === true) fail("--version is required"); - if (!mobileVersion || mobileVersion === true) - fail("--mobile-version is required"); - - const resolvedNotes = path.resolve(notesPath); - if (!fs.existsSync(resolvedNotes)) { - fail(`release notes file not found: ${resolvedNotes}`); - } - - const notes = fs.readFileSync(resolvedNotes, "utf8"); - const summary = extractSection(notes, "SUMMARY"); - const youtube = extractSection(notes, "YOUTUBE"); - const updateLog = extractSection(notes, "UPDATE_LOG"); - const bugFixes = extractSection(notes, "BUG_FIXES"); - - const videoId = youtubeId(youtube); - const embed = [ - ``, - ` YouTube`, - ``, - ].join("\n"); - - const table = buildTable(version, mobileVersion); +function buildBody({ version, mobileVersion, mobileTag, changelog }) { + const notes = changelogSection(changelog, version); + if (!notes) fail(`CHANGELOG.md has no notes for ${version}`); const donateAlert = [ "> [!TIP]", "> Termix is free and always will be. If it's useful to you, consider [donating](https://donate.termix.site/donate/) to support development.", ].join("\n"); - const body = [ + return [ donateAlert, "", - summary, + notes, "", - embed, + "### Downloads", "", - table, - "", - "Update Log:", - updateLog, - "", - "Bug Fixes:", - bugFixes, + buildTable(version, mobileVersion, mobileTag), ].join("\n"); - - process.stdout.write(body + "\n"); } -main(); +function main() { + const args = parseArgs(process.argv.slice(2)); + const version = args.version; + const mobileVersion = args["mobile-version"]; + const mobileTag = + typeof args["mobile-tag"] === "string" ? args["mobile-tag"] : undefined; + const changelogPath = path.resolve(args.changelog || "CHANGELOG.md"); + + if (!version || version === true) fail("--version is required"); + if (!mobileVersion || mobileVersion === true) + fail("--mobile-version is required"); + if (!fs.existsSync(changelogPath)) { + fail(`changelog not found: ${changelogPath}`); + } + + const changelog = fs.readFileSync(changelogPath, "utf8"); + process.stdout.write( + buildBody({ version, mobileVersion, mobileTag, changelog }) + "\n", + ); +} + +if (require.main === module) { + main(); +} + +module.exports = { buildBody }; diff --git a/scripts/generate-release-body.test.ts b/scripts/generate-release-body.test.ts new file mode 100644 index 000000000..d585f9999 --- /dev/null +++ b/scripts/generate-release-body.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { createRequire } from "module"; + +const require = createRequire(import.meta.url); +const { buildBody } = require("./generate-release-body.cjs"); + +const CHANGELOG = [ + "# Changelog", + "", + "## 26.11.0", + "", + "### Fixed", + "- A bug", + "", + "## 26.10.0", + "", + "### Added", + "- Plugins", + "", +].join("\n"); + +describe("buildBody", () => { + it("uses the version's changelog section and v tags for downloads", () => { + const body = buildBody({ + version: "26.10.0", + mobileVersion: "1.2.0", + changelog: CHANGELOG, + }); + expect(body).toContain("### Added\n- Plugins"); + expect(body).not.toContain("A bug"); + expect(body).toContain("/releases/download/v26.10.0/termix_"); + expect(body).toContain( + "Mobile/releases/download/release-1.2.0-tag/termix_android.apk", + ); + }); + + it("works for the real changelog", () => { + const fs = require("fs"); + const body = buildBody({ + version: require("../package.json").version, + mobileVersion: "1.0.0", + changelog: fs.readFileSync("CHANGELOG.md", "utf8"), + }); + expect(body.length).toBeGreaterThan(0); + }); +}); diff --git a/scripts/lib/bundled-plugins.cjs b/scripts/lib/bundled-plugins.cjs index 0153851d9..940e1e02e 100644 --- a/scripts/lib/bundled-plugins.cjs +++ b/scripts/lib/bundled-plugins.cjs @@ -254,7 +254,7 @@ const LOCAL_FILES = [ "locales", "migrations", "README.md", - "CHANGELOG.json", + "CHANGELOG.md", ]; /** diff --git a/scripts/lib/changelog-section.cjs b/scripts/lib/changelog-section.cjs new file mode 100644 index 000000000..4b8132d52 --- /dev/null +++ b/scripts/lib/changelog-section.cjs @@ -0,0 +1,24 @@ +/** + * The Markdown under one version's "## x.y.z" heading in CHANGELOG.md. + * The release scripts run before the SDK is built, so this is a small copy + * of changelogSection from packages/plugin-sdk/src/changelog.ts; lint checks + * the whole file with the real parser. + */ +function changelogSection(markdown, version) { + const lines = markdown.replace(/\r/g, "").split("\n"); + const heading = + /^##\s+\[?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\]?(?:\s+-\s+.+?)?\s*$/; + let start = -1; + let fence = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line.trim().startsWith("```")) fence = !fence; + if (fence || !/^##\s/.test(line)) continue; + if (start >= 0) return lines.slice(start, i).join("\n").trim(); + const match = heading.exec(line.trim()); + if (match && match[1] === version) start = i + 1; + } + return start >= 0 ? lines.slice(start).join("\n").trim() : null; +} + +module.exports = { changelogSection }; diff --git a/scripts/plugin-changelog.test.ts b/scripts/plugin-changelog.test.ts index 99e24f9ce..3b163a9b5 100644 --- a/scripts/plugin-changelog.test.ts +++ b/scripts/plugin-changelog.test.ts @@ -1,113 +1,177 @@ import { describe, expect, it } from "vitest"; -import { validateChangelog } from "../packages/plugin-sdk/cli/lib/changelog.mjs"; +import { + changelogSection, + parseChangelog, + parseReleaseNotes, + validateChangelog, +} from "../packages/plugin-sdk/src/changelog.ts"; -function release(version: string, extra: Record = {}) { - return { - version, - date: "2026-10-01", - changes: [{ type: "added", text: "Something new" }], - ...extra, - }; -} +const FILE = [ + "# Changelog", + "", + "## Unreleased", + "", + "### Fixed", + "- A bug not shipped yet", + "", + "## 1.1.0 - 2026-10-06", + "", + "Second release.", + "", + "### Added", + "- Something new", + " that wraps", + "* Another thing", + "", + "### Fixed", + "- Something broken", + "", + "## 1.0.0", + "", + "### Added", + "- The first version", + "", +].join("\n"); -describe("validateChangelog", () => { - it("accepts a changelog whose newest release matches the manifest", () => { - const changelog = { - $schema: "https://example.com/changelog.schema.json", - unreleased: [{ type: "fixed", text: "A bug" }], - releases: [ - release("1.1.0", { notes: "Second release." }), - release("1.0.0"), - ], - }; - expect(validateChangelog(changelog, "1.1.0")).toEqual([]); +describe("parseChangelog", () => { + it("reads releases, summaries, sections and unreleased changes", () => { + const { changelog, problems } = parseChangelog(FILE); + expect(problems).toEqual([]); + expect(changelog.unreleased).toEqual([ + { type: "fixed", text: "A bug not shipped yet" }, + ]); + expect(changelog.releases).toEqual([ + { + version: "1.1.0", + date: "2026-10-06", + summary: "Second release.", + changes: [ + { type: "added", text: "Something new\nthat wraps" }, + { type: "added", text: "Another thing" }, + { type: "fixed", text: "Something broken" }, + ], + }, + { + version: "1.0.0", + changes: [{ type: "added", text: "The first version" }], + }, + ]); }); - it("accepts a release that has no date yet", () => { - const { date: _date, ...undated } = release("1.0.0"); - expect(validateChangelog({ releases: [undated] }, "1.0.0")).toEqual([]); - }); - - it("flags a manifest version with no release notes", () => { - const problems = validateChangelog( - { releases: [release("1.0.0")] }, - "1.1.0", + it("accepts Keep a Changelog brackets, CRLF and calendar versions", () => { + const { changelog, problems } = parseChangelog( + "## [26.10.0] - 2026-10-06\r\n\r\n### Changed\r\n- A thing\r\n", ); - expect(problems).toContain( - "the newest release is 1.0.0, but manifest version is 1.1.0", - ); - }); - - it("requires releases newest first and without duplicates", () => { - expect( - validateChangelog( - { releases: [release("1.0.0"), release("1.2.0")] }, - "1.0.0", - ), - ).toContain("releases must be listed newest first"); - expect( - validateChangelog( - { releases: [release("1.0.0"), release("1.0.0")] }, - "1.0.0", - ), - ).toContain("releases[1].version 1.0.0 is listed twice"); + expect(problems).toEqual([]); + expect(changelog.releases[0]).toMatchObject({ + version: "26.10.0", + date: "2026-10-06", + }); }); it("orders a prerelease below its release", () => { - expect( - validateChangelog( - { releases: [release("2.0.0"), release("2.0.0-beta.1")] }, - "2.0.0", - ), - ).toEqual([]); - expect( - validateChangelog( - { releases: [release("2.0.0-beta.1"), release("2.0.0")] }, - "2.0.0-beta.1", - ), - ).toContain("releases must be listed newest first"); + const md = (a: string, b: string) => + `## ${a}\n### Added\n- x\n## ${b}\n### Added\n- y\n`; + expect(parseChangelog(md("2.0.0", "2.0.0-beta.1")).problems).toEqual([]); + expect(parseChangelog(md("2.0.0-beta.1", "2.0.0")).problems).toContain( + "releases must be listed newest first", + ); }); - it("rejects bad versions, dates, change types and empty text", () => { - const problems = validateChangelog( - { - releases: [ - { - version: "v1", - date: "October 1", - changes: [{ type: "improved", text: " " }], - }, - ], - }, - undefined, + it("flags bad headings, dates, sections, stray text and duplicates", () => { + const { problems } = parseChangelog( + [ + "## v1", + "## 1.0.0 - October 1", + "### Improved", + "- x", + "### Added", + "loose text", + "- y", + "## 1.0.0", + "### Added", + "- z", + ].join("\n"), ); expect(problems).toEqual( expect.arrayContaining([ - "releases[0].version must be a version like 1.2.3", - "releases[0].date must be a date like 2026-10-01", - expect.stringContaining("releases[0].changes[0].type must be one of"), - "releases[0].changes[0].text must be a non-empty string", + 'line 1: "## v1" should look like "## 1.2.0 - 2026-10-06"', + '1.0.0 has a date "October 1", use YYYY-MM-DD', + expect.stringContaining('unknown section "Improved"'), + expect.stringContaining("that is not a list item: loose text"), + "1.0.0 is listed twice", ]), ); }); - it("rejects unknown keys, empty change lists and missing releases", () => { - expect(validateChangelog({ releases: [] }, "1.0.0")).toEqual([ - "releases must be a non-empty array", - ]); - expect( - validateChangelog( - { extra: true, releases: [release("1.0.0", { changes: [] })] }, - "1.0.0", - ), - ).toEqual( - expect.arrayContaining([ - 'CHANGELOG.json has unknown key "extra"', - "releases[0].changes must list at least one change", - ]), + it("flags a release with no changes and Unreleased out of place", () => { + const { problems } = parseChangelog( + "## 1.0.0\n\nJust a summary.\n\n## Unreleased\n### Added\n- x\n", ); - expect(validateChangelog([], "1.0.0")).toEqual([ - "CHANGELOG.json must be an object", + expect(problems).toContain("1.0.0 has no changes listed"); + expect(problems).toContain('"## Unreleased" has to come first'); + }); + + it("ignores headings inside code fences", () => { + const { changelog, problems } = parseChangelog( + "## 1.0.0\n\n```\n## not a heading\n```\n\n### Added\n- x\n", + ); + expect(problems).toEqual([]); + expect(changelog.releases).toHaveLength(1); + }); +}); + +describe("validateChangelog", () => { + it("passes when the newest release is the manifest version", () => { + expect(validateChangelog(FILE, "1.1.0")).toEqual([]); + }); + + it("flags a manifest version with no notes", () => { + expect(validateChangelog(FILE, "1.2.0")).toContain( + "the newest release is 1.1.0, but the manifest version is 1.2.0", + ); + }); + + it("flags a file with no releases", () => { + expect(validateChangelog("# Changelog\n", "1.0.0")).toEqual([ + 'no releases found, add a "## 1.0.0" heading', ]); }); }); + +describe("changelogSection", () => { + it("returns the Markdown under a version's heading", () => { + expect(changelogSection(FILE, "1.1.0")).toBe( + [ + "Second release.", + "", + "### Added", + "- Something new", + " that wraps", + "* Another thing", + "", + "### Fixed", + "- Something broken", + ].join("\n"), + ); + expect(changelogSection(FILE, "1.0.0")).toBe( + "### Added\n- The first version", + ); + }); + + it("is null for a version that is not there", () => { + expect(changelogSection(FILE, "9.9.9")).toBeNull(); + }); +}); + +describe("parseReleaseNotes", () => { + it("parses a section body on its own", () => { + expect( + parseReleaseNotes("Summary.\n\n### Security\n- Patched a hole"), + ).toEqual({ + summary: "Summary.", + changes: [{ type: "security", text: "Patched a hole" }], + problems: [], + }); + }); +}); diff --git a/scripts/publish-youtube.cjs b/scripts/publish-youtube.cjs deleted file mode 100644 index a98368575..000000000 --- a/scripts/publish-youtube.cjs +++ /dev/null @@ -1,121 +0,0 @@ -const fs = require("fs"); -const path = require("path"); - -function extractYoutubeId(notes) { - const match = notes.match( - /([\s\S]*?)/, - ); - if (!match || !match[1].trim()) { - throw new Error( - "missing or empty section in release notes", - ); - } - return parseYoutubeId(match[1].trim()); -} - -function parseYoutubeId(raw) { - const value = raw.trim(); - let m = value.match(/[?&]v=([A-Za-z0-9_-]+)/); - if (m) return m[1]; - m = value.match(/youtu\.be\/([A-Za-z0-9_-]+)/); - if (m) return m[1]; - m = value.match(/embed\/([A-Za-z0-9_-]+)/); - if (m) return m[1]; - if (/^[A-Za-z0-9_-]+$/.test(value)) return value; - throw new Error(`could not parse a YouTube video id from "${value}"`); -} - -async function getAccessToken({ clientId, clientSecret, refreshToken }) { - const res = await fetch("https://oauth2.googleapis.com/token", { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - refresh_token: refreshToken, - grant_type: "refresh_token", - }), - }); - if (!res.ok) { - throw new Error( - `token exchange failed (${res.status}): ${await res.text()}`, - ); - } - const json = await res.json(); - return json.access_token; -} - -async function getVideoStatus(accessToken, videoId) { - const res = await fetch( - `https://www.googleapis.com/youtube/v3/videos?part=status&id=${videoId}`, - { headers: { Authorization: `Bearer ${accessToken}` } }, - ); - if (!res.ok) - throw new Error(`videos.list failed (${res.status}): ${await res.text()}`); - const json = await res.json(); - return json.items?.[0]?.status?.privacyStatus ?? null; -} - -async function setVideoPublic(accessToken, videoId) { - const status = await getVideoStatus(accessToken, videoId); - if (status === "public") { - console.log(`Video ${videoId} is already public, skipping.`); - return; - } - const res = await fetch( - "https://www.googleapis.com/youtube/v3/videos?part=status", - { - method: "PUT", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - id: videoId, - status: { privacyStatus: "public" }, - }), - }, - ); - if (!res.ok) { - throw new Error( - `videos.update failed (${res.status}): ${await res.text()}`, - ); - } - return res.json(); -} - -async function main() { - const clientId = process.env.YOUTUBE_CLIENT_ID; - const clientSecret = process.env.YOUTUBE_CLIENT_SECRET; - const refreshToken = process.env.YOUTUBE_REFRESH_TOKEN; - - if (!clientId || !clientSecret || !refreshToken) { - throw new Error( - "YOUTUBE_CLIENT_ID, YOUTUBE_CLIENT_SECRET, and YOUTUBE_REFRESH_TOKEN are required", - ); - } - - const notesPath = path.resolve( - process.env.RELEASE_NOTES || "RELEASE_NOTES.md", - ); - const notes = fs.readFileSync(notesPath, "utf8"); - const videoId = extractYoutubeId(notes); - - const accessToken = await getAccessToken({ - clientId, - clientSecret, - refreshToken, - }); - await setVideoPublic(accessToken, videoId); - - console.log(`Set YouTube video ${videoId} to public.`); -} - -module.exports = { extractYoutubeId, parseYoutubeId }; - -if (require.main === module) { - main().catch((err) => { - console.error(`publish-youtube: ${err.message}`); - process.exit(1); - }); -} diff --git a/scripts/publish-youtube.test.ts b/scripts/publish-youtube.test.ts deleted file mode 100644 index 79c1fedd6..000000000 --- a/scripts/publish-youtube.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { createRequire } from "module"; - -const require = createRequire(import.meta.url); -const { extractYoutubeId, parseYoutubeId } = require("./publish-youtube.cjs"); - -describe("parseYoutubeId", () => { - it("parses a youtu.be short link", () => { - expect(parseYoutubeId("https://youtu.be/At8iDk6-Q_s")).toBe("At8iDk6-Q_s"); - }); - - it("parses a watch?v= link", () => { - expect(parseYoutubeId("https://www.youtube.com/watch?v=At8iDk6-Q_s")).toBe( - "At8iDk6-Q_s", - ); - }); - - it("parses an embed link", () => { - expect(parseYoutubeId("https://www.youtube.com/embed/At8iDk6-Q_s")).toBe( - "At8iDk6-Q_s", - ); - }); - - it("accepts a bare id", () => { - expect(parseYoutubeId("At8iDk6-Q_s")).toBe("At8iDk6-Q_s"); - }); - - it("throws on garbage", () => { - expect(() => parseYoutubeId("https://example.com/")).toThrow(); - }); -}); - -describe("extractYoutubeId", () => { - it("pulls the id from a release notes YOUTUBE section", () => { - const notes = [ - "", - "stuff", - "", - "", - "https://youtu.be/At8iDk6-Q_s", - "", - ].join("\n"); - expect(extractYoutubeId(notes)).toBe("At8iDk6-Q_s"); - }); - - it("throws when the YOUTUBE section is missing", () => { - expect(() => extractYoutubeId("no section here")).toThrow(/YOUTUBE/); - }); - - it("throws when the YOUTUBE section is empty", () => { - expect(() => - extractYoutubeId("\n\n"), - ).toThrow(/empty/); - }); -}); diff --git a/src/backend/database/routes/plugins.ts b/src/backend/database/routes/plugins.ts index 6cc1f9900..2d7901b36 100644 --- a/src/backend/database/routes/plugins.ts +++ b/src/backend/database/routes/plugins.ts @@ -18,6 +18,7 @@ import { invalidatePluginPermissionCache } from "../../plugins/permissions.js"; import { getPluginPublicHttpRoutes } from "../../plugins/http.js"; import { getPluginPublicWsRoutes } from "../../plugins/ws.js"; import { PluginManageError } from "../../plugins/manage.js"; +import { youtubeVideoId } from "../../plugins/manifest.js"; import { isPluginChoice, type PluginChoice, @@ -255,6 +256,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { let description: string | undefined; let author: string | undefined; let repository: string | undefined; + let videoId: string | undefined; try { const manifest = JSON.parse(record.manifestJson) as { contributes?: unknown; @@ -263,6 +265,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { description?: unknown; author?: unknown; repository?: unknown; + video?: unknown; dependencies?: Record; optionalDependencies?: Record; }; @@ -284,6 +287,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { typeof manifest?.repository === "string" ? manifest.repository : undefined; + videoId = youtubeVideoId(manifest?.video) ?? undefined; } catch { contributes = null; } @@ -313,6 +317,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { description, author, repository, + videoId, signedBy: loaded?.signedBy ?? null, capabilities, grantedCapabilities: grantsByPlugin.get(record.id) ?? [], @@ -1984,6 +1989,48 @@ router.get( }, ); +/** + * @openapi + * /plugins/{id}/changelog: + * get: + * summary: Read an installed plugin's release notes + * description: The releases in the plugin's CHANGELOG.md, newest first, each with its date, summary and changes. Empty when the plugin ships no changelog. + * tags: + * - Plugins + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The releases. + * 403: + * description: The caller lacks admin.plugins.manage. + * 404: + * description: No such plugin. + */ +router.get( + "/:id/changelog", + authenticateJWT, + requireManagePlugins, + async (req: Request, res: Response) => { + const pluginId = String(req.params.id); + try { + const { getPluginChangelog } = await import("../../plugins/manage.js"); + res.json({ id: pluginId, releases: await getPluginChangelog(pluginId) }); + } catch (error) { + sendManageError( + res, + error, + "Failed to read the plugin's release notes", + "plugin_changelog", + ); + } + }, +); + /** * @openapi * /plugins/developer-mode: diff --git a/src/backend/plugins/manage.ts b/src/backend/plugins/manage.ts index 26c2cb58f..cedc0200e 100644 --- a/src/backend/plugins/manage.ts +++ b/src/backend/plugins/manage.ts @@ -45,6 +45,12 @@ import { readInstallCounts } from "./install-counts.js"; import { readArtifactManifest, sameCapabilities } from "./artifact-manifest.js"; import { parseManifest, type PluginManifest } from "./manifest.js"; import { requireSignedPlugins } from "./trust.js"; +import { + parseChangelog, + parseReleaseNotes, + type ChangelogRelease, + type ReleaseNotes, +} from "@termix-ssh/plugin-sdk/changelog"; const UNINSTALLED_KEY = "plugins_uninstalled"; const DEVELOPER_MODE_KEY = "plugins_developer_mode"; @@ -206,12 +212,14 @@ export interface RegistryListing { category: string; repository?: string; icon?: string; + videoId?: string; versions: Array<{ version: string; compatible: boolean; capabilities: string[]; publishedAt?: string; releaseNotesUrl?: string; + notes?: ReleaseNotes; size: number; }>; latestVersion: string | null; @@ -263,12 +271,14 @@ export async function listRegistry( category: plugin.category, repository: plugin.repository, icon: plugin.icon, + videoId: plugin.videoId, versions: plugin.versions.map((entry) => ({ version: entry.version, compatible: isApiCompatible(entry.api), capabilities: entry.capabilities, publishedAt: entry.publishedAt, releaseNotesUrl: entry.releaseNotesUrl, + notes: readReleaseNotes(entry.notes), size: entry.size, })), latestVersion: latest?.version ?? null, @@ -963,6 +973,40 @@ export function setPluginOptions( }); } +const MAX_CHANGELOG_BYTES = 512 * 1024; + +/** A registry version's Markdown notes, parsed. Undefined when empty. */ +export function readReleaseNotes( + markdown: string | undefined, +): ReleaseNotes | undefined { + if (!markdown) return undefined; + const { summary, changes } = parseReleaseNotes(markdown); + if (!summary && changes.length === 0) return undefined; + return { ...(summary ? { summary } : {}), changes }; +} + +/** + * The releases in an installed plugin's CHANGELOG.md, newest first. Empty when + * the plugin ships none or it cannot be read. + */ +export async function getPluginChangelog( + pluginId: string, +): Promise { + const plugin = getPluginRuntime().loader.get(pluginId); + if (!plugin) { + throw new PluginManageError("Plugin not found", 404, "NOT_FOUND"); + } + const file = path.join(plugin.dir, "CHANGELOG.md"); + try { + const stat = await fs.promises.stat(file); + if (!stat.isFile() || stat.size > MAX_CHANGELOG_BYTES) return []; + return parseChangelog(await fs.promises.readFile(file, "utf8")).changelog + .releases; + } catch { + return []; + } +} + async function directorySize(dir: string): Promise { let total = 0; let entries: fs.Dirent[]; diff --git a/src/backend/plugins/manifest.ts b/src/backend/plugins/manifest.ts index f9e293355..e1fe84ce4 100644 --- a/src/backend/plugins/manifest.ts +++ b/src/backend/plugins/manifest.ts @@ -17,6 +17,7 @@ export { SYSTEM_ROLE_NAMES, RESERVED_PERMISSION_PREFIXES, qualifyPermission, + youtubeVideoId, } from "@termix-ssh/plugin-sdk/manifest"; export type { diff --git a/src/backend/plugins/registry-index.ts b/src/backend/plugins/registry-index.ts index 1976f18e4..3d36f3203 100644 --- a/src/backend/plugins/registry-index.ts +++ b/src/backend/plugins/registry-index.ts @@ -12,7 +12,10 @@ import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import semver from "semver"; -import { SUPPORTED_PLUGIN_API_VERSION } from "@termix-ssh/plugin-sdk/manifest"; +import { + SUPPORTED_PLUGIN_API_VERSION, + youtubeVideoId, +} from "@termix-ssh/plugin-sdk/manifest"; import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js"; import { pluginLogger } from "../utils/logger.js"; import { verifyPluginArtifact } from "./trust.js"; @@ -27,6 +30,7 @@ const CACHE_MS = 15 * 60_000; const MAX_INDEX_BYTES = 5 * 1024 * 1024; const MAX_ARTIFACT_BYTES = 200 * 1024 * 1024; const MAX_REDIRECTS = 5; +const MAX_NOTES_LENGTH = 20_000; const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; export interface RegistryVersion { @@ -38,6 +42,8 @@ export interface RegistryVersion { size: number; capabilities: string[]; releaseNotesUrl?: string; + /** This version's CHANGELOG.md section, as Markdown. */ + notes?: string; publishedAt?: string; } @@ -49,6 +55,8 @@ export interface RegistryPlugin { category: string; repository?: string; icon?: string; + /** Only ever a YouTube video id, never a link. */ + videoId?: string; /** Newest first. */ versions: RegistryVersion[]; } @@ -95,6 +103,7 @@ function parseVersion(raw: unknown): RegistryVersion | null { ? entry.capabilities.filter((c): c is string => typeof c === "string") : [], releaseNotesUrl: asString(entry.releaseNotesUrl), + notes: asString(entry.notes)?.slice(0, MAX_NOTES_LENGTH), publishedAt: asString(entry.publishedAt), }; } @@ -128,6 +137,7 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex { category: asString(entry.category) ?? "", repository: asString(entry.repository), icon: asString(entry.icon), + videoId: youtubeVideoId(entry.video) ?? undefined, versions, }); } diff --git a/src/backend/tests/database/routes/plugins.test.ts b/src/backend/tests/database/routes/plugins.test.ts index 1572c6fa6..46f9fa3e0 100644 --- a/src/backend/tests/database/routes/plugins.test.ts +++ b/src/backend/tests/database/routes/plugins.test.ts @@ -170,6 +170,7 @@ const manageMock = vi.hoisted(() => ({ uninstallPlugin: vi.fn(), setPluginOptions: vi.fn(), getPluginDataSummary: vi.fn(), + getPluginChangelog: vi.fn(), deletePluginData: vi.fn(), planStateChange: vi.fn(), setPluginState: vi.fn(), @@ -832,6 +833,21 @@ describe("plugins route", () => { expect(manageMock.setPluginState).not.toHaveBeenCalled(); }); + it("returns an installed plugin's release notes", async () => { + manageMock.getPluginChangelog.mockResolvedValue([ + { version: "1.0.0", changes: [{ type: "added", text: "First" }] }, + ]); + const res = await fetch(`${baseUrl}/plugins/docker/changelog`); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + id: "docker", + releases: [ + { version: "1.0.0", changes: [{ type: "added", text: "First" }] }, + ], + }); + expect(manageMock.getPluginChangelog).toHaveBeenCalledWith("docker"); + }); + it("answers 502 when the registry is unreachable", async () => { manageMock.listRegistry.mockRejectedValue(new Error("offline")); const res = await fetch(`${baseUrl}/plugins/registry`); @@ -856,6 +872,7 @@ describe("plugins route", () => { body: JSON.stringify({ autoUpdate: true }), }), fetch(`${baseUrl}/plugins/docker/data`, { headers }), + fetch(`${baseUrl}/plugins/docker/changelog`, { headers }), fetch(`${baseUrl}/plugins/developer-mode`, { headers }), fetch(`${baseUrl}/plugins/developer-mode`, { method: "PUT", diff --git a/src/backend/tests/plugins/manage.test.ts b/src/backend/tests/plugins/manage.test.ts index 65a7c960b..61c1697e7 100644 --- a/src/backend/tests/plugins/manage.test.ts +++ b/src/backend/tests/plugins/manage.test.ts @@ -680,3 +680,40 @@ describe("installing from a file", () => { ).rejects.toMatchObject({ code: "UPLOAD_EXPIRED" }); }); }); + +describe("release notes", () => { + it("reads an installed plugin's CHANGELOG.md", async () => { + createFixturePlugin({ id: "docker", root: bundled }); + fs.writeFileSync( + path.join(bundled, "docker", "CHANGELOG.md"), + "# Changelog\n\n## 1.1.0 - 2026-10-06\n\n### Fixed\n- A bug\n\n## 1.0.0\n\n### Added\n- First\n", + ); + await boot(); + expect(await manage.getPluginChangelog("docker")).toEqual([ + { + version: "1.1.0", + date: "2026-10-06", + changes: [{ type: "fixed", text: "A bug" }], + }, + { version: "1.0.0", changes: [{ type: "added", text: "First" }] }, + ]); + }); + + it("is empty without a changelog and 404s for an unknown plugin", async () => { + createFixturePlugin({ id: "docker", root: bundled }); + await boot(); + expect(await manage.getPluginChangelog("docker")).toEqual([]); + await expect(manage.getPluginChangelog("nope")).rejects.toMatchObject({ + status: 404, + }); + }); + + it("parses registry notes and drops empty ones", () => { + expect(manage.readReleaseNotes("Summary.\n\n### Added\n- Thing")).toEqual({ + summary: "Summary.", + changes: [{ type: "added", text: "Thing" }], + }); + expect(manage.readReleaseNotes("")).toBeUndefined(); + expect(manage.readReleaseNotes(undefined)).toBeUndefined(); + }); +}); diff --git a/src/backend/tests/plugins/manifest.test.ts b/src/backend/tests/plugins/manifest.test.ts index 6c58f7f5f..cb99679b6 100644 --- a/src/backend/tests/plugins/manifest.test.ts +++ b/src/backend/tests/plugins/manifest.test.ts @@ -12,6 +12,7 @@ import { validateManifest, SUPPORTED_PLUGIN_API_VERSION, isTermixCompatible, + youtubeVideoId, } from "../../plugins/manifest.js"; function base(overrides: Record = {}) { @@ -852,3 +853,47 @@ describe("contributes.protocols", () => { expect(errors.join("\n")).toMatch(/extra/); }); }); + +describe("manifest video", () => { + it("accepts YouTube links", () => { + for (const video of [ + "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "https://youtu.be/dQw4w9WgXcQ", + "https://www.youtube.com/embed/dQw4w9WgXcQ", + "https://youtube.com/shorts/dQw4w9WgXcQ", + ]) { + expect(validateManifest(base({ video }))).toEqual([]); + } + }); + + it("rejects anything that is not a YouTube video", () => { + for (const video of [ + "https://evil.example/watch?v=dQw4w9WgXcQ", + "http://www.youtube.com/watch?v=dQw4w9WgXcQ", + "https://www.youtube.com.evil.example/watch?v=dQw4w9WgXcQ", + "https://user@www.youtube.com/watch?v=dQw4w9WgXcQ", + "https://www.youtube.com/watch?v=short", + "https://www.youtube.com/channel/UC123", + "javascript:alert(1)", + "", + 42, + ]) { + expect(validateManifest(base({ video }))).toEqual([ + expect.stringContaining('Field "video" must be a YouTube link'), + ]); + } + }); +}); + +describe("youtubeVideoId", () => { + it("returns only the id", () => { + expect( + youtubeVideoId("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42"), + ).toBe("dQw4w9WgXcQ"); + expect(youtubeVideoId("https://youtu.be/dQw4w9WgXcQ?si=abc")).toBe( + "dQw4w9WgXcQ", + ); + expect(youtubeVideoId("https://youtu.be/../../x")).toBeNull(); + expect(youtubeVideoId(undefined)).toBeNull(); + }); +}); diff --git a/src/backend/tests/plugins/registry-index.test.ts b/src/backend/tests/plugins/registry-index.test.ts index d4e33712e..b3dae7566 100644 --- a/src/backend/tests/plugins/registry-index.test.ts +++ b/src/backend/tests/plugins/registry-index.test.ts @@ -121,6 +121,28 @@ describe("parseRegistryIndex", () => { expect(index.plugins.map((p) => p.id)).toEqual(["ok"]); }); + it("keeps release notes and only a YouTube video id", () => { + const NOTES = "### Added\n- Thing"; + const index = parseRegistryIndex({ + plugins: [ + { + id: "docker", + video: "https://youtu.be/dQw4w9WgXcQ", + versions: [version({ notes: NOTES })], + }, + { + id: "other", + video: "https://evil.example/watch?v=dQw4w9WgXcQ", + versions: [version({ notes: "x".repeat(30_000) })], + }, + ], + }); + expect(index.plugins[0].videoId).toBe("dQw4w9WgXcQ"); + expect(index.plugins[0].versions[0].notes).toBe(NOTES); + expect(index.plugins[1].videoId).toBeUndefined(); + expect(index.plugins[1].versions[0].notes).toHaveLength(20_000); + }); + it("refuses something that is not an index", () => { expect(() => parseRegistryIndex({ nope: true })).toThrow(/no plugins/); }); diff --git a/src/ui/api/plugins-api.ts b/src/ui/api/plugins-api.ts index 2e991789c..0d588b53f 100644 --- a/src/ui/api/plugins-api.ts +++ b/src/ui/api/plugins-api.ts @@ -1,5 +1,9 @@ import { rbacApi } from "@/main-axios"; import type { ChoiceAdjustment, PluginChoice } from "@/types/plugin-onboarding"; +import type { + ChangelogRelease, + ReleaseNotes, +} from "@termix-ssh/plugin-sdk/changelog"; export type { PluginChoice }; @@ -246,6 +250,8 @@ export interface PluginSummary { description?: string; author?: string; repository?: string; + /** A YouTube video id from the manifest. Admins only. */ + videoId?: string; signedBy?: string | null; } @@ -267,6 +273,7 @@ export interface RegistryPluginVersion { capabilities: string[]; publishedAt?: string; releaseNotesUrl?: string; + notes?: ReleaseNotes; size: number; } @@ -278,6 +285,7 @@ export interface RegistryPluginEntry { category: string; repository?: string; icon?: string; + videoId?: string; versions: RegistryPluginVersion[]; latestVersion: string | null; installed: boolean; @@ -448,6 +456,13 @@ export async function getPluginData( return response.data; } +export async function getPluginChangelog( + pluginId: string, +): Promise { + const response = await rbacApi.get(`${pluginPath(pluginId)}/changelog`); + return response.data?.releases ?? []; +} + export async function deletePluginData(pluginId: string): Promise { try { await rbacApi.delete(`${pluginPath(pluginId)}/data`); diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 993a78f7f..7a0388277 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -2105,8 +2105,20 @@ "switchVersion": "Switch", "latestVersion": "{{version}} (latest)", "versionChange": "{{from}} to {{to}}", - "versionHistory": "Version history", "releaseNotes": "Release notes", + "noReleaseNotes": "No notes for this version.", + "viewRelease": "View on GitHub", + "showAllReleases": "Show all {{count}}", + "showFewerReleases": "Show fewer", + "videoTitle": "{{name}} video", + "changeTypes": { + "added": "Added", + "changed": "Changed", + "fixed": "Fixed", + "removed": "Removed", + "deprecated": "Deprecated", + "security": "Security" + }, "installedBadge": "Installed", "incompatible": "Needs a newer Termix", "whatItCanDo": "What it can do", diff --git a/src/ui/plugins/PluginDetail.tsx b/src/ui/plugins/PluginDetail.tsx index ce29e5cc9..5c88f3d07 100644 --- a/src/ui/plugins/PluginDetail.tsx +++ b/src/ui/plugins/PluginDetail.tsx @@ -37,17 +37,12 @@ import { type PluginEntry, } from "./plugin-model"; import { openPluginSettings } from "./open-plugins"; +import { PluginReleaseNotes, PluginVideo } from "./PluginReleaseNotes"; import type { PluginsManager } from "./use-plugins-manager"; const LINK_ROW = "flex items-center gap-2 border border-border bg-card px-3 py-2.5 text-left text-[11px] text-muted-foreground transition-colors hover:border-accent-brand/40 hover:text-foreground"; -function formatDate(value: string | null | undefined): string { - if (!value) return ""; - const date = new Date(value); - return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString(); -} - export function PluginDetail({ plugin, manager, @@ -206,6 +201,8 @@ export function PluginDetail({
+ +
{orderByRisk(plugin.capabilities).map((capability) => ( @@ -224,48 +221,7 @@ export function PluginDetail({
- {plugin.versions.length > 0 && ( - -
- {plugin.versions.map((v) => ( -
-
- {v.version} - {v.version === plugin.version && ( - - {t("plugins.manager.installedBadge")} - - )} - {!v.compatible && ( - - {t("plugins.manager.incompatible")} - - )} - - {formatDate(v.publishedAt)} - -
- {v.releaseNotesUrl && ( - - {t("plugins.manager.releaseNotes")} - - )} -
- ))} -
-
- )} + {plugin.installed && ( + {text.split(/(`[^`]+`)/).map((part, i) => + part.length > 2 && part.startsWith("`") && part.endsWith("`") ? ( + + {part.slice(1, -1)} + + ) : ( + {part} + ), + )} + + ); +} + +function Release({ + row, + installedVersion, +}: { + row: ReleaseRow; + installedVersion: string | null; +}) { + const { t } = useTranslation(); + const groups = groupChanges(row.changes); + + return ( +
+
+ {row.version} + {row.version === installedVersion && ( + + {t("plugins.manager.installedBadge")} + + )} + {!row.compatible && ( + + {t("plugins.manager.incompatible")} + + )} + + {formatDate(row.date)} + +
+ {row.summary && ( +

+ +

+ )} + {groups.map((group) => ( +
+ + {t(`plugins.manager.changeTypes.${group.type}`)} + +
    + {group.items.map((item, i) => ( +
  • + +
  • + ))} +
+
+ ))} + {groups.length === 0 && !row.summary && ( + + {t("plugins.manager.noReleaseNotes")} + + )} + {row.releaseNotesUrl && ( + + {t("plugins.manager.viewRelease")} + + )} +
+ ); +} + +/** + * Every version's notes, from the registry and the installed CHANGELOG.md. + * Renders nothing when there are no versions to list. + */ +export function PluginReleaseNotes({ plugin }: { plugin: PluginEntry }) { + const { t } = useTranslation(); + const [local, setLocal] = useState([]); + const [expanded, setExpanded] = useState(false); + + useEffect(() => { + let cancelled = false; + setLocal([]); + if (!plugin.installed) return; + getPluginChangelog(plugin.id) + .then((releases) => { + if (!cancelled) setLocal(releases); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [plugin.id, plugin.installed, plugin.version]); + + const rows = useMemo( + () => mergeReleaseNotes(plugin.versions, local), + [plugin.versions, local], + ); + if (rows.length === 0) return null; + const shown = expanded ? rows : rows.slice(0, SHOWN); + + return ( + SHOWN ? ( + + ) : undefined + } + > +
+ {shown.map((row) => ( + + ))} +
+
+ ); +} + +/** The plugin's YouTube video. Nothing is loaded when it has none. */ +export function PluginVideo({ + videoId, + name, +}: { + videoId?: string; + name: string; +}) { + const { t } = useTranslation(); + const src = youtubeEmbedUrl(videoId); + if (!src) return null; + + return ( +
+