From 2fda5519303e0116af18ed0006c9fbd0ddd7e02d Mon Sep 17 00:00:00 2001 From: Ethan Green Date: Wed, 23 Sep 2026 16:52:21 +0300 Subject: [PATCH 1/8] Read upload zips with zip.js --- .../upload/__tests__/readZipEntryText.test.ts | 79 ++++++ .../__tests__/uploadZipValidation.test.ts | 71 +---- .../app/upload/__tests__/zipFixtures.ts | 125 +++++++++ .../app/upload/readZipFilenames.ts | 248 +++++------------- apps/cyberstorm-remix/package.json | 1 + pnpm-lock.yaml | 10 + 6 files changed, 276 insertions(+), 258 deletions(-) create mode 100644 apps/cyberstorm-remix/app/upload/__tests__/readZipEntryText.test.ts create mode 100644 apps/cyberstorm-remix/app/upload/__tests__/zipFixtures.ts diff --git a/apps/cyberstorm-remix/app/upload/__tests__/readZipEntryText.test.ts b/apps/cyberstorm-remix/app/upload/__tests__/readZipEntryText.test.ts new file mode 100644 index 000000000..59381d8fe --- /dev/null +++ b/apps/cyberstorm-remix/app/upload/__tests__/readZipEntryText.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; + +import { readZipEntryText, readZipFilenames } from "../readZipFilenames"; +import { deflatedEntry, storedEntry, zipFile } from "./zipFixtures"; + +const MANIFEST = JSON.stringify({ name: "MyMod", version_number: "1.0.0" }); + +describe("readZipEntryText", () => { + it("reads a stored entry", async () => { + const file = zipFile(["manifest.json", "README.md"], "package.zip", { + "manifest.json": storedEntry(MANIFEST), + }); + await expect(readZipEntryText(file, "manifest.json")).resolves.toBe( + MANIFEST + ); + }); + + it("matches the entry name exactly", async () => { + const file = zipFile(["Manifest.json"], "package.zip", { + "Manifest.json": storedEntry(MANIFEST), + }); + await expect(readZipEntryText(file, "manifest.json")).resolves.toBeNull(); + }); + + it("returns null for entries above the size cap", async () => { + const file = zipFile(["manifest.json"], "package.zip", { + "manifest.json": await deflatedEntry("x".repeat(1024 * 1024 + 1)), + }); + await expect(readZipEntryText(file, "manifest.json")).resolves.toBeNull(); + }); + + it("accepts a deflated entry exactly at the size cap", async () => { + const text = "x".repeat(1024 * 1024); + const file = zipFile(["manifest.json"], "package.zip", { + "manifest.json": await deflatedEntry(text), + }); + await expect(readZipEntryText(file, "manifest.json")).resolves.toBe(text); + }); + + it("reads metadata without buffering the package payload", async () => { + const file = zipFile(["manifest.json", "payload.bin"], "package.zip", { + "manifest.json": storedEntry(MANIFEST), + "payload.bin": storedEntry("x".repeat(2 * 1024 * 1024)), + }); + const wholeFileRead = vi.spyOn(file, "arrayBuffer"); + const slices = vi.spyOn(file, "slice"); + try { + await expect(readZipFilenames(file)).resolves.toEqual([ + "manifest.json", + "payload.bin", + ]); + await expect(readZipEntryText(file, "manifest.json")).resolves.toBe( + MANIFEST + ); + expect(wholeFileRead).not.toHaveBeenCalled(); + const bytesRead = slices.mock.results.reduce( + (size, result) => size + result.value.size, + 0 + ); + expect(bytesRead).toBeLessThan(192 * 1024); + } finally { + wholeFileRead.mockRestore(); + slices.mockRestore(); + } + }); + + it("rejects content with an invalid checksum", async () => { + const entry = storedEntry(MANIFEST); + const file = zipFile(["manifest.json"], "package.zip", { + "manifest.json": { ...entry, crc32: entry.crc32! ^ 1 }, + }); + await expect(readZipEntryText(file, "manifest.json")).resolves.toBeNull(); + }); + + it("returns null for something that is not a ZIP", async () => { + const file = new File(["zip"], "package.zip", { type: "application/zip" }); + await expect(readZipEntryText(file, "manifest.json")).resolves.toBeNull(); + }); +}); diff --git a/apps/cyberstorm-remix/app/upload/__tests__/uploadZipValidation.test.ts b/apps/cyberstorm-remix/app/upload/__tests__/uploadZipValidation.test.ts index 8ea390c31..952b8c11a 100644 --- a/apps/cyberstorm-remix/app/upload/__tests__/uploadZipValidation.test.ts +++ b/apps/cyberstorm-remix/app/upload/__tests__/uploadZipValidation.test.ts @@ -5,79 +5,10 @@ import { evaluateZipContents, validatePackageZip, } from "../uploadZipValidation"; +import { buildZip, zipFile } from "./zipFixtures"; const VALID_ROOT = ["manifest.json", "icon.png", "README.md"]; -/** - * Builds a minimal, uncompressed (stored) ZIP containing the given entries - * with empty contents. Only the structures `readZipFilenames` parses (local - * headers, central directory, EOCD) are produced — enough to exercise the - * filename reader without pulling in a ZIP library. - */ -function buildZip(filenames: string[]): Uint8Array { - const encoder = new TextEncoder(); - const localChunks: Uint8Array[] = []; - const centralChunks: Uint8Array[] = []; - const localOffsets: number[] = []; - let offset = 0; - - for (const name of filenames) { - const nameBytes = encoder.encode(name); - localOffsets.push(offset); - - const local = new Uint8Array(30 + nameBytes.length); - const view = new DataView(local.buffer); - view.setUint32(0, 0x04034b50, true); - view.setUint16(4, 20, true); - view.setUint16(6, 0x0800, true); - view.setUint16(26, nameBytes.length, true); - local.set(nameBytes, 30); - - localChunks.push(local); - offset += local.length; - } - - const centralStart = offset; - filenames.forEach((name, index) => { - const nameBytes = encoder.encode(name); - const central = new Uint8Array(46 + nameBytes.length); - const view = new DataView(central.buffer); - view.setUint32(0, 0x02014b50, true); - view.setUint16(4, 20, true); - view.setUint16(6, 20, true); - view.setUint16(8, 0x0800, true); - view.setUint16(28, nameBytes.length, true); - view.setUint32(42, localOffsets[index], true); - central.set(nameBytes, 46); - - centralChunks.push(central); - offset += central.length; - }); - const centralSize = offset - centralStart; - - const eocd = new Uint8Array(22); - const eocdView = new DataView(eocd.buffer); - eocdView.setUint32(0, 0x06054b50, true); - eocdView.setUint16(8, filenames.length, true); - eocdView.setUint16(10, filenames.length, true); - eocdView.setUint32(12, centralSize, true); - eocdView.setUint32(16, centralStart, true); - - const chunks = [...localChunks, ...centralChunks, eocd]; - const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0); - const out = new Uint8Array(total); - let cursor = 0; - for (const chunk of chunks) { - out.set(chunk, cursor); - cursor += chunk.length; - } - return out; -} - -function zipFile(filenames: string[], name = "package.zip"): File { - return new File([buildZip(filenames)], name, { type: "application/zip" }); -} - describe("evaluateZipContents", () => { it("accepts a well-formed package with no warnings or errors", () => { const result = evaluateZipContents("package.zip", VALID_ROOT); diff --git a/apps/cyberstorm-remix/app/upload/__tests__/zipFixtures.ts b/apps/cyberstorm-remix/app/upload/__tests__/zipFixtures.ts new file mode 100644 index 000000000..64b171e31 --- /dev/null +++ b/apps/cyberstorm-remix/app/upload/__tests__/zipFixtures.ts @@ -0,0 +1,125 @@ +/** Builds ZIP fixtures with independently adjustable headers and payloads. */ + +export interface ZipEntryContent { + /** Compression method as written to the headers: 0 stored, 8 deflate. */ + method: number; + /** Bytes written after the local header, already compressed for method 8. */ + data: Uint8Array; + uncompressedSize: number; + crc32?: number; +} + +export function buildZip( + filenames: string[], + contents: Record = {} +): Uint8Array { + const encoder = new TextEncoder(); + const localChunks: Uint8Array[] = []; + const centralChunks: Uint8Array[] = []; + const localOffsets: number[] = []; + let offset = 0; + + for (const name of filenames) { + const nameBytes = encoder.encode(name); + const content = contents[name]; + localOffsets.push(offset); + + const local = new Uint8Array( + 30 + nameBytes.length + (content?.data.length ?? 0) + ); + const view = new DataView(local.buffer); + view.setUint32(0, 0x04034b50, true); + view.setUint16(4, 20, true); + view.setUint16(6, 0x0800, true); + view.setUint16(8, content?.method ?? 0, true); + view.setUint32(14, content?.crc32 ?? 0, true); + view.setUint32(18, content?.data.length ?? 0, true); + view.setUint32(22, content?.uncompressedSize ?? 0, true); + view.setUint16(26, nameBytes.length, true); + local.set(nameBytes, 30); + if (content) local.set(content.data, 30 + nameBytes.length); + + localChunks.push(local); + offset += local.length; + } + + const centralStart = offset; + filenames.forEach((name, index) => { + const nameBytes = encoder.encode(name); + const content = contents[name]; + const central = new Uint8Array(46 + nameBytes.length); + const view = new DataView(central.buffer); + view.setUint32(0, 0x02014b50, true); + view.setUint16(4, 20, true); + view.setUint16(6, 20, true); + view.setUint16(8, 0x0800, true); + view.setUint16(10, content?.method ?? 0, true); + view.setUint32(16, content?.crc32 ?? 0, true); + view.setUint32(20, content?.data.length ?? 0, true); + view.setUint32(24, content?.uncompressedSize ?? 0, true); + view.setUint16(28, nameBytes.length, true); + view.setUint32(42, localOffsets[index], true); + central.set(nameBytes, 46); + + centralChunks.push(central); + offset += central.length; + }); + const centralSize = offset - centralStart; + + const eocd = new Uint8Array(22); + const eocdView = new DataView(eocd.buffer); + eocdView.setUint32(0, 0x06054b50, true); + eocdView.setUint16(8, filenames.length, true); + eocdView.setUint16(10, filenames.length, true); + eocdView.setUint32(12, centralSize, true); + eocdView.setUint32(16, centralStart, true); + + const chunks = [...localChunks, ...centralChunks, eocd]; + const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const out = new Uint8Array(total); + let cursor = 0; + for (const chunk of chunks) { + out.set(chunk, cursor); + cursor += chunk.length; + } + return out; +} + +export function zipFile( + filenames: string[], + name = "package.zip", + contents?: Record +): File { + return new File([buildZip(filenames, contents)], name, { + type: "application/zip", + }); +} + +function crc32(data: Uint8Array): number { + let crc = 0xffffffff; + for (const byte of data) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +export function storedEntry(text: string): ZipEntryContent { + const data = new TextEncoder().encode(text); + return { method: 0, data, uncompressedSize: data.length, crc32: crc32(data) }; +} + +export async function deflatedEntry(text: string): Promise { + const raw = new TextEncoder().encode(text); + const compressed = await new Response( + new Blob([raw]).stream().pipeThrough(new CompressionStream("deflate-raw")) + ).arrayBuffer(); + return { + method: 8, + data: new Uint8Array(compressed), + uncompressedSize: raw.length, + crc32: crc32(raw), + }; +} diff --git a/apps/cyberstorm-remix/app/upload/readZipFilenames.ts b/apps/cyberstorm-remix/app/upload/readZipFilenames.ts index 1d5b7a556..6a050572a 100644 --- a/apps/cyberstorm-remix/app/upload/readZipFilenames.ts +++ b/apps/cyberstorm-remix/app/upload/readZipFilenames.ts @@ -1,206 +1,78 @@ -/** - * Reads the list of entry filenames from a ZIP file by parsing its central - * directory, without decompressing any contents. - * - * The pre-upload package validation (see {@link ./uploadZipValidation}) only - * needs the entry names — never the file data — so this deliberately avoids a - * full ZIP library. It reads just the tail of the file (the End Of Central - * Directory record and the central directory itself) via `Blob.slice`, so it - * stays cheap even for the 10GB maximum upload size. - * - * Returns `null` when the archive can't be parsed (corrupt, unsupported, or - * not actually a ZIP). Callers should treat `null` as "couldn't analyse" and - * fall back to server-side validation rather than blocking the upload. - */ - -const EOCD_SIGNATURE = 0x06054b50; -const EOCD_MIN_SIZE = 22; -const ZIP64_EOCD_LOCATOR_SIGNATURE = 0x07064b50; -const ZIP64_EOCD_LOCATOR_SIZE = 20; -const ZIP64_EOCD_SIGNATURE = 0x06064b50; -const ZIP64_EOCD_SIZE = 56; -const CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x02014b50; -const CENTRAL_DIRECTORY_HEADER_MIN_SIZE = 46; +import { + BlobReader, + TextWriter, + ZipReader, +} from "@zip.js/zip.js/lib/zip-core-native.js"; -/** Max ZIP comment length is 0xffff; the EOCD lives within this of the end. */ -const MAX_EOCD_SEARCH = EOCD_MIN_SIZE + 0xffff; -/** Guard against pathological archives: cap the central directory we read. */ -const MAX_CENTRAL_DIRECTORY_SIZE = 64 * 1024 * 1024; -/** Guard against runaway loops on malformed central directories. */ +const MAX_READ_SIZE = 64 * 1024 * 1024; const MAX_ENTRIES = 200_000; +const MAX_ENTRY_SIZE = 1024 * 1024; -async function sliceToDataView( - file: File, - start: number, - end?: number -): Promise { - const buffer = await file.slice(start, end).arrayBuffer(); - return new DataView(buffer); -} - -/** Reads an unsigned 64-bit little-endian int, or null if it exceeds 2^53-1. */ -function readSafeUint64(view: DataView, offset: number): number | null { - const low = view.getUint32(offset, true); - const high = view.getUint32(offset + 4, true); - const value = high * 0x1_0000_0000 + low; - return Number.isSafeInteger(value) ? value : null; -} - -interface CentralDirectoryLocation { - offset: number; - size: number; -} - -function locateCentralDirectory( - eocd: DataView, - eocdOffset: number -): CentralDirectoryLocation | "needs-zip64" | null { - const size = eocd.getUint32(eocdOffset + 12, true); - const offset = eocd.getUint32(eocdOffset + 16, true); - - // Only a maxed-out directory size/offset forces ZIP64. The record counts - // (offsets +8/+10) legitimately read 0xffff for exactly 65535 entries, and - // we never rely on them (the directory is walked by signature), so a 0xffff - // count must NOT trigger the ZIP64 path — doing so made a valid 65535-entry - // archive unreadable. - if (size === 0xffffffff || offset === 0xffffffff) { - return "needs-zip64"; - } - - return { offset, size }; -} - -async function locateZip64CentralDirectory( - file: File, - eocdAbsoluteOffset: number -): Promise { - const locatorOffset = eocdAbsoluteOffset - ZIP64_EOCD_LOCATOR_SIZE; - if (locatorOffset < 0) return null; - - const locator = await sliceToDataView( - file, - locatorOffset, - locatorOffset + ZIP64_EOCD_LOCATOR_SIZE - ); - if (locator.getUint32(0, true) !== ZIP64_EOCD_LOCATOR_SIGNATURE) return null; - - const zip64EocdOffset = readSafeUint64(locator, 8); - if ( - zip64EocdOffset === null || - zip64EocdOffset + ZIP64_EOCD_SIZE > file.size - ) { - return null; - } - - const zip64Eocd = await sliceToDataView( - file, - zip64EocdOffset, - zip64EocdOffset + ZIP64_EOCD_SIZE - ); - if (zip64Eocd.getUint32(0, true) !== ZIP64_EOCD_SIGNATURE) return null; - - const size = readSafeUint64(zip64Eocd, 40); - const offset = readSafeUint64(zip64Eocd, 48); - if (size === null || offset === null) return null; - - return { offset, size }; -} +// Inspect metadata without buffering multi-gigabyte uploads or unbounded directories. +class MetadataReader extends BlobReader { + private bytesRead = 0; -function findEocdOffset(eocd: DataView): number | null { - // Scan backwards: the EOCD is the last record, optionally followed only by - // its own variable-length comment. - for (let i = eocd.byteLength - EOCD_MIN_SIZE; i >= 0; i--) { - if (eocd.getUint32(i, true) !== EOCD_SIGNATURE) continue; - const commentLength = eocd.getUint16(i + 20, true); - // Accept the record as long as its declared comment fits within the slice. - // Requiring it to end exactly at EOF rejected otherwise-valid archives that - // carry trailing bytes after the EOCD (e.g. appended signatures/metadata). - if (i + EOCD_MIN_SIZE + commentLength <= eocd.byteLength) { - return i; - } - } - return null; -} - -function parseCentralDirectoryNames( - centralDirectory: DataView -): string[] | null { - const decoder = new TextDecoder("utf-8"); - const names: string[] = []; - let cursor = 0; - - while ( - cursor + CENTRAL_DIRECTORY_HEADER_MIN_SIZE <= - centralDirectory.byteLength - ) { + override async readUint8Array(offset: number, length: number) { + this.bytesRead += length; if ( - centralDirectory.getUint32(cursor, true) !== - CENTRAL_DIRECTORY_HEADER_SIGNATURE + !Number.isSafeInteger(length) || + length < 0 || + this.bytesRead > MAX_READ_SIZE ) { - // The central directory is contiguous fixed-signature records; a record - // that doesn't start with the signature means it's truncated or corrupt. - // Return null (defer to the server) rather than a partial, misleading - // list that would raise false "missing manifest/icon/README" errors. - return null; + throw new Error("ZIP inspection exceeds the read limit"); } - - const nameLength = centralDirectory.getUint16(cursor + 28, true); - const extraLength = centralDirectory.getUint16(cursor + 30, true); - const commentLength = centralDirectory.getUint16(cursor + 32, true); - const nameStart = cursor + CENTRAL_DIRECTORY_HEADER_MIN_SIZE; - const nameEnd = nameStart + nameLength; - if (nameEnd > centralDirectory.byteLength) return null; - // The record also declares extra and comment fields after the name. A final - // record whose declared length runs past the directory is truncated, so - // defer to the server rather than returning a partial, misleading list. - const nextCursor = nameEnd + extraLength + commentLength; - if (nextCursor > centralDirectory.byteLength) return null; - - const nameBytes = new Uint8Array( - centralDirectory.buffer, - centralDirectory.byteOffset + nameStart, - nameLength - ); - names.push(decoder.decode(nameBytes)); - - cursor = nextCursor; - // An archive with more entries than we're willing to scan: defer to the - // server instead of validating against a truncated list. - if (names.length > MAX_ENTRIES) return null; + return super.readUint8Array(offset, length); } - - // A non-empty central directory that yields no parseable headers is corrupt. - return names.length > 0 ? names : null; } +/** Returns null when inspection fails so server-side validation can take over. */ export async function readZipFilenames(file: File): Promise { + const reader = new ZipReader(new MetadataReader(file)); try { - const fileSize = file.size; - if (fileSize < EOCD_MIN_SIZE) return null; - - const searchSize = Math.min(fileSize, MAX_EOCD_SEARCH); - const searchStart = fileSize - searchSize; - const eocd = await sliceToDataView(file, searchStart); - - const eocdOffset = findEocdOffset(eocd); - if (eocdOffset === null) return null; - const eocdAbsoluteOffset = searchStart + eocdOffset; - - let location = locateCentralDirectory(eocd, eocdOffset); - if (location === "needs-zip64") { - location = await locateZip64CentralDirectory(file, eocdAbsoluteOffset); + const names: string[] = []; + for await (const entry of reader.getEntriesGenerator()) { + if (names.length === MAX_ENTRIES) return null; + names.push(entry.filename); } - if (location === null) return null; - - const { offset, size } = location; - // An empty but well-formed archive has a zero-length central directory. - if (size === 0) return []; - if (size < 0 || size > MAX_CENTRAL_DIRECTORY_SIZE) return null; - if (offset < 0 || offset + size > fileSize) return null; + return names; + } catch { + return null; + } finally { + await reader.close(); + } +} - const centralDirectory = await sliceToDataView(file, offset, offset + size); - return parseCentralDirectoryNames(centralDirectory); +/** + * Extracts only small metadata files. zip.js rejects output that exceeds the + * declared size, so the declared-size limit also bounds the decoded bytes. + */ +export async function readZipEntryText( + file: File, + entryName: string +): Promise { + const reader = new ZipReader(new MetadataReader(file), { + useWebWorkers: false, + }); + try { + let count = 0; + for await (const entry of reader.getEntriesGenerator()) { + if (++count > MAX_ENTRIES) return null; + if (entry.filename !== entryName) continue; + if ( + entry.directory || + entry.encrypted || + entry.compressedSize > MAX_ENTRY_SIZE || + entry.uncompressedSize > MAX_ENTRY_SIZE + ) { + return null; + } + + return await entry.getData(new TextWriter(), { checkSignature: true }); + } + return null; } catch { return null; + } finally { + await reader.close(); } } diff --git a/apps/cyberstorm-remix/package.json b/apps/cyberstorm-remix/package.json index 76025c003..991946ec7 100644 --- a/apps/cyberstorm-remix/package.json +++ b/apps/cyberstorm-remix/package.json @@ -42,6 +42,7 @@ "@thunderstore/ts-uploader-react": "workspace:*", "@types/hast": "3.0.4", "@types/semver": "7.7.1", + "@zip.js/zip.js": "2.11.1", "buffer": "6.0.3", "hast-util-sanitize": "5.0.2", "isbot": "5.1.32", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 926be3b23..82426cdb3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -215,6 +215,9 @@ importers: '@types/semver': specifier: 7.7.1 version: 7.7.1 + '@zip.js/zip.js': + specifier: 2.11.1 + version: 2.11.1 buffer: specifier: 6.0.3 version: 6.0.3 @@ -3723,6 +3726,10 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + '@zip.js/zip.js@2.11.1': + resolution: {integrity: sha512-x86tIhuszaDZ9ek8Tp9AkcSvyWffiTH8SiDNhsmtW+DoSKkTDnLkX3lHGG39whb6gMWo6q+cU6DYTF/2htjwuQ==} + engines: {bun: '>=0.7.0', deno: '>=1.0.0', node: '>=18.0.0'} + accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -4227,6 +4234,7 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained. css-color-keywords@1.0.0: resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==} @@ -10590,6 +10598,8 @@ snapshots: '@xtuc/long@4.2.2': {} + '@zip.js/zip.js@2.11.1': {} + accepts@1.3.8: dependencies: mime-types: 2.1.35 From 626238c2191faf9a0ae7380f22e9efef8fd26ca1 Mon Sep 17 00:00:00 2001 From: Ethan Green Date: Wed, 23 Sep 2026 16:52:22 +0300 Subject: [PATCH 2/8] Add API client functions for README and CHANGELOG overrides --- packages/dapper-fake/src/fakers/markup.ts | 4 ++ packages/dapper-fake/src/fakers/package.ts | 1 + packages/dapper/src/types/package.ts | 1 + packages/dapper/src/types/shared.ts | 4 ++ .../get/packageVersionChangelogMarkdownRaw.ts | 50 +++++++++++++++++ .../get/packageVersionReadmeMarkdownRaw.ts | 50 +++++++++++++++++ packages/thunderstore-api/src/index.ts | 4 ++ .../__tests__/packageVersionMarkdown.test.ts | 54 +++++++++++++++++++ .../src/post/packageVersionChangelog.ts | 38 +++++++++++++ .../src/post/packageVersionReadme.ts | 38 +++++++++++++ .../src/schemas/objectSchemas.ts | 1 + .../src/schemas/requestSchemas.ts | 27 ++++++++++ .../src/schemas/responseSchemas.ts | 30 +++++++++++ 13 files changed, 302 insertions(+) create mode 100644 packages/thunderstore-api/src/get/packageVersionChangelogMarkdownRaw.ts create mode 100644 packages/thunderstore-api/src/get/packageVersionReadmeMarkdownRaw.ts create mode 100644 packages/thunderstore-api/src/post/__tests__/packageVersionMarkdown.test.ts create mode 100644 packages/thunderstore-api/src/post/packageVersionChangelog.ts create mode 100644 packages/thunderstore-api/src/post/packageVersionReadme.ts diff --git a/packages/dapper-fake/src/fakers/markup.ts b/packages/dapper-fake/src/fakers/markup.ts index b947b1ad9..2635c24c4 100644 --- a/packages/dapper-fake/src/fakers/markup.ts +++ b/packages/dapper-fake/src/fakers/markup.ts @@ -1,4 +1,6 @@ export const getFakeChangelog = async () => ({ + is_edited: false, + edited_at: null, html: `

v1.2.3

    @@ -33,6 +35,8 @@ export const getFakeChangelog = async () => ({ }); export const getFakeReadme = async () => ({ + is_edited: false, + edited_at: null, html: `

    Markdown syntax guide

    This is a Heading h2

    diff --git a/packages/dapper-fake/src/fakers/package.ts b/packages/dapper-fake/src/fakers/package.ts index 13db7c300..3949fdcfb 100644 --- a/packages/dapper-fake/src/fakers/package.ts +++ b/packages/dapper-fake/src/fakers/package.ts @@ -281,6 +281,7 @@ export const getFakePackageVersions = async ( download_count: faker.number.int({ min: 0, max: 200000 }), download_url: `https://thunderstore.io/package/download/${namespace}/${name}/${versionNumber}/`, install_url: `ror2mm://v1/install/thunderstore.io/${namespace}/${name}/${versionNumber}/`, + is_readme_edited: false, }; }); }; diff --git a/packages/dapper/src/types/package.ts b/packages/dapper/src/types/package.ts index 63520cb8f..634ca1a40 100644 --- a/packages/dapper/src/types/package.ts +++ b/packages/dapper/src/types/package.ts @@ -83,6 +83,7 @@ export interface PackageVersion { download_count: number; download_url: string; install_url: string; + is_readme_edited?: boolean; } export interface PackageVersionDependency { diff --git a/packages/dapper/src/types/shared.ts b/packages/dapper/src/types/shared.ts index 1eb54d901..fb9a66dc2 100644 --- a/packages/dapper/src/types/shared.ts +++ b/packages/dapper/src/types/shared.ts @@ -5,10 +5,14 @@ export type DynamicLink = { export type MarkdownResponse = { html: string; + is_edited?: boolean; + edited_at?: string | null; }; export type HTMLContentResponse = { html: string; + is_edited?: boolean; + edited_at?: string | null; }; export interface PackageCategory { diff --git a/packages/thunderstore-api/src/get/packageVersionChangelogMarkdownRaw.ts b/packages/thunderstore-api/src/get/packageVersionChangelogMarkdownRaw.ts new file mode 100644 index 000000000..dab782edd --- /dev/null +++ b/packages/thunderstore-api/src/get/packageVersionChangelogMarkdownRaw.ts @@ -0,0 +1,50 @@ +import { apiFetch } from "../apiFetch"; +import { ApiError } from "../index"; +import type { ApiEndpointProps } from "../index"; +import type { PackageVersionMarkdownRequestParams } from "../schemas/requestSchemas"; +import { packageVersionRawMarkdownResponseDataSchema } from "../schemas/responseSchemas"; +import type { PackageVersionRawMarkdownResponseData } from "../schemas/responseSchemas"; + +/** + * Fetches the resolved raw CHANGELOG of a version from the experimental API. + * Serves the override when one exists, the packaged file otherwise. Served + * from a five-minute server cache that edits do not bust. + */ +export function fetchPackageVersionChangelogMarkdownRaw( + props: ApiEndpointProps +): Promise { + const { config, params } = props; + const path = `/api/experimental/package/${params.namespace}/${params.package}/${params.version}/changelog/`; + + return apiFetch({ + args: { + config, + path, + request: { cache: "no-store" }, + }, + requestSchema: undefined, + queryParamsSchema: undefined, + responseSchema: packageVersionRawMarkdownResponseDataSchema, + }); +} + +/** + * Fetches the raw CHANGELOG override of a version from the download endpoint. + * Returns null when the version has no CHANGELOG override. Plain-text + * endpoint, so this bypasses apiFetch's JSON handling. + */ +export async function fetchPackageVersionChangelogOverrideRaw( + props: ApiEndpointProps +): Promise { + const { config, params } = props; + const path = `/api/cyberstorm/package/${params.namespace}/${params.package}/v/${params.version}/markdown/changelog/download/`; + + const response = await fetch(new URL(path, config().apiHost), { + cache: "no-store", + }); + if (response.status === 404) return null; + if (!response.ok) { + throw await ApiError.createFromResponse(response); + } + return await response.text(); +} diff --git a/packages/thunderstore-api/src/get/packageVersionReadmeMarkdownRaw.ts b/packages/thunderstore-api/src/get/packageVersionReadmeMarkdownRaw.ts new file mode 100644 index 000000000..e1744ad5d --- /dev/null +++ b/packages/thunderstore-api/src/get/packageVersionReadmeMarkdownRaw.ts @@ -0,0 +1,50 @@ +import { apiFetch } from "../apiFetch"; +import { ApiError } from "../index"; +import type { ApiEndpointProps } from "../index"; +import type { PackageVersionMarkdownRequestParams } from "../schemas/requestSchemas"; +import { packageVersionRawMarkdownResponseDataSchema } from "../schemas/responseSchemas"; +import type { PackageVersionRawMarkdownResponseData } from "../schemas/responseSchemas"; + +/** + * Fetches the resolved raw README of a version from the experimental API. + * Serves the override when one exists, the packaged file otherwise. Served + * from a five-minute server cache that edits do not bust. + */ +export function fetchPackageVersionReadmeMarkdownRaw( + props: ApiEndpointProps +): Promise { + const { config, params } = props; + const path = `/api/experimental/package/${params.namespace}/${params.package}/${params.version}/readme/`; + + return apiFetch({ + args: { + config, + path, + request: { cache: "no-store" }, + }, + requestSchema: undefined, + queryParamsSchema: undefined, + responseSchema: packageVersionRawMarkdownResponseDataSchema, + }); +} + +/** + * Fetches the raw README override of a version from the download endpoint. + * Returns null when the version has no README override. Plain-text endpoint, + * so this bypasses apiFetch's JSON handling. + */ +export async function fetchPackageVersionReadmeOverrideRaw( + props: ApiEndpointProps +): Promise { + const { config, params } = props; + const path = `/api/cyberstorm/package/${params.namespace}/${params.package}/v/${params.version}/markdown/readme/download/`; + + const response = await fetch(new URL(path, config().apiHost), { + cache: "no-store", + }); + if (response.status === 404) return null; + if (!response.ok) { + throw await ApiError.createFromResponse(response); + } + return await response.text(); +} diff --git a/packages/thunderstore-api/src/index.ts b/packages/thunderstore-api/src/index.ts index 98e002d29..2e5c41c4e 100644 --- a/packages/thunderstore-api/src/index.ts +++ b/packages/thunderstore-api/src/index.ts @@ -37,6 +37,8 @@ export * from "./get/packageListingDetails"; export * from "./get/packageReadme"; export * from "./get/packageSubmission"; export * from "./get/packageVersionDependencies"; +export * from "./get/packageVersionChangelogMarkdownRaw"; +export * from "./get/packageVersionReadmeMarkdownRaw"; export * from "./get/packageVersions"; export * from "./get/packageVersionDetails"; export * from "./get/packageWiki"; @@ -50,6 +52,8 @@ export * from "./patch/teamEditMember"; export * from "./post/frontend"; export * from "./post/package"; export * from "./post/packageListing"; +export * from "./post/packageVersionChangelog"; +export * from "./post/packageVersionReadme"; export * from "./post/packageWiki"; export * from "./post/submission"; export * from "./post/team"; diff --git a/packages/thunderstore-api/src/post/__tests__/packageVersionMarkdown.test.ts b/packages/thunderstore-api/src/post/__tests__/packageVersionMarkdown.test.ts new file mode 100644 index 000000000..e156384da --- /dev/null +++ b/packages/thunderstore-api/src/post/__tests__/packageVersionMarkdown.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { RequestBodyParseError } from "../../index"; +import { postPackageVersionChangelog } from "../packageVersionChangelog"; +import { postPackageVersionReadme } from "../packageVersionReadme"; + +const props = { + config: () => ({ + apiHost: "https://api.example.invalid", + sessionId: "session", + }), + params: { namespace: "Team", package: "Mod", version: "1.0.0" }, + queryParams: {}, +}; + +const state = { is_edited: false, edited_at: null }; + +afterEach(() => vi.unstubAllGlobals()); + +describe.each([ + [ + "readme", + (readme: string | null) => + postPackageVersionReadme({ ...props, data: { readme } }), + ], + [ + "changelog", + (changelog: string | null) => + postPackageVersionChangelog({ ...props, data: { changelog } }), + ], +] as const)("post %s override", (document, send) => { + it("rejects invalid values before sending a request", async () => { + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + await expect(send(123 as unknown as string)).rejects.toBeInstanceOf( + RequestBodyParseError + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + it.each([[""], [null]])("sends %j", async (value) => { + const response = new Response( + JSON.stringify({ readme: state, changelog: state }), + { status: 200 } + ); + const fetch = vi.fn().mockResolvedValue(response); + vi.stubGlobal("fetch", fetch); + await send(value); + expect(fetch).toHaveBeenCalledWith( + expect.any(URL), + expect.objectContaining({ body: JSON.stringify({ [document]: value }) }) + ); + }); +}); diff --git a/packages/thunderstore-api/src/post/packageVersionChangelog.ts b/packages/thunderstore-api/src/post/packageVersionChangelog.ts new file mode 100644 index 000000000..6c93de141 --- /dev/null +++ b/packages/thunderstore-api/src/post/packageVersionChangelog.ts @@ -0,0 +1,38 @@ +import { apiFetch } from "../apiFetch"; +import type { ApiEndpointProps } from "../index"; +import { + type PackageVersionChangelogRequestData, + type PackageVersionMarkdownRequestParams, + packageVersionChangelogRequestDataSchema, +} from "../schemas/requestSchemas"; +import { packageVersionMarkdownResponseDataSchema } from "../schemas/responseSchemas"; +import type { PackageVersionMarkdownResponseData } from "../schemas/responseSchemas"; + +/** Saves a version's CHANGELOG override, or discards it when changelog is null. */ +export function postPackageVersionChangelog( + props: ApiEndpointProps< + PackageVersionMarkdownRequestParams, + object, + PackageVersionChangelogRequestData + > +): Promise { + const { config, params, data } = props; + const path = `/api/cyberstorm/package/${params.namespace}/${params.package}/v/${params.version}/markdown/`; + + return apiFetch({ + args: { + config, + path, + request: { + method: "POST", + cache: "no-store", + body: JSON.stringify(data), + }, + bodyRaw: data, + useSession: true, + }, + requestSchema: packageVersionChangelogRequestDataSchema, + queryParamsSchema: undefined, + responseSchema: packageVersionMarkdownResponseDataSchema, + }); +} diff --git a/packages/thunderstore-api/src/post/packageVersionReadme.ts b/packages/thunderstore-api/src/post/packageVersionReadme.ts new file mode 100644 index 000000000..4aab4bd7e --- /dev/null +++ b/packages/thunderstore-api/src/post/packageVersionReadme.ts @@ -0,0 +1,38 @@ +import { apiFetch } from "../apiFetch"; +import type { ApiEndpointProps } from "../index"; +import { + type PackageVersionMarkdownRequestParams, + type PackageVersionReadmeRequestData, + packageVersionReadmeRequestDataSchema, +} from "../schemas/requestSchemas"; +import { packageVersionMarkdownResponseDataSchema } from "../schemas/responseSchemas"; +import type { PackageVersionMarkdownResponseData } from "../schemas/responseSchemas"; + +/** Saves a version's README override, or discards it when readme is null. */ +export function postPackageVersionReadme( + props: ApiEndpointProps< + PackageVersionMarkdownRequestParams, + object, + PackageVersionReadmeRequestData + > +): Promise { + const { config, params, data } = props; + const path = `/api/cyberstorm/package/${params.namespace}/${params.package}/v/${params.version}/markdown/`; + + return apiFetch({ + args: { + config, + path, + request: { + method: "POST", + cache: "no-store", + body: JSON.stringify(data), + }, + bodyRaw: data, + useSession: true, + }, + requestSchema: packageVersionReadmeRequestDataSchema, + queryParamsSchema: undefined, + responseSchema: packageVersionMarkdownResponseDataSchema, + }); +} diff --git a/packages/thunderstore-api/src/schemas/objectSchemas.ts b/packages/thunderstore-api/src/schemas/objectSchemas.ts index a597056d1..2839b18ff 100644 --- a/packages/thunderstore-api/src/schemas/objectSchemas.ts +++ b/packages/thunderstore-api/src/schemas/objectSchemas.ts @@ -261,6 +261,7 @@ export const packageVersionSchema = z.object({ download_count: z.number().int(), download_url: z.string(), install_url: z.string(), + is_readme_edited: z.boolean().optional().default(false), }); export const decompilationSchema = z.object({ diff --git a/packages/thunderstore-api/src/schemas/requestSchemas.ts b/packages/thunderstore-api/src/schemas/requestSchemas.ts index d1b90570d..6d611edb5 100644 --- a/packages/thunderstore-api/src/schemas/requestSchemas.ts +++ b/packages/thunderstore-api/src/schemas/requestSchemas.ts @@ -392,6 +392,33 @@ export type PackageListingUpdateRequestData = z.infer< typeof packageListingUpdateRequestDataSchema >; +// PackageVersionMarkdownRequest +export const packageVersionMarkdownRequestParamsSchema = z.object({ + namespace: z.string(), + package: z.string(), + version: z.string(), +}); + +export type PackageVersionMarkdownRequestParams = z.infer< + typeof packageVersionMarkdownRequestParamsSchema +>; + +export const packageVersionReadmeRequestDataSchema = z.object({ + readme: z.string().nullable(), +}); + +export type PackageVersionReadmeRequestData = z.infer< + typeof packageVersionReadmeRequestDataSchema +>; + +export const packageVersionChangelogRequestDataSchema = z.object({ + changelog: z.string().nullable(), +}); + +export type PackageVersionChangelogRequestData = z.infer< + typeof packageVersionChangelogRequestDataSchema +>; + // PackageRateRequest export const packageRateRequestParamsSchema = z.object({ namespace: z.string(), diff --git a/packages/thunderstore-api/src/schemas/responseSchemas.ts b/packages/thunderstore-api/src/schemas/responseSchemas.ts index 81c65dc35..2f07271d0 100644 --- a/packages/thunderstore-api/src/schemas/responseSchemas.ts +++ b/packages/thunderstore-api/src/schemas/responseSchemas.ts @@ -116,6 +116,8 @@ export type DynamicHTMLResponseData = z.infer< // PackageChangelogResponse export const packageChangelogResponseDataSchema = z.object({ html: z.string(), + is_edited: z.boolean().optional().default(false), + edited_at: z.string().datetime().nullable().optional().default(null), }); export type PackageChangelogResponseData = z.infer< @@ -177,12 +179,40 @@ export type PackageSourceResponseData = z.infer< // PackageReadmeResponse export const packageReadmeResponseDataSchema = z.object({ html: z.string(), + is_edited: z.boolean().optional().default(false), + edited_at: z.string().datetime().nullable().optional().default(null), }); export type PackageReadmeResponseData = z.infer< typeof packageReadmeResponseDataSchema >; +// PackageVersionMarkdownResponse (override write endpoint) +export const packageVersionMarkdownStateSchema = z.object({ + is_edited: z.boolean(), + edited_at: z.string().datetime().nullable(), +}); + +export const packageVersionMarkdownResponseDataSchema = z.object({ + readme: packageVersionMarkdownStateSchema, + changelog: packageVersionMarkdownStateSchema, +}); + +export type PackageVersionMarkdownResponseData = z.infer< + typeof packageVersionMarkdownResponseDataSchema +>; + +// PackageVersionRawMarkdownResponse (experimental raw markdown endpoints) +export const packageVersionRawMarkdownResponseDataSchema = z.object({ + markdown: z.string().nullable(), + is_edited: z.boolean().optional().default(false), + edited_at: z.string().datetime().nullable().optional().default(null), +}); + +export type PackageVersionRawMarkdownResponseData = z.infer< + typeof packageVersionRawMarkdownResponseDataSchema +>; + // PackageVersionsResponse export const packageVersionsResponseDataSchema = z.array(packageVersionSchema); From 51f311bb6fafd92527119fbae8fafc76149f070b Mon Sep 17 00:00:00 2001 From: Ethan Green Date: Wed, 23 Sep 2026 16:52:22 +0300 Subject: [PATCH 3/8] Export ValidationBar from the CodeInput module --- packages/cyberstorm/src/index.ts | 1 + .../src/newComponents/CodeInput/CodeInput.tsx | 28 +++++++++++++++---- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/cyberstorm/src/index.ts b/packages/cyberstorm/src/index.ts index 3912b8054..62f2c2efc 100644 --- a/packages/cyberstorm/src/index.ts +++ b/packages/cyberstorm/src/index.ts @@ -73,6 +73,7 @@ export { CardPackage } from "./newComponents/Card/CardPackage/CardPackage"; export { CodeInput, type CodeInputProps, + ValidationBar as NewValidationBar, } from "./newComponents/CodeInput/CodeInput"; export { Container, diff --git a/packages/cyberstorm/src/newComponents/CodeInput/CodeInput.tsx b/packages/cyberstorm/src/newComponents/CodeInput/CodeInput.tsx index 6015c64e3..28e83cdf1 100644 --- a/packages/cyberstorm/src/newComponents/CodeInput/CodeInput.tsx +++ b/packages/cyberstorm/src/newComponents/CodeInput/CodeInput.tsx @@ -90,46 +90,62 @@ export const CodeInput = React.forwardRef( CodeInput.displayName = "CodeInput"; -function ValidationBar(props: { +export function ValidationBar(props: { status: "waiting" | "processing" | "success" | "failure"; message?: string; + rootClasses?: string; + children?: ReactNode; }): ReactNode { if (props.status === "waiting") { return ( -
    +
    {props.message ? props.message : "Waiting for input"} + {props.children}
    ); } else if (props.status === "processing") { return ( -
    - +
    + {props.message ? props.message : "Processing..."} + {props.children}
    ); } else if (props.status === "success") { return ( -
    +
    {props.message ? props.message : "All systems go!"} + {props.children}
    ); } else { return ( -
    +
    {props.message ? props.message : "Problem, alarm, danger. Everything is going to explode."} + {props.children}
    ); } From a99aa372d9fab6b64496c09158bb5c8ab9124c48 Mon Sep 17 00:00:00 2001 From: Ethan Green Date: Wed, 23 Sep 2026 16:52:22 +0300 Subject: [PATCH 4/8] Add a link to the package markdown editor --- apps/cyberstorm-remix/cyberstorm/utils/LinkLibrary.tsx | 7 +++++++ .../cyberstorm/src/components/Links/LinkingProvider.tsx | 5 +++++ packages/cyberstorm/src/components/Links/Links.tsx | 1 + 3 files changed, 13 insertions(+) diff --git a/apps/cyberstorm-remix/cyberstorm/utils/LinkLibrary.tsx b/apps/cyberstorm-remix/cyberstorm/utils/LinkLibrary.tsx index 6858e4331..745f74770 100644 --- a/apps/cyberstorm-remix/cyberstorm/utils/LinkLibrary.tsx +++ b/apps/cyberstorm-remix/cyberstorm/utils/LinkLibrary.tsx @@ -192,6 +192,13 @@ const library: LinkLibrary = { ref={p.customRef} /> ), + PackageVersionReadmeEdit: (p) => ( + + ), PackageVersionWithoutCommunity: (p) => ( RE | null; /** PackageVersion's versions view */ PackageVersionVersions: (props: AnyProps & PackageVersionProps) => RE | null; + /** PackageVersion's readme/changelog editor */ + PackageVersionReadmeEdit: ( + props: AnyProps & PackageVersionProps + ) => RE | null; /** PackageVersionWithoutCommunity's detail view */ PackageVersionWithoutCommunity: ( props: AnyProps & Omit @@ -175,6 +179,7 @@ const library: LinkLibrary = { PackageVersion: noop, PackageVersionRequired: noop, PackageVersionVersions: noop, + PackageVersionReadmeEdit: noop, PackageVersionWithoutCommunity: noop, PackageVersionWithoutCommunityRequired: noop, PackageVersionWithoutCommunityVersions: noop, diff --git a/packages/cyberstorm/src/components/Links/Links.tsx b/packages/cyberstorm/src/components/Links/Links.tsx index c6b34a380..2349b00e9 100644 --- a/packages/cyberstorm/src/components/Links/Links.tsx +++ b/packages/cyberstorm/src/components/Links/Links.tsx @@ -41,6 +41,7 @@ export type CyberstormLinkIds = | "PackageVersion" | "PackageVersionRequired" | "PackageVersionVersions" + | "PackageVersionReadmeEdit" | "PackageVersionWithoutCommunity" | "PackageVersionWithoutCommunityRequired" | "PackageVersionWithoutCommunityVersions" From 402c5ac2fc2fb98fb6abaffc51e35b655d424947 Mon Sep 17 00:00:00 2001 From: Ethan Green Date: Wed, 23 Sep 2026 16:52:22 +0300 Subject: [PATCH 5/8] Resolve vitest imports through tsconfig paths --- apps/cyberstorm-remix/vitest.config.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/apps/cyberstorm-remix/vitest.config.ts b/apps/cyberstorm-remix/vitest.config.ts index cab9b8264..cc5330f36 100644 --- a/apps/cyberstorm-remix/vitest.config.ts +++ b/apps/cyberstorm-remix/vitest.config.ts @@ -1,16 +1,8 @@ -import path from "node:path"; -import { fileURLToPath } from "node:url"; +import tsconfigPaths from "vite-tsconfig-paths"; import { defineProject } from "vitest/config"; -const projectDir = path.dirname(fileURLToPath(import.meta.url)); -const cyberstormRoot = path.join(projectDir, "cyberstorm"); - export default defineProject({ - resolve: { - alias: { - cyberstorm: cyberstormRoot, - }, - }, + plugins: [tsconfigPaths()], test: { include: ["**/__tests__/**/*.test.ts"], exclude: ["**/node_modules/**", "dist/**/*"], From 85d14d83c2a13d539a0c7d1205648e5ae9df9b62 Mon Sep 17 00:00:00 2001 From: Ethan Green Date: Wed, 23 Sep 2026 16:52:22 +0300 Subject: [PATCH 6/8] Add the package README and CHANGELOG editor --- .../Breadcrumbs/Breadcrumbs.tsx | 9 +- .../app/p/readmeEdit/ReadmeEdit.css | 175 ++++ .../app/p/readmeEdit/ReadmeEdit.tsx | 796 ++++++++++++++++++ .../p/readmeEdit/__tests__/ReadmeEdit.test.ts | 536 ++++++++++++ .../app/p/readmeEdit/overrideMigration.ts | 66 ++ apps/cyberstorm-remix/app/root.tsx | 4 + apps/cyberstorm-remix/app/routes.ts | 4 + 7 files changed, 1587 insertions(+), 3 deletions(-) create mode 100644 apps/cyberstorm-remix/app/p/readmeEdit/ReadmeEdit.css create mode 100644 apps/cyberstorm-remix/app/p/readmeEdit/ReadmeEdit.tsx create mode 100644 apps/cyberstorm-remix/app/p/readmeEdit/__tests__/ReadmeEdit.test.ts create mode 100644 apps/cyberstorm-remix/app/p/readmeEdit/overrideMigration.ts diff --git a/apps/cyberstorm-remix/app/commonComponents/Breadcrumbs/Breadcrumbs.tsx b/apps/cyberstorm-remix/app/commonComponents/Breadcrumbs/Breadcrumbs.tsx index f665091de..2af149a65 100644 --- a/apps/cyberstorm-remix/app/commonComponents/Breadcrumbs/Breadcrumbs.tsx +++ b/apps/cyberstorm-remix/app/commonComponents/Breadcrumbs/Breadcrumbs.tsx @@ -58,6 +58,9 @@ export function Breadcrumbs() { (m) => m.id === "tools/markdown-preview/markdownPreview" ); const loginPage = matches.find((m) => m.id === "login/login"); + const readmeEditPage = matches.find( + (m) => m.id === "p/readmeEdit/ReadmeEdit" + ); const communityBreadcrumb = useMemo( () => @@ -249,9 +252,9 @@ export function Breadcrumbs() { return Log in; }, [loginPage]); - // No breadcrumb bar on the home page. After the hooks, as the component - // stays mounted across navigations. - if (rootIndexPage) { + // Hide breadcrumbs on the home page and the editor, which has its own + // "Go back" header. Keep this after the hooks for client-side navigation. + if (rootIndexPage || readmeEditPage) { return null; } diff --git a/apps/cyberstorm-remix/app/p/readmeEdit/ReadmeEdit.css b/apps/cyberstorm-remix/app/p/readmeEdit/ReadmeEdit.css new file mode 100644 index 000000000..539e8d3db --- /dev/null +++ b/apps/cyberstorm-remix/app/p/readmeEdit/ReadmeEdit.css @@ -0,0 +1,175 @@ +@layer nimbus-layout { + .readme-edit { + padding-bottom: var(--space-32); + } + + .readme-edit__skeleton { + height: 24rem; + } + + .readme-edit__toolbar { + gap: var(--space-4); + min-height: 48px; + padding: var(--space-8); + border-bottom: var(--border-width--px) solid var(--readme-edit-frame-color); + border-radius: var(--radius-md) var(--radius-md) 0 0; + background-color: var(--color-surface-3); + box-shadow: none; + } + + .readme-edit__back { + position: relative; + flex-shrink: 0; + margin-right: var(--space-24); + + --button-text-color: var(--link-cyber-color--default); + --button-icon-color: var(--link-cyber-color--default); + } + + .readme-edit__back:hover { + --button-text-color: var(--link-cyber-color--hover); + --button-icon-color: var(--link-cyber-color--hover); + } + + .readme-edit__back::after { + position: absolute; + top: 50%; + right: calc(-1 * var(--space-12)); + height: 28px; + border-right: var(--border-width--px) solid var(--color-surface-7); + transform: translateY(-50%); + content: ""; + } + + .readme-edit__identity { + flex-shrink: 0; + margin-left: auto; + padding-left: var(--space-16); + color: var(--color-text-tertiary); + font-size: var(--font-size-body-md); + } + + .readme-edit__tab { + padding: var(--space-8) var(--space-12); + border: none; + border-radius: var(--radius-md); + background: none; + cursor: pointer; + } + + /* aria-disabled keeps the tab focusable for its tooltip. Restore pointer + events so the explanation is also available on hover. */ + .readme-edit__tab[aria-disabled="true"] { + cursor: not-allowed; + pointer-events: auto; + } + + .readme-edit__tab:hover:not([aria-disabled="true"], [aria-current="true"]) { + background-color: var(--color-ui-surface-2); + } + + .readme-edit__tab[aria-current="true"] { + color: var(--color-text-primary); + background-color: var(--color-surface-6); + } + + .readme-edit__tab-edited { + --icon-color: currentcolor; + + margin-left: var(--space-8); + color: var(--color-text-tertiary); + font-size: var(--font-size-body-sm); + cursor: help; + + /* The current tab disables pointer events, which would hide the title. */ + pointer-events: auto; + } + + .readme-edit__workspace { + --readme-edit-frame-color: var(--color-surface-a8); + + /* Viewport height left over after the navigation header, the tab strip, + the status bar and the page paddings. */ + --readme-edit-pane-height: max( + 24rem, + calc(100vh - var(--header-height) - 13.5rem) + ); + + display: flex; + flex-direction: column; + border: var(--border-width--px) solid var(--readme-edit-frame-color); + border-radius: var(--radius-md); + background-color: var(--color-surface-3); + } + + .readme-edit__workspace:has(.validation-bar--failure) { + --readme-edit-frame-color: var(--validation-bar-border-top-color--failure); + } + + .readme-edit__panes { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .readme-edit__editor { + min-width: 0; + border: none; + border-right: var(--border-width--px) solid var(--readme-edit-frame-color); + border-radius: 0; + background-color: var(--color-ui-surface-2); + } + + .readme-edit__panes .code-input { + min-width: 0; + min-height: var(--readme-edit-pane-height); + padding: var(--space-12) var(--space-16); + font-size: 13px; + line-height: 1.5; + } + + .readme-edit__preview { + min-width: 0; + max-height: var(--readme-edit-pane-height); + padding: var(--space-24) var(--space-32); + overflow-y: auto; + background-color: var(--color-surface-2); + } + + .readme-edit__bar { + flex-wrap: wrap; + gap: var(--space-16); + padding: var(--space-8) var(--space-16); + border-top-color: var(--readme-edit-frame-color); + border-radius: 0 0 var(--radius-md) var(--radius-md); + background-color: var(--color-surface-3); + } + + .readme-edit__bar-actions { + display: inline-flex; + flex-wrap: wrap; + gap: var(--space-8); + align-items: center; + margin-left: auto; + } + + @container (width <= 48rem) { + .readme-edit__workspace { + --readme-edit-pane-height: 18rem; + } + + .readme-edit__panes { + grid-template-columns: minmax(0, 1fr); + } + + .readme-edit__editor { + border-right: none; + border-bottom: var(--border-width--px) solid + var(--readme-edit-frame-color); + } + + /* Stacked, the preview may be taller than the editor pane. */ + .readme-edit__preview { + max-height: 32rem; + } + } +} diff --git a/apps/cyberstorm-remix/app/p/readmeEdit/ReadmeEdit.tsx b/apps/cyberstorm-remix/app/p/readmeEdit/ReadmeEdit.tsx new file mode 100644 index 000000000..7919550b7 --- /dev/null +++ b/apps/cyberstorm-remix/app/p/readmeEdit/ReadmeEdit.tsx @@ -0,0 +1,796 @@ +import { faArrowLeft, faEdit } from "@fortawesome/pro-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { getSessionTools } from "cyberstorm/security/publicEnvVariables"; +import { redirectToLogin } from "cyberstorm/utils/ThunderstoreAuth"; +import { createSeo } from "cyberstorm/utils/meta"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + useBeforeUnload, + useBlocker, + useLoaderData, + useOutletContext, + useSearchParams, +} from "react-router"; +import { useDebounce } from "use-debounce"; +import { Markdown } from "~/commonComponents/Markdown/Markdown"; +import { Page } from "~/commonComponents/Page/Page"; +import { type OutletContextShape } from "~/root"; + +import { + CodeInput, + NewAlert, + NewButton, + NewIcon, + NewValidationBar, + SkeletonBox, + Tabs, + TooltipWrapper, + classnames, + isRecord, + useToast, +} from "@thunderstore/cyberstorm"; +import { DapperTs } from "@thunderstore/dapper-ts"; +import { + type PackageVersionRawMarkdownResponseData, + extractApiErrorMessage, + fetchPackageVersionChangelogMarkdownRaw, + fetchPackageVersionChangelogOverrideRaw, + fetchPackageVersionReadmeMarkdownRaw, + fetchPackageVersionReadmeOverrideRaw, + isApiError, + postPackageVersionChangelog, + postPackageVersionReadme, + toolsMarkdownPreview, +} from "@thunderstore/thunderstore-api"; + +import { getPrivateListing } from "../listingUtils"; +import type { Route } from "./+types/ReadmeEdit"; +import "./ReadmeEdit.css"; +import { + type PreviousOverride, + findPreviousReadmeOverride, +} from "./overrideMigration"; + +// Matches the backend's MAX_MARKDOWN_SIZE (100,000 characters). +const MAX_MARKDOWN_SIZE = 100000; + +type DocumentKey = "readme" | "changelog"; + +const DOCUMENT_LABELS: Record = { + readme: "README", + changelog: "CHANGELOG", +}; + +type MarkdownRequest = Omit< + Parameters[0], + "data" +>; + +const documentApi: Record< + DocumentKey, + { + fetchOverride: typeof fetchPackageVersionReadmeOverrideRaw; + fetchRaw: typeof fetchPackageVersionReadmeMarkdownRaw; + save: ( + request: MarkdownRequest, + markdown: string | null + ) => ReturnType; + } +> = { + readme: { + fetchOverride: fetchPackageVersionReadmeOverrideRaw, + fetchRaw: fetchPackageVersionReadmeMarkdownRaw, + save: (request, readme) => + postPackageVersionReadme({ ...request, data: { readme } }), + }, + changelog: { + fetchOverride: fetchPackageVersionChangelogOverrideRaw, + fetchRaw: fetchPackageVersionChangelogMarkdownRaw, + save: (request, changelog) => + postPackageVersionChangelog({ ...request, data: { changelog } }), + }, +}; + +interface DocumentState { + markdown: string; + baseline: string; + needsReload: boolean; + is_edited: boolean; + edited_at: string | null; +} + +type LoadedDocument = PackageVersionRawMarkdownResponseData & { + needsReload?: boolean; +}; + +type PreviewState = { + status: "waiting" | "processing" | "success" | "failure"; + message?: string; +}; + +function getEditorParams(params: Route.LoaderArgs["params"]) { + const { communityId, namespaceId, packageId, packageVersion } = params; + if (!communityId || !namespaceId || !packageId || !packageVersion) { + throw new Response("Not Found", { status: 404 }); + } + return { communityId, namespaceId, packageId, packageVersion }; +} + +function editorSeo({ + namespaceId, + packageId, +}: ReturnType) { + return createSeo({ + descriptors: [{ title: `Edit ${namespaceId}-${packageId} | Thunderstore` }], + }); +} + +// Textareas report line breaks as \n, so text with \r\n would never match its +// baseline again after an edit. +function normalizeLineEndings(text: string) { + return text.replace(/\r\n?/g, "\n"); +} + +function createDocumentState( + loaded?: Partial | null +): DocumentState { + const markdown = normalizeLineEndings(loaded?.markdown ?? ""); + return { + markdown, + baseline: markdown, + needsReload: loaded?.needsReload ?? false, + is_edited: loaded?.is_edited ?? false, + edited_at: loaded?.edited_at ?? null, + }; +} + +function errorMessage(error: unknown) { + return isApiError(error) ? extractApiErrorMessage(error) : "unknown error"; +} + +function siteEditTitle(doc: DocumentKey, editedAt: string | null) { + const lastSaved = editedAt + ? `, last saved ${new Date(editedAt).toISOString().slice(0, 10)}` + : ""; + return `This ${DOCUMENT_LABELS[doc]} has a site edit${lastSaved}.`; +} + +// Prefer the uncached override download so reopening the editor immediately +// after saving does not load stale content from the experimental endpoint. +async function loadDocument( + request: MarkdownRequest, + doc: DocumentKey +): Promise { + const shared = { ...request, data: {} }; + const override = await documentApi[doc].fetchOverride(shared); + if (override !== null) { + return { markdown: override, is_edited: true, edited_at: null }; + } + const raw = await documentApi[doc].fetchRaw(shared); + // With no override to download, an edited response is a cache entry left + // over from a discard. Keep the document locked until the cache updates. + if (raw.is_edited) { + return { + markdown: "", + is_edited: false, + edited_at: null, + needsReload: true, + }; + } + return raw; +} + +export async function loader({ params }: Route.LoaderArgs) { + const editorParams = getEditorParams(params); + return { ...editorParams, seo: editorSeo(editorParams) }; +} + +export { noStoreHeaders as headers } from "cyberstorm/utils/ssrLoader"; + +export async function clientLoader({ + params: routeParams, + request, +}: Route.ClientLoaderArgs) { + const params = getEditorParams(routeParams); + const url = new URL(request.url); + const returnUrl = url.pathname + url.search + url.hash; + + const tools = getSessionTools(); + const sessionId = tools?.getConfig().sessionId; + if (!sessionId) { + return redirectToLogin(returnUrl); + } + + const config = () => ({ + apiHost: tools?.getConfig().apiHost, + sessionId, + }); + const dapper = new DapperTs(config); + + try { + const permissions = await dapper.getPackagePermissions( + params.communityId, + params.namespaceId, + params.packageId + ); + if (!permissions) { + return redirectToLogin(returnUrl); + } + if (!permissions.permissions.can_manage_wiki) { + throw new Response("Unauthorized", { status: 403 }); + } + } catch (error) { + if (isApiError(error) && error.response.status === 404) { + throw new Response("Package not found", { status: 404 }); + } + throw error; + } + + // Looking up the listing by version also 404s a missing or inactive version. + const listing = await getPrivateListing(dapper, params); + const isLatest = listing.latest_version_number === params.packageVersion; + + const markdownRequest: MarkdownRequest = { + config, + params: { + namespace: params.namespaceId, + package: params.packageId, + version: params.packageVersion, + }, + queryParams: {}, + }; + const [readme, changelog] = await Promise.all([ + loadDocument(markdownRequest, "readme"), + isLatest ? loadDocument(markdownRequest, "changelog") : null, + ]); + + return { ...params, isLatest, readme, changelog, seo: editorSeo(params) }; +} + +clientLoader.hydrate = true; + +export function HydrateFallback() { + return ( + + + + ); +} + +type EditorData = Exclude>, Response>; + +export default function ReadmeEdit() { + const data = useLoaderData(); + // Reset drafts when the package version changes, but preserve them when + // the same route revalidates. + const key = [ + data.communityId, + data.namespaceId, + data.packageId, + data.packageVersion, + ].join("/"); + return ; +} + +function usePreviousReadmeOverride( + requestConfig: OutletContextShape["requestConfig"], + namespaceId: string, + packageId: string, + packageVersion: string, + readmeIsEdited: boolean +) { + const [previousOverride, setPreviousOverride] = + useState(null); + + // Keyed on the live edit state, not the loader snapshot, so discarding a + // site edit in-session brings the offer back without a reload. + useEffect(() => { + if (readmeIsEdited) return; + let cancelled = false; + findPreviousReadmeOverride( + requestConfig, + namespaceId, + packageId, + packageVersion + ) + .then((result) => { + if (!cancelled) setPreviousOverride(result); + }) + .catch(() => { + // Best effort + }); + return () => { + cancelled = true; + }; + }, [namespaceId, packageId, packageVersion, readmeIsEdited]); + + return [previousOverride, setPreviousOverride] as const; +} + +function useUnsavedChangesPrompt(hasUnsavedChanges: boolean) { + const blocker = useBlocker(hasUnsavedChanges); + useEffect(() => { + if (blocker.state !== "blocked") return; + if ( + window.confirm("Leave without saving your README or CHANGELOG changes?") + ) { + blocker.proceed(); + } else { + blocker.reset(); + } + }, [blocker]); + useBeforeUnload( + useCallback( + (event) => { + if (!hasUnsavedChanges) return; + event.preventDefault(); + event.returnValue = ""; + }, + [hasUnsavedChanges] + ) + ); +} + +function useMarkdownPreview( + requestConfig: OutletContextShape["requestConfig"], + text: string, + selectedDoc: DocumentKey +) { + const [previewHtml, setPreviewHtml] = useState(undefined); + const [preview, setPreview] = useState({ + status: "waiting", + message: "Waiting for input", + }); + const [debouncedText, debouncedTextControls] = useDebounce(text, 1000); + + // Render the newly selected document right away instead of showing the + // previous one as rendered until the debounce settles. + useEffect(() => { + debouncedTextControls.flush(); + }, [selectedDoc, debouncedTextControls]); + + useEffect(() => { + let cancelled = false; + if (debouncedText.trim() === "") { + setPreviewHtml(undefined); + setPreview({ status: "waiting", message: "Waiting for input" }); + return; + } + setPreview({ status: "processing" }); + toolsMarkdownPreview({ + config: requestConfig, + data: { + markdown: Array.from(debouncedText) + .slice(0, MAX_MARKDOWN_SIZE) + .join(""), + }, + params: {}, + queryParams: {}, + }) + .then((response) => { + if (cancelled) return; + if (isRecord(response) && typeof response.html === "string") { + setPreviewHtml(response.html); + setPreview({ status: "success", message: "Rendered" }); + } + }) + .catch((error) => { + if (cancelled) return; + setPreview({ + status: "failure", + message: + isApiError(error) && isRecord(error.responseJson) + ? String(error.responseJson.detail ?? "Render failed") + : "Render failed", + }); + }); + return () => { + cancelled = true; + }; + }, [debouncedText]); + + return { previewHtml, preview }; +} + +function ReadmeEditor({ data }: { data: EditorData }) { + const outletContext = useOutletContext() as OutletContextShape; + const toast = useToast(); + + const { communityId, namespaceId, packageId, packageVersion, isLatest } = + data; + + const markdownRequest: MarkdownRequest = { + config: outletContext.requestConfig, + params: { + namespace: namespaceId, + package: packageId, + version: packageVersion, + }, + queryParams: {}, + }; + + const [searchParams] = useSearchParams(); + // The latest version can be opened from either listing. Keep an explicit + // return target so refreshes and opening the editor in a new tab work too. + const returnToVersion = searchParams.get("from") === "version" || !isLatest; + const [selectedDoc, setSelectedDoc] = useState(() => + isLatest && searchParams.get("document") === "changelog" + ? "changelog" + : "readme" + ); + const [documents, setDocuments] = useState< + Record + >({ + readme: createDocumentState(data.readme), + changelog: isLatest ? createDocumentState(data.changelog) : null, + }); + const [saving, setSaving] = useState(false); + const [discardConfirming, setDiscardConfirming] = useState(false); + const fileInputRef = useRef(null); + // File.text() cannot be aborted. Ignore its result after another read, + // an edit, a save, a discard, or unmount supersedes it. + const fileReadIds = useRef({ readme: 0, changelog: 0 }); + + useEffect(() => { + const reads = fileReadIds.current; + return () => { + reads.readme++; + reads.changelog++; + }; + }, []); + + const [previousOverride, setPreviousOverride] = usePreviousReadmeOverride( + outletContext.requestConfig, + namespaceId, + packageId, + packageVersion, + documents.readme?.is_edited ?? false + ); + + const currentDoc = documents[selectedDoc]; + const documentLabel = DOCUMENT_LABELS[selectedDoc]; + const currentText = currentDoc?.markdown ?? ""; + const needsReload = currentDoc?.needsReload ?? false; + const isDirty = currentDoc !== null && currentText !== currentDoc.baseline; + const hasUnsavedChanges = Object.values(documents).some( + (doc) => doc !== null && !doc.needsReload && doc.markdown !== doc.baseline + ); + useUnsavedChangesPrompt(hasUnsavedChanges); + + const { previewHtml, preview } = useMarkdownPreview( + outletContext.requestConfig, + currentText, + selectedDoc + ); + + const characterCount = Array.from(currentText).length; + const overLimit = characterCount > MAX_MARKDOWN_SIZE; + const barState: PreviewState = overLimit + ? { + status: "failure", + message: `Too long: ${characterCount.toLocaleString()} / ${MAX_MARKDOWN_SIZE.toLocaleString()} characters`, + } + : preview; + + const canSave = isDirty && !overLimit && !saving && !needsReload; + const offeredOverride = + selectedDoc === "readme" && !currentDoc?.is_edited && !needsReload + ? previousOverride + : null; + + function updateDocument(doc: DocumentKey, state: Partial) { + fileReadIds.current[doc]++; + setDocuments((docs) => ({ + ...docs, + [doc]: { ...(docs[doc] ?? createDocumentState()), ...state }, + })); + } + + function setDocumentText(doc: DocumentKey, markdown: string) { + updateDocument(doc, { markdown: normalizeLineEndings(markdown) }); + setDiscardConfirming(false); + } + + function selectDocument(doc: DocumentKey) { + setSelectedDoc(doc); + setDiscardConfirming(false); + } + + async function save() { + if (!canSave) return; + fileReadIds.current[selectedDoc]++; + setSaving(true); + try { + const response = await documentApi[selectedDoc].save( + markdownRequest, + currentText + ); + updateDocument( + selectedDoc, + createDocumentState({ + ...response[selectedDoc], + markdown: currentText, + }) + ); + toast.addToast({ + csVariant: "success", + children: "Saved. Changes might take several minutes to show publicly!", + duration: 8000, + }); + } catch (error) { + toast.addToast({ + csVariant: "danger", + children: `Save failed: ${errorMessage(error)}`, + duration: 8000, + }); + } finally { + setSaving(false); + } + } + + async function discard() { + if (saving) return; + if (!discardConfirming) { + setDiscardConfirming(true); + return; + } + setDiscardConfirming(false); + fileReadIds.current[selectedDoc]++; + setSaving(true); + try { + await documentApi[selectedDoc].save(markdownRequest, null); + updateDocument(selectedDoc, { + markdown: currentText, + is_edited: false, + edited_at: null, + needsReload: true, + }); + await reloadPackagedContent(); + } catch (error) { + toast.addToast({ + csVariant: "danger", + children: `Discard failed: ${errorMessage(error)}`, + duration: 8000, + }); + } finally { + setSaving(false); + } + } + + async function reloadPackagedContent() { + setSaving(true); + try { + const raw = await documentApi[selectedDoc].fetchRaw({ + ...markdownRequest, + data: {}, + }); + // A successful discard can still be followed by a cached override. + // Keep the editor locked until the packaged content is available. + if (raw.is_edited) { + throw new Error("The cached README or changelog has not updated yet."); + } + updateDocument( + selectedDoc, + createDocumentState({ markdown: raw.markdown }) + ); + toast.addToast({ + csVariant: "success", + children: "Site edit discarded, the packaged content is restored.", + duration: 6000, + }); + } catch { + toast.addToast({ + csVariant: "danger", + children: `The original ${documentLabel} was restored, but the editor couldn’t reload it.`, + duration: 8000, + }); + } finally { + setSaving(false); + } + } + + function loadPreviousOverride() { + if (!previousOverride) return; + setDocumentText("readme", previousOverride.markdown); + setPreviousOverride(null); + } + + function loadFromFile(file: File | undefined) { + if (!file) return; + // Text-length validation happens on save. This only guards against + // reading an obviously wrong selection into memory. + if (file.size > MAX_MARKDOWN_SIZE * 4) { + toast.addToast({ + csVariant: "danger", + children: "File is too large to be a package markdown file.", + duration: 6000, + }); + return; + } + const readId = ++fileReadIds.current[selectedDoc]; + file + .text() + .then((text) => { + if (fileReadIds.current[selectedDoc] === readId) { + setDocumentText(selectedDoc, text); + } + }) + .catch(() => { + if (fileReadIds.current[selectedDoc] !== readId) return; + toast.addToast({ + csVariant: "danger", + children: "The file could not be read. Please try again.", + duration: 6000, + }); + }); + } + + return ( + + {needsReload ? ( + + The original {documentLabel} was restored, but the editor couldn’t + reload it. + + Reload {documentLabel} + + + ) : null} + +
    + + + + + + Go back + + {(["readme", "changelog"] as const).map((doc) => ( + selectDocument(doc)} + /> + ))} + + {namespaceId}-{packageId} {packageVersion} + + +
    + + setDocumentText(selectedDoc, e.currentTarget.value) + } + value={currentText} + disabled={saving || needsReload} + rootClasses="readme-edit__editor" + /> +
    + +
    +
    + + + {offeredOverride ? ( + + Load site edit from {offeredOverride.versionNumber} + + ) : null} + { + loadFromFile(e.target.files?.[0]); + e.target.value = ""; + }} + /> + fileInputRef.current?.click()} + disabled={saving || needsReload} + > + Load from file + + {currentDoc?.is_edited ? ( + + {discardConfirming + ? "Confirm: restore packaged content" + : "Discard site edit"} + + ) : null} + + {saving ? "Saving…" : "Save"} + + + +
    +
    + ); +} + +function DocumentTab(props: { + doc: DocumentKey; + state: DocumentState | null; + selected: boolean; + unavailable: boolean; + onSelect: () => void; +}) { + return ( + + + + ); +} diff --git a/apps/cyberstorm-remix/app/p/readmeEdit/__tests__/ReadmeEdit.test.ts b/apps/cyberstorm-remix/app/p/readmeEdit/__tests__/ReadmeEdit.test.ts new file mode 100644 index 000000000..ae5f5a704 --- /dev/null +++ b/apps/cyberstorm-remix/app/p/readmeEdit/__tests__/ReadmeEdit.test.ts @@ -0,0 +1,536 @@ +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { Link, Outlet, RouterProvider, createMemoryRouter } from "react-router"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import ReadmeEdit, { clientLoader } from "../ReadmeEdit"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const mocks = vi.hoisted(() => ({ + download: vi.fn(), + raw: vi.fn(), + post: vi.fn(), + preview: vi.fn(), + toast: vi.fn(), + permissions: vi.fn(), + listing: vi.fn(), +})); + +vi.mock("~/root", () => ({})); +vi.mock("cyberstorm/security/publicEnvVariables", () => ({ + getSessionTools: () => ({ + getConfig: () => ({ + apiHost: "https://api.example.invalid", + sessionId: "session", + }), + }), +})); +vi.mock("cyberstorm/utils/ThunderstoreAuth", () => ({ + redirectToLogin: vi.fn(), +})); +vi.mock("cyberstorm/utils/gatedSsr", () => ({ + hasSessionCookie: () => true, +})); +vi.mock("cyberstorm/utils/ssrLoader", () => ({ noStoreHeaders: vi.fn() })); +vi.mock("@thunderstore/dapper-ts", () => ({ + DapperTs: class { + getPackageListingDetails = mocks.listing; + getPackagePermissions = mocks.permissions; + }, +})); +vi.mock("@thunderstore/thunderstore-api", () => ({ + fetchPackageVersionReadmeOverrideRaw: mocks.download, + fetchPackageVersionChangelogOverrideRaw: mocks.download, + fetchPackageVersionReadmeMarkdownRaw: (props: object) => + mocks.raw({ ...props, document: "readme" }), + fetchPackageVersionChangelogMarkdownRaw: (props: object) => + mocks.raw({ ...props, document: "changelog" }), + extractApiErrorMessage: (error: Error) => error.message, + postPackageVersionReadme: mocks.post, + postPackageVersionChangelog: mocks.post, + toolsMarkdownPreview: mocks.preview, + isApiError: (error: unknown) => + !!error && typeof error === "object" && "response" in error, +})); +vi.mock("../overrideMigration", () => ({ + findPreviousReadmeOverride: () => Promise.resolve(null), +})); +vi.mock("~/commonComponents/Markdown/Markdown", () => ({ + Markdown: () => null, +})); +vi.mock("~/commonComponents/Page/Page", () => ({ + Page: ({ children }: { children: React.ReactNode }) => children, +})); +vi.mock("@thunderstore/cyberstorm", () => ({ + CodeInput: ({ + value, + onChange, + disabled, + }: { + value: string; + onChange: React.FormEventHandler; + disabled: boolean; + }) => + React.createElement("textarea", { + value, + onInput: onChange, + disabled, + readOnly: true, + }), + NewAlert: ({ children }: { children: React.ReactNode }) => + React.createElement("div", { role: "alert" }, children), + NewButton: ({ + children, + onClick, + disabled, + primitiveType, + }: { + children: React.ReactNode; + onClick?: () => void; + disabled?: boolean; + primitiveType?: string; + }) => + primitiveType === "cyberstormLink" + ? React.createElement(Link, { to: "/away" }, children) + : React.createElement("button", { onClick, disabled }, children), + NewIcon: () => null, + SkeletonBox: () => null, + NewValidationBar: ({ + children, + message, + }: { + children: React.ReactNode; + message?: string; + }) => React.createElement("div", null, message, children), + Tabs: ({ children }: { children: React.ReactNode }) => children, + TooltipWrapper: ({ children }: { children: React.ReactNode }) => children, + classnames: (...args: unknown[]) => args.filter(Boolean).join(" "), + isRecord: (value: unknown) => !!value && typeof value === "object", + useToast: () => ({ addToast: mocks.toast }), +})); + +const params = { + communityId: "game", + namespaceId: "Team", + packageId: "Mod", + packageVersion: "1.0.0", +}; +const documentState = (markdown: string | null, is_edited = false) => ({ + markdown, + is_edited, + edited_at: null, +}); +let cleanup: (() => void) | undefined; + +async function renderEditor( + readme = "Packaged README", + edited = false, + isLatest = true +) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const router = createMemoryRouter( + [ + { + path: "/", + element: React.createElement(Outlet, { + context: { + requestConfig: () => ({ apiHost: "https://api.example.invalid" }), + }, + }), + children: [ + { + path: "edit/:version?", + loader: ({ params: routeParams }) => ({ + ...params, + packageVersion: routeParams.version ?? params.packageVersion, + isLatest, + readme: documentState( + routeParams.version ? `README ${routeParams.version}` : readme, + edited + ), + changelog: documentState("Packaged changelog"), + }), + element: React.createElement(ReadmeEdit), + }, + { path: "away", element: React.createElement("div", null, "Away") }, + ], + }, + ], + { initialEntries: ["/edit"] } + ); + await act(async () => { + root.render( + React.createElement( + React.StrictMode, + null, + React.createElement(RouterProvider, { router }) + ) + ); + }); + cleanup = () => { + act(() => root.unmount()); + router.dispose(); + container.remove(); + }; + return { container, router }; +} + +async function click(container: HTMLElement, text: string) { + const button = Array.from(container.querySelectorAll("button")).find( + (item) => item.textContent === text + ); + expect(button, text).toBeDefined(); + await act(async () => { + button?.click(); + }); +} + +async function edit(container: HTMLElement, text: string) { + const textarea = container.querySelector("textarea") as HTMLTextAreaElement; + await act(async () => { + textarea.value = text; + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +async function selectFile(container: HTMLElement, file: File) { + const input = container.querySelector( + 'input[type="file"]' + ) as HTMLInputElement; + const files = new DataTransfer(); + files.items.add(file); + await act(async () => { + input.files = files.files; + input.dispatchEvent(new Event("change", { bubbles: true })); + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.download.mockReset().mockResolvedValue(null); + mocks.raw.mockReset().mockResolvedValue(documentState("Packaged README")); + mocks.post.mockReset(); + mocks.post.mockResolvedValue({ + readme: documentState("", true), + changelog: documentState("", true), + }); + mocks.preview.mockResolvedValue({ html: "

    Preview

    " }); + mocks.permissions.mockResolvedValue({ + permissions: { can_manage_wiki: true }, + }); + mocks.listing.mockResolvedValue({ latest_version_number: "1.0.0" }); +}); + +afterEach(() => { + cleanup?.(); + cleanup = undefined; + vi.restoreAllMocks(); +}); + +const request = new Request("https://example.invalid/edit?document=changelog"); + +describe("README editor loading", () => { + it("retries a hidden version's listing with the session", async () => { + mocks.listing.mockImplementation((...args: unknown[]) => + args[4] === true + ? Promise.resolve({ latest_version_number: "1.0.0" }) + : Promise.reject({ response: { status: 404 } }) + ); + const result = await clientLoader({ params, request } as never); + expect(result).toMatchObject({ readme: documentState("Packaged README") }); + expect(mocks.listing).toHaveBeenNthCalledWith( + 1, + "game", + "Team", + "Mod", + "1.0.0" + ); + expect(mocks.listing).toHaveBeenNthCalledWith( + 2, + "game", + "Team", + "Mod", + "1.0.0", + true + ); + }); + + it("does not hide an override download failure behind the cached endpoint", async () => { + mocks.download.mockRejectedValue( + new Error("Override download failed: 503") + ); + await expect(clientLoader({ params, request } as never)).rejects.toThrow( + "503" + ); + expect(mocks.raw).not.toHaveBeenCalled(); + }); + + it("locks a document whose cached response still shows a discarded edit", async () => { + mocks.raw.mockResolvedValue(documentState("Discarded edit", true)); + const result = await clientLoader({ params, request } as never); + expect(result).toMatchObject({ + readme: { markdown: "", is_edited: false, needsReload: true }, + }); + }); + + it("loads the changelog on the latest version", async () => { + mocks.raw.mockImplementation(({ document }) => + Promise.resolve( + documentState(document === "changelog" ? null : "Packaged README") + ) + ); + const result = await clientLoader({ params, request } as never); + expect(result).toMatchObject({ + readme: documentState("Packaged README"), + changelog: documentState(null), + }); + }); +}); + +describe("README editor", () => { + it("keeps an unavailable changelog tab focusable without switching documents", async () => { + const { container } = await renderEditor("Historical README", false, false); + const tab = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "CHANGELOG" + )!; + expect(tab.getAttribute("aria-disabled")).toBe("true"); + tab.focus(); + expect(document.activeElement).toBe(tab); + await act(async () => tab.click()); + expect(container.querySelector("textarea")?.value).toBe( + "Historical README" + ); + expect(tab.getAttribute("aria-current")).toBe("false"); + }); + + it("loads fresh state when navigating to another package version", async () => { + const { container, router } = await renderEditor(); + await act(async () => { + await router.navigate("/edit/2.0.0"); + }); + expect(container.textContent).toContain("2.0.0"); + expect(container.querySelector("textarea")?.value).toBe("README 2.0.0"); + }); + + it("preserves a draft when the current route revalidates", async () => { + const { container, router } = await renderEditor(); + await edit(container, "Unsaved README"); + await act(async () => { + await router.revalidate(); + }); + expect(container.querySelector("textarea")?.value).toBe("Unsaved README"); + }); + + it("does not replace newer typing with a delayed file read", async () => { + const { container } = await renderEditor(); + let finishRead!: (text: string) => void; + const file = new File(["Imported README"], "README.md"); + vi.spyOn(file, "text").mockReturnValue( + new Promise((resolve) => { + finishRead = resolve; + }) + ); + await selectFile(container, file); + await edit(container, "Newer typing"); + await act(async () => { + finishRead("Imported README"); + }); + expect(container.querySelector("textarea")?.value).toBe("Newer typing"); + }); + + it("applies a delayed file read to its original document after switching tabs", async () => { + const { container } = await renderEditor(); + let finishRead!: (text: string) => void; + const file = new File(["Imported README"], "README.md"); + vi.spyOn(file, "text").mockReturnValue( + new Promise((resolve) => { + finishRead = resolve; + }) + ); + await selectFile(container, file); + await click(container, "CHANGELOG"); + await act(async () => { + finishRead("Imported README"); + }); + expect(container.querySelector("textarea")?.value).toBe( + "Packaged changelog" + ); + await click(container, "README"); + expect(container.querySelector("textarea")?.value).toBe("Imported README"); + }); + + it("ignores an older file read when a newer selection finishes first", async () => { + const { container } = await renderEditor(); + let finishRead!: (text: string) => void; + const first = new File(["First"], "first.md"); + vi.spyOn(first, "text").mockReturnValue( + new Promise((resolve) => { + finishRead = resolve; + }) + ); + await selectFile(container, first); + const second = new File(["Second"], "second.md"); + vi.spyOn(second, "text").mockResolvedValue("Second"); + await selectFile(container, second); + expect(container.querySelector("textarea")?.value).toBe("Second"); + await act(async () => { + finishRead("First"); + }); + expect(container.querySelector("textarea")?.value).toBe("Second"); + }); + + it("ignores a file-read failure after navigating away", async () => { + const { container, router } = await renderEditor(); + let failRead!: (reason: Error) => void; + const file = new File(["Imported README"], "README.md"); + vi.spyOn(file, "text").mockReturnValue( + new Promise((_, reject) => { + failRead = reject; + }) + ); + await selectFile(container, file); + await act(async () => { + await router.navigate("/away"); + }); + await act(async () => { + failRead(new Error("Read failed")); + }); + expect(mocks.toast).not.toHaveBeenCalled(); + }); + + it("applies a delayed save to its original document after switching tabs", async () => { + const { container } = await renderEditor(); + let finishSave!: (value: unknown) => void; + mocks.post.mockReturnValueOnce( + new Promise((resolve) => { + finishSave = resolve; + }) + ); + await edit(container, "Saved README"); + await click(container, "Save"); + await click(container, "CHANGELOG"); + await act(async () => { + finishSave({ + readme: documentState("", true), + changelog: documentState("", false), + }); + }); + expect(container.querySelector("textarea")?.value).toBe( + "Packaged changelog" + ); + await click(container, "README"); + expect(container.querySelector("textarea")?.value).toBe("Saved README"); + const save = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Save" + ); + expect(save?.disabled).toBe(true); + }); + + it("retains text and offers reload after discard succeeds but fetching content fails", async () => { + const { container } = await renderEditor("Edited README", true); + mocks.raw.mockRejectedValueOnce(new Error("offline")); + await click(container, "Discard site edit"); + await click(container, "Confirm: restore packaged content"); + expect(container.querySelector("textarea")?.value).toBe("Edited README"); + expect(container.querySelector("textarea")?.disabled).toBe(true); + expect(container.textContent).toContain("Reload README"); + expect(mocks.toast).not.toHaveBeenCalledWith( + expect.objectContaining({ csVariant: "success" }) + ); + await click(container, "Reload README"); + expect(container.querySelector("textarea")?.value).toBe("Packaged README"); + expect(container.querySelector("textarea")?.disabled).toBe(false); + expect(mocks.post).toHaveBeenCalledTimes(1); + }); + + it("does not label a cached override as restored packaged content", async () => { + const { container } = await renderEditor("Edited README", true); + mocks.raw.mockResolvedValue(documentState("Cached override", true)); + await click(container, "Discard site edit"); + await click(container, "Confirm: restore packaged content"); + expect(container.querySelector("textarea")?.value).toBe("Edited README"); + expect(container.textContent).toContain("Reload README"); + }); + + it("accepts a missing packaged changelog after discarding its override", async () => { + const { container } = await renderEditor(); + await click(container, "CHANGELOG"); + await edit(container, "Edited changelog"); + await click(container, "Save"); + mocks.raw.mockResolvedValue(documentState(null)); + await click(container, "Discard site edit"); + await click(container, "Confirm: restore packaged content"); + expect(container.querySelector("textarea")?.value).toBe(""); + expect(container.querySelector("textarea")?.disabled).toBe(false); + expect(container.textContent).not.toContain("Reload CHANGELOG"); + }); + + it("treats a CRLF document as clean after reverting an edit", async () => { + const { container } = await renderEditor("Line one\r\nLine two"); + await edit(container, "Line one\nLine two!"); + await edit(container, "Line one\nLine two"); + const save = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Save" + ); + expect(save?.disabled).toBe(true); + }); + + it("guards navigation and unloading when an inactive document is dirty", async () => { + const { container, router } = await renderEditor(); + await edit(container, "Unsaved README"); + await click(container, "CHANGELOG"); + const unload = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(unload); + expect(unload.defaultPrevented).toBe(true); + const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); + await act(async () => { + await router.navigate("/away"); + }); + expect(router.state.location.pathname).toBe("/edit"); + expect(confirm).toHaveBeenCalledTimes(1); + confirm.mockReturnValue(true); + await act(async () => { + await router.navigate("/away"); + }); + expect(router.state.location.pathname).toBe("/away"); + }); + + it("does not block navigation after saving", async () => { + const { container, router } = await renderEditor(); + await edit(container, "Saved README"); + await click(container, "Save"); + expect(container.querySelector("textarea")?.value).toBe("Saved README"); + const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); + await act(async () => { + await router.navigate("/away"); + }); + expect(router.state.location.pathname).toBe("/away"); + expect(confirm).not.toHaveBeenCalled(); + }); + + it("counts Unicode code points rather than UTF-16 units", async () => { + const { container } = await renderEditor(); + const text = "😀".repeat(100000); + await edit(container, text); + await click(container, "Save"); + expect(mocks.post).toHaveBeenCalledWith( + expect.objectContaining({ data: { readme: text } }) + ); + await edit(container, text + "x"); + const save = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Save" + ); + expect(save?.disabled).toBe(true); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1100)); + }); + expect(mocks.preview).toHaveBeenLastCalledWith( + expect.objectContaining({ data: { markdown: text } }) + ); + }); +}); diff --git a/apps/cyberstorm-remix/app/p/readmeEdit/overrideMigration.ts b/apps/cyberstorm-remix/app/p/readmeEdit/overrideMigration.ts new file mode 100644 index 000000000..17a05f56a --- /dev/null +++ b/apps/cyberstorm-remix/app/p/readmeEdit/overrideMigration.ts @@ -0,0 +1,66 @@ +import { + type RequestConfig, + fetchPackageVersionReadmeOverrideRaw, + fetchPackageVersions, +} from "@thunderstore/thunderstore-api"; + +export interface PreviousOverride { + versionNumber: string; + markdown: string; +} + +/** + * Finds the most recent version, excluding excludeVersion, that carries a + * README override, and returns its raw markdown. Returns null when no such + * version exists. + */ +export async function findPreviousReadmeOverride( + config: () => RequestConfig, + namespace: string, + packageName: string, + excludeVersion?: string +): Promise { + const versions = await fetchPackageVersions({ + config, + params: { namespace_id: namespace, package_name: packageName }, + data: {}, + queryParams: {}, + }); + + const candidates = versions + .filter((v) => v.is_readme_edited && v.version_number !== excludeVersion) + .sort( + (a, b) => + new Date(b.datetime_created).getTime() - + new Date(a.datetime_created).getTime() + ); + + for (const candidate of candidates) { + const markdown = await fetchPackageVersionReadmeOverrideRaw({ + config, + params: { + namespace, + package: packageName, + version: candidate.version_number, + }, + data: {}, + queryParams: {}, + }); + if (markdown !== null) { + return { versionNumber: candidate.version_number, markdown }; + } + } + + return null; +} + +/** Hands the override text to the browser as a README.md download. */ +export function downloadOverrideText(markdown: string): void { + const blob = new Blob([markdown], { type: "text/markdown" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = "README.md"; + anchor.click(); + URL.revokeObjectURL(url); +} diff --git a/apps/cyberstorm-remix/app/root.tsx b/apps/cyberstorm-remix/app/root.tsx index 2c989d068..6bf3501fd 100644 --- a/apps/cyberstorm-remix/app/root.tsx +++ b/apps/cyberstorm-remix/app/root.tsx @@ -272,8 +272,12 @@ export function Layout({ children }: { children: React.ReactNode }) { // Routes where the ad surface is allowed at all. The NitroPay script (and its // consent banner) loads on these; account / login / upload / tools routes get // neither. (/auth is backend-proxied, so the app never renders it.) + const isReadmeEditPage = matches.some( + (m) => m.id === "p/readmeEdit/ReadmeEdit" + ); const adsAllowedOnRoute = !adsDisabled && + !isReadmeEditPage && !["/teams", "/settings", "/package/create", "/tools", "/login"].some( (prefix) => location.pathname.startsWith(prefix) ); diff --git a/apps/cyberstorm-remix/app/routes.ts b/apps/cyberstorm-remix/app/routes.ts index 6cc439f4e..167089b31 100644 --- a/apps/cyberstorm-remix/app/routes.ts +++ b/apps/cyberstorm-remix/app/routes.ts @@ -73,6 +73,10 @@ export default [ ] ), */ + route( + "/c/:communityId/p/:namespaceId/:packageId/v/:packageVersion/readme/edit", + "p/readmeEdit/ReadmeEdit.tsx" + ), route( "/c/:communityId/p/:namespaceId/:packageId/dependants", "p/dependants/Dependants.tsx" From a818aef752510c9422d6fa2c3012379fc9969551 Mon Sep 17 00:00:00 2001 From: Ethan Green Date: Wed, 23 Sep 2026 16:52:23 +0300 Subject: [PATCH 7/8] Show site-edit notes and edit buttons on package pages --- .../EditMarkdownButton/EditMarkdownButton.tsx | 45 ++++++++++++ .../MarkdownEditedNote/MarkdownEditedNote.css | 12 +++ .../MarkdownEditedNote/MarkdownEditedNote.tsx | 26 +++++++ .../cyberstorm-remix/app/p/packageListing.css | 31 ++++++++ .../cyberstorm-remix/app/p/packageListing.tsx | 23 ++++++ .../app/p/packageListingVersion.tsx | 35 ++++++++- .../app/p/tabs/Changelog/Changelog.tsx | 2 + .../p/tabs/Readme/PackageVersionReadme.tsx | 22 +++--- .../app/p/tabs/Readme/Readme.tsx | 18 +++-- .../tabs/__tests__/markdownDisclosure.test.ts | 73 +++++++++++++++++++ 10 files changed, 266 insertions(+), 21 deletions(-) create mode 100644 apps/cyberstorm-remix/app/p/components/EditMarkdownButton/EditMarkdownButton.tsx create mode 100644 apps/cyberstorm-remix/app/p/components/MarkdownEditedNote/MarkdownEditedNote.css create mode 100644 apps/cyberstorm-remix/app/p/components/MarkdownEditedNote/MarkdownEditedNote.tsx create mode 100644 apps/cyberstorm-remix/app/p/tabs/__tests__/markdownDisclosure.test.ts diff --git a/apps/cyberstorm-remix/app/p/components/EditMarkdownButton/EditMarkdownButton.tsx b/apps/cyberstorm-remix/app/p/components/EditMarkdownButton/EditMarkdownButton.tsx new file mode 100644 index 000000000..13eb5de02 --- /dev/null +++ b/apps/cyberstorm-remix/app/p/components/EditMarkdownButton/EditMarkdownButton.tsx @@ -0,0 +1,45 @@ +import { faEdit } from "@fortawesome/pro-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Suspense } from "react"; +import { Await } from "react-router"; + +import { NewButton, NewIcon } from "@thunderstore/cyberstorm"; + +import { type getUserPermissions } from "../../listingUtils"; + +export function EditMarkdownButton(props: { + permissions: ReturnType | undefined; + community: string; + namespace: string; + package: string; + version: string; + queryParams?: string; +}) { + return ( + + }> + {(resolvedPermissions) => + resolvedPermissions?.permissions.can_manage_wiki ? ( + + + + + Edit + + ) : null + } + + + ); +} diff --git a/apps/cyberstorm-remix/app/p/components/MarkdownEditedNote/MarkdownEditedNote.css b/apps/cyberstorm-remix/app/p/components/MarkdownEditedNote/MarkdownEditedNote.css new file mode 100644 index 000000000..e7a2a0491 --- /dev/null +++ b/apps/cyberstorm-remix/app/p/components/MarkdownEditedNote/MarkdownEditedNote.css @@ -0,0 +1,12 @@ +@layer nimbus-layout { + .markdown-edited-note { + width: fit-content; + margin-bottom: var(--space-8); + color: var(--color-text-tertiary); + font-size: var(--font-size-body-sm); + } + + .markdown-edited-note__label { + cursor: help; + } +} diff --git a/apps/cyberstorm-remix/app/p/components/MarkdownEditedNote/MarkdownEditedNote.tsx b/apps/cyberstorm-remix/app/p/components/MarkdownEditedNote/MarkdownEditedNote.tsx new file mode 100644 index 000000000..e69b2e094 --- /dev/null +++ b/apps/cyberstorm-remix/app/p/components/MarkdownEditedNote/MarkdownEditedNote.tsx @@ -0,0 +1,26 @@ +import { LocalDateTime } from "@thunderstore/cyberstorm"; + +import "./MarkdownEditedNote.css"; + +export function MarkdownEditedNote(props: { + doc: { is_edited?: boolean; edited_at?: string | null } | null; + label: "README" | "CHANGELOG"; +}) { + if (!props.doc?.is_edited) return null; + return ( +
    + + Edited + + {props.doc.edited_at ? ( + <> + {" · "} + + + ) : null} +
    + ); +} diff --git a/apps/cyberstorm-remix/app/p/packageListing.css b/apps/cyberstorm-remix/app/p/packageListing.css index 5f450a3bf..f5cc62f6d 100644 --- a/apps/cyberstorm-remix/app/p/packageListing.css +++ b/apps/cyberstorm-remix/app/p/packageListing.css @@ -47,6 +47,28 @@ height: 47px; } + .package-listing__tabs-slot { + display: flex; + flex-shrink: 0; + align-items: center; + + /* Match the tab items' border-bottom so the button centers on tab text. */ + margin-bottom: 3px; + margin-left: auto; + padding-left: var(--space-16); + } + + /* Narrow-screen stand-in for the tab-strip slot (see packageListing.tsx). */ + .package-listing__edit-button-row { + display: none; + } + + /* The edit button renders nothing for viewers who cannot edit. */ + .package-listing__tabs-slot:empty, + .package-listing__edit-button-row:empty { + display: none; + } + .package-listing__content { display: flex; flex: 1 1 auto; @@ -206,6 +228,15 @@ display: flex; } + .package-listing__tabs-slot { + display: none; + } + + .package-listing__edit-button-row { + display: flex; + align-items: center; + } + .package-listing__main { grid-template-columns: minmax(0, 1fr); width: 100%; diff --git a/apps/cyberstorm-remix/app/p/packageListing.tsx b/apps/cyberstorm-remix/app/p/packageListing.tsx index 0abf6bc2c..c60c5abca 100644 --- a/apps/cyberstorm-remix/app/p/packageListing.tsx +++ b/apps/cyberstorm-remix/app/p/packageListing.tsx @@ -51,6 +51,7 @@ import { PackageLikeAction } from "@thunderstore/cyberstorm-forms"; import { DapperTs, type DapperTsInterface } from "@thunderstore/dapper-ts"; import type { Route } from "./+types/packageListing"; +import { EditMarkdownButton } from "./components/EditMarkdownButton/EditMarkdownButton"; import { PackageActions } from "./components/PackageListing/PackageActions"; import { PackageCategoriesAndTags } from "./components/PackageListing/PackageCategoriesAndTags"; import { PackageDependencyString } from "./components/PackageListing/PackageDependencyString"; @@ -381,6 +382,22 @@ export default function PackageListing() { return ; } + // Duplicated below the tab strip for narrow screens, where the strip scrolls + // the slot out of view. + const editButton = + currentTab === "details" || currentTab === "changelog" ? ( + + ) : null; + // TODO: some variables are available in props (communityId, namespaceId, packageId) return ( <> @@ -598,7 +615,13 @@ export default function PackageListing() { > Analysis + {editButton ? ( +
    {editButton}
    + ) : null} + {editButton ? ( +
    {editButton}
    + ) : null}
    (); + const { community, listing, permissions, packageVersion, team } = + useLoaderData(); const location = useLocation(); const outletContext = useOutletContext() as OutletContextShape; @@ -211,6 +222,18 @@ export default function PackageListingVersion() { return
    Loading listing...
    ; } + const editButton = + currentTab === "details" ? ( + + ) : null; + return ( <> {/* Community hero banner — its own grid row (see @@ -371,7 +394,13 @@ export default function PackageListingVersion() { > Versions + {editButton ? ( +
    {editButton}
    + ) : null} + {editButton ? ( +
    {editButton}
    + ) : null}
    diff --git a/apps/cyberstorm-remix/app/p/tabs/Changelog/Changelog.tsx b/apps/cyberstorm-remix/app/p/tabs/Changelog/Changelog.tsx index 053cff052..cd26ae023 100644 --- a/apps/cyberstorm-remix/app/p/tabs/Changelog/Changelog.tsx +++ b/apps/cyberstorm-remix/app/p/tabs/Changelog/Changelog.tsx @@ -1,4 +1,5 @@ import { FetchErrorState } from "app/commonComponents/FetchErrorState/FetchErrorState"; +import { MarkdownEditedNote } from "app/p/components/MarkdownEditedNote/MarkdownEditedNote"; import { TabFetchState } from "app/p/components/TabFetchState/TabFetchState"; import { getSessionTools } from "cyberstorm/security/publicEnvVariables"; import { getApiHostForSsr } from "cyberstorm/utils/env"; @@ -91,6 +92,7 @@ export default function Changelog() { {(resolvedValue) => resolvedValue ? (
    +
    } > - {(resolvedValue) => - resolvedValue && resolvedValue.html ? ( -
    + {(resolvedValue) => ( +
    + + {resolvedValue?.html ? (
    -
    - ) : ( - - ) - } + ) : ( + + )} +
    + )} ); diff --git a/apps/cyberstorm-remix/app/p/tabs/Readme/Readme.tsx b/apps/cyberstorm-remix/app/p/tabs/Readme/Readme.tsx index 748dc513f..fb0e253f0 100644 --- a/apps/cyberstorm-remix/app/p/tabs/Readme/Readme.tsx +++ b/apps/cyberstorm-remix/app/p/tabs/Readme/Readme.tsx @@ -1,4 +1,5 @@ import { FetchErrorState } from "app/commonComponents/FetchErrorState/FetchErrorState"; +import { MarkdownEditedNote } from "app/p/components/MarkdownEditedNote/MarkdownEditedNote"; import { TabFetchState } from "app/p/components/TabFetchState/TabFetchState"; import { getSessionTools } from "cyberstorm/security/publicEnvVariables"; import { getApiHostForSsr } from "cyberstorm/utils/env"; @@ -85,18 +86,19 @@ export default function Readme() { resolve={readme} errorElement={} > - {(resolvedValue) => - resolvedValue && resolvedValue.html ? ( -
    + {(resolvedValue) => ( +
    + + {resolvedValue?.html ? (
    -
    - ) : ( - - ) - } + ) : ( + + )} +
    + )} ); diff --git a/apps/cyberstorm-remix/app/p/tabs/__tests__/markdownDisclosure.test.ts b/apps/cyberstorm-remix/app/p/tabs/__tests__/markdownDisclosure.test.ts new file mode 100644 index 000000000..592abafd4 --- /dev/null +++ b/apps/cyberstorm-remix/app/p/tabs/__tests__/markdownDisclosure.test.ts @@ -0,0 +1,73 @@ +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { RouterProvider, createMemoryRouter } from "react-router"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import PackageVersionReadme from "../Readme/PackageVersionReadme"; +import Readme from "../Readme/Readme"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock("cyberstorm/utils/ssrLoader", () => ({ + ssrLoader: (loader: unknown) => loader, + forwardLoaderHeaders: vi.fn(), +})); +vi.mock("@thunderstore/dapper-ts", () => ({ DapperTs: vi.fn() })); +vi.mock("app/commonComponents/FetchErrorState/FetchErrorState", () => ({ + FetchErrorState: () => null, +})); +vi.mock("app/p/components/TabFetchState/TabFetchState", () => ({ + TabFetchState: () => null, +})); +vi.mock("@thunderstore/cyberstorm", () => ({ + SkeletonBox: () => null, + LocalDateTime: ({ time }: { time: string }) => time, +})); + +let cleanup: (() => void) | undefined; + +async function renderPage(component: React.ComponentType, data: unknown) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const router = createMemoryRouter([ + { path: "/", loader: () => data, element: React.createElement(component) }, + ]); + await act(async () => { + root.render(React.createElement(RouterProvider, { router })); + }); + cleanup = () => { + act(() => root.unmount()); + router.dispose(); + container.remove(); + }; + return container; +} + +afterEach(() => { + cleanup?.(); +}); + +const edited_at = "2026-09-08T00:30:00Z"; + +describe("edited Markdown disclosure", () => { + it.each([ + ["latest", Readme], + ["historical", PackageVersionReadme], + ])("marks an empty %s README as edited", async (_, component) => { + const container = await renderPage(component, { + readme: { html: "", is_edited: true, edited_at }, + }); + expect(container.textContent).toContain("Edited"); + expect(container.textContent).toContain(edited_at); + }); + + it("does not mark an unedited README", async () => { + const container = await renderPage(Readme, { + readme: { html: "

    x

    ", is_edited: false, edited_at: null }, + }); + expect(container.textContent).not.toContain("Edited"); + }); +}); From fc67abc54ce243cf24183a8d233d935dede8a499 Mon Sep 17 00:00:00 2001 From: Ethan Green Date: Wed, 23 Sep 2026 16:52:23 +0300 Subject: [PATCH 8/8] Offer to carry site-edited READMEs over on upload --- .../app/upload/__tests__/readmeCarry.test.ts | 192 ++++++++++++++++++ .../app/upload/__tests__/uploadHooks.test.ts | 77 +++++++ .../components/OverrideMigrationNotice.tsx | 136 +++++++++++++ .../upload/components/SubmissionResult.css | 14 ++ .../upload/components/SubmissionResult.tsx | 11 + .../upload/components/UploadFileSection.css | 22 ++ .../upload/components/UploadFileSection.tsx | 48 ++++- .../components/UploadSubmissionStatus.tsx | 8 +- .../__tests__/OverrideMigrationNotice.test.ts | 128 ++++++++++++ .../__tests__/UploadSubmissionStatus.test.ts | 5 + apps/cyberstorm-remix/app/upload/upload.tsx | 23 +++ .../app/upload/uploadHooks.ts | 54 +++++ 12 files changed, 716 insertions(+), 2 deletions(-) create mode 100644 apps/cyberstorm-remix/app/upload/__tests__/readmeCarry.test.ts create mode 100644 apps/cyberstorm-remix/app/upload/components/OverrideMigrationNotice.tsx create mode 100644 apps/cyberstorm-remix/app/upload/components/__tests__/OverrideMigrationNotice.test.ts diff --git a/apps/cyberstorm-remix/app/upload/__tests__/readmeCarry.test.ts b/apps/cyberstorm-remix/app/upload/__tests__/readmeCarry.test.ts new file mode 100644 index 000000000..e2739d2a0 --- /dev/null +++ b/apps/cyberstorm-remix/app/upload/__tests__/readmeCarry.test.ts @@ -0,0 +1,192 @@ +import { getByRole } from "@testing-library/dom"; +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { Outlet, RouterProvider, createMemoryRouter } from "react-router"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import Upload from "../upload"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock("../../root", () => ({})); +vi.mock("cyberstorm/utils/ssrLoader", () => ({ + ssrLoader: (loader: unknown) => loader, +})); +vi.mock("cyberstorm/utils/StrongForm/useStrongForm", () => ({ + useStrongForm: () => ({ submitting: true, submit: vi.fn() }), +})); +vi.mock("@thunderstore/dapper-ts", () => ({ + DapperTs: vi.fn(), + postPackageSubmissionMetadata: vi.fn(), +})); +vi.mock("@thunderstore/ts-uploader-react", () => ({ + useUploadProgress: () => undefined, +})); +vi.mock("../../commonComponents/ErrorBoundary/RouteErrorBoundary", () => ({ + RouteErrorBoundary: () => null, +})); +vi.mock("../../commonComponents/Page/Page", () => ({ + Page: ({ children }: { children: React.ReactNode }) => children, +})); +vi.mock("../../commonComponents/PageHeader/PageHeader", () => ({ + PageHeader: ({ children }: { children: React.ReactNode }) => children, +})); +vi.mock("../../commonComponents/FormSection/FormSection", () => ({ + FormSections: ({ children }: { children: React.ReactNode }) => children, + FormSectionSeparator: () => null, +})); +vi.mock("@thunderstore/cyberstorm", () => ({ + NewAlert: ({ children }: { children: React.ReactNode }) => children, + NewLink: ({ children }: { children: React.ReactNode }) => children, +})); +vi.mock("../components/UploadFileSection", () => ({ + UploadFileSection: ({ + carryOverride, + onCarryOverrideChange, + }: { + carryOverride: boolean; + onCarryOverrideChange: (value: boolean) => void; + }) => + React.createElement("button", { + role: "switch", + "aria-checked": carryOverride, + onClick: () => onCarryOverrideChange(!carryOverride), + }), +})); +vi.mock("../uploadHooks", () => ({ + usePackageFileUpload: () => ({ + file: null, + fileWarnings: [], + fileErrors: [], + selectFile: vi.fn(), + clearFile: vi.fn(), + fileInputRef: { current: null }, + }), + usePreviousOverrideWarning: () => ({ + versionNumber: "1.0.1", + markdown: "Edited README", + }), + useSubmissionStatusPolling: () => ({ + pollingError: null, + setPollingError: vi.fn(), + retryPolling: vi.fn(), + }), + useUploadCategoryOptions: () => ({}), +})); +vi.mock("../components/UploadTeamSection", () => ({ + UploadTeamSection: ({ + authorName, + onAuthorNameChange, + }: { + authorName: string; + onAuthorNameChange: (name: string) => void; + }) => + React.createElement( + "select", + { + "aria-label": "Team", + value: authorName, + onChange: (event: React.ChangeEvent) => + onAuthorNameChange(event.target.value), + }, + React.createElement("option", { value: "" }, "Select team"), + React.createElement("option", { value: "TeamA" }, "Team A"), + React.createElement("option", { value: "TeamB" }, "Team B") + ), +})); +vi.mock("../components/UploadCategoriesSection", () => ({ + UploadCategoriesSection: () => null, +})); +vi.mock("../components/UploadCommunitiesSection", () => ({ + UploadCommunitiesSection: () => null, +})); +vi.mock("../components/UploadNsfwSection", () => ({ + UploadNsfwSection: () => null, +})); +vi.mock("../components/UploadSubmissionStatus", () => ({ + UploadSubmissionStatus: ({ overrideToCarry }: { overrideToCarry: unknown }) => + React.createElement("output", null, JSON.stringify(overrideToCarry)), +})); +vi.mock("../components/UploadSubmitSection", () => ({ + UploadSubmitSection: ({ onSubmit }: { onSubmit: () => void }) => + React.createElement("button", { onClick: onSubmit }, "Submit"), +})); + +let cleanup: (() => void) | undefined; + +async function renderUpload() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const router = createMemoryRouter([ + { + path: "/", + element: React.createElement(Outlet, { + context: { requestConfig: () => ({}), currentUser: { teams_full: [] } }, + }), + children: [ + { + index: true, + loader: () => ({ results: [] }), + element: React.createElement(Upload), + }, + ], + }, + ]); + await act(async () => { + root.render(React.createElement(RouterProvider, { router })); + }); + cleanup = () => { + act(() => root.unmount()); + router.dispose(); + container.remove(); + }; + return container; +} + +afterEach(() => cleanup?.()); + +describe("README carry-forward opt-in", () => { + it("resets the opt-in when the selected team changes", async () => { + const container = await renderUpload(); + const team = getByRole(container, "combobox", { + name: "Team", + }) as HTMLSelectElement; + await act(async () => { + team.value = "TeamA"; + team.dispatchEvent(new Event("change", { bubbles: true })); + }); + const carry = getByRole(container, "switch"); + await act(async () => carry.click()); + expect(carry.getAttribute("aria-checked")).toBe("true"); + await act(async () => { + team.value = "TeamB"; + team.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(carry.getAttribute("aria-checked")).toBe("false"); + }); + + it("carries the override the form showed at submit", async () => { + const container = await renderUpload(); + const carry = getByRole(container, "switch"); + await act(async () => carry.click()); + await act(async () => + getByRole(container, "button", { name: "Submit" }).click() + ); + await act(async () => carry.click()); + expect(container.querySelector("output")?.textContent).toBe( + JSON.stringify({ versionNumber: "1.0.1", markdown: "Edited README" }) + ); + }); + + it("ignores switch changes after submitting", async () => { + const container = await renderUpload(); + await act(async () => + getByRole(container, "button", { name: "Submit" }).click() + ); + await act(async () => getByRole(container, "switch").click()); + expect(container.querySelector("output")?.textContent).toBe("null"); + }); +}); diff --git a/apps/cyberstorm-remix/app/upload/__tests__/uploadHooks.test.ts b/apps/cyberstorm-remix/app/upload/__tests__/uploadHooks.test.ts index 6203d808d..9820a7b4e 100644 --- a/apps/cyberstorm-remix/app/upload/__tests__/uploadHooks.test.ts +++ b/apps/cyberstorm-remix/app/upload/__tests__/uploadHooks.test.ts @@ -5,8 +5,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { PackageSubmissionStatus } from "@thunderstore/dapper/types"; +import type { PreviousOverride } from "../../p/readmeEdit/overrideMigration"; import { usePackageFileUpload, + usePreviousOverrideWarning, useSubmissionStatusPolling, useUploadCategoryOptions, } from "../uploadHooks"; @@ -16,6 +18,20 @@ const { mockAbort, mockStart } = vi.hoisted(() => ({ mockAbort: vi.fn(), })); +const overrideMocks = vi.hoisted(() => ({ + readEntry: vi.fn(), + find: vi.fn(), +})); + +vi.mock("../readZipFilenames", async (importOriginal) => ({ + ...(await importOriginal()), + readZipEntryText: overrideMocks.readEntry, +})); + +vi.mock("../../p/readmeEdit/overrideMigration", () => ({ + findPreviousReadmeOverride: overrideMocks.find, +})); + vi.mock("@thunderstore/ts-uploader", () => { class MockMultipartUpload { handle = { uuid: "upload-uuid-1" }; @@ -403,3 +419,64 @@ describe("useUploadCategoryOptions", () => { unmount(); }); }); + +describe("usePreviousOverrideWarning", () => { + const requestConfig = () => ({ apiHost: "https://api.example.com" }); + const firstOverride: PreviousOverride = { + versionNumber: "1.0.1", + markdown: "First README", + }; + const secondOverride: PreviousOverride = { + versionNumber: "1.0.2", + markdown: "Second README", + }; + let latest: PreviousOverride | null | undefined; + let finishFirst: (value: PreviousOverride) => void; + + function Harness({ file }: { file: File }) { + latest = usePreviousOverrideWarning(requestConfig, file, "TeamA"); + return null; + } + + const settle = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + beforeEach(() => { + latest = undefined; + overrideMocks.readEntry.mockImplementation((file: File) => + Promise.resolve( + JSON.stringify({ name: file.name === "first.zip" ? "First" : "Second" }) + ) + ); + overrideMocks.find.mockImplementation( + (_config: unknown, _team: string, name: string) => + name === "First" + ? new Promise((resolve) => { + finishFirst = resolve; + }) + : Promise.resolve(secondOverride) + ); + }); + + it("ignores a lookup that finishes after the file changed", async () => { + const container = document.createElement("div"); + const root = createRoot(container); + await act(async () => { + root.render(React.createElement(Harness, { file: zipFile("first.zip") })); + }); + await settle(); + await act(async () => { + root.render( + React.createElement(Harness, { file: zipFile("second.zip") }) + ); + }); + await settle(); + await act(async () => { + finishFirst(firstOverride); + }); + expect(latest).toEqual(secondOverride); + act(() => root.unmount()); + }); +}); diff --git a/apps/cyberstorm-remix/app/upload/components/OverrideMigrationNotice.tsx b/apps/cyberstorm-remix/app/upload/components/OverrideMigrationNotice.tsx new file mode 100644 index 000000000..04cb79560 --- /dev/null +++ b/apps/cyberstorm-remix/app/upload/components/OverrideMigrationNotice.tsx @@ -0,0 +1,136 @@ +import { useEffect, useRef, useState } from "react"; +import { useOutletContext } from "react-router"; + +import { NewAlert, NewButton, useToast } from "@thunderstore/cyberstorm"; +import { + extractApiErrorMessage, + isApiError, + postPackageVersionReadme, +} from "@thunderstore/thunderstore-api"; + +import { + type PreviousOverride, + downloadOverrideText, + findPreviousReadmeOverride, +} from "../../p/readmeEdit/overrideMigration"; +import type { OutletContextShape } from "../../root"; + +export interface OverrideMigrationNoticeProps { + namespace: string; + packageName: string; + newVersion: string; + /** The override the upload form offered and the submitter opted to carry. + It is copied on mount, and the manual offer remains as the fallback if + that fails. */ + overrideToCarry?: PreviousOverride | null; +} + +/** + * Post-upload notice: when the previous version carried a site-edited README, + * the new upload starts clean, so tell the submitter and offer to carry the + * edit over or download it. + */ +export function OverrideMigrationNotice({ + namespace, + packageName, + newVersion, + overrideToCarry, +}: OverrideMigrationNoticeProps) { + const outletContext = useOutletContext() as OutletContextShape; + const toast = useToast(); + + const [previousOverride, setPreviousOverride] = useState( + overrideToCarry ?? null + ); + const [copying, setCopying] = useState(false); + const [copied, setCopied] = useState(false); + const carryAttempted = useRef(false); + + useEffect(() => { + if (overrideToCarry) return; + let cancelled = false; + findPreviousReadmeOverride( + outletContext.requestConfig, + namespace, + packageName, + newVersion + ) + .then((result) => { + if (!cancelled) setPreviousOverride(result); + }) + .catch(() => { + // The notice is best-effort. A probe failure must not break the + // submission result page. + }); + return () => { + cancelled = true; + }; + }, [namespace, packageName, newVersion, overrideToCarry]); + + async function copyReadme(override: PreviousOverride) { + setCopying(true); + try { + await postPackageVersionReadme({ + config: outletContext.requestConfig, + params: { namespace, package: packageName, version: newVersion }, + data: { readme: override.markdown }, + queryParams: {}, + }); + toast.addToast({ + csVariant: "success", + children: + "Site-edited README carried over. Changes might take several minutes to show publicly!", + duration: 8000, + }); + setCopied(true); + } catch (error) { + toast.addToast({ + csVariant: "danger", + children: `Carrying the edit over failed: ${ + isApiError(error) ? extractApiErrorMessage(error) : "unknown error" + }`, + duration: 8000, + }); + } finally { + setCopying(false); + } + } + + useEffect(() => { + if (!overrideToCarry || carryAttempted.current) return; + carryAttempted.current = true; + copyReadme(overrideToCarry); + }, [overrideToCarry]); + + if (!previousOverride || copied) return null; + + return ( + +
    + + Version {newVersion} uses the README included in your package. Version{" "} + {previousOverride.versionNumber} still has its edited README. You can + copy that README to this version’s Thunderstore page. + + + copyReadme(previousOverride)} + disabled={copying} + > + {copying ? "Copying…" : "Copy edited README"} + + downloadOverrideText(previousOverride.markdown)} + disabled={copying} + > + Download edited README + + +
    +
    + ); +} diff --git a/apps/cyberstorm-remix/app/upload/components/SubmissionResult.css b/apps/cyberstorm-remix/app/upload/components/SubmissionResult.css index 154a07228..7ddd662c8 100644 --- a/apps/cyberstorm-remix/app/upload/components/SubmissionResult.css +++ b/apps/cyberstorm-remix/app/upload/components/SubmissionResult.css @@ -4,4 +4,18 @@ flex-wrap: wrap; gap: var(--gap-2xs); } + + .override-migration-notice { + display: flex; + flex-wrap: wrap; + gap: var(--space-8); + align-items: center; + justify-content: space-between; + width: 100%; + } + + .override-migration-notice__actions { + display: flex; + gap: var(--space-8); + } } diff --git a/apps/cyberstorm-remix/app/upload/components/SubmissionResult.tsx b/apps/cyberstorm-remix/app/upload/components/SubmissionResult.tsx index 12229e103..45ad7b7f2 100644 --- a/apps/cyberstorm-remix/app/upload/components/SubmissionResult.tsx +++ b/apps/cyberstorm-remix/app/upload/components/SubmissionResult.tsx @@ -13,14 +13,18 @@ import { type PackageSubmissionResult } from "@thunderstore/dapper/types"; import { Island } from "../../commonComponents/Island/Island"; import { PageHeader } from "../../commonComponents/PageHeader/PageHeader"; +import { type PreviousOverride } from "../../p/readmeEdit/overrideMigration"; +import { OverrideMigrationNotice } from "./OverrideMigrationNotice"; import "./SubmissionResult.css"; export interface SubmissionResultProps { submissionStatusResult: PackageSubmissionResult; + overrideToCarry: PreviousOverride | null; } export function SubmissionResult({ submissionStatusResult, + overrideToCarry, }: SubmissionResultProps) { return ( @@ -57,6 +61,13 @@ export function SubmissionResult({ {submissionStatusResult.package_version.name} + + diff --git a/apps/cyberstorm-remix/app/upload/components/UploadFileSection.css b/apps/cyberstorm-remix/app/upload/components/UploadFileSection.css index 5bbc4571e..41945acf4 100644 --- a/apps/cyberstorm-remix/app/upload/components/UploadFileSection.css +++ b/apps/cyberstorm-remix/app/upload/components/UploadFileSection.css @@ -90,4 +90,26 @@ .drag-n-drop:hover .drag-n-drop__remove-button { color: var(--color-text-primary); } + + .upload-override-warning { + display: flex; + flex-wrap: wrap; + gap: var(--space-8); + align-items: center; + justify-content: space-between; + width: 100%; + } + + .upload-override-warning__actions { + display: inline-flex; + flex-wrap: wrap; + gap: var(--space-16); + align-items: center; + } + + .upload-override-warning__carry { + display: inline-flex; + gap: var(--space-8); + align-items: center; + } } diff --git a/apps/cyberstorm-remix/app/upload/components/UploadFileSection.tsx b/apps/cyberstorm-remix/app/upload/components/UploadFileSection.tsx index b641872bb..d175398a9 100644 --- a/apps/cyberstorm-remix/app/upload/components/UploadFileSection.tsx +++ b/apps/cyberstorm-remix/app/upload/components/UploadFileSection.tsx @@ -7,11 +7,21 @@ import { import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import type { RefObject } from "react"; -import { NewAlert, NewIcon, classnames } from "@thunderstore/cyberstorm"; +import { + NewAlert, + NewButton, + NewIcon, + NewSwitch, + classnames, +} from "@thunderstore/cyberstorm"; import { DnDFileInput } from "@thunderstore/react-dnd"; import type { IBaseUploadHandle } from "@thunderstore/ts-uploader"; import { FormSection } from "../../commonComponents/FormSection/FormSection"; +import { + type PreviousOverride, + downloadOverrideText, +} from "../../p/readmeEdit/overrideMigration"; import { PACKAGE_ZIP_ACCEPT, formatBytes } from "../uploadUtils"; import { SectionErrors } from "./SectionErrors"; import "./UploadFileSection.css"; @@ -23,9 +33,12 @@ export interface UploadFileSectionProps { sectionErrors: string[]; fileWarnings: string[]; fileValidationErrors: string[]; + previousOverride: PreviousOverride | null; + carryOverride: boolean; fileInputRef: RefObject; onFileChange: (file: File | null) => void; onRemoveFile: () => void; + onCarryOverrideChange: (carryOverride: boolean) => void; } export function UploadFileSection({ @@ -35,9 +48,12 @@ export function UploadFileSection({ sectionErrors, fileWarnings, fileValidationErrors, + previousOverride, + carryOverride, fileInputRef, onFileChange, onRemoveFile, + onCarryOverrideChange, }: UploadFileSectionProps) { return ( ) : null} + {previousOverride ? ( + +
    + + Version {previousOverride.versionNumber} has a README edited on + Thunderstore. This upload uses the README in your package unless + you choose to copy that edit. + + + + + + + downloadOverrideText(previousOverride.markdown)} + > + Download edited README + + +
    +
    + ) : null}
    diff --git a/apps/cyberstorm-remix/app/upload/components/UploadSubmissionStatus.tsx b/apps/cyberstorm-remix/app/upload/components/UploadSubmissionStatus.tsx index 7d7d5ab7a..c0f150d54 100644 --- a/apps/cyberstorm-remix/app/upload/components/UploadSubmissionStatus.tsx +++ b/apps/cyberstorm-remix/app/upload/components/UploadSubmissionStatus.tsx @@ -2,6 +2,7 @@ import { NewAlert, NewButton } from "@thunderstore/cyberstorm"; import type { PackageSubmissionStatus } from "@thunderstore/dapper/types"; import { FormSectionSeparator } from "../../commonComponents/FormSection/FormSection"; +import { type PreviousOverride } from "../../p/readmeEdit/overrideMigration"; import { SectionErrors } from "./SectionErrors"; import { SubmissionProcessingSkeleton } from "./SubmissionProcessingSkeleton"; import { SubmissionResult } from "./SubmissionResult"; @@ -11,6 +12,7 @@ export interface UploadSubmissionStatusProps { submissionStatus?: PackageSubmissionStatus; pollingError: string | null; submitSectionErrors: string[]; + overrideToCarry: PreviousOverride | null; onRetryPolling: () => void; } @@ -19,6 +21,7 @@ export function UploadSubmissionStatus({ submissionStatus, pollingError, submitSectionErrors, + overrideToCarry, onRetryPolling, }: UploadSubmissionStatusProps) { const showProcessing = @@ -31,7 +34,10 @@ export function UploadSubmissionStatus({
    {submissionStatus?.result ? ( - + ) : null} {showProcessing ? : null} {!showProcessing && pollingError != null ? ( diff --git a/apps/cyberstorm-remix/app/upload/components/__tests__/OverrideMigrationNotice.test.ts b/apps/cyberstorm-remix/app/upload/components/__tests__/OverrideMigrationNotice.test.ts new file mode 100644 index 000000000..1a0ad67fb --- /dev/null +++ b/apps/cyberstorm-remix/app/upload/components/__tests__/OverrideMigrationNotice.test.ts @@ -0,0 +1,128 @@ +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { Outlet, RouterProvider, createMemoryRouter } from "react-router"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { PreviousOverride } from "../../../p/readmeEdit/overrideMigration"; +import { OverrideMigrationNotice } from "../OverrideMigrationNotice"; + +( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +const mocks = vi.hoisted(() => ({ + find: vi.fn(), + post: vi.fn(), + toast: vi.fn(), +})); + +vi.mock("@thunderstore/cyberstorm", () => ({ + NewAlert: ({ children }: { children: React.ReactNode }) => children, + NewButton: ({ + children, + onClick, + disabled, + }: { + children: React.ReactNode; + onClick: () => void; + disabled?: boolean; + }) => React.createElement("button", { onClick, disabled }, children), + useToast: () => ({ addToast: mocks.toast }), +})); +vi.mock("@thunderstore/thunderstore-api", () => ({ + extractApiErrorMessage: (error: Error) => error.message, + isApiError: (error: unknown) => + !!error && typeof error === "object" && "response" in error, + postPackageVersionReadme: mocks.post, +})); +vi.mock("../../../p/readmeEdit/overrideMigration", () => ({ + downloadOverrideText: vi.fn(), + findPreviousReadmeOverride: mocks.find, +})); + +const override: PreviousOverride = { + versionNumber: "1.0.1", + markdown: "Edited README", +}; +let cleanup: (() => void) | undefined; + +async function renderNotice(overrideToCarry: PreviousOverride | null) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const router = createMemoryRouter([ + { + path: "/", + element: React.createElement(Outlet, { + context: { requestConfig: () => ({}) }, + }), + children: [ + { + index: true, + element: React.createElement(OverrideMigrationNotice, { + namespace: "Team", + packageName: "Mod", + newVersion: "2.0.0", + overrideToCarry, + }), + }, + ], + }, + ]); + await act(async () => { + root.render( + React.createElement( + React.StrictMode, + null, + React.createElement(RouterProvider, { router }) + ) + ); + }); + cleanup = () => { + act(() => root.unmount()); + router.dispose(); + container.remove(); + }; + return container; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.find.mockResolvedValue(override); + mocks.post.mockResolvedValue({}); +}); + +afterEach(() => { + cleanup?.(); + cleanup = undefined; +}); + +describe("OverrideMigrationNotice", () => { + it("copies the override chosen on the upload form exactly once", async () => { + await renderNotice(override); + expect(mocks.post).toHaveBeenCalledTimes(1); + expect(mocks.post).toHaveBeenCalledWith( + expect.objectContaining({ + params: { namespace: "Team", package: "Mod", version: "2.0.0" }, + data: { readme: "Edited README" }, + }) + ); + expect(mocks.find).not.toHaveBeenCalled(); + }); + + it("offers a manual copy when the automatic copy fails", async () => { + mocks.post.mockRejectedValue(new Error("offline")); + const container = await renderNotice(override); + expect(container.textContent).toContain("Copy edited README"); + expect(mocks.toast).toHaveBeenCalledWith( + expect.objectContaining({ csVariant: "danger" }) + ); + }); + + it("only offers a copy when the submitter did not opt in", async () => { + const container = await renderNotice(null); + expect(mocks.find).toHaveBeenCalled(); + expect(container.textContent).toContain("Copy edited README"); + expect(mocks.post).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/cyberstorm-remix/app/upload/components/__tests__/UploadSubmissionStatus.test.ts b/apps/cyberstorm-remix/app/upload/components/__tests__/UploadSubmissionStatus.test.ts index 5e069adc2..469c13cd1 100644 --- a/apps/cyberstorm-remix/app/upload/components/__tests__/UploadSubmissionStatus.test.ts +++ b/apps/cyberstorm-remix/app/upload/components/__tests__/UploadSubmissionStatus.test.ts @@ -95,6 +95,7 @@ describe("UploadSubmissionStatus", () => { submissionStatus: asStatus("PENDING"), pollingError: null, submitSectionErrors: [], + overrideToCarry: null, onRetryPolling: vi.fn(), }) ); @@ -113,6 +114,7 @@ describe("UploadSubmissionStatus", () => { submitting: true, pollingError: null, submitSectionErrors: [], + overrideToCarry: null, onRetryPolling: vi.fn(), }) ); @@ -132,6 +134,7 @@ describe("UploadSubmissionStatus", () => { submissionStatus: asStatus("FINISHED", true), pollingError: null, submitSectionErrors: ["Submit failed"], + overrideToCarry: null, onRetryPolling: vi.fn(), }) ); @@ -152,6 +155,7 @@ describe("UploadSubmissionStatus", () => { submissionStatus: asStatus("PENDING"), pollingError: "network error", submitSectionErrors: [], + overrideToCarry: null, onRetryPolling: vi.fn(), }) ); @@ -172,6 +176,7 @@ describe("UploadSubmissionStatus", () => { submissionStatus: asStatus("FINISHED"), pollingError: "network error", submitSectionErrors: [], + overrideToCarry: null, onRetryPolling: vi.fn(), }) ); diff --git a/apps/cyberstorm-remix/app/upload/upload.tsx b/apps/cyberstorm-remix/app/upload/upload.tsx index e644bcac6..e44c68cf8 100644 --- a/apps/cyberstorm-remix/app/upload/upload.tsx +++ b/apps/cyberstorm-remix/app/upload/upload.tsx @@ -23,6 +23,7 @@ import { } from "../commonComponents/FormSection/FormSection"; import { Page } from "../commonComponents/Page/Page"; import { PageHeader } from "../commonComponents/PageHeader/PageHeader"; +import { type PreviousOverride } from "../p/readmeEdit/overrideMigration"; import { type OutletContextShape } from "../root"; import type { Route } from "./+types/upload"; import "./Upload.css"; @@ -35,6 +36,7 @@ import { UploadSubmitSection } from "./components/UploadSubmitSection"; import { UploadTeamSection } from "./components/UploadTeamSection"; import { usePackageFileUpload, + usePreviousOverrideWarning, useSubmissionStatusPolling, useUploadCategoryOptions, } from "./uploadHooks"; @@ -170,6 +172,17 @@ export default function Upload() { formInputs.communities ); + const previousOverride = usePreviousOverrideWarning( + requestConfig, + file, + formInputs.author_name + ); + const [carryOverride, setCarryOverride] = useState(false); + // Fixed at submit, so the result copies the override the form showed and + // later switch changes cannot write to the published version. + const [overrideToCarry, setOverrideToCarry] = + useState(null); + type SubmitorOutput = Awaited< ReturnType >; @@ -254,12 +267,15 @@ export default function Upload() { const handleReset = () => { clearFile(); + setCarryOverride(false); + setOverrideToCarry(null); setSubmitError(null); setSubmissionStatus(undefined); dispatchForm("reset"); }; const handleSubmit = () => { + setOverrideToCarry(carryOverride ? previousOverride : null); setSubmitError(null); setPollingError(null); setSubmissionStatus(undefined); @@ -316,9 +332,12 @@ export default function Upload() { sectionErrors={submissionErrorsBySection.uploadFile} fileWarnings={fileWarnings} fileValidationErrors={fileErrors} + previousOverride={previousOverride} + carryOverride={carryOverride} fileInputRef={fileInputRef} onFileChange={(nextFile) => { selectFile(nextFile); + setCarryOverride(false); updateFormFieldState({ field: "upload_uuid", value: "", @@ -326,17 +345,20 @@ export default function Upload() { }} onRemoveFile={() => { clearFile(); + setCarryOverride(false); updateFormFieldState({ field: "upload_uuid", value: "", }); }} + onCarryOverrideChange={setCarryOverride} /> { + setCarryOverride(false); updateFormFieldState({ field: "author_name", value: authorName, @@ -404,6 +426,7 @@ export default function Upload() { submissionStatus={submissionStatus} pollingError={pollingError} submitSectionErrors={submissionErrorsBySection.submit} + overrideToCarry={overrideToCarry} onRetryPolling={retryPolling} /> ) : null} diff --git a/apps/cyberstorm-remix/app/upload/uploadHooks.ts b/apps/cyberstorm-remix/app/upload/uploadHooks.ts index bd2924735..5d99e025b 100644 --- a/apps/cyberstorm-remix/app/upload/uploadHooks.ts +++ b/apps/cyberstorm-remix/app/upload/uploadHooks.ts @@ -7,7 +7,12 @@ import { type UserMedia, } from "@thunderstore/ts-uploader"; +import { + type PreviousOverride, + findPreviousReadmeOverride, +} from "../p/readmeEdit/overrideMigration"; import type { OutletContextShape } from "../root"; +import { readZipEntryText } from "./readZipFilenames"; import { type CategoryOption, PACKAGE_ZIP_FILE_ERROR_MESSAGE, @@ -296,3 +301,52 @@ export function useUploadCategoryOptions( return categoryOptions; } + +async function readPackageManifestName(file: File): Promise { + const manifest = await readZipEntryText(file, "manifest.json"); + if (manifest === null) return null; + try { + const name: unknown = JSON.parse(manifest)?.name; + return typeof name === "string" && name !== "" ? name : null; + } catch { + return null; + } +} + +/** + * Looks up whether the package this file will become a new version of carries + * a site-edited README, so the form can warn before the upload rather than + * only after it. Advisory: any failure simply yields no warning. + */ +export function usePreviousOverrideWarning( + requestConfig: OutletContextShape["requestConfig"], + file: File | null, + authorName: string +): PreviousOverride | null { + const [previousOverride, setPreviousOverride] = + useState(null); + + useEffect(() => { + setPreviousOverride(null); + if (!file || !authorName) return; + + let cancelled = false; + readPackageManifestName(file) + .then((name) => + name + ? findPreviousReadmeOverride(requestConfig, authorName, name) + : null + ) + .then((result) => { + if (!cancelled) setPreviousOverride(result); + }) + .catch(() => { + // Advisory only: a failed probe must not disturb the form. + }); + return () => { + cancelled = true; + }; + }, [file, authorName]); + + return previousOverride; +}