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}

    +
    +
    + + + +
    + ); +}