fix: sticky header regression (#5869)

* fix: sticky header regression

* fix: github release changelog format for app release workflow

* fix: lint
This commit is contained in:
Calum H.
2026-04-20 16:20:54 +01:00
committed by GitHub
parent bee4391df1
commit da47c50320
6 changed files with 256 additions and 74 deletions

View File

@@ -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: |

3
.gitignore vendored
View File

@@ -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

View File

@@ -1,7 +1,7 @@
<template>
<div v-if="instance" class="flex h-full flex-col">
<div v-if="instance" :class="{ 'flex h-full flex-col': isFixedRender }">
<div
class="shrink-0 p-6 pr-2 pb-4"
:class="['p-6 pr-2 pb-4', { 'shrink-0': isFixedRender }]"
@contextmenu.prevent.stop="(event) => handleRightClick(event)"
>
<ExportModal ref="exportModal" :instance="instance" />
@@ -208,10 +208,10 @@
</template>
</ContentPageHeader>
</div>
<div class="shrink-0 px-6">
<div :class="['px-6', { 'shrink-0': isFixedRender }]">
<NavTabs :links="tabs" />
</div>
<div v-if="!!instance" class="min-h-0 flex-1 overflow-y-auto p-6 pt-4">
<div :class="['p-6 pt-4', { 'min-h-0 flex-1 overflow-y-auto': isFixedRender }]">
<RouterView
v-if="route.path.startsWith('/instance')"
v-slot="{ Component }"
@@ -436,6 +436,19 @@ watch(
const basePath = computed(() => `/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',

View File

@@ -240,6 +240,7 @@ export default new createRouter({
component: Instance.Logs,
meta: {
useRootContext: true,
renderMode: 'fixed',
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Logs' }],
},
},

View File

@@ -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": {

View File

@@ -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 <version>')
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<string, HostingSectionAgg>()
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()