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:
71
.github/workflows/theseus-release.yml
vendored
71
.github/workflows/theseus-release.yml
vendored
@@ -55,76 +55,7 @@ jobs:
|
|||||||
- name: 📝 Extract app changelog
|
- name: 📝 Extract app changelog
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ env.VERSION_TAG }}
|
VERSION: ${{ env.VERSION_TAG }}
|
||||||
run: |
|
run: npx --yes tsx scripts/build-theseus-release-notes.ts
|
||||||
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
|
|
||||||
|
|
||||||
- name: 🛠️ Generate version manifest
|
- name: 🛠️ Generate version manifest
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -81,3 +81,6 @@ apps/frontend/src/public/robots.txt
|
|||||||
|
|
||||||
# Oh My Code
|
# Oh My Code
|
||||||
.omc/
|
.omc/
|
||||||
|
|
||||||
|
# Local dry-run output for scripts/build-theseus-release-notes.mjs
|
||||||
|
/test_result.md
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="instance" class="flex h-full flex-col">
|
<div v-if="instance" :class="{ 'flex h-full flex-col': isFixedRender }">
|
||||||
<div
|
<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)"
|
@contextmenu.prevent.stop="(event) => handleRightClick(event)"
|
||||||
>
|
>
|
||||||
<ExportModal ref="exportModal" :instance="instance" />
|
<ExportModal ref="exportModal" :instance="instance" />
|
||||||
@@ -208,10 +208,10 @@
|
|||||||
</template>
|
</template>
|
||||||
</ContentPageHeader>
|
</ContentPageHeader>
|
||||||
</div>
|
</div>
|
||||||
<div class="shrink-0 px-6">
|
<div :class="['px-6', { 'shrink-0': isFixedRender }]">
|
||||||
<NavTabs :links="tabs" />
|
<NavTabs :links="tabs" />
|
||||||
</div>
|
</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
|
<RouterView
|
||||||
v-if="route.path.startsWith('/instance')"
|
v-if="route.path.startsWith('/instance')"
|
||||||
v-slot="{ Component }"
|
v-slot="{ Component }"
|
||||||
@@ -436,6 +436,19 @@ watch(
|
|||||||
|
|
||||||
const basePath = computed(() => `/instance/${encodeURIComponent(route.params.id as string)}`)
|
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(() => [
|
const tabs = computed(() => [
|
||||||
{
|
{
|
||||||
label: 'Content',
|
label: 'Content',
|
||||||
|
|||||||
@@ -240,6 +240,7 @@ export default new createRouter({
|
|||||||
component: Instance.Logs,
|
component: Instance.Logs,
|
||||||
meta: {
|
meta: {
|
||||||
useRootContext: true,
|
useRootContext: true,
|
||||||
|
renderMode: 'fixed',
|
||||||
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Logs' }],
|
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Logs' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"build-storybook": "pnpm --filter @modrinth/ui build-storybook",
|
"build-storybook": "pnpm --filter @modrinth/ui build-storybook",
|
||||||
"icons:add": "pnpm --filter @modrinth/assets icons:add",
|
"icons:add": "pnpm --filter @modrinth/assets icons:add",
|
||||||
"changelog:collect": "node scripts/run.mjs collect-changelog",
|
"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"
|
"scripts": "node scripts/run.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
233
scripts/build-theseus-release-notes.ts
Normal file
233
scripts/build-theseus-release-notes.ts
Normal 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()
|
||||||
Reference in New Issue
Block a user