learning-website-nextjs1-4 Exercise 1: Prepare a Fragment, and Prove It Matches Django =============================================================================================== A content file is not yet what a site shows. Three steps (the same ones the Django importer takes) turn it into a fragment: 1. a complete HTML document gives its body, preceded by the styles from its head; a fragment loses the banner comment at its top; 2. every .txt link is pointed at the course's own solutions folder, whatever the old link said; 3. a site may add one change of its own: the languages site marks each line in the language being taught with a lang attribute (lang="hu", lang="ja", ...), so screen readers and browsers treat it correctly. Save as packages/content/src/fragment.ts: import { posix } from "node:path"; import type { SiteName } from "@lw/sites"; import { addLanguageAttributes } from "./languages.ts"; import { ContentParseError } from "./parse.ts"; /** * Turn a raw content file into the fragment a site shows. The same steps as the Django importer: * a complete document gives its body (plus the styles from its head), a fragment loses its banner comment, * every .txt link is pointed at the course's own solutions folder, and a site may add its own change. */ const BODY_OPEN = /]*>/i; const BODY_CLOSE = /<\/body>/i; const HEAD = /]*>([\s\S]*?)<\/head>/i; const STYLE_ONE = /]*>[\s\S]*?<\/style>/i; const STYLE_ALL = /]*>[\s\S]*?<\/style>/gi; const LEADING_COMMENT = /^\s*\s*/; const TXT_HREF = /href="([^"]*?)([^"/]+\.txt)"/gi; export class MalformedDocument extends ContentParseError { constructor(message: string) { super(message); this.name = "MalformedDocument"; } } export function extractFragment(raw: string): string { const opened = BODY_OPEN.exec(raw); if (!opened) return raw.replace(LEADING_COMMENT, ""); // a fragment loses its banner comment const closed = BODY_CLOSE.exec(raw); if (!closed || closed.index < opened.index) throw new MalformedDocument(" without a matching "); const inner = raw.slice(opened.index + opened[0].length, closed.index); const head = HEAD.exec(raw); const headText = head?.[1] ?? ""; const styles = STYLE_ONE.test(headText) ? (headText.match(STYLE_ALL) ?? []).join("\n") + "\n" : ""; return styles + inner; } /** Percent-decoding like Python's unquote: bad sequences become U+FFFD, they never throw. */ export function unquote(text: string): string { return text.replace(/(?:%[0-9a-fA-F]{2})+/g, (run) => new TextDecoder("utf-8").decode(Uint8Array.from(run.slice(1).split("%").map((hex) => parseInt(hex, 16))))); } /** Every .txt link is a solution file: point it at /solutions/, whatever it said before. */ export function rewriteSolutionLinks(fragment: string, courseUrlPath: string): string { const prefix = courseUrlPath.startsWith("/") ? courseUrlPath : "/" + courseUrlPath; return fragment.replace(TXT_HREF, (_whole, _before: string, file: string) => `href="${prefix}/solutions/${unquote(file)}"`); } export type Transform = (fragment: string) => string; /** Changes that only one site wants. */ export const SITE_TRANSFORMS: Partial> = { languages: [addLanguageAttributes], }; export function prepareFragment(raw: string, path: string, site: SiteName): string { const courseUrlPath = path.includes("/") ? "/" + posix.dirname(path) : ""; let fragment = rewriteSolutionLinks(extractFragment(raw), courseUrlPath); for (const transform of SITE_TRANSFORMS[site] ?? []) fragment = transform(fragment); return fragment; } Save as packages/content/src/languages.ts: /** * Mark the text in the language being taught with a lang attribute. * * Lesson fragments wrap each line in the language being taught in ,