react-bootstrap migration, review & parser fixes
UI / react-bootstrap: Migrate client components to react-bootstrap (Card, Table, Form, Alert, Badge, Nav, Button, Spinner, Row, Col): the ZXDB explorers and detail pages (Labels, Genres, Languages, MachineTypes, Releases, Entries), TapeIdentifier, home page, Navbar and ThemeDropdown. Server components (home, zxdb hub, magazines, issues) keep raw HTML+className — react-bootstrap barrel imports resolve to undefined under Turbopack in server components. Replace bi bi-* CSS icons with react-bootstrap-icons. Add aria-labels to search inputs and visually-hidden captions to data tables. Code-review remediation (docs/todo.md): - FileViewer: replace useState-as-effect with a proper useEffect. - register.service: restore request-level caching of parsed registers. - middleware: convert .js to .ts, dev-only request logging. - Extract shared types to src/types/zxdb.ts; add src/server/repo barrel for incremental per-domain splitting. - Extract helpers: parseIdList (params.ts), serialize (serialize.ts), buildRegisterSummary/isInfoLine (register_helpers.ts). - Add loading.tsx skeletons for dynamic ZXDB detail routes. - generateMetadata + notFound() on entry/release/label detail pages. - opengraph-image: stable keys; ThemeDropdown: drop hardcoded cookie domain; remove unused page.module.css. Register parser & data: - Update data/nextreg.txt from upstream tbblue (SpectrumNext FPGA): 0x04/0x0A/0x0F/0x80/0x81 bit changes, new Issue 5 board id, 0x43 renamed "Palette Control", 0xF0/0xF8/0xF9/0xFA now "Issues 4 and 5 Only". - Add reg_44 custom parser for 0x44 (Palette Value 9-bit): the two consecutive writes render as separate "1st write" / "2nd write" modes. - Skip commented-out register headers so the disabled 0xA3 block no longer leaks a phantom register. - Add detailHasContent guard so body-less registers (0xC7/0xCB/0xCF/ 0xFF) and 0xF0's leading blank no longer emit empty tab strips. - Capture 0xF0's leading "Issues 4 and 5 Only" line as register text. - Add isIssueRestricted (case-sensitive) to detect the issue badge across rewording without flagging per-bit "(issue 5 only)" notes; update badge label to "Issues 4 & 5 Only". claude-opus-4-8@lucy
This commit is contained in:
29
src/utils/params.ts
Normal file
29
src/utils/params.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Parse a comma-separated string of positive integer IDs.
|
||||
* Accepts either a plain string or a string[] (from searchParams).
|
||||
*/
|
||||
export function parseIdList(value: string | string[] | undefined): number[] | undefined {
|
||||
if (!value) return undefined;
|
||||
const raw = Array.isArray(value) ? value.join(",") : value;
|
||||
const ids = raw
|
||||
.split(",")
|
||||
.map((id) => Number(id.trim()))
|
||||
.filter((id) => Number.isFinite(id) && id > 0);
|
||||
return ids.length ? ids : undefined;
|
||||
}
|
||||
|
||||
/** Default machine type IDs for entry/release searches */
|
||||
export const preferredMachineIds = [27, 26, 8, 9];
|
||||
|
||||
/**
|
||||
* Parse machine type IDs from a comma-separated string,
|
||||
* falling back to preferredMachineIds when empty.
|
||||
*/
|
||||
export function parseMachineIds(value?: string): number[] {
|
||||
if (!value) return preferredMachineIds.slice();
|
||||
const ids = value
|
||||
.split(",")
|
||||
.map((id) => Number(id.trim()))
|
||||
.filter((id) => Number.isFinite(id) && id > 0);
|
||||
return ids.length ? ids : preferredMachineIds.slice();
|
||||
}
|
||||
93
src/utils/register_helpers.ts
Normal file
93
src/utils/register_helpers.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
type RegisterLike = {
|
||||
description: string;
|
||||
text: string;
|
||||
modes: { text: string }[];
|
||||
};
|
||||
|
||||
/** Returns true if a line contains useful register info (not bitfield/access markers) */
|
||||
export function isInfoLine(line: string): boolean {
|
||||
return (
|
||||
line.length > 0 &&
|
||||
!line.startsWith("//") &&
|
||||
!line.startsWith("(R") &&
|
||||
!line.startsWith("(W") &&
|
||||
!line.startsWith("(R/W") &&
|
||||
!line.startsWith("*") &&
|
||||
!/^bits?\s+\d/i.test(line)
|
||||
);
|
||||
}
|
||||
|
||||
/** Build a single-line summary string for metadata descriptions */
|
||||
export function buildRegisterSummary(register: RegisterLike): string {
|
||||
const trimLine = (line: string) => line.trim();
|
||||
|
||||
const modeLines = register.modes
|
||||
.flatMap((mode) => mode.text.split("\n"))
|
||||
.map(trimLine)
|
||||
.filter(isInfoLine);
|
||||
const textLines = register.text.split("\n").map(trimLine).filter(isInfoLine);
|
||||
const descriptionLines = register.description.split("\n").map(trimLine).filter(isInfoLine);
|
||||
|
||||
const rawSummary = [...textLines, ...modeLines, ...descriptionLines]
|
||||
.join(" ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
if (!rawSummary) return "Spectrum Next register details and bit-level behavior.";
|
||||
if (rawSummary.length <= 180) return rawSummary;
|
||||
return `${rawSummary.slice(0, 177).trimEnd()}...`;
|
||||
}
|
||||
|
||||
/** Build deduplicated summary lines for OG image rendering */
|
||||
export function buildRegisterSummaryLines(register: RegisterLike): string[] {
|
||||
const normalizeLines = (raw: string) => {
|
||||
const lines: string[] = [];
|
||||
const rawLines = raw.split("\n");
|
||||
for (const rawLine of rawLines) {
|
||||
const trimmed = rawLine.trim();
|
||||
if (!trimmed) {
|
||||
if (lines.length > 0 && lines[lines.length - 1] !== "") {
|
||||
lines.push("");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isInfoLine(trimmed)) {
|
||||
lines.push(trimmed);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
||||
const textLines = normalizeLines(register.text);
|
||||
const modeLines = register.modes.flatMap((mode) => normalizeLines(mode.text));
|
||||
const descriptionLines = normalizeLines(register.description);
|
||||
|
||||
const combined: string[] = [];
|
||||
const appendBlock = (block: string[]) => {
|
||||
if (block.length === 0) return;
|
||||
if (combined.length > 0 && combined[combined.length - 1] !== "") {
|
||||
combined.push("");
|
||||
}
|
||||
combined.push(...block);
|
||||
};
|
||||
|
||||
appendBlock(textLines);
|
||||
appendBlock(modeLines);
|
||||
appendBlock(descriptionLines);
|
||||
|
||||
const deduped: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const line of combined) {
|
||||
if (!line) {
|
||||
if (deduped.length > 0 && deduped[deduped.length - 1] !== "") {
|
||||
deduped.push("");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (seen.has(line)) continue;
|
||||
seen.add(line);
|
||||
deduped.push(line);
|
||||
}
|
||||
|
||||
return deduped.length > 0 ? deduped : ["Spectrum Next register details and bit-level behavior."];
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { parseDescriptionDefault } from "./register_parsers/reg_default";
|
||||
import { parseDescriptionF0 } from "./register_parsers/reg_f0";
|
||||
import { parseDescription44 } from "./register_parsers/reg_44";
|
||||
|
||||
export interface RegisterBitwiseOperation {
|
||||
bits: string;
|
||||
@@ -41,12 +42,43 @@ export interface Register {
|
||||
notes: Note[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects a source line marking the whole register as restricted to certain
|
||||
* board issues, e.g. "Issue 4 Only" or "Issues 4 and 5 Only". Matched loosely
|
||||
* across wording so upstream rewording (the tbblue source has used both forms)
|
||||
* keeps setting the flag rather than silently dropping the badge.
|
||||
*
|
||||
* Case-sensitive on purpose: register-level markers are capitalised, while an
|
||||
* incidental per-bit caveat like "(issue 5 only)" (e.g. nextreg 0x81 bit 3) is
|
||||
* lowercase and must not flag the entire register.
|
||||
*/
|
||||
export function isIssueRestricted(line: string): boolean {
|
||||
return /Issues?\b.*\bOnly/.test(line);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a parsed RegisterDetail carries something worth rendering — a mode
|
||||
* name, an access block, or mode-level text. Body-less registers (e.g. the
|
||||
* "Reserved" entries 0xC7/0xCB/0xCF/0xFF) would otherwise contribute an empty
|
||||
* mode that renders as a stray, contentless tab strip. Parsers call this before
|
||||
* pushing a detail into reg.modes.
|
||||
*/
|
||||
export function detailHasContent(detail: RegisterDetail): boolean {
|
||||
return Boolean(
|
||||
detail.modeName ||
|
||||
detail.read ||
|
||||
detail.write ||
|
||||
detail.common ||
|
||||
(detail.text && detail.text.trim())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the content of the nextreg.txt file and returns an array of register objects.
|
||||
* @param fileContent The content of the nextreg.txt file.
|
||||
* @returns A promise that resolves to an array of Register objects.
|
||||
*/
|
||||
export async function parseNextReg(fileContent: string): Promise<Register[]> {
|
||||
export function parseNextReg(fileContent: string): Register[] {
|
||||
const registers: Register[] = [];
|
||||
const paragraphs = fileContent.split(/\n\s*\n/);
|
||||
|
||||
@@ -64,6 +96,13 @@ export function processRegisterBlock(paragraph: string, registers: Register[]) {
|
||||
const lines = paragraph.trim().split('\n');
|
||||
const firstLine = lines[0];
|
||||
|
||||
// Skip commented-out register blocks. The header regex below is not anchored,
|
||||
// so a disabled entry like "// 0xA3 (163) => ..." would otherwise match and
|
||||
// leak a phantom register into the output.
|
||||
if (firstLine.trim().startsWith('//')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const registerMatch = firstLine.match(/([0-9a-fA-F,x]+)\s*\((.*?)\)\s*=>\s*(.*)/);
|
||||
|
||||
if (!registerMatch) {
|
||||
@@ -134,6 +173,9 @@ export function processRegisterBlock(paragraph: string, registers: Register[]) {
|
||||
case '0xF0':
|
||||
parseDescriptionF0(reg, description);
|
||||
break;
|
||||
case '0x44':
|
||||
parseDescription44(reg, description);
|
||||
break;
|
||||
default:
|
||||
parseDescriptionDefault(reg, description);
|
||||
break;
|
||||
|
||||
91
src/utils/register_parsers/reg_44.ts
Normal file
91
src/utils/register_parsers/reg_44.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
// Special-case parser for 0x44 (Palette Value, 9 bit colour).
|
||||
// The 9-bit colour is written with two consecutive byte writes, each carrying
|
||||
// its own bit layout. The source nests these under "1st write:" / "2nd write:"
|
||||
// headers (two leading spaces, trailing colon) with their bit definitions
|
||||
// indented beneath (four leading spaces):
|
||||
//
|
||||
// (R/W)
|
||||
// Two consecutive writes are needed to write the 9 bit colour
|
||||
// 1st write:
|
||||
// bits 7:0 = RRRGGGBB
|
||||
// 2nd write:
|
||||
// bits 7:1 = Reserved, must be 0
|
||||
// ...
|
||||
//
|
||||
// Each write becomes its own mode with a single Read/Write access block so the
|
||||
// two distinct bit layouts render as separate tables. Two-space prose outside a
|
||||
// write header is register-level commentary; six-space lines continue the
|
||||
// preceding bit definition.
|
||||
import { Register, RegisterAccess, RegisterDetail, isIssueRestricted } from "@/utils/register_parser";
|
||||
|
||||
export const parseDescription44 = (reg: Register, description: string) => {
|
||||
const descriptionLines = description.split('\n');
|
||||
reg.modes = reg.modes || [];
|
||||
|
||||
let currentDetail: RegisterDetail | null = null;
|
||||
let currentAccess: RegisterAccess | null = null;
|
||||
|
||||
const finishDetail = () => {
|
||||
if (currentDetail && currentAccess) {
|
||||
currentDetail.common = currentAccess;
|
||||
reg.modes.push(currentDetail);
|
||||
}
|
||||
currentDetail = null;
|
||||
currentAccess = null;
|
||||
};
|
||||
|
||||
for (const line of descriptionLines) {
|
||||
reg.source.push(line);
|
||||
reg.search += line.toLowerCase() + " ";
|
||||
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine) continue;
|
||||
if (trimmedLine.startsWith('//')) continue;
|
||||
if (isIssueRestricted(line)) reg.issue_4_only = true;
|
||||
|
||||
const spaces_at_start = line.match(/^(\s*)/)?.[0].length || 0;
|
||||
|
||||
// The lone "(R/W)" marker just confirms the access type; both writes are R/W.
|
||||
if (trimmedLine.startsWith('(R/W')) continue;
|
||||
|
||||
// A write header: "1st write:", "2nd write:", etc. starts a new mode.
|
||||
if (spaces_at_start <= 2 && /^\d+(st|nd|rd|th)\s+write:?$/i.test(trimmedLine)) {
|
||||
finishDetail();
|
||||
currentDetail = { read: undefined, write: undefined, common: undefined, text: '' };
|
||||
currentDetail.modeName = trimmedLine.replace(/:$/, '');
|
||||
currentAccess = { operations: [], notes: [] };
|
||||
continue;
|
||||
}
|
||||
|
||||
const bitMatch = trimmedLine.match(/^(bits?|bit)\s+([\d:-]+)\s*=\s*(.*)/);
|
||||
|
||||
// Bit definitions live under a write header at deeper indentation.
|
||||
if (currentAccess && spaces_at_start >= 4 && bitMatch) {
|
||||
currentAccess.operations.push({
|
||||
bits: bitMatch[2],
|
||||
description: bitMatch[3].trim(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Six-space (or deeper) prose continues the previous bit definition.
|
||||
if (currentAccess && spaces_at_start >= 6 && currentAccess.operations.length > 0) {
|
||||
const ops = currentAccess.operations;
|
||||
ops[ops.length - 1].description += `\n${trimmedLine}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Four-space prose inside a write block is access-level description.
|
||||
if (currentAccess && spaces_at_start >= 4) {
|
||||
currentAccess.description = currentAccess.description
|
||||
? `${currentAccess.description}\n${trimmedLine}`
|
||||
: trimmedLine;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Anything shallower (two-space prose outside a write block) is register commentary.
|
||||
reg.text += `${trimmedLine}\n`;
|
||||
}
|
||||
|
||||
finishDetail();
|
||||
};
|
||||
@@ -1,5 +1,9 @@
|
||||
import {Register, RegisterAccess, RegisterDetail, Note} from "@/utils/register_parser";
|
||||
import {Register, RegisterAccess, RegisterDetail, Note, detailHasContent, isIssueRestricted} from "@/utils/register_parser";
|
||||
|
||||
// Default parser for the common register format: optional (R) / (W) / (R/W)
|
||||
// access blocks, each holding "bit(s) N = description" rows, with footnotes
|
||||
// (*, **, ...) that may span multiple indented lines, plus free prose. Produces
|
||||
// a single RegisterDetail (mode) per register.
|
||||
export const parseDescriptionDefault = (reg: Register, description: string) => {
|
||||
const descriptionLines = description.split('\n');
|
||||
let currentAccess: 'read' | 'write' | 'common' | null = null;
|
||||
@@ -10,13 +14,11 @@ export const parseDescriptionDefault = (reg: Register, description: string) => {
|
||||
// Footnote multiline state
|
||||
let inFootnote = false;
|
||||
let footnoteBaseIndent = 0;
|
||||
// let footnoteTarget: 'global' | 'access' | null = null;
|
||||
let currentFootnote: Note | null = null;
|
||||
|
||||
const endFootnoteIfActive = () => {
|
||||
inFootnote = false;
|
||||
footnoteBaseIndent = 0;
|
||||
// footnoteTarget = null;
|
||||
currentFootnote = null;
|
||||
};
|
||||
|
||||
@@ -30,7 +32,7 @@ export const parseDescriptionDefault = (reg: Register, description: string) => {
|
||||
reg.search += line.toLowerCase() + " ";
|
||||
const spaces_at_start = line.match(/^(\s*)/)?.[0].length || 0;
|
||||
|
||||
if (line.includes('Issue 4 Only')) reg.issue_4_only = true;
|
||||
if (isIssueRestricted(line)) reg.issue_4_only = true;
|
||||
|
||||
// Handle multiline footnote continuation
|
||||
if (inFootnote) {
|
||||
@@ -73,7 +75,8 @@ export const parseDescriptionDefault = (reg: Register, description: string) => {
|
||||
currentAccess = 'common';
|
||||
continue;
|
||||
}
|
||||
// New top-level text block (no leading spaces)
|
||||
// A line with no leading whitespace (line === its own trimmed form)
|
||||
// starts a new top-level text block, so close any open access block.
|
||||
if (line.startsWith(trimmedLine)) {
|
||||
if (currentAccess) {
|
||||
detail[currentAccess] = accessData;
|
||||
@@ -89,10 +92,8 @@ export const parseDescriptionDefault = (reg: Register, description: string) => {
|
||||
const note: Note = { ref: noteMatch[1], text: noteMatch[2] };
|
||||
if (currentAccess) {
|
||||
accessData.notes.push(note);
|
||||
// footnoteTarget = 'access';
|
||||
} else {
|
||||
reg.notes.push(note);
|
||||
// footnoteTarget = 'global';
|
||||
}
|
||||
currentFootnote = note;
|
||||
inFootnote = true;
|
||||
@@ -103,7 +104,6 @@ export const parseDescriptionDefault = (reg: Register, description: string) => {
|
||||
|
||||
if (currentAccess) {
|
||||
const bitMatch = trimmedLine.match(/^(bits?|bit)\s+([\d:-]+)\s*=\s*(.*)/);
|
||||
// const valueMatch = !line.match(/^\s+/) && trimmedLine.match(/^([01\s]+)\s*=\s*(.*)/);
|
||||
|
||||
if (bitMatch) {
|
||||
let bitDescription = bitMatch[3];
|
||||
@@ -118,13 +118,9 @@ export const parseDescriptionDefault = (reg: Register, description: string) => {
|
||||
description: bitDescription,
|
||||
footnoteRef: footnoteRef,
|
||||
});
|
||||
// } else if (valueMatch) {
|
||||
// console.error("VALUE MATCH",valueMatch);
|
||||
// accessData.operations.push({
|
||||
// bits: valueMatch[1].trim().replace(/\s/g, ''),
|
||||
// description: valueMatch[2].trim(),
|
||||
// });
|
||||
} else if (trimmedLine) {
|
||||
// Prose indented exactly two spaces inside an access block is
|
||||
// register-level commentary rather than part of the bit table.
|
||||
if(spaces_at_start == 2) {
|
||||
reg.text += `${line}\n`;
|
||||
continue;
|
||||
@@ -151,7 +147,10 @@ export const parseDescriptionDefault = (reg: Register, description: string) => {
|
||||
if (currentAccess) {
|
||||
detail[currentAccess] = accessData;
|
||||
}
|
||||
// Push the parsed detail into modes
|
||||
// Push the parsed detail into modes, unless it is empty — body-less
|
||||
// registers (e.g. the "Reserved" entries) would otherwise add a blank mode.
|
||||
reg.modes = reg.modes || [];
|
||||
reg.modes.push(detail);
|
||||
if (detailHasContent(detail)) {
|
||||
reg.modes.push(detail);
|
||||
}
|
||||
};
|
||||
@@ -5,7 +5,7 @@
|
||||
// - Lines with three or more leading spaces (>=3) belong to the current mode.
|
||||
// - A line with exactly two spaces followed by '*' is a parent (register-level) note, not a mode note.
|
||||
// - Inside access blocks for F0, lines starting with '*' are headings for description (not notes).
|
||||
import { Register, RegisterAccess, RegisterDetail } from "@/utils/register_parser";
|
||||
import { Register, RegisterAccess, RegisterDetail, detailHasContent, isIssueRestricted } from "@/utils/register_parser";
|
||||
|
||||
export const parseDescriptionF0 = (reg: Register, description: string) => {
|
||||
const descriptionLines = description.split('\n');
|
||||
@@ -37,7 +37,10 @@ export const parseDescriptionF0 = (reg: Register, description: string) => {
|
||||
// finalize previous access block into detail
|
||||
detail[currentAccess] = accessData;
|
||||
}
|
||||
reg.modes.push(detail);
|
||||
// Skip the initial blank detail that precedes the first mode header.
|
||||
if (detailHasContent(detail)) {
|
||||
reg.modes.push(detail);
|
||||
}
|
||||
detail = {read: undefined, write: undefined, common: undefined, text: ''};
|
||||
detail.modeName = trimmedLine;
|
||||
|
||||
@@ -47,7 +50,7 @@ export const parseDescriptionF0 = (reg: Register, description: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (line.includes('Issue 4 Only')) reg.issue_4_only = true;
|
||||
if (isIssueRestricted(line)) reg.issue_4_only = true;
|
||||
|
||||
|
||||
if (trimmedLine.startsWith('//')) continue;
|
||||
@@ -110,20 +113,17 @@ export const parseDescriptionF0 = (reg: Register, description: string) => {
|
||||
}
|
||||
}
|
||||
} else if (trimmedLine) {
|
||||
if (line.match(/^\s+/) && accessData.operations.length > 0) {
|
||||
accessData.operations[accessData.operations.length - 1].description += `\n${line}`;
|
||||
} else {
|
||||
if (!accessData.description) {
|
||||
accessData.description = '';
|
||||
}
|
||||
accessData.description += `\n${trimmedLine}`;
|
||||
}
|
||||
// Prose outside any access block (the leading "R/W Issues 4 and 5 Only -
|
||||
// (soft reset = 0x80)" line) is register-level commentary.
|
||||
reg.text += `${trimmedLine}\n`;
|
||||
}
|
||||
}
|
||||
if (currentAccess) {
|
||||
detail[currentAccess] = accessData;
|
||||
}
|
||||
|
||||
// Push the parsed detail into modes
|
||||
reg.modes.push(detail);
|
||||
// Push the final mode's detail into modes (if it carries content).
|
||||
if (detailHasContent(detail)) {
|
||||
reg.modes.push(detail);
|
||||
}
|
||||
};
|
||||
|
||||
8
src/utils/serialize.ts
Normal file
8
src/utils/serialize.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Deep-clone a value through JSON round-trip.
|
||||
* Strips non-serializable properties (e.g. Drizzle decimal wrappers)
|
||||
* so the result is safe to pass from Server Components to Client Components.
|
||||
*/
|
||||
export function serialize<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
Reference in New Issue
Block a user