From 1eb05f387a9b6151cfd74c16b1da1baa36a58c16 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 17 May 2026 10:52:20 -0400 Subject: [PATCH 1/2] fix(website): improved rendering of releases --- .../scripts/generate-packages-with-cleanup.js | 45 -- .github/scripts/generate-packages.js | 63 ++- .github/scripts/sync-assets-with-metadata.js | 21 +- .github/scripts/sync-assets.js | 80 +++- .github/workflows/update-pages.yml | 24 +- README.md | 24 +- gh-pages-template/assets/css/packages.css | 28 ++ gh-pages-template/assets/js/app.js | 403 +++++++++++++++--- gh-pages-template/index.html | 6 +- 9 files changed, 526 insertions(+), 168 deletions(-) delete mode 100644 .github/scripts/generate-packages-with-cleanup.js diff --git a/.github/scripts/generate-packages-with-cleanup.js b/.github/scripts/generate-packages-with-cleanup.js deleted file mode 100644 index 87c5e194..00000000 --- a/.github/scripts/generate-packages-with-cleanup.js +++ /dev/null @@ -1,45 +0,0 @@ -const fs = require('fs'); -const { generatePackagesJson, writePackagesJson } = require('./generate-packages.js'); -const { cleanupNonVPrefixedReleases } = require('./cleanup-releases.js'); - -/** - * Main function to generate packages.json with cleanup - * @param {string} distPath - Path to the dist directory (default: current directory) - */ -function generatePackagesWithCleanup(distPath = '.') { - console.log('Starting packages.json generation process...'); - - try { - // Step 1: Clean up non-v-prefixed release directories - cleanupNonVPrefixedReleases(distPath); - - // Step 2: Read repository metadata from previous step - let repositoryMetadata = []; - const metadataPath = 'repo-metadata.json'; - - try { - const metadataContent = fs.readFileSync(metadataPath, 'utf8'); - repositoryMetadata = JSON.parse(metadataContent); - console.log(`Loaded metadata for ${repositoryMetadata.length} repositories`); - } catch (error) { - console.log('No repository metadata found, continuing without archived status'); - } - - // Step 3: Generate the packages data - const packagesData = generatePackagesJson(distPath, repositoryMetadata); - - // Step 4: Write the packages.json file - writePackagesJson(packagesData, './packages.json'); - - console.log('Packages.json generation completed successfully'); - return packagesData; - - } catch (error) { - console.error('Error during packages.json generation:', error); - throw error; - } -} - -module.exports = { - generatePackagesWithCleanup -}; diff --git a/.github/scripts/generate-packages.js b/.github/scripts/generate-packages.js index 48a96f0e..0f5ae09b 100644 --- a/.github/scripts/generate-packages.js +++ b/.github/scripts/generate-packages.js @@ -1,6 +1,15 @@ const fs = require('fs'); const path = require('path'); +/** + * Encode path segments for a browser URL without changing the dist directory layout. + * @param {...string} segments - URL path segments. + * @returns {string} Relative URL for GitHub Pages. + */ +function encodeDirectAssetUrl(...segments) { + return segments.map(segment => encodeURIComponent(segment)).join('/'); +} + /** * Build packages.json data with stats from repository entries. * @param {Array} repositories - Repository data to include. @@ -13,10 +22,12 @@ function buildPackagesData(repositories) { // Calculate totals const totalReleases = repositories.reduce((sum, repo) => sum + repo.releases.length, 0); const totalAssets = repositories.reduce((sum, repo) => - sum + repo.releases.reduce((releaseSum, release) => releaseSum + release.assetCount, 0), 0 + sum + repo.releases.reduce((releaseSum, release) => + releaseSum + (release.assetCount || (Array.isArray(release.assets) ? release.assets.length : 0)), 0), 0 ); const packagesData = { + generatedAt: new Date().toISOString(), repositories: repositories, stats: { totalRepositories: repositories.length, @@ -43,17 +54,40 @@ function normalizeRepositoryMetadata(repositoryMetadata = []) { .filter(repo => repo && Array.isArray(repo.releases) && repo.releases.length > 0) .map(repo => { const releases = repo.releases - .filter(release => release && release.assetCount > 0) - .map(release => ({ - tag: release.tag, - assetCount: release.assetCount - })); + .filter(release => release && (release.assetCount > 0 || (Array.isArray(release.assets) && release.assets.length > 0))) + .map(release => { + const assets = Array.isArray(release.assets) + ? release.assets + .filter(asset => asset && asset.name) + .map(asset => ({ + name: asset.name, + size: asset.size, + githubUrl: asset.githubUrl || asset.browserDownloadUrl, + directUrl: asset.directUrl + })) + : []; + + const releaseData = { + tag: release.tag, + url: release.url, + publishedAt: release.publishedAt, + assetCount: release.assetCount || assets.length + }; + + if (assets.length > 0) { + assets.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })); + releaseData.assets = assets; + } + + return releaseData; + }); releases.sort((a, b) => b.tag.localeCompare(a.tag, undefined, { numeric: true, sensitivity: 'base' })); return { name: repo.name, archived: Boolean(repo.archived), + url: repo.url, releases }; }) @@ -186,9 +220,23 @@ function scanReleaseDirectory(releasePath) { }); if (assetFiles.length > 0) { + const assets = assetFiles + .map(assetFile => { + const assetPath = path.join(releasePath, assetFile.name); + const repoName = path.basename(path.dirname(releasePath)); + + return { + name: assetFile.name, + size: fs.statSync(assetPath).size, + directUrl: encodeDirectAssetUrl(repoName, releaseTag, assetFile.name) + }; + }) + .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })); + return { tag: releaseTag, - assetCount: assetFiles.length + assetCount: assetFiles.length, + assets }; } @@ -218,6 +266,7 @@ function writePackagesJson(packagesData, outputPath = './packages.json') { module.exports = { buildPackagesData, + encodeDirectAssetUrl, generatePackagesJson, normalizeRepositoryMetadata, scanRepositoryDirectory, diff --git a/.github/scripts/sync-assets-with-metadata.js b/.github/scripts/sync-assets-with-metadata.js index dc83b74b..a9244b8d 100644 --- a/.github/scripts/sync-assets-with-metadata.js +++ b/.github/scripts/sync-assets-with-metadata.js @@ -1,8 +1,11 @@ const fs = require('fs'); const { syncReleaseAssets } = require('./sync-assets.js'); +const { buildPackagesData, writePackagesJson } = require('./generate-packages.js'); + +const LEGACY_METADATA_PATH = 'repo-metadata.json'; /** - * Main function to sync assets and store repository metadata + * Main function to sync assets and write the package index. * @param {Object} github - GitHub API client * @param {Object} context - GitHub Actions context * @param {boolean} isPullRequest - Whether this is a pull request event @@ -15,14 +18,20 @@ async function syncAssetsWithMetadata(github, context, isPullRequest = false, ma // Run the asset synchronization const repositoryData = await syncReleaseAssets(github, context, isPullRequest, maxNewAssets); - // Store repository data for use in next step - const metadataPath = 'repo-metadata.json'; - fs.writeFileSync(metadataPath, JSON.stringify(repositoryData, null, 2)); + // Store the public package index directly. This replaces the old + // repo-metadata.json handoff so GitHub Pages only needs one JSON file. + const packagesData = buildPackagesData(repositoryData); + writePackagesJson(packagesData, 'packages.json'); + + if (fs.existsSync(LEGACY_METADATA_PATH)) { + fs.unlinkSync(LEGACY_METADATA_PATH); + console.log(`Removed legacy metadata file: ${LEGACY_METADATA_PATH}`); + } - console.log(`Stored metadata for ${repositoryData.length} repositories in ${metadataPath}`); + console.log(`Stored package index for ${repositoryData.length} repositories in packages.json`); console.log('Asset synchronization completed successfully'); - return repositoryData; + return packagesData; } catch (error) { console.error('Error during asset synchronization:', error); diff --git a/.github/scripts/sync-assets.js b/.github/scripts/sync-assets.js index bb466b78..2732d6bd 100644 --- a/.github/scripts/sync-assets.js +++ b/.github/scripts/sync-assets.js @@ -8,6 +8,7 @@ const { ensureDir, fileExists } = require('./file-utils'); const { generateHashFiles } = require('./hash-utils'); const { downloadAssetWithRetry } = require('./download-utils'); const { loadPackageConfig, shouldIncludeAsset } = require('./package-config'); +const { cleanupNonVPrefixedReleases } = require('./cleanup-releases'); /** * Check whether a filename is one of the generated hash sidecar files. @@ -68,6 +69,36 @@ function canDownloadNewAsset(maxNewAssets, currentNewAssets, releaseNewAssets) { return maxNewAssets <= 0 || (currentNewAssets + releaseNewAssets) < maxNewAssets; } +/** + * Encode path segments for a browser URL without changing the dist directory layout. + * @param {...string} segments - URL path segments. + * @returns {string} Relative URL for GitHub Pages. + */ +function encodeDirectAssetUrl(...segments) { + return segments.map(segment => encodeURIComponent(segment)).join('/'); +} + +/** + * Build public metadata for a GitHub release asset. + * @param {string} repoName - Repository name. + * @param {string} releaseTag - Release tag. + * @param {Object} asset - GitHub release asset API response object. + * @returns {Object} Package index asset metadata. + */ +function buildAssetMetadata(repoName, releaseTag, asset) { + const assetData = { + name: asset.name, + size: asset.size, + githubUrl: asset.browser_download_url + }; + + if (fileExists(path.join(repoName, releaseTag, asset.name))) { + assetData.directUrl = encodeDirectAssetUrl(repoName, releaseTag, asset.name); + } + + return assetData; +} + /** * Remove stored files from dist that are no longer allowed by packages.config.json. * This only affects mirrored files; release metadata is still generated from GitHub. @@ -176,6 +207,7 @@ async function processRepository(github, context, repo, repositoryData, totalAss const repoData = { name: repo.name, archived: repo.archived, + url: repo.html_url, releases: [] }; @@ -196,7 +228,10 @@ async function processRepository(github, context, repo, repositoryData, totalAss if (assetCount > 0) { repoData.releases.push({ tag: release.tag_name, - assetCount: assetCount + url: release.html_url, + publishedAt: release.published_at, + assetCount: assetCount, + assets: result.assets }); processedReleasesWithAssets++; } @@ -220,46 +255,46 @@ async function processRepository(github, context, repo, repositoryData, totalAss * @param {Object} packageConfig - Loaded package config. * @param {number} maxNewAssets - Maximum new assets to download. * @param {number} currentNewAssets - Number of new assets already downloaded. - * @returns {Promise<{assetCount: number, newAssets: number}>} + * @returns {Promise<{assetCount: number, newAssets: number, assets: Array}>} */ async function processRelease(repoName, release, packageConfig, maxNewAssets = 0, currentNewAssets = 0) { console.log(`Processing release: ${release.tag_name}`); if (release.assets.length === 0) { console.log(`No assets found for release ${release.tag_name}`); - return { assetCount: 0, newAssets: 0 }; + return { assetCount: 0, newAssets: 0, assets: [] }; } const assetCount = release.assets.length; const includedAssets = release.assets.filter(asset => shouldIncludeAsset(packageConfig, repoName, asset.name)); + let newAssets = 0; if (includedAssets.length === 0) { console.log(`No configured assets found for release ${release.tag_name}`); - return { assetCount, newAssets: 0 }; - } - - // Create directory structure - const releaseDir = path.join(repoName, release.tag_name); - ensureDir(releaseDir); - - let newAssets = 0; - - for (const asset of includedAssets) { - // Check if we've reached the new assets download limit - if (!canDownloadNewAsset(maxNewAssets, currentNewAssets, newAssets)) { - console.log(`Reached maximum new assets limit (${maxNewAssets}) for this run. Stopping asset processing for release ${release.tag_name}.`); - break; - } + } else { + // Create directory structure + const releaseDir = path.join(repoName, release.tag_name); + ensureDir(releaseDir); + + for (const asset of includedAssets) { + // Check if we've reached the new assets download limit + if (!canDownloadNewAsset(maxNewAssets, currentNewAssets, newAssets)) { + console.log(`Reached maximum new assets limit (${maxNewAssets}) for this run. Stopping asset processing for release ${release.tag_name}.`); + break; + } - const result = await processAsset(releaseDir, asset); - if (result.downloaded) { - if (result.isNew) { + const result = await processAsset(releaseDir, asset); + if (result.downloaded && result.isNew) { newAssets++; } } } - return { assetCount, newAssets }; + const assets = release.assets + .map(asset => buildAssetMetadata(repoName, release.tag_name, asset)) + .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })); + + return { assetCount, newAssets, assets }; } /** @@ -371,6 +406,7 @@ async function syncReleaseAssets(github, context, isPullRequest = false, maxNewA console.log(`Found ${repos.length} repositories`); const packageConfig = loadPackageConfig(process.cwd()); + cleanupNonVPrefixedReleases('.'); cleanupStoredAssets('.', packageConfig); const repositoryData = []; diff --git a/.github/workflows/update-pages.yml b/.github/workflows/update-pages.yml index 2902ffa9..1ffd7609 100644 --- a/.github/workflows/update-pages.yml +++ b/.github/workflows/update-pages.yml @@ -58,7 +58,7 @@ jobs: git init --initial-branch=dist git remote add origin "https://github.com/${GITHUB_REPOSITORY}.git" - - name: Get organization repositories and download assets + - name: Get organization repositories, download assets, and update package index uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ secrets.GH_BOT_TOKEN || github.token }} @@ -75,35 +75,21 @@ jobs: // Change working directory to dist for file operations process.chdir('./dist'); - // Run the asset synchronization and store metadata + // Run the asset synchronization and write packages.json await syncAssetsWithMetadata(github, context, isPullRequest, maxNewAssets); - - name: Generate packages.json - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - github-token: ${{ secrets.GH_BOT_TOKEN || github.token }} - script: | - // Import the packages generation module - const { generatePackagesWithCleanup } = require('./.github/scripts/generate-packages-with-cleanup.js'); - - console.log('Generating packages.json from dist directory...'); - - // Change to dist directory - process.chdir('./dist'); - - // Generate packages.json with cleanup - generatePackagesWithCleanup('.'); - - name: GitHub Commit & Push if: github.event_name != 'pull_request' uses: actions-js/push@5a7cbd780d82c0c937b5977586e641b2fd94acc5 # v1.5 with: + amend: ${{ steps.dist_branch.outputs.exists == 'true' }} author_email: ${{ secrets.GH_BOT_EMAIL }} author_name: ${{ vars.GH_BOT_NAME }} branch: dist directory: dist + force: ${{ steps.dist_branch.outputs.exists == 'true' }} github_token: ${{ secrets.GH_BOT_TOKEN }} - message: 'Update release assets - ${{ github.run_id }}' + message: 'Update release assets' - name: Prepare artifact run: | diff --git a/README.md b/README.md index 5e0f97d5..dba6c9aa 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # LizardByte Package Repository This repository serves as a centralized package index and filtered release asset mirror for repositories within the -LizardByte organization. Configured release assets are automatically downloaded and organized in the `dist` branch for -easy access and distribution. +LizardByte organization. Release metadata is published to GitHub Pages, while configured assets are also downloaded and +organized in the `dist` branch for direct access and distribution. ## Structure @@ -42,14 +42,16 @@ retention is controlled by `packages.config.json`. 3. Downloads configured release assets 4. Removes previously stored assets that are no longer configured 5. Generates hash files for integrity verification -6. Commits changes to the `dist` branch -7. Rebuilds GitHub Pages and publishes the filtered `dist` assets there too +6. Writes `packages.json` with release and asset links for GitHub Pages +7. Amends the current `dist` branch commit and force-pushes it +8. Rebuilds GitHub Pages and publishes the filtered `dist` assets there too ## Configuration -`packages.config.json` controls which release assets are retained in `dist` and published to GitHub Pages. -`packages.json` is still generated from GitHub release metadata for all discovered assets. `defaultInclude` is -`false`, so repositories must opt in with asset patterns for files that should be mirrored. +`packages.config.json` controls which release assets are retained in `dist` and get direct GitHub Pages links. +`packages.json` is generated from GitHub release metadata for all discovered assets. Every asset gets a GitHub Releases +download URL, and mirrored assets also get a direct GitHub Pages URL. `defaultInclude` is `false`, so repositories must +opt in with asset patterns for files that should be mirrored. ```json { @@ -69,7 +71,7 @@ retention is controlled by `packages.config.json`. Configured release assets are available in the `dist` branch of this repository. You can: -- Browse assets directly on GitHub -- Clone the `dist` branch for offline access -- Use GitHub Pages direct URLs that mirror the `dist` layout, e.g. `/packages///` -- Use the hash files to verify asset integrity +- Browse the GitHub Pages package index for per-release asset links +- Use GitHub Releases URLs for every release asset +- Use GitHub Pages direct URLs for mirrored assets, e.g. `/packages///` +- Use the hash files beside mirrored assets to verify asset integrity diff --git a/gh-pages-template/assets/css/packages.css b/gh-pages-template/assets/css/packages.css index 59793549..c7392071 100644 --- a/gh-pages-template/assets/css/packages.css +++ b/gh-pages-template/assets/css/packages.css @@ -11,8 +11,36 @@ flex-grow: 1; } +.release-heading { + border-bottom: 1px solid var(--bs-border-color); + padding-bottom: 1rem; +} + +.release-assets-table th, +.release-assets-table td { + vertical-align: middle; +} + +.asset-name { + min-width: 16rem; + word-break: break-word; +} + +.asset-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: flex-end; +} + /* Custom focus styles for search input to match theme */ #searchInput:focus { border-color: var(--bs-primary); box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); } + +@media (max-width: 575.98px) { + .asset-actions { + justify-content: flex-start; + } +} diff --git a/gh-pages-template/assets/js/app.js b/gh-pages-template/assets/js/app.js index b232cdd9..7f218758 100644 --- a/gh-pages-template/assets/js/app.js +++ b/gh-pages-template/assets/js/app.js @@ -1,6 +1,69 @@ +const DEFAULT_PAGE_TITLE = document.title || 'Packages'; + +/** + * Escape a value for safe insertion into HTML content. + * @param {unknown} value - Value to escape. + * @returns {string} Escaped HTML string. + */ +function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Escape a value for safe insertion into an HTML attribute. + * @param {unknown} value - Value to escape. + * @returns {string} Escaped attribute string. + */ +function escapeAttribute(value) { + return escapeHtml(value); +} + +/** + * Build the client-side release route for a repository release. + * @param {string} repoName - Repository name. + * @param {string} releaseTag - Release tag. + * @returns {string} Hash route for the release detail view. + */ +function buildReleaseHash(repoName, releaseTag) { + return `#/release/${encodeURIComponent(repoName)}/${encodeURIComponent(releaseTag)}`; +} + +/** + * Parse the current hash into a release route, when one is active. + * @returns {{repoName: string, releaseTag: string}|null} Parsed route or null for the package list. + */ +function parseReleaseHash() { + const prefix = '#/release/'; + const hash = window.location.hash || ''; + + if (!hash.startsWith(prefix)) { + return null; + } + + const parts = hash.slice(prefix.length).split('/'); + if (parts.length < 2) { + return null; + } + + try { + return { + repoName: decodeURIComponent(parts[0]), + releaseTag: decodeURIComponent(parts.slice(1).join('/')) + }; + } catch (error) { + console.warn('Invalid release route:', error); + return null; + } +} + /** * Repository Data Manager - * Handles loading and managing repository data from a single JSON file + * Handles loading and managing repository data from packages.json. */ class RepositoryDataManager { constructor() { @@ -10,7 +73,7 @@ class RepositoryDataManager { } /** - * Fetch the last successful workflow run time from GitHub API + * Fetch the last successful workflow run time from GitHub API. */ async fetchLastWorkflowRun() { try { @@ -41,7 +104,7 @@ class RepositoryDataManager { } /** - * Load repository data from the packages.json published with GitHub Pages + * Load repository data from the packages.json published with GitHub Pages. */ async loadRepositoryData() { try { @@ -60,20 +123,28 @@ class RepositoryDataManager { throw new Error('Invalid packages.json format: missing repositories array'); } - this.repositoryData = data.repositories; + // Normalize optional fields so the UI can handle both old and new packages.json files. + this.repositoryData = data.repositories.map(repo => ({ + ...repo, + releases: Array.isArray(repo.releases) ? repo.releases.map(release => ({ + ...release, + assetCount: release.assetCount || (Array.isArray(release.assets) ? release.assets.length : 0), + assets: Array.isArray(release.assets) ? release.assets : [] + })) : [] + })); console.log(`Loaded data for ${this.repositoryData.length} repositories from packages.json`); - // Fetch the last successful workflow run time - const lastUpdated = await this.fetchLastWorkflowRun(); + // Use packages.json time first, then fall back to the workflow API for older package indexes. + const lastUpdated = data.generatedAt || data.lastUpdated || await this.fetchLastWorkflowRun(); return { repositories: this.repositoryData, - lastUpdated: lastUpdated || new Date().toISOString(), - totalRepositories: this.repositoryData.length, - totalReleases: this.repositoryData.reduce((sum, repo) => sum + (repo.releases ? repo.releases.length : 0), 0), - totalAssets: this.repositoryData.reduce((sum, repo) => - sum + (repo.releases ? repo.releases.reduce((releaseSum, release) => releaseSum + (release.assetCount || 0), 0) : 0), 0) + lastUpdated, + totalRepositories: data.stats?.totalRepositories ?? this.repositoryData.length, + totalReleases: data.stats?.totalReleases ?? this.repositoryData.reduce((sum, repo) => sum + repo.releases.length, 0), + totalAssets: data.stats?.totalAssets ?? this.repositoryData.reduce((sum, repo) => + sum + repo.releases.reduce((releaseSum, release) => releaseSum + release.assetCount, 0), 0) }; } catch (error) { @@ -92,14 +163,31 @@ class RepositoryDataManager { } /** - * Get all repository data + * Get all repository data. */ getRepositories() { return this.repositoryData; } /** - * Filter repositories based on search term and archived status + * Find a release by repository name and tag. + */ + findRelease(repoName, releaseTag) { + const repo = this.repositoryData.find(candidate => candidate.name === repoName); + if (!repo) { + return null; + } + + const release = repo.releases.find(candidate => candidate.tag === releaseTag); + if (!release) { + return null; + } + + return { repo, release }; + } + + /** + * Filter repositories based on search term and archived status. */ filterRepositories(searchTerm, showArchived = false) { let filteredRepos = this.repositoryData; @@ -114,10 +202,17 @@ class RepositoryDataManager { return filteredRepos; } + const normalizedSearch = searchTerm.toLowerCase(); + return filteredRepos.filter(repo => { - const repoMatch = repo.name.toLowerCase().includes(searchTerm.toLowerCase()); - const releaseMatch = repo.releases?.some(release => - release.tag.toLowerCase().includes(searchTerm.toLowerCase())); + const repoMatch = repo.name.toLowerCase().includes(normalizedSearch); + const releaseMatch = repo.releases?.some(release => { + const tagMatch = release.tag.toLowerCase().includes(normalizedSearch); + const assetMatch = release.assets?.some(asset => + String(asset.name || '').toLowerCase().includes(normalizedSearch)); + return tagMatch || assetMatch; + }); + return repoMatch || releaseMatch; }); } @@ -125,7 +220,7 @@ class RepositoryDataManager { /** * UI Manager - * Handles all DOM manipulation and rendering + * Handles all DOM manipulation and rendering. */ class UIManager { constructor() { @@ -135,52 +230,84 @@ class UIManager { this.releaseCountElement = document.getElementById('releaseCount'); this.assetCountElement = document.getElementById('assetCount'); this.updateTimeElement = document.getElementById('updateTime'); + this.homeSections = [ + document.getElementById('browseControls'), + document.getElementById('archiveControls'), + document.getElementById('statsGrid') + ].filter(Boolean); this.orgName = 'LizardByte'; this.lastUpdated = null; // Store lastUpdated from packages.json + this.expandedRepositories = new Set(); + this.currentRepos = []; + } + + /** + * Show or hide controls that only apply to the repository list view. + * @param {boolean} visible - Whether home controls should be visible. + */ + setHomeControlsVisible(visible) { + this.homeSections.forEach(section => { + section.classList.toggle('d-none', !visible); + }); + } + + /** + * Get a release asset count from either explicit count or asset details. + * @param {Object} release - Release data. + * @returns {number} Asset count. + */ + getReleaseAssetCount(release) { + return release.assetCount || (Array.isArray(release.assets) ? release.assets.length : 0); } /** - * Render repositories in the grid + * Render repositories in the grid. */ renderRepositories(repos) { + this.setHomeControlsVisible(true); + this.currentRepos = repos; + if (repos.length === 0) { this.repositoryGrid.innerHTML = '
No repositories found.
'; return; } this.repositoryGrid.innerHTML = repos.map(repo => { - // Show only the latest 5 releases - const displayReleases = repo.releases ? repo.releases.slice(0, 5) : []; - const hasMoreReleases = repo.releases && repo.releases.length > 5; - const remainingCount = hasMoreReleases ? repo.releases.length - 5 : 0; + const releases = Array.isArray(repo.releases) ? repo.releases : []; + const isExpanded = this.expandedRepositories.has(repo.name); + // Show only the latest 5 releases unless the repository card is expanded. + const displayReleases = isExpanded ? releases : releases.slice(0, 5); + const hasMoreReleases = releases.length > 5; + const remainingCount = Math.max(releases.length - 5, 0); // Extract ternary operation for better readability - const releaseText = remainingCount > 1 ? 's' : ''; + const releaseText = remainingCount === 1 ? '' : 's'; return ` -
+
- ${repo.name} + ${escapeHtml(repo.name)} ${repo.archived ? 'Archived' : ''}
@@ -189,28 +316,153 @@ class UIManager {
`; }).join(''); + + this.bindReleaseToggleButtons(); + } + + /** + * Bind show-more/show-fewer handlers for repository release lists. + */ + bindReleaseToggleButtons() { + this.repositoryGrid.querySelectorAll('.js-toggle-releases').forEach(button => { + button.addEventListener('click', () => { + const repoName = button.dataset.repo; + if (this.expandedRepositories.has(repoName)) { + this.expandedRepositories.delete(repoName); + } else { + this.expandedRepositories.add(repoName); + } + + this.renderRepositories(this.currentRepos); + }); + }); + } + + /** + * Render one release's asset links. + */ + renderReleaseDetail(repo, release) { + this.setHomeControlsVisible(false); + + const assets = Array.isArray(release.assets) ? release.assets : []; + const releaseUrl = release.url || `https://github.com/${this.orgName}/${encodeURIComponent(repo.name)}/releases/tag/${encodeURIComponent(release.tag)}`; + const publishedAt = release.publishedAt ? this.formatUpdateTime(release.publishedAt) : null; + const mirroredCount = assets.filter(asset => asset.directUrl).length; + const assetCount = this.getReleaseAssetCount(release); + const assetLabel = assetCount === 1 ? 'asset' : 'assets'; + const assetRows = assets.map(asset => this.renderAssetRow(asset)).join(''); + + document.title = `${repo.name} ${release.tag} - ${DEFAULT_PAGE_TITLE}`; + + this.repositoryGrid.innerHTML = ` +
+ + +
+

+ ${escapeHtml(repo.name)} ${escapeHtml(release.tag)} + ${repo.archived ? 'Archived' : ''} +

+
+ ${assetCount} ${assetLabel}${publishedAt ? ` published ${escapeHtml(publishedAt)}` : ''} + ${mirroredCount > 0 ? `, ${mirroredCount} mirrored` : ''} +
+
+ + ${assets.length > 0 ? ` +
+ + + + + + + + + + ${assetRows} + +
AssetSizeLinks
+
+ ` : ` +
+ Asset details are not available in this package index yet. +
+ `} +
+ `; + } + + /** + * Render one release asset table row. + * @param {Object} asset - Asset data from packages.json. + * @returns {string} Table row markup. + */ + renderAssetRow(asset) { + const githubUrl = asset.githubUrl || asset.browserDownloadUrl; + + return ` + + ${escapeHtml(asset.name)} + ${escapeHtml(this.formatBytes(asset.size))} + +
+ ${githubUrl ? ` + + GitHub + + ` : 'Unavailable'} + ${asset.directUrl ? ` + + Direct + + ` : ''} +
+ + + `; + } + + /** + * Render the not-found state for an invalid release route. + * @param {{repoName: string, releaseTag: string}} route - Requested route. + */ + renderReleaseNotFound(route) { + this.setHomeControlsVisible(false); + document.title = DEFAULT_PAGE_TITLE; + this.repositoryGrid.innerHTML = ` +
+

Release not found: ${escapeHtml(route.repoName)} ${escapeHtml(route.releaseTag)}

+ All packages +
+ `; } /** - * Update statistics display + * Update statistics display. */ updateStats(repos) { const repoCount = repos.length; const releaseCount = repos.reduce((sum, repo) => sum + (repo.releases ? repo.releases.length : 0), 0); const assetCount = repos.reduce((sum, repo) => - sum + (repo.releases ? repo.releases.reduce((releaseSum, release) => releaseSum + (release.assetCount || 0), 0) : 0), 0); + sum + (repo.releases ? repo.releases.reduce((releaseSum, release) => releaseSum + this.getReleaseAssetCount(release), 0) : 0), 0); this.repoCountElement.textContent = repoCount; this.releaseCountElement.textContent = releaseCount; this.assetCountElement.textContent = assetCount; - if (this.lastUpdated) { // Only set update time if lastUpdated is set + if (this.lastUpdated) { this.updateTimeElement.textContent = this.formatUpdateTime(this.lastUpdated); } } /** - * Set the last updated time from packages.json + * Set the last updated time from packages.json. */ setUpdateTime(lastUpdated) { this.lastUpdated = lastUpdated; @@ -218,7 +470,7 @@ class UIManager { } /** - * Format the update time for display + * Format the update time for display. */ formatUpdateTime(isoString) { if (!isoString) return '-'; @@ -228,9 +480,32 @@ class UIManager { } /** - * Show loading state + * Format a byte count for display. + * @param {number} bytes - Size in bytes. + * @returns {string} Human-readable file size. + */ + formatBytes(bytes) { + if (typeof bytes !== 'number' || Number.isNaN(bytes)) { + return '-'; + } + + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; + } + + /** + * Show loading state. */ showLoading() { + this.setHomeControlsVisible(true); this.repositoryGrid.innerHTML = '
Loading repository data...
'; this.repoCountElement.textContent = '-'; this.releaseCountElement.textContent = '-'; @@ -242,7 +517,7 @@ class UIManager { /** * Filter Manager - * Handles search and archived repository filtering functionality + * Handles search and archived repository filtering functionality. */ class FilterManager { constructor(dataManager, uiManager) { @@ -254,22 +529,22 @@ class FilterManager { } /** - * Initialize filter functionality + * Initialize filter functionality. */ initializeFilters() { // Search input handler - this.searchInput.addEventListener('input', (e) => { + this.searchInput.addEventListener('input', () => { this.applyFilters(); }); // Archived toggle handler - this.archivedToggle.addEventListener('change', (e) => { + this.archivedToggle.addEventListener('change', () => { this.applyFilters(); }); } /** - * Apply all filters and update UI + * Apply all filters and update UI. */ applyFilters() { const searchTerm = this.searchInput.value; @@ -278,10 +553,11 @@ class FilterManager { const filteredRepos = this.dataManager.filterRepositories(searchTerm, showArchived); this.uiManager.renderRepositories(filteredRepos); this.uiManager.updateStats(filteredRepos); + document.title = DEFAULT_PAGE_TITLE; } /** - * Reset all filters + * Reset all filters. */ resetFilters() { this.searchInput.value = ''; @@ -292,18 +568,17 @@ class FilterManager { /** * Main Application - * Coordinates all components and manages application state + * Coordinates all components and manages application state. */ class LizardByteAssetsApp { constructor() { this.dataManager = new RepositoryDataManager(); this.uiManager = new UIManager(); this.filterManager = null; - this.lastUpdated = null; } /** - * Initialize the application + * Initialize the application. */ async init() { try { @@ -312,14 +587,12 @@ class LizardByteAssetsApp { // Load repository data from packages.json const data = await this.dataManager.loadRepositoryData(); - this.lastUpdated = data.lastUpdated; - this.uiManager.setUpdateTime(this.lastUpdated); + this.uiManager.setUpdateTime(data.lastUpdated); - // Initialize filter functionality first + // Initialize filter functionality before rendering the current route this.filterManager = new FilterManager(this.dataManager, this.uiManager); - - // Apply initial filters (this will render repositories and update stats) - this.filterManager.applyFilters(); + window.addEventListener('hashchange', () => this.renderRoute()); + this.renderRoute(); const repositories = this.dataManager.getRepositories(); console.log(`Loaded ${repositories.length} repositories`); @@ -330,6 +603,26 @@ class LizardByteAssetsApp { '
Failed to load repository data. Please try again later.
'; } } + + /** + * Render the current client-side route. + */ + renderRoute() { + const route = parseReleaseHash(); + + if (!route) { + this.filterManager.applyFilters(); + return; + } + + const match = this.dataManager.findRelease(route.repoName, route.releaseTag); + if (!match) { + this.uiManager.renderReleaseNotFound(route); + return; + } + + this.uiManager.renderReleaseDetail(match.repo, match.release); + } } // Initialize the application when the DOM is loaded diff --git a/gh-pages-template/index.html b/gh-pages-template/index.html index c9a51166..f9c765e1 100644 --- a/gh-pages-template/index.html +++ b/gh-pages-template/index.html @@ -14,13 +14,13 @@
-
+
-
+
@@ -31,7 +31,7 @@
-
+
From 6fcd14bfa8eda535b2e3a414254ac7de10d6ecf2 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 17 May 2026 11:27:21 -0400 Subject: [PATCH 2/2] Improve release UI and sanitize inputs CSS: center and constrain release detail view (.release-detail), center .release-heading, change asset action alignment to start, and remove the small-screen override. JS: harden escapeHtml to handle only primitive types and use replaceAll, use globalThis for location/hash and event listeners, add safer asset name checks, and refactor UIManager release list rendering into prebuilt HTML fragments with a toggle button. These changes improve XSS safety, robustness across runtimes, and tidy up release list layout and behavior. --- gh-pages-template/assets/css/packages.css | 24 +++++-- gh-pages-template/assets/js/app.js | 85 +++++++++++++++-------- 2 files changed, 72 insertions(+), 37 deletions(-) diff --git a/gh-pages-template/assets/css/packages.css b/gh-pages-template/assets/css/packages.css index c7392071..2bb02973 100644 --- a/gh-pages-template/assets/css/packages.css +++ b/gh-pages-template/assets/css/packages.css @@ -11,9 +11,25 @@ flex-grow: 1; } +.release-detail { + margin-inline: auto; + max-width: 72rem; +} + .release-heading { border-bottom: 1px solid var(--bs-border-color); padding-bottom: 1rem; + text-align: center; +} + +.release-detail .table-responsive { + margin-inline: auto; + max-width: 100%; + width: max-content; +} + +.release-assets-table { + width: auto; } .release-assets-table th, @@ -30,7 +46,7 @@ display: flex; flex-wrap: wrap; gap: 0.5rem; - justify-content: flex-end; + justify-content: flex-start; } /* Custom focus styles for search input to match theme */ @@ -38,9 +54,3 @@ border-color: var(--bs-primary); box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); } - -@media (max-width: 575.98px) { - .asset-actions { - justify-content: flex-start; - } -} diff --git a/gh-pages-template/assets/js/app.js b/gh-pages-template/assets/js/app.js index 7f218758..aced22cc 100644 --- a/gh-pages-template/assets/js/app.js +++ b/gh-pages-template/assets/js/app.js @@ -6,12 +6,28 @@ const DEFAULT_PAGE_TITLE = document.title || 'Packages'; * @returns {string} Escaped HTML string. */ function escapeHtml(value) { - return String(value ?? '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); + if (value === null || value === undefined) { + return ''; + } + + let stringValue; + switch (typeof value) { + case 'bigint': + case 'boolean': + case 'number': + case 'string': + stringValue = `${value}`; + break; + default: + return ''; + } + + return stringValue + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); } /** @@ -39,7 +55,7 @@ function buildReleaseHash(repoName, releaseTag) { */ function parseReleaseHash() { const prefix = '#/release/'; - const hash = window.location.hash || ''; + const hash = globalThis.location.hash || ''; if (!hash.startsWith(prefix)) { return null; @@ -208,8 +224,10 @@ class RepositoryDataManager { const repoMatch = repo.name.toLowerCase().includes(normalizedSearch); const releaseMatch = repo.releases?.some(release => { const tagMatch = release.tag.toLowerCase().includes(normalizedSearch); - const assetMatch = release.assets?.some(asset => - String(asset.name || '').toLowerCase().includes(normalizedSearch)); + const assetMatch = release.assets?.some(asset => { + const assetName = typeof asset.name === 'string' ? asset.name : ''; + return assetName.toLowerCase().includes(normalizedSearch); + }); return tagMatch || assetMatch; }); @@ -282,6 +300,29 @@ class UIManager { // Extract ternary operation for better readability const releaseText = remainingCount === 1 ? '' : 's'; + const releaseItemsHtml = displayReleases.length > 0 + ? displayReleases.map(release => ` +
  • + + ${escapeHtml(release.tag)} + + ${this.getReleaseAssetCount(release)} +
  • + `).join('') + : '
  • No releases found
  • '; + const toggleButtonText = isExpanded ? 'Show fewer releases' : `Show ${remainingCount} more release${releaseText}`; + const toggleReleasesHtml = hasMoreReleases + ? ` +
  • + +
  • + ` + : ''; return `
    @@ -292,24 +333,8 @@ class UIManager { ${repo.archived ? 'Archived' : ''}
      - ${displayReleases.length > 0 ? displayReleases.map(release => ` -
    • - - ${escapeHtml(release.tag)} - - ${this.getReleaseAssetCount(release)} -
    • - `).join('') : '
    • No releases found
    • '} - ${hasMoreReleases ? ` -
    • - -
    • - ` : ''} + ${releaseItemsHtml} + ${toggleReleasesHtml}
    @@ -355,7 +380,7 @@ class UIManager { document.title = `${repo.name} ${release.tag} - ${DEFAULT_PAGE_TITLE}`; this.repositoryGrid.innerHTML = ` -