Files

350 lines
16 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
/**
* `/appendix`: the glossary of LeetCode topic tags the campaign meets without
* teaching them as a topic (`appendix.ts`), one card per term, filed A–Z under
* a letter index. A card is the target of that tag's badge on every problem
* page (`/appendix#<slug>`, from `tagTarget()`), so it carries what the badge
* shows (the term's icon in the tag's colour from `lib/tag-colors.ts`, over
* the same tag universe `ProblemBadges.astro` colours) and lists the problem
* pages whose badge links here: the backlinks a per-term page used to have.
* Companies file into the same letters: one card per company any problem
* page's `companies:` names (`companies.ts`), the target of that company's
* pill in the page's company table, whose backlinks are the solved problems
* it asked in the last six months, each with how recently. The filter is a
* custom element over static markup, the way the Carousel is built; with no
* JS the page is the full glossary.
*/
import { getEntry } from "astro:content";
import data from "blume:data";
import Icon from "blume/components/Icon.astro";
import { withBase } from "blume/components/islands/base-path.ts";
import PageLayout from "blume/components/layout/PageLayout.astro";
import { APPENDIX } from "../appendix.ts";
import { RECENCY, RECENCY_LABEL, companyHref, type Company } from "../companies.ts";
import { tagColors } from "../lib/tag-colors.ts";
import { TOPIC_LANDINGS, tagTarget, type TopicTag } from "../topic-landings.ts";
const { config } = data;
const served = new Set<string>(data.routes.map((r: { path: string }) => r.path));
// Problem pages (`<num>. <name>` titles, the shape sync writes) with their tags.
const problems = await Promise.all(
data.routes
.filter((r: { title: string }) => /^\d+\.\s/u.test(r.title))
.map(async (r: { path: string; title: string; collection: string; entryId: string }) => {
const entry = await getEntry(r.collection, r.entryId);
const [, num, name] = r.title.match(/^(\d+)\.\s+(.+)$/u)!;
return {
route: r.path,
num: Number(num),
name: name!,
difficulty: entry?.data.sidebar?.badge as string | undefined,
tags: (entry?.data.topics ?? []) as TopicTag[],
companies: (entry?.data.companies ?? []) as Company[],
};
}),
);
const colors = tagColors(
[...Object.keys(TOPIC_LANDINGS), ...Object.keys(APPENDIX)],
problems.map((p) => p.tags.map((t) => t.slug)),
);
/** One card: a glossary term or a company, filed together under its first letter. */
interface Entry {
id: string;
name: string;
icon: string;
color?: string;
category: string;
definition?: string;
leetcode?: string;
problems: { route: string; num: number; name: string; difficulty?: string; note?: string }[];
}
const terms: Entry[] = Object.entries(APPENDIX).map(([slug, term]) => ({
id: slug,
...term,
color: colors.get(slug),
leetcode: `https://leetcode.com/problem-list/${slug}/`,
problems: problems
.filter((p) => p.tags.some((tag) => tagTarget(tag, p, served).href === `/appendix#${slug}`))
.sort((a, b) => a.num - b.num),
}));
// Every company a problem page names; its backlinks most recent window first,
// then by number.
const companies: Entry[] = [
...Map.groupBy(
problems.flatMap((p) => p.companies.map((c) => ({ ...p, company: c.name, period: c.period }))),
(row) => row.company,
),
].map(([name, rows]) => ({
id: companyHref(name).slice("/appendix#".length),
name,
icon: "building-2",
category: "Company",
problems: rows
.sort((a, b) => RECENCY.indexOf(a.period) - RECENCY.indexOf(b.period) || a.num - b.num)
.map((row) => ({ ...row, note: `within ${RECENCY_LABEL[row.period]}` })),
}));
const sections = new Map<string, Entry[]>();
for (const entry of [...terms, ...companies].sort((a, b) => a.name.localeCompare(b.name))) {
const letter = entry.name[0]!.toUpperCase();
sections.set(letter, [...(sections.get(letter) ?? []), entry]);
}
const LETTERS = [..."ABCDEFGHIJKLMNOPQRSTUVWXYZ"];
// A card's Solved list shows this many rows at a time; a longer one (Array has
// dozens) pages through them with numbered buttons under the list.
const PAGE_SIZE = 5;
// The /progress cards' difficulty palette (--chart-1..3 in theme.css), as on
// the changelog, so a Hard reads the same colour everywhere.
const DIFFICULTY: Record<string, { label: string; class: string }> = {
Easy: { label: "Easy", class: "border-chart-1/40 bg-chart-1/10 text-chart-1" },
Medium: { label: "Med.", class: "border-chart-2/40 bg-chart-2/10 text-chart-2" },
Hard: { label: "Hard", class: "border-chart-3/40 bg-chart-3/10 text-chart-3" },
};
const description =
"The LeetCode topics the campaign meets without teaching them as a topic, what each one means and the solved problems that use it, and the companies that asked those problems in the last six months.";
---
<PageLayout
site={{ title: config.title, description: config.description }}
logo={config.logo}
favicon={config.favicon}
banner={config.banner}
analytics={config.analytics}
navigation={data.navigation}
fontCssVars={data.fontCssVars}
themeMode={config.theme.mode}
searchEnabled={config.search.enabled}
siteUrl={config.site}
ogEnabled={config.og.enabled}
structuredDataEnabled={config.structuredData}
page={{ title: `Appendix - ${config.title}`, description, route: "/appendix" }}
>
<section class="mx-auto w-full max-w-5xl px-6 py-16">
<h1 class="m-0 text-4xl font-semibold tracking-tight text-foreground">Appendix</h1>
<p class="mt-3 max-w-3xl text-lg text-muted-foreground">{description}</p>
<appendix-filter class="mt-10 block">
<div class="flex flex-col gap-4 border-border border-b pb-6 sm:flex-row sm:items-center sm:justify-between">
<nav aria-label="Letters" class="flex flex-wrap gap-1 font-mono text-sm">
{
LETTERS.map((letter) =>
sections.has(letter) ? (
<a
href={`#letter-${letter}`}
data-letter-link={letter}
class="inline-flex size-7 items-center justify-center rounded-md text-foreground no-underline transition-colors hover:bg-muted"
>
{letter}
</a>
) : (
<span class="inline-flex size-7 items-center justify-center text-muted-foreground/40" aria-hidden="true">
{letter}
</span>
),
)
}
</nav>
<label class="relative block sm:w-64">
<span class="sr-only">Filter terms</span>
<Icon
class="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-muted-foreground"
name="search"
size={14}
/>
<input
type="search"
placeholder={`Filter ${terms.length} terms, ${companies.length} companies`}
class="w-full rounded-md border border-border bg-background py-1.5 pr-3 pl-8 text-foreground text-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-foreground/40"
/>
</label>
</div>
{
[...sections].map(([letter, group]) => (
<section id={`letter-${letter}`} data-letter={letter} class="scroll-mt-24 pt-10">
<h2 class="m-0 font-mono text-2xl font-semibold text-foreground">{letter}</h2>
<div class="mt-4 grid gap-4 sm:grid-cols-2">
{group.map((entry) => (
<article
id={entry.id}
data-term={`${entry.name} ${entry.category} ${entry.definition ?? ""}`.toLowerCase()}
class="flex scroll-mt-24 flex-col rounded-lg border border-border bg-muted/30 p-5 transition-colors target:border-foreground/40 target:bg-muted/60"
>
<header class="flex items-start gap-3">
<span
class="flex size-9 shrink-0 items-center justify-center rounded-md border border-border text-muted-foreground"
style={entry.color && `color: ${entry.color}; border-color: ${entry.color}66; background: ${entry.color}1f`}
>
<Icon name={entry.icon} size={18} />
</span>
<div class="min-w-0 flex-1">
<h3 class="m-0 text-base font-semibold leading-snug">
<a class="text-foreground no-underline hover:underline" href={`#${entry.id}`}>
{entry.name}
</a>
</h3>
<p class="m-0 mt-0.5 font-mono text-[11px] uppercase tracking-wide text-muted-foreground">
{entry.category}
</p>
</div>
{entry.leetcode && (
<a
class="shrink-0 font-mono text-xs text-muted-foreground no-underline hover:underline"
href={entry.leetcode}
target="_blank"
rel="noopener"
>
LeetCode ↗
</a>
)}
</header>
{entry.definition && (
<p class="mt-3 mb-0 text-sm leading-relaxed text-muted-foreground">{entry.definition}</p>
)}
{entry.problems.length > 0 && (
<details class="group mt-4 border-border border-t pt-3" open>
<summary class="mb-1 flex cursor-pointer list-none items-center justify-between gap-2 text-muted-foreground transition-colors hover:text-foreground [&::-webkit-details-marker]:hidden">
<span class="flex items-baseline gap-2 font-medium text-xs uppercase tracking-wide">
Solved
<span class="tabular-nums">{entry.problems.length}</span>
</span>
<Icon class="shrink-0 transition-transform group-open:rotate-180" name="chevron-down" size={14} />
</summary>
<solved-pages class="block">
<ul class="m-0 list-none p-0 text-sm">
{entry.problems.map((p, i) => (
<li data-page={Math.floor(i / PAGE_SIZE)}>
<a
class="-mx-2 flex items-baseline gap-3 rounded-md px-2 py-1 text-foreground no-underline transition-colors hover:bg-muted"
href={withBase(p.route)}
>
<span class="w-10 shrink-0 text-right font-mono text-muted-foreground text-xs tabular-nums">
{p.num}
</span>
<span class="min-w-0 flex-1 truncate">{p.name}</span>
{p.note && <span class="shrink-0 text-muted-foreground text-xs">{p.note}</span>}
{p.difficulty && DIFFICULTY[p.difficulty] && (
<span
class:list={[
"shrink-0 rounded-full border px-2 py-px font-mono text-[11px] leading-4",
DIFFICULTY[p.difficulty]!.class,
]}
>
{DIFFICULTY[p.difficulty]!.label}
</span>
)}
</a>
</li>
))}
</ul>
{entry.problems.length > PAGE_SIZE && (
<nav
aria-label={`${entry.name} solved pages`}
class="mt-2 flex flex-nowrap justify-center gap-1 font-mono text-xs"
hidden
>
{Array.from({ length: Math.ceil(entry.problems.length / PAGE_SIZE) }, (_, page) => (
<button
type="button"
data-goto={page}
aria-label={`Page ${page + 1}`}
class="inline-flex size-7 cursor-pointer items-center justify-center rounded-md border border-transparent text-muted-foreground tabular-nums transition-colors hover:bg-muted hover:text-foreground aria-[current=page]:border-border aria-[current=page]:bg-muted aria-[current=page]:text-foreground"
>
{page + 1}
</button>
))}
</nav>
)}
</solved-pages>
</details>
)}
</article>
))}
</div>
</section>
))
}
<p data-empty hidden class="mt-10 text-muted-foreground">Nothing matches that filter.</p>
</appendix-filter>
</section>
</PageLayout>
<script>
/** Hides the cards (and letters) the filter text does not match. */
class AppendixFilter extends HTMLElement {
connectedCallback() {
const input = this.querySelector("input");
const empty = this.querySelector<HTMLElement>("[data-empty]");
if (!input || !empty) return;
input.addEventListener("input", () => {
const query = input.value.trim().toLowerCase();
let shown = 0;
for (const section of this.querySelectorAll<HTMLElement>("[data-letter]")) {
let visible = 0;
for (const card of section.querySelectorAll<HTMLElement>("[data-term]")) {
card.hidden = !card.dataset.term!.includes(query);
if (!card.hidden) visible++;
}
section.hidden = visible === 0;
const link = this.querySelector<HTMLElement>(`[data-letter-link="${section.dataset.letter}"]`);
link?.classList.toggle("opacity-30", visible === 0);
shown += visible;
}
empty.hidden = shown > 0;
});
}
}
customElements.define("appendix-filter", AppendixFilter);
/**
* Pages a card's Solved list: shows the rows of one page and marks its
* number button current. Server markup lists every row with the buttons
* hidden, so without JS the card shows the whole list. Only the first,
* last, current and its neighbours stay visible, a "…" standing for each
* gap, so a long list (Amazon has 20+ pages) still pages on one line.
*/
class SolvedPages extends HTMLElement {
connectedCallback() {
const nav = this.querySelector<HTMLElement>("nav");
if (!nav) return;
const rows = this.querySelectorAll<HTMLElement>("li[data-page]");
const buttons = [...nav.querySelectorAll<HTMLButtonElement>("button[data-goto]")];
const last = buttons.length - 1;
const show = (page: string) => {
const current = Number(page);
for (const row of rows) row.hidden = row.dataset.page !== page;
for (const gap of nav.querySelectorAll("[data-gap]")) gap.remove();
let previous = -1;
buttons.forEach((button, i) => {
if (i === current) button.setAttribute("aria-current", "page");
else button.removeAttribute("aria-current");
button.hidden = i !== 0 && i !== last && Math.abs(i - current) > 1;
if (button.hidden) return;
if (i - previous > 1 && previous >= 0) {
const gap = document.createElement("span");
gap.dataset.gap = "";
gap.ariaHidden = "true";
gap.className = "inline-flex size-7 items-center justify-center text-muted-foreground";
gap.textContent = "…";
button.before(gap);
}
previous = i;
});
};
nav.addEventListener("click", (event) => {
const button = (event.target as Element).closest<HTMLButtonElement>("button[data-goto]");
if (button) show(button.dataset.goto!);
});
show("0");
nav.hidden = false;
}
}
customElements.define("solved-pages", SolvedPages);
</script>