From 42600d6aa464ae4c5c93c900f20806cdcd17bd70 Mon Sep 17 00:00:00 2001 From: Harry Randall Date: Wed, 23 Sep 2026 17:41:59 +1000 Subject: [PATCH] feat: rebuild the structure page around what its content means The Related card listed every code the ANU page mentioned, and Information printed each scraped line as its own row, page furniture included. A structure now shows the degrees it is offered in, the majors, minors and specialisations a programme lets students choose, and what it cannot be combined with, each under its own heading with titles rather than bare codes. Information shows the fixed sections as tidied prose: lists read as lists, course codes and record links open in Coursemap, other links open safely and nothing is rendered as raw HTML. The jump list appears only when there are enough sections to need it. --- apps/web/lib/catalogue/record-reference.ts | 48 +++++ apps/web/tests/catalogue-markdown.test.tsx | 68 ++++++ apps/web/tests/record-reference.test.ts | 39 ++++ apps/web/tests/structure-detail-view.test.tsx | 130 +++++++++++ apps/web/ui/common/catalogue-markdown.tsx | 204 ++++++++++++++++++ .../ui/requirements/structure-detail-view.tsx | 165 ++++---------- .../web/ui/requirements/structure-related.tsx | 137 ++++++++++++ .../web/ui/requirements/structure-section.tsx | 42 ++++ 8 files changed, 706 insertions(+), 127 deletions(-) create mode 100644 apps/web/lib/catalogue/record-reference.ts create mode 100644 apps/web/tests/catalogue-markdown.test.tsx create mode 100644 apps/web/tests/record-reference.test.ts create mode 100644 apps/web/tests/structure-detail-view.test.tsx create mode 100644 apps/web/ui/common/catalogue-markdown.tsx create mode 100644 apps/web/ui/requirements/structure-related.tsx create mode 100644 apps/web/ui/requirements/structure-section.tsx diff --git a/apps/web/lib/catalogue/record-reference.ts b/apps/web/lib/catalogue/record-reference.ts new file mode 100644 index 00000000..52779021 --- /dev/null +++ b/apps/web/lib/catalogue/record-reference.ts @@ -0,0 +1,48 @@ +import type { CatalogueKind } from "./content.ts"; + +const COURSE_CODE = /^[A-Z]{4}\d{4}[A-Z]?$/u; +const STRUCTURE_CODE = /^[A-Z0-9][A-Z0-9-]{1,31}$/u; +const ANU_RECORD_PATH = + /^\/(?:\d{4}\/)?(course|program|major|minor|specialisation)\/([A-Za-z0-9-]+)\/?$/iu; +const ANU_HOST = "programsandcourses.anu.edu.au"; + +/** The catalogue kind an ANU code belongs to, read from its shape. */ +export function catalogueKindForCode(code: string): CatalogueKind { + if (COURSE_CODE.test(code)) return "course"; + if (code.endsWith("-MAJ")) return "major"; + if (code.endsWith("-MIN")) return "minor"; + if (/-(?:HSPC|SPEC)$/u.test(code)) return "specialisation"; + return "programme"; +} + +/** + * The Coursemap record a link in imported text points at, if any. The model + * writes a record either as its bare code, from the page's own links, or as + * the full ANU address it copied; both open the record in Coursemap rather + * than sending the reader back to ANU. + */ +export function catalogueRecordFromReference( + target: string, +): { kind: CatalogueKind; code: string } | null { + const trimmed = target.trim(); + // A link target already written as an upper-case code is a record; words + // and addresses are not. + if (STRUCTURE_CODE.test(trimmed)) { + return { kind: catalogueKindForCode(trimmed), code: trimmed }; + } + let url: URL; + try { + url = new URL(trimmed); + } catch { + return null; + } + if (url.hostname !== ANU_HOST) return null; + const match = ANU_RECORD_PATH.exec(url.pathname); + if (!match) return null; + const code = match[2].toUpperCase(); + const kind = + match[1].toLowerCase() === "program" + ? "programme" + : (match[1].toLowerCase() as CatalogueKind); + return { kind, code }; +} diff --git a/apps/web/tests/catalogue-markdown.test.tsx b/apps/web/tests/catalogue-markdown.test.tsx new file mode 100644 index 00000000..8393510b --- /dev/null +++ b/apps/web/tests/catalogue-markdown.test.tsx @@ -0,0 +1,68 @@ +import { render, screen } from "@testing-library/react"; +import { TooltipProvider } from "@coursemap/ui/primitives/tooltip"; +import { expect, test } from "vitest"; + +import { CatalogueMarkdown } from "@/ui/common/catalogue-markdown"; + +function renderMarkdown(markdown: string, available: string[] = []) { + return render( + + + , + ); +} + +test("a recommended-course list reads as a list with course links", () => { + renderMarkdown( + [ + "**What courses should you take in first year?**", + "", + "- MATH1115 Advanced Mathematics and Applications 1", + "- MATH1116 Advanced Mathematics and Applications 2", + ].join("\n"), + ["MATH1115"], + ); + expect(screen.getAllByRole("listitem")).toHaveLength(2); + expect(screen.getByRole("link", { name: "MATH1115" })).toHaveAttribute( + "href", + "/courses/2026/math1115", + ); + // An unpublished course is named but not linked. + expect(screen.queryByRole("link", { name: "MATH1116" })).toBeNull(); + expect( + screen + .getByText("What courses should you take in first year?") + .closest("strong"), + ).not.toBeNull(); +}); + +test("record links stay in Coursemap and other links leave safely", () => { + renderMarkdown( + "Take it with a [Mathematics Major](http://programsandcourses.anu.edu.au/major/MATH-MAJ) or [Bachelor of Arts](BARTS). [Apply](https://study.anu.edu.au/apply) or email students.cos@anu.edu.au.", + ); + expect( + screen.getByRole("link", { name: "Mathematics Major" }), + ).toHaveAttribute("href", "/majors/2026/math-maj"); + expect( + screen.getByRole("link", { name: "Bachelor of Arts" }), + ).toHaveAttribute("href", "/programmes/2026/barts"); + const external = screen.getByRole("link", { name: "Apply" }); + expect(external).toHaveAttribute("target", "_blank"); + expect(external).toHaveAttribute("rel", "noopener noreferrer"); + expect( + screen.getByRole("link", { name: "students.cos@anu.edu.au" }), + ).toHaveAttribute("href", "mailto:students.cos@anu.edu.au"); +}); + +test("markup in imported text is shown as text, never run", () => { + const { container } = renderMarkdown( + ' [click](javascript:alert(1))', + ); + expect(container.querySelector("img")).toBeNull(); + expect(screen.queryByRole("link", { name: "click" })).toBeNull(); + expect(screen.getByText(/ { + expect(catalogueKindForCode("MATH1115")).toBe("course"); + expect(catalogueKindForCode("MATH-MAJ")).toBe("major"); + expect(catalogueKindForCode("AARB-MIN")).toBe("minor"); + expect(catalogueKindForCode("ADMA-SPEC")).toBe("specialisation"); + expect(catalogueKindForCode("COMP-HSPC")).toBe("specialisation"); + expect(catalogueKindForCode("BARTS")).toBe("programme"); +}); + +test("record links open in Coursemap whether written as a code or an ANU address", () => { + expect(catalogueRecordFromReference("BARTS")).toEqual({ + kind: "programme", + code: "BARTS", + }); + expect( + catalogueRecordFromReference( + "http://programsandcourses.anu.edu.au/major/MATH-MAJ", + ), + ).toEqual({ kind: "major", code: "MATH-MAJ" }); + expect( + catalogueRecordFromReference( + "https://programsandcourses.anu.edu.au/2026/program/AACOM", + ), + ).toEqual({ kind: "programme", code: "AACOM" }); + expect( + catalogueRecordFromReference("https://study.anu.edu.au/apply"), + ).toBeNull(); + expect( + catalogueRecordFromReference("mailto:students.cos@anu.edu.au"), + ).toBeNull(); + expect(catalogueRecordFromReference("apply")).toBeNull(); +}); diff --git a/apps/web/tests/structure-detail-view.test.tsx b/apps/web/tests/structure-detail-view.test.tsx new file mode 100644 index 00000000..389d2909 --- /dev/null +++ b/apps/web/tests/structure-detail-view.test.tsx @@ -0,0 +1,130 @@ +import { render, screen } from "@testing-library/react"; +import { Tabs } from "@coursemap/ui/primitives/tabs"; +import { TooltipProvider } from "@coursemap/ui/primitives/tooltip"; +import { expect, test } from "vitest"; + +import type { StructureDetails } from "@/lib/coursemap/structure-types"; +import { readingTreeContext } from "@/ui/requirements/requirement-presentation"; +import { StructureDetailView } from "@/ui/requirements/structure-detail-view"; + +function structure( + overrides: Partial = {}, +): StructureDetails { + return { + code: "AARB-MIN", + kind: "minor", + year: 2026, + name: "Advanced Arabic", + acronym: null, + shortName: null, + introduction: "Build on intermediate Arabic.", + description: null, + units: 24, + durationYears: null, + academicCareer: "Undergraduate", + college: "ANU College of Arts and Social Sciences", + modeOfDelivery: null, + selectionRank: null, + atar: null, + studyAs: null, + contactText: null, + sections: [], + learningOutcomes: [], + fees: [], + relationships: [], + requirements: null, + ...overrides, + }; +} + +function renderView(tab: string, details: StructureDetails) { + return render( + + + + + , + ); +} + +test("a minor names the degrees it is offered in, by title", () => { + renderView( + "overview", + structure({ + relationships: [ + { + position: 1, + relationshipKind: "offered_in", + targetKind: "programme", + targetCode: "BARTS", + targetTitle: "Bachelor of Arts", + }, + { + position: 2, + relationshipKind: "offered_in", + targetKind: "programme", + targetCode: "ELANG", + targetTitle: "Diploma of Languages", + }, + ], + }), + ); + expect( + screen.getByRole("heading", { name: "Offered in" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /Bachelor of Arts/u }), + ).toHaveAttribute("href", "/programmes/2026/barts"); + expect(screen.queryByText(/Mentioned by the ANU page/u)).toBeNull(); +}); + +test("a programme groups the structures it offers by kind", () => { + renderView( + "overview", + structure({ + code: "AACOM", + kind: "programme", + relationships: [ + { + position: 1, + relationshipKind: "option", + targetKind: "specialisation", + targetCode: "ARIN-SPEC", + targetTitle: "Artificial Intelligence", + }, + ], + }), + ); + expect( + screen.getByRole("heading", { name: "Choose from" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: "Specialisations" }), + ).toBeInTheDocument(); +}); + +test("information reads as tidy sections without a jump list for one card", () => { + renderView( + "information", + structure({ + sections: [ + { + position: 1, + sectionKey: "first_year_advice", + heading: "First-year advice", + markdown: + "- MATH1115 Advanced Mathematics and Applications 1\n- MATH1116 Advanced Mathematics and Applications 2", + }, + ], + }), + ); + expect( + screen.getByRole("heading", { name: "First-year advice" }), + ).toBeInTheDocument(); + expect(screen.getAllByRole("listitem")).toHaveLength(2); + expect(screen.queryByRole("navigation")).toBeNull(); + expect(screen.queryByText(/Back to the top/iu)).toBeNull(); +}); diff --git a/apps/web/ui/common/catalogue-markdown.tsx b/apps/web/ui/common/catalogue-markdown.tsx new file mode 100644 index 00000000..47c68c75 --- /dev/null +++ b/apps/web/ui/common/catalogue-markdown.tsx @@ -0,0 +1,204 @@ +"use client"; + +import Link from "next/link"; +import type { ReactNode } from "react"; +import { catalogueRecordFromReference } from "@/lib/catalogue/record-reference"; +import { publicCatalogueRecordPath } from "@/lib/coursemap/catalogue-kinds"; +import { CourseReferenceText } from "@/ui/courses/course-reference"; + +type Block = + | { kind: "heading"; text: string } + | { kind: "paragraph"; text: string } + | { kind: "list"; ordered: boolean; items: string[] }; + +const LIST_ITEM = /^\s*(?:[-*+]|(\d+)[.)])\s+(.*)$/u; +const HEADING = /^\s*#{1,6}\s+(.*)$/u; +const INLINE = + /\*\*(.+?)\*\*|\*(\S(?:.*?\S)?)\*|\[([^\]]+)\]\(([^)\s]+)\)|([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})/u; + +/** + * Splits imported Markdown into the blocks the model actually writes: + * headings, paragraphs and flat lists. Lines within a paragraph are joined, + * because ANU wraps prose at arbitrary points. + */ +function markdownBlocks(markdown: string): Block[] { + const blocks: Block[] = []; + let paragraph: string[] = []; + const endParagraph = () => { + if (paragraph.length) { + blocks.push({ kind: "paragraph", text: paragraph.join(" ") }); + paragraph = []; + } + }; + for (const line of markdown.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (!trimmed) { + endParagraph(); + continue; + } + const heading = HEADING.exec(trimmed); + if (heading) { + endParagraph(); + blocks.push({ kind: "heading", text: heading[1] }); + continue; + } + const item = LIST_ITEM.exec(line); + if (item) { + endParagraph(); + const ordered = item[1] !== undefined; + const previous = blocks.at(-1); + if (previous?.kind === "list" && previous.ordered === ordered) { + previous.items.push(item[2]); + } else { + blocks.push({ kind: "list", ordered, items: [item[2]] }); + } + continue; + } + paragraph.push(trimmed); + } + endParagraph(); + return blocks; +} + +function Inline({ + text, + academicYear, + availableCourseCodes, +}: { + text: string; + academicYear: number; + availableCourseCodes: ReadonlySet; +}) { + const parts: ReactNode[] = []; + let rest = text; + let key = 0; + const plain = (value: string) => { + if (!value) return; + parts.push( + , + ); + }; + for (let match = INLINE.exec(rest); match; match = INLINE.exec(rest)) { + plain(rest.slice(0, match.index)); + const [whole, bold, italic, linkText, linkTarget, email] = match; + const inner = (value: string) => ( + + ); + if (bold !== undefined) { + parts.push({inner(bold)}); + } else if (italic !== undefined) { + parts.push({inner(italic)}); + } else if (linkText !== undefined && linkTarget !== undefined) { + const record = catalogueRecordFromReference(linkTarget); + if (record) { + parts.push( + + {linkText} + , + ); + } else if (/^(?:https?:|mailto:)/u.test(linkTarget)) { + parts.push( + + {linkText} + , + ); + } else { + plain(linkText); + } + } else if (email !== undefined) { + parts.push( + + {email} + , + ); + } + rest = rest.slice(match.index + whole.length); + } + plain(rest); + return <>{parts}; +} + +/** + * Imported catalogue prose, rendered from the small Markdown vocabulary the + * model writes. Record links and course codes open in Coursemap, other links + * leave for the page they name, and nothing is ever rendered as raw HTML. + */ +export function CatalogueMarkdown({ + markdown, + academicYear, + availableCourseCodes, +}: { + markdown: string; + academicYear: number; + availableCourseCodes: ReadonlySet; +}) { + const inline = (text: string) => ( + + ); + return ( +
+ {markdownBlocks(markdown).map((block, index) => { + if (block.kind === "heading") { + return ( +

+ {inline(block.text)} +

+ ); + } + if (block.kind === "list") { + const List = block.ordered ? "ol" : "ul"; + return ( + + {block.items.map((item, itemIndex) => ( +
  • {inline(item)}
  • + ))} +
    + ); + } + return

    {inline(block.text)}

    ; + })} +
    + ); +} diff --git a/apps/web/ui/requirements/structure-detail-view.tsx b/apps/web/ui/requirements/structure-detail-view.tsx index f626442c..de48509a 100644 --- a/apps/web/ui/requirements/structure-detail-view.tsx +++ b/apps/web/ui/requirements/structure-detail-view.tsx @@ -1,6 +1,5 @@ "use client"; -import Link from "next/link"; import { Banknote, BookOpen, @@ -28,11 +27,7 @@ import { TabsList, TabsTrigger, } from "@coursemap/ui/primitives/tabs"; -import { - CATALOGUE_KIND_LABELS, - publicCatalogueRecordPath, -} from "@/lib/coursemap/catalogue-kinds"; -import { isCatalogueKind } from "@/lib/catalogue/content"; +import { CATALOGUE_KIND_LABELS } from "@/lib/coursemap/catalogue-kinds"; import type { StructureDetails, StructureFee, @@ -42,10 +37,15 @@ import { STRUCTURE_FEE_BASIS_LABELS, STRUCTURE_FEE_TYPE_LABELS, } from "@/lib/coursemap/structure-types"; -import { STRUCTURE_RELATIONSHIP_LABELS } from "@/lib/catalogue/structure-vocabulary"; +import { CatalogueMarkdown } from "@/ui/common/catalogue-markdown"; import { SectionNavigation } from "@/ui/common/section-navigation"; import { RequirementGroupView } from "@/ui/requirements/requirement-tree"; import type { TreeContext } from "@/ui/requirements/requirement-presentation"; +import { StructureRelated } from "@/ui/requirements/structure-related"; +import { + StructureSectionCard, + structureSectionAnchor, +} from "@/ui/requirements/structure-section"; export const structureDetailTabs = [ { id: "overview", label: "Overview", icon: BookOpen }, @@ -86,54 +86,6 @@ function feeAmount(fee: StructureFee) { return basis ? `${amount} ${basis}` : amount; } -const CODE_LINE = /^[A-Z]{4}[0-9]{4}[A-Z]?$|^[A-Z0-9][A-Z0-9-]{1,31}$/u; - -/** - * ANU section bodies arrive as one line per scraped element, so rendering them - * as pre-wrapped text produced a wall with no rhythm: a course code, its - * title and its unit value read as three unrelated sentences. Each line is - * given its own row, and a bare code is set in the monospace face so a study - * plan scans as a list of courses rather than prose. - */ -function SectionLines({ markdown }: { markdown: string }) { - const lines = markdown - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean); - if (lines.length === 0) - return ( -

    - The ANU page left this section empty. -

    - ); - if (lines.length === 1) - return ( -

    {lines[0]}

    - ); - return ( -
      - {lines.map((line, index) => ( -
    • - {line} -
    • - ))} -
    - ); -} - -function sectionAnchor(sectionKey: string) { - return `section-${sectionKey}`; -} - /** * The reader's body of a structure page. The student route and the import * preview both render this, so a draft preview cannot drift away from what a @@ -166,7 +118,9 @@ export function StructureDetailView({ ["Study as", structure.studyAs], ].filter((entry): entry is [string, string] => Boolean(entry[1])); - const informationSections = structure.sections; + const availableCourseCodes = new Set( + treeContext.catalogue.courses.map((course) => course.code), + ); return (
    @@ -207,12 +161,18 @@ export function StructureDetailView({ {structure.introduction ? ( -

    - {structure.introduction} -

    + ) : null} {structure.description ? ( -

    {structure.description}

    + ) : null} {!structure.introduction && !structure.description ? (

    @@ -296,53 +256,7 @@ export function StructureDetailView({ ) : null} - {structure.relationships.length ? ( - - - -

    Related

    - - - -
      - {structure.relationships.map((relationship) => ( -
    • - - - - {relationship.targetCode} - - {relationship.targetTitle ? ( - - {relationship.targetTitle} - - ) : null} - - - { - STRUCTURE_RELATIONSHIP_LABELS[ - relationship.relationshipKind - ] - } - - -
    • - ))} -
    -
    - - ) : null} +
    @@ -370,28 +284,25 @@ export function StructureDetailView({ - {informationSections.length ? ( + {structure.sections.length ? ( <> - ({ - id: sectionAnchor(section.sectionKey), - label: section.heading, - }))} - /> - {informationSections.map((section) => ( - = 3 ? ( + ({ + id: structureSectionAnchor(section), + label: section.heading, + }))} + /> + ) : null} + {structure.sections.map((section) => ( + - - -

    {section.heading}

    -
    -
    - - - -
    + section={section} + academicYear={structure.year} + availableCourseCodes={availableCourseCodes} + /> ))} ) : ( diff --git a/apps/web/ui/requirements/structure-related.tsx b/apps/web/ui/requirements/structure-related.tsx new file mode 100644 index 00000000..b7f0a5ea --- /dev/null +++ b/apps/web/ui/requirements/structure-related.tsx @@ -0,0 +1,137 @@ +import Link from "next/link"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@coursemap/ui/primitives/card"; +import type { StructureRelationshipKind } from "@/lib/catalogue/structure-vocabulary"; +import { publicCatalogueRecordPath } from "@/lib/coursemap/catalogue-kinds"; +import type { + StructureDetails, + StructureKind, + StructureRelationship, +} from "@/lib/coursemap/structure-types"; + +const OPTION_GROUPS: Array<{ kind: StructureKind; title: string }> = [ + { kind: "major", title: "Majors" }, + { kind: "minor", title: "Minors" }, + { kind: "specialisation", title: "Specialisations" }, +]; + +function uniqueTargets( + relationships: readonly StructureRelationship[], + kind: StructureRelationshipKind, + targetKind?: StructureKind, +) { + return [ + ...new Map( + relationships + .filter( + (relationship) => + relationship.relationshipKind === kind && + (!targetKind || relationship.targetKind === targetKind), + ) + .map((relationship) => [relationship.targetCode, relationship]), + ).values(), + ]; +} + +function RecordLinks({ + records, + year, +}: { + records: readonly StructureRelationship[]; + year: number; +}) { + return ( +
      + {records.map((record) => ( +
    • + + + {record.targetTitle ?? record.targetCode} + + + {record.targetCode} + + +
    • + ))} +
    + ); +} + +function RelatedCard({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( + + + +

    {title}

    +
    +
    + + {children} + +
    + ); +} + +/** + * The records a structure relates to, one card per meaning: the degrees it + * can be studied in, the majors, minors and specialisations a programme + * offers, and what it cannot be combined with. Each card appears only when + * the structure has something to put in it. + */ +export function StructureRelated({ + structure, +}: { + structure: StructureDetails; +}) { + const offeredIn = uniqueTargets(structure.relationships, "offered_in"); + const optionGroups = OPTION_GROUPS.map((group) => ({ + ...group, + records: uniqueTargets(structure.relationships, "option", group.kind), + })).filter((group) => group.records.length); + const incompatible = uniqueTargets(structure.relationships, "incompatible"); + + return ( + <> + {offeredIn.length ? ( + + + + ) : null} + {optionGroups.length ? ( + + {optionGroups.map((group) => ( +
    +

    + {group.title} +

    + +
    + ))} +
    + ) : null} + {incompatible.length ? ( + + + + ) : null} + + ); +} diff --git a/apps/web/ui/requirements/structure-section.tsx b/apps/web/ui/requirements/structure-section.tsx new file mode 100644 index 00000000..d3a70972 --- /dev/null +++ b/apps/web/ui/requirements/structure-section.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@coursemap/ui/primitives/card"; +import type { StructureSection } from "@/lib/coursemap/structure-types"; +import { CatalogueMarkdown } from "@/ui/common/catalogue-markdown"; + +export function structureSectionAnchor(section: StructureSection) { + return `section-${section.sectionKey}`; +} + +/** One of a structure's fixed information sections, as tidied prose. */ +export function StructureSectionCard({ + section, + academicYear, + availableCourseCodes, +}: { + section: StructureSection; + academicYear: number; + availableCourseCodes: ReadonlySet; +}) { + return ( + + + +

    {section.heading}

    +
    +
    + + + +
    + ); +}