From da47c50320fe8b63d0e47a8e645a8e7237aea30d Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Mon, 20 Apr 2026 16:20:54 +0100 Subject: [PATCH] fix: sticky header regression (#5869) * fix: sticky header regression * fix: github release changelog format for app release workflow * fix: lint --- .github/workflows/theseus-release.yml | 71 +----- .gitignore | 3 + .../app-frontend/src/pages/instance/Index.vue | 21 +- apps/app-frontend/src/routes.js | 1 + package.json | 1 + scripts/build-theseus-release-notes.ts | 233 ++++++++++++++++++ 6 files changed, 256 insertions(+), 74 deletions(-) create mode 100644 scripts/build-theseus-release-notes.ts diff --git a/.github/workflows/theseus-release.yml b/.github/workflows/theseus-release.yml index 751ad1c0a..20d6fa331 100644 --- a/.github/workflows/theseus-release.yml +++ b/.github/workflows/theseus-release.yml @@ -55,76 +55,7 @@ jobs: - name: 📝 Extract app changelog env: VERSION: ${{ env.VERSION_TAG }} - run: | - node <<'EOF' - const fs = require('fs'); - const version = process.env.VERSION.replace(/^v/, ''); - const src = fs.readFileSync('packages/blog/changelog.ts', 'utf8'); - - // Parse every entry in the VERSIONS array, preserving their order - // (which is reverse chronological). - const entryRe = /\{\s*date:\s*`([^`]+)`,\s*product:\s*'(\w+)',(?:\s*version:\s*[`']([^`']+)[`'],)?\s*body:\s*`([\s\S]*?)`,\s*\}/g; - const entries = []; - let match; - while ((match = entryRe.exec(src)) !== null) { - entries.push({ - date: match[1], - product: match[2], - version: match[3], - body: match[4], - }); - } - - const currentIdx = entries.findIndex( - (e) => e.product === 'app' && e.version === version, - ); - if (currentIdx === -1) { - console.error(`No app changelog entry found for version ${version}`); - process.exit(1); - } - - // Find the surrounding app entries so we can scope hosting changes to - // exactly what shipped between the previous app release and this one. - // Entries are in reverse chronological order, so newer entries have - // smaller indices than older entries. - let newerAppIdx = -1; - for (let i = currentIdx - 1; i >= 0; i--) { - if (entries[i].product === 'app') { - newerAppIdx = i; - break; - } - } - let previousAppIdx = entries.length; - for (let i = currentIdx + 1; i < entries.length; i++) { - if (entries[i].product === 'app') { - previousAppIdx = i; - break; - } - } - - const hostingEntries = []; - for (let i = newerAppIdx + 1; i < previousAppIdx; i++) { - if (entries[i].product === 'hosting') { - hostingEntries.push(entries[i]); - } - } - - let output = entries[currentIdx].body; - if (hostingEntries.length > 0) { - // Demote any top-level section headings inside hosting bodies so - // they nest cleanly under the "Modrinth Hosting (included)" header. - const demoteHeadings = (body) => - body.replace(/^(#{1,5})\s/gm, (_, hashes) => `${hashes}# `); - const hostingBody = hostingEntries - .map((e) => demoteHeadings(e.body)) - .join('\n\n'); - output += `\n\n---\n\n## Modrinth Hosting (included)\n\n${hostingBody}`; - } - - fs.writeFileSync('release-notes.md', output); - console.log(`Extracted changelog for app ${version}:`); - console.log(output); - EOF + run: npx --yes tsx scripts/build-theseus-release-notes.ts - name: 🛠️ Generate version manifest run: | diff --git a/.gitignore b/.gitignore index 6f54d56d2..dddf6bab9 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,6 @@ apps/frontend/src/public/robots.txt # Oh My Code .omc/ + +# Local dry-run output for scripts/build-theseus-release-notes.mjs +/test_result.md diff --git a/apps/app-frontend/src/pages/instance/Index.vue b/apps/app-frontend/src/pages/instance/Index.vue index 4d399a03d..f3c76fa53 100644 --- a/apps/app-frontend/src/pages/instance/Index.vue +++ b/apps/app-frontend/src/pages/instance/Index.vue @@ -1,7 +1,7 @@ -
+
-
+
`/instance/${encodeURIComponent(route.params.id as string)}`) +/** + * Per-route layout mode. + * - `'scroll'` (default): the whole instance page scrolls inside `.app-viewport`. This lets + * `position: sticky` children (and the viewport-rooted `IntersectionObserver` used by + * `useStickyObserver`) work correctly. + * - `'fixed'`: the header + tabs are pinned and only the tab body scrolls in its own container. + * Used by tabs whose content (e.g. the log console) needs a bounded height to resolve `h-full`. + */ +const renderMode = computed<'scroll' | 'fixed'>(() => + route.meta.renderMode === 'fixed' ? 'fixed' : 'scroll', +) +const isFixedRender = computed(() => renderMode.value === 'fixed') + const tabs = computed(() => [ { label: 'Content', diff --git a/apps/app-frontend/src/routes.js b/apps/app-frontend/src/routes.js index b80137915..00f726881 100644 --- a/apps/app-frontend/src/routes.js +++ b/apps/app-frontend/src/routes.js @@ -240,6 +240,7 @@ export default new createRouter({ component: Instance.Logs, meta: { useRootContext: true, + renderMode: 'fixed', breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Logs' }], }, }, diff --git a/package.json b/package.json index 6baa3104f..79c1175f1 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "build-storybook": "pnpm --filter @modrinth/ui build-storybook", "icons:add": "pnpm --filter @modrinth/assets icons:add", "changelog:collect": "node scripts/run.mjs collect-changelog", + "changelog:combine-for-app": "node scripts/run.mjs build-theseus-release-notes", "scripts": "node scripts/run.mjs" }, "devDependencies": { diff --git a/scripts/build-theseus-release-notes.ts b/scripts/build-theseus-release-notes.ts new file mode 100644 index 000000000..d6078e82e --- /dev/null +++ b/scripts/build-theseus-release-notes.ts @@ -0,0 +1,233 @@ +/** + * Builds merged app + Modrinth Hosting release notes for GitHub releases and Tauri updates.json. + * Hosting bullets are folded into each ## section as `- **Modrinth Hosting:** …`. + * + * Hosting-only sections (present in hosting changelog but not app) are appended after app sections, + * ordered by: added, changed, deprecated, removed, fixed, security, then other titles alphabetically. + * + * Run locally: `pnpm changelog:combine-for-app -- --dry-run 0.13.2` or `pnpm scripts build-theseus-release-notes -- ...` + */ + +import * as fs from 'fs' +import { dirname, join } from 'path' +import { fileURLToPath } from 'url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const REPO_ROOT = join(__dirname, '..') + +type Product = 'web' | 'app' | 'hosting' + +interface ChangelogEntry { + date: string + product: Product + version: string | undefined + body: string +} + +interface ParsedSection { + key: string + title: string + rawLines: string[] +} + +interface HostingSectionAgg { + title: string + bullets: string[] +} + +// Mirror scripts/collect-changelog.ts — used to order hosting-only sections at the end +const KNOWN_SECTION_ORDER = ['added', 'changed', 'deprecated', 'removed', 'fixed', 'security'] as const + +function parseArgs(argv: string[]): { dryRun: boolean; version: string; outFile: string } { + let dryRun = false + let dryRunVersion: string | undefined + let version = process.env.VERSION ? process.env.VERSION.replace(/^v/, '') : undefined + + for (let i = 0; i < argv.length; i++) { + const a = argv[i] + if (a === '--dry-run') { + dryRun = true + dryRunVersion = argv[++i] + if (!dryRunVersion || dryRunVersion.startsWith('--')) { + console.error('Usage: --dry-run ') + process.exit(1) + } + } else if (a === '--version') { + const v = argv[++i] + if (!v || v.startsWith('--')) { + console.error('--version requires a value') + process.exit(1) + } + version = v.replace(/^v/, '') + } + } + + if (dryRun) { + version = dryRunVersion!.replace(/^v/, '') + } else if (!version) { + console.error('Set VERSION (e.g. from tag) or pass --version') + process.exit(1) + } + + const outFile = dryRun ? join(REPO_ROOT, 'test_result.md') : join(process.cwd(), 'release-notes.md') + return { dryRun, version, outFile } +} + +/** + * Parse every entry in changelog.ts VERSIONS (reverse chronological order). + */ +function parseChangelogEntries(src: string): ChangelogEntry[] { + const entryRe = + /\{\s*date:\s*`([^`]+)`,\s*product:\s*'(\w+)',(?:\s*version:\s*[`']([^`']+)[`'],)?\s*body:\s*`([\s\S]*?)`,\s*\}/g + const entries: ChangelogEntry[] = [] + let match: RegExpExecArray | null + while ((match = entryRe.exec(src)) !== null) { + entries.push({ + date: match[1], + product: match[2] as Product, + version: match[3], + body: match[4], + }) + } + return entries +} + +function findAppAndHosting(entries: ChangelogEntry[], version: string): { appBody: string; hostingEntries: ChangelogEntry[] } { + const currentIdx = entries.findIndex((e) => e.product === 'app' && e.version === version) + if (currentIdx === -1) { + throw new Error(`No app changelog entry found for version ${version}`) + } + + let newerAppIdx = -1 + for (let i = currentIdx - 1; i >= 0; i--) { + if (entries[i].product === 'app') { + newerAppIdx = i + break + } + } + let previousAppIdx = entries.length + for (let i = currentIdx + 1; i < entries.length; i++) { + if (entries[i].product === 'app') { + previousAppIdx = i + break + } + } + + const hostingEntries: ChangelogEntry[] = [] + for (let i = newerAppIdx + 1; i < previousAppIdx; i++) { + if (entries[i].product === 'hosting') { + hostingEntries.push(entries[i]) + } + } + + return { + appBody: entries[currentIdx].body, + hostingEntries, + } +} + +function parseSections(markdown: string): ParsedSection[] { + const lines = markdown.split('\n') + const sections: ParsedSection[] = [] + let current: ParsedSection | null = null + + for (const line of lines) { + const m = line.match(/^## (.+)$/) + if (m) { + const title = m[1].trim() + const key = title.toLowerCase() + current = { key, title, rawLines: [] } + sections.push(current) + } else if (current) { + current.rawLines.push(line) + } + } + return sections +} + +function extractBulletLines(rawLines: string[]): string[] { + const out: string[] = [] + for (const line of rawLines) { + if (/^\s*-\s/.test(line)) { + out.push(line.trim()) + } + } + return out +} + +function toHostingBullet(line: string): string { + const m = line.match(/^\s*-\s(.*)$/) + const rest = m ? m[1].trim() : line.trim() + return `- **Modrinth Hosting:** ${rest}` +} + +function sortHostingOnlyKeys(keys: string[]): string[] { + return [...keys].sort((a, b) => { + const ia = KNOWN_SECTION_ORDER.indexOf(a as (typeof KNOWN_SECTION_ORDER)[number]) + const ib = KNOWN_SECTION_ORDER.indexOf(b as (typeof KNOWN_SECTION_ORDER)[number]) + const aKnown = ia !== -1 + const bKnown = ib !== -1 + if (aKnown && bKnown) return ia - ib + if (aKnown) return -1 + if (bKnown) return 1 + return a.localeCompare(b) + }) +} + +function mergeAppAndHosting(appBody: string, hostingEntries: ChangelogEntry[]): string { + if (!hostingEntries.length) { + return appBody.replace(/\s*$/, '\n') + } + + const appSections = parseSections(appBody) + const hostingByKey = new Map() + + for (const entry of hostingEntries) { + for (const sec of parseSections(entry.body)) { + const bullets = extractBulletLines(sec.rawLines).map(toHostingBullet) + if (bullets.length === 0) continue + + if (!hostingByKey.has(sec.key)) { + hostingByKey.set(sec.key, { title: sec.title, bullets: [] }) + } + hostingByKey.get(sec.key)!.bullets.push(...bullets) + } + } + + const parts: string[] = [] + + for (const sec of appSections) { + const appBullets = extractBulletLines(sec.rawLines) + const hostBlock = hostingByKey.get(sec.key) + const hostBullets = hostBlock ? hostBlock.bullets : [] + if (hostBlock) { + hostingByKey.delete(sec.key) + } + + const lines = [`## ${sec.title}`, '', ...appBullets, ...hostBullets] + parts.push(lines.join('\n')) + } + + for (const key of sortHostingOnlyKeys([...hostingByKey.keys()])) { + const block = hostingByKey.get(key)! + parts.push(`## ${block.title}\n\n${block.bullets.join('\n')}`) + } + + return `${parts.join('\n\n')}\n` +} + +function main() { + const { dryRun, version, outFile } = parseArgs(process.argv.slice(2)) + const changelogPath = join(REPO_ROOT, 'packages/blog/changelog.ts') + const src = fs.readFileSync(changelogPath, 'utf8') + const entries = parseChangelogEntries(src) + const { appBody, hostingEntries } = findAppAndHosting(entries, version) + const output = mergeAppAndHosting(appBody, hostingEntries) + + fs.writeFileSync(outFile, output, 'utf8') + const mode = dryRun ? 'dry-run' : 'release' + const n = hostingEntries.length + console.log(`Wrote ${outFile} (${mode}, app ${version}, ${n} hosting entr${n === 1 ? 'y' : 'ies'})`) +} + +main()