Skip to content

Commit da47c50

Browse files
authored
fix: sticky header regression (#5869)
* fix: sticky header regression * fix: github release changelog format for app release workflow * fix: lint
1 parent bee4391 commit da47c50

6 files changed

Lines changed: 256 additions & 74 deletions

File tree

.github/workflows/theseus-release.yml

Lines changed: 1 addition & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -55,76 +55,7 @@ jobs:
5555
- name: 📝 Extract app changelog
5656
env:
5757
VERSION: ${{ env.VERSION_TAG }}
58-
run: |
59-
node <<'EOF'
60-
const fs = require('fs');
61-
const version = process.env.VERSION.replace(/^v/, '');
62-
const src = fs.readFileSync('packages/blog/changelog.ts', 'utf8');
63-
64-
// Parse every entry in the VERSIONS array, preserving their order
65-
// (which is reverse chronological).
66-
const entryRe = /\{\s*date:\s*`([^`]+)`,\s*product:\s*'(\w+)',(?:\s*version:\s*[`']([^`']+)[`'],)?\s*body:\s*`([\s\S]*?)`,\s*\}/g;
67-
const entries = [];
68-
let match;
69-
while ((match = entryRe.exec(src)) !== null) {
70-
entries.push({
71-
date: match[1],
72-
product: match[2],
73-
version: match[3],
74-
body: match[4],
75-
});
76-
}
77-
78-
const currentIdx = entries.findIndex(
79-
(e) => e.product === 'app' && e.version === version,
80-
);
81-
if (currentIdx === -1) {
82-
console.error(`No app changelog entry found for version ${version}`);
83-
process.exit(1);
84-
}
85-
86-
// Find the surrounding app entries so we can scope hosting changes to
87-
// exactly what shipped between the previous app release and this one.
88-
// Entries are in reverse chronological order, so newer entries have
89-
// smaller indices than older entries.
90-
let newerAppIdx = -1;
91-
for (let i = currentIdx - 1; i >= 0; i--) {
92-
if (entries[i].product === 'app') {
93-
newerAppIdx = i;
94-
break;
95-
}
96-
}
97-
let previousAppIdx = entries.length;
98-
for (let i = currentIdx + 1; i < entries.length; i++) {
99-
if (entries[i].product === 'app') {
100-
previousAppIdx = i;
101-
break;
102-
}
103-
}
104-
105-
const hostingEntries = [];
106-
for (let i = newerAppIdx + 1; i < previousAppIdx; i++) {
107-
if (entries[i].product === 'hosting') {
108-
hostingEntries.push(entries[i]);
109-
}
110-
}
111-
112-
let output = entries[currentIdx].body;
113-
if (hostingEntries.length > 0) {
114-
// Demote any top-level section headings inside hosting bodies so
115-
// they nest cleanly under the "Modrinth Hosting (included)" header.
116-
const demoteHeadings = (body) =>
117-
body.replace(/^(#{1,5})\s/gm, (_, hashes) => `${hashes}# `);
118-
const hostingBody = hostingEntries
119-
.map((e) => demoteHeadings(e.body))
120-
.join('\n\n');
121-
output += `\n\n---\n\n## Modrinth Hosting (included)\n\n${hostingBody}`;
122-
}
123-
124-
fs.writeFileSync('release-notes.md', output);
125-
console.log(`Extracted changelog for app ${version}:`);
126-
console.log(output);
127-
EOF
58+
run: npx --yes tsx scripts/build-theseus-release-notes.ts
12859

12960
- name: 🛠️ Generate version manifest
13061
run: |

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,6 @@ apps/frontend/src/public/robots.txt
8181

8282
# Oh My Code
8383
.omc/
84+
85+
# Local dry-run output for scripts/build-theseus-release-notes.mjs
86+
/test_result.md

apps/app-frontend/src/pages/instance/Index.vue

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<template>
2-
<div v-if="instance" class="flex h-full flex-col">
2+
<div v-if="instance" :class="{ 'flex h-full flex-col': isFixedRender }">
33
<div
4-
class="shrink-0 p-6 pr-2 pb-4"
4+
:class="['p-6 pr-2 pb-4', { 'shrink-0': isFixedRender }]"
55
@contextmenu.prevent.stop="(event) => handleRightClick(event)"
66
>
77
<ExportModal ref="exportModal" :instance="instance" />
@@ -208,10 +208,10 @@
208208
</template>
209209
</ContentPageHeader>
210210
</div>
211-
<div class="shrink-0 px-6">
211+
<div :class="['px-6', { 'shrink-0': isFixedRender }]">
212212
<NavTabs :links="tabs" />
213213
</div>
214-
<div v-if="!!instance" class="min-h-0 flex-1 overflow-y-auto p-6 pt-4">
214+
<div :class="['p-6 pt-4', { 'min-h-0 flex-1 overflow-y-auto': isFixedRender }]">
215215
<RouterView
216216
v-if="route.path.startsWith('/instance')"
217217
v-slot="{ Component }"
@@ -436,6 +436,19 @@ watch(
436436
437437
const basePath = computed(() => `/instance/${encodeURIComponent(route.params.id as string)}`)
438438
439+
/**
440+
* Per-route layout mode.
441+
* - `'scroll'` (default): the whole instance page scrolls inside `.app-viewport`. This lets
442+
* `position: sticky` children (and the viewport-rooted `IntersectionObserver` used by
443+
* `useStickyObserver`) work correctly.
444+
* - `'fixed'`: the header + tabs are pinned and only the tab body scrolls in its own container.
445+
* Used by tabs whose content (e.g. the log console) needs a bounded height to resolve `h-full`.
446+
*/
447+
const renderMode = computed<'scroll' | 'fixed'>(() =>
448+
route.meta.renderMode === 'fixed' ? 'fixed' : 'scroll',
449+
)
450+
const isFixedRender = computed(() => renderMode.value === 'fixed')
451+
439452
const tabs = computed(() => [
440453
{
441454
label: 'Content',

apps/app-frontend/src/routes.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ export default new createRouter({
240240
component: Instance.Logs,
241241
meta: {
242242
useRootContext: true,
243+
renderMode: 'fixed',
243244
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Logs' }],
244245
},
245246
},

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"build-storybook": "pnpm --filter @modrinth/ui build-storybook",
2626
"icons:add": "pnpm --filter @modrinth/assets icons:add",
2727
"changelog:collect": "node scripts/run.mjs collect-changelog",
28+
"changelog:combine-for-app": "node scripts/run.mjs build-theseus-release-notes",
2829
"scripts": "node scripts/run.mjs"
2930
},
3031
"devDependencies": {
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
/**
2+
* Builds merged app + Modrinth Hosting release notes for GitHub releases and Tauri updates.json.
3+
* Hosting bullets are folded into each ## section as `- **Modrinth Hosting:** …`.
4+
*
5+
* Hosting-only sections (present in hosting changelog but not app) are appended after app sections,
6+
* ordered by: added, changed, deprecated, removed, fixed, security, then other titles alphabetically.
7+
*
8+
* Run locally: `pnpm changelog:combine-for-app -- --dry-run 0.13.2` or `pnpm scripts build-theseus-release-notes -- ...`
9+
*/
10+
11+
import * as fs from 'fs'
12+
import { dirname, join } from 'path'
13+
import { fileURLToPath } from 'url'
14+
15+
const __dirname = dirname(fileURLToPath(import.meta.url))
16+
const REPO_ROOT = join(__dirname, '..')
17+
18+
type Product = 'web' | 'app' | 'hosting'
19+
20+
interface ChangelogEntry {
21+
date: string
22+
product: Product
23+
version: string | undefined
24+
body: string
25+
}
26+
27+
interface ParsedSection {
28+
key: string
29+
title: string
30+
rawLines: string[]
31+
}
32+
33+
interface HostingSectionAgg {
34+
title: string
35+
bullets: string[]
36+
}
37+
38+
// Mirror scripts/collect-changelog.ts — used to order hosting-only sections at the end
39+
const KNOWN_SECTION_ORDER = ['added', 'changed', 'deprecated', 'removed', 'fixed', 'security'] as const
40+
41+
function parseArgs(argv: string[]): { dryRun: boolean; version: string; outFile: string } {
42+
let dryRun = false
43+
let dryRunVersion: string | undefined
44+
let version = process.env.VERSION ? process.env.VERSION.replace(/^v/, '') : undefined
45+
46+
for (let i = 0; i < argv.length; i++) {
47+
const a = argv[i]
48+
if (a === '--dry-run') {
49+
dryRun = true
50+
dryRunVersion = argv[++i]
51+
if (!dryRunVersion || dryRunVersion.startsWith('--')) {
52+
console.error('Usage: --dry-run <version>')
53+
process.exit(1)
54+
}
55+
} else if (a === '--version') {
56+
const v = argv[++i]
57+
if (!v || v.startsWith('--')) {
58+
console.error('--version requires a value')
59+
process.exit(1)
60+
}
61+
version = v.replace(/^v/, '')
62+
}
63+
}
64+
65+
if (dryRun) {
66+
version = dryRunVersion!.replace(/^v/, '')
67+
} else if (!version) {
68+
console.error('Set VERSION (e.g. from tag) or pass --version')
69+
process.exit(1)
70+
}
71+
72+
const outFile = dryRun ? join(REPO_ROOT, 'test_result.md') : join(process.cwd(), 'release-notes.md')
73+
return { dryRun, version, outFile }
74+
}
75+
76+
/**
77+
* Parse every entry in changelog.ts VERSIONS (reverse chronological order).
78+
*/
79+
function parseChangelogEntries(src: string): ChangelogEntry[] {
80+
const entryRe =
81+
/\{\s*date:\s*`([^`]+)`,\s*product:\s*'(\w+)',(?:\s*version:\s*[`']([^`']+)[`'],)?\s*body:\s*`([\s\S]*?)`,\s*\}/g
82+
const entries: ChangelogEntry[] = []
83+
let match: RegExpExecArray | null
84+
while ((match = entryRe.exec(src)) !== null) {
85+
entries.push({
86+
date: match[1],
87+
product: match[2] as Product,
88+
version: match[3],
89+
body: match[4],
90+
})
91+
}
92+
return entries
93+
}
94+
95+
function findAppAndHosting(entries: ChangelogEntry[], version: string): { appBody: string; hostingEntries: ChangelogEntry[] } {
96+
const currentIdx = entries.findIndex((e) => e.product === 'app' && e.version === version)
97+
if (currentIdx === -1) {
98+
throw new Error(`No app changelog entry found for version ${version}`)
99+
}
100+
101+
let newerAppIdx = -1
102+
for (let i = currentIdx - 1; i >= 0; i--) {
103+
if (entries[i].product === 'app') {
104+
newerAppIdx = i
105+
break
106+
}
107+
}
108+
let previousAppIdx = entries.length
109+
for (let i = currentIdx + 1; i < entries.length; i++) {
110+
if (entries[i].product === 'app') {
111+
previousAppIdx = i
112+
break
113+
}
114+
}
115+
116+
const hostingEntries: ChangelogEntry[] = []
117+
for (let i = newerAppIdx + 1; i < previousAppIdx; i++) {
118+
if (entries[i].product === 'hosting') {
119+
hostingEntries.push(entries[i])
120+
}
121+
}
122+
123+
return {
124+
appBody: entries[currentIdx].body,
125+
hostingEntries,
126+
}
127+
}
128+
129+
function parseSections(markdown: string): ParsedSection[] {
130+
const lines = markdown.split('\n')
131+
const sections: ParsedSection[] = []
132+
let current: ParsedSection | null = null
133+
134+
for (const line of lines) {
135+
const m = line.match(/^## (.+)$/)
136+
if (m) {
137+
const title = m[1].trim()
138+
const key = title.toLowerCase()
139+
current = { key, title, rawLines: [] }
140+
sections.push(current)
141+
} else if (current) {
142+
current.rawLines.push(line)
143+
}
144+
}
145+
return sections
146+
}
147+
148+
function extractBulletLines(rawLines: string[]): string[] {
149+
const out: string[] = []
150+
for (const line of rawLines) {
151+
if (/^\s*-\s/.test(line)) {
152+
out.push(line.trim())
153+
}
154+
}
155+
return out
156+
}
157+
158+
function toHostingBullet(line: string): string {
159+
const m = line.match(/^\s*-\s(.*)$/)
160+
const rest = m ? m[1].trim() : line.trim()
161+
return `- **Modrinth Hosting:** ${rest}`
162+
}
163+
164+
function sortHostingOnlyKeys(keys: string[]): string[] {
165+
return [...keys].sort((a, b) => {
166+
const ia = KNOWN_SECTION_ORDER.indexOf(a as (typeof KNOWN_SECTION_ORDER)[number])
167+
const ib = KNOWN_SECTION_ORDER.indexOf(b as (typeof KNOWN_SECTION_ORDER)[number])
168+
const aKnown = ia !== -1
169+
const bKnown = ib !== -1
170+
if (aKnown && bKnown) return ia - ib
171+
if (aKnown) return -1
172+
if (bKnown) return 1
173+
return a.localeCompare(b)
174+
})
175+
}
176+
177+
function mergeAppAndHosting(appBody: string, hostingEntries: ChangelogEntry[]): string {
178+
if (!hostingEntries.length) {
179+
return appBody.replace(/\s*$/, '\n')
180+
}
181+
182+
const appSections = parseSections(appBody)
183+
const hostingByKey = new Map<string, HostingSectionAgg>()
184+
185+
for (const entry of hostingEntries) {
186+
for (const sec of parseSections(entry.body)) {
187+
const bullets = extractBulletLines(sec.rawLines).map(toHostingBullet)
188+
if (bullets.length === 0) continue
189+
190+
if (!hostingByKey.has(sec.key)) {
191+
hostingByKey.set(sec.key, { title: sec.title, bullets: [] })
192+
}
193+
hostingByKey.get(sec.key)!.bullets.push(...bullets)
194+
}
195+
}
196+
197+
const parts: string[] = []
198+
199+
for (const sec of appSections) {
200+
const appBullets = extractBulletLines(sec.rawLines)
201+
const hostBlock = hostingByKey.get(sec.key)
202+
const hostBullets = hostBlock ? hostBlock.bullets : []
203+
if (hostBlock) {
204+
hostingByKey.delete(sec.key)
205+
}
206+
207+
const lines = [`## ${sec.title}`, '', ...appBullets, ...hostBullets]
208+
parts.push(lines.join('\n'))
209+
}
210+
211+
for (const key of sortHostingOnlyKeys([...hostingByKey.keys()])) {
212+
const block = hostingByKey.get(key)!
213+
parts.push(`## ${block.title}\n\n${block.bullets.join('\n')}`)
214+
}
215+
216+
return `${parts.join('\n\n')}\n`
217+
}
218+
219+
function main() {
220+
const { dryRun, version, outFile } = parseArgs(process.argv.slice(2))
221+
const changelogPath = join(REPO_ROOT, 'packages/blog/changelog.ts')
222+
const src = fs.readFileSync(changelogPath, 'utf8')
223+
const entries = parseChangelogEntries(src)
224+
const { appBody, hostingEntries } = findAppAndHosting(entries, version)
225+
const output = mergeAppAndHosting(appBody, hostingEntries)
226+
227+
fs.writeFileSync(outFile, output, 'utf8')
228+
const mode = dryRun ? 'dry-run' : 'release'
229+
const n = hostingEntries.length
230+
console.log(`Wrote ${outFile} (${mode}, app ${version}, ${n} hosting entr${n === 1 ? 'y' : 'ies'})`)
231+
}
232+
233+
main()

0 commit comments

Comments
 (0)