This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Personal portfolio + MDX blog at ahnafnafee.dev. Next.js 16 (App Router) + React 19 + TypeScript + Tailwind CSS v4. Deployed to Vercel; also supports a stripped-down static export (used for a university-hosted mirror).
Yarn is the package manager — yarn.lock is committed. Don't use npm install.
yarn dev # dev server (Turbopack, env from .env.development.local)
yarn dev:webpack # dev server forced to webpack (use if Turbopack misbehaves with mermaid/MDX)
yarn build # production build (Vercel)
yarn export # static export — runs export:prepare → next build → export:cleanup
yarn export:university # static export with .env.development.static.local (BASE_PATH-aware)
yarn lint # eslint . (flat config in eslint.config.mjs)
yarn lint:fix # eslint . --fix
yarn type-check # tsc --noEmit
yarn test # vitest run (unit + integration suite)
yarn test:watch # vitest in watch mode
yarn test:coverage # vitest with v8 coverage report
yarn format # prettier write across js/ts/tsx/md/mdx/json
yarn analyze # bundle analyzer (cross-env ANALYZE=true next build)
yarn validate:json-ld # walk built HTML, verify every <script type="application/ld+json">
yarn validate:sitemap # verify public/sitemap.xml exists, parses, and uses canonical URLs
yarn audit:alt-text # scan MDX for weak <ContentImage>/<img> alt attributes
yarn generate:sitemap # regenerate public/sitemap.xml via custom-next-sitemap.js (chained into build)
yarn generate:llms # regenerate public/llms.txt + public/llms-full.txt (chained into build)
npx tsx indexing/sendIndexingRequest.ts # batch-submit URLs to Google Indexing APITests live under __tests__ directories co-located with the code they cover (e.g. src/libs/seo/__tests__/, src/services/content/__tests__/). The Vitest config (vitest.config.ts) uses happy-dom and resolves @/* paths via Vite's native resolve.tsconfigPaths: true. CI runs the suite on every push/PR via .github/workflows/ci.yml.
All blog, portfolio, and research entries are MDX files on disk — there's no CMS.
- Source files live in
src/data/blog/*.mdx,src/data/portfolio/*.mdx, andsrc/data/research/*.mdx. Filename (minus.mdx) becomes the URL slug. - Frontmatter is parsed by
gray-matterinsrc/services/content/. The shape is enforced by ambient types insrc/types/index.d.ts(declare module 'me') —Blog,Portfolio,Research(plus its sub-typesAuthor,Affiliation,Venue,ResearchLinks,ResearchIdentifiers),Snippet, etc. Add new content fields there first, then update the MDX files. - Reading content:
getContents<T>('/blog')— list all entries (used bygenerateStaticParamsand listing pages).getContentBySlug<T>('/blog', slug)— single entry.getContentHeaders<T>('/blog')— header-only (skips MDX body parsing) — use this whenever a page only needs frontmatter.- All three resolve paths through
LOCATION_DIRinsrc/services/directory/location.ts(=src/data). Change content location there, not in the readers.
- Rendering uses
next-mdx-remote/rsc'sMDXRemoteinsrc/app/blog/[slug]/page.tsxand the portfolio / research equivalents. Plugin set is centralized insrc/libs/mdxConfig.ts(remark-gfm, remark-math, rehype-prism-plus, rehype-slug, rehype-katex). - Custom MDX components live in
src/components/content/mdx/and are wired into theMDXComponentsmap inmdx/index.ts. Notable overrides:Pre,Code— code blocks with prism syntax highlighting.Mermaid— renders```mermaidblocks; mermaid + stylis are transpiled and pre-bundled (seetranspilePackagesandexperimental.optimizePackageImportsinnext.config.js).ContentImage— explicit component for ImageKit-hosted images. The defaultimgMDX mapping was intentionally removed so external images / SVGs in MDX don't break; use<ContentImage>(or a raw<img>) explicitly.MDXLink,Blockquote,Table,Headings— styled overrides.- Interactive features register through
mdx/interactive.ts. Compose the shared panel and choice controls insrc/components/content/interactive/; keep feature data and behavior in its own content folder. See that folder's README for the addition steps.
- Research-specific components live in
src/components/content/research/:- Detail-page:
HeadingResearch(status chip, title, structured authors + affiliations, venue, action-button row, hr — author/affiliation<sup>markers are conditional + carrytitletooltips, see Conventions),ResearchTeaser(full-bleed figure + caption, lives outsideproseso it can breathe),ResearchAbstract(purple-accentednot-prosecard),ResearchBibTeX(anchored at#bibtex, copy-to-clipboard, mirrorsPre.tsxchrome). - Listing-page:
ResearchOverview(intro paragraph),ResearchNews(date-column timeline; entries live in theNEWSarray inside the file),ResearchAreas(colored chip row),ResearchItem+ResearchSections(listing card + section grouping).ComingSoonImageis a pastel-gradient placeholder used in place of the listing thumbnail when an entry hascomingSoon: true. - Shared:
SectionHeadingis the borderedtext-lg md:text-xl font-bold text-black dark:text-whiteh2 used by every section on the listing page (Overview / News / Research Areas / Sections groupers). Use it for any new sections to keep the rhythm consistent. - The detail page composition is in
src/app/research/[slug]/page.tsx. The listing page is insrc/app/research/page.tsx— both passpt-6 md:pt-10toAppLayoutPageto give the first section breathing room from the sticky header.
- Detail-page:
- SEO is generated per-page in
src/app/blog/[slug]/page.tsxand the portfolio / research equivalents:generateMetadatabuilds Next.js Metadata (OG, Twitter, canonical,modifiedTime), and the page itself injects a single@graphJSON-LD block:- Blog:
BlogPosting+WebPage+BreadcrumbList - Portfolio:
SoftwareSourceCode+WebPage+BreadcrumbList - Research:
ScholarlyArticle+WebPage+BreadcrumbList. The first author (matchingSITE_AUTHOR.name) maps to{'@id': PERSON_ID}; coauthors emit inlinePersonnodes withaffiliationand ORCIDidentifier.isPartOfresolves toPeriodicalforpublished/accepted/workshopvenues andCreativeWorkotherwise. DOI / arXiv / ResearchGate IDs fromidentifiersbecomePropertyValueentries onidentifier. The Person@id(https://www.ahnafnafee.dev/#person) is referenced from author/publisher; the full Person node is emitted only on the home page. When you add a new content type, mirror this pattern.
- Blog:
- OG image fallback: if a post has no
thumbnail,generateOgImagefromsrc/libs/metapageconstructs one via/api/og. For research listing cards, the resolution order isthumbnail > teaser > generated OG; ImageKit URLs without an explicit?tr=get?tr=w-600appended so retina displays render sharp on the 176px max slot. TheisImagekitUrlcheck parsesnew URL(...).hostname(CodeQL flagged the substring form as bypassable).
tsconfig.json baseUrl is ./src. Use:
@/*→src/*
Imports are auto-sorted by @ianvs/prettier-plugin-sort-imports (order defined in .prettierrc.js): @/components → @/services → @/libs → @/... → relative → third-party → @/styles/*. Empty strings in importOrder produce blank-line separators between groups. Don't reshuffle by hand; let Prettier do it.
Two roots under src/components/:
ui/— shadcn/ui primitives. Every file here is generated bynpx shadcn@latest add <name>and follows shadcn's data-slot, variant, and class conventions. Don't edit by hand unless intentionally extending; instead usenpx shadcn@latest add <name> --diffto merge upstream changes. Built onradix-ui(configured incomponents.jsonasstyle: radix-nova);radix-uiis not a competing UI system — it's shadcn's primitive layer.site/— bespoke composition components specific to this site (Header, Footer, Nav, AppLayoutPage, Hero, Searchbar, BackToTop, EmptyResult, MobileNav, ThemeMenu, links, image wrappers). They composeui/primitives + project state.
When you need a new primitive, search shadcn first (npx shadcn@latest search). Only fall back to a bespoke component in site/ (or a feature folder) if no primitive fits.
Two layers feed Tailwind v4:
tailwind.config.jskeeps the legacy palette scales —primary: colors.blue,theme: colors.neutral, plus a customgray-{0..900}ramp. Existing classes likebg-primary-500,text-theme-700,dark:bg-gray-900resolve through this. Bridged into v4 via@config "../../tailwind.config.js"inglobals.css.globals.css@theme inline+:root/.darkblocks define the shadcn semantic tokens (--background,--foreground,--primary,--muted,--border,--ring, etc.) and intentionally map them to the same OKLCH values as the legacy scales:--primary↔blue-500(light) /blue-400(dark) — same as legacyprimary-500/400.--background↔neutral-50(light) / customgray-900#111(dark) — matches legacy body bg.--foreground↔neutral-800/100,--muted↔neutral-100/800,--border↔neutral-200/700,--ring↔ blue-500/400 (legacy focus ring).
Net result: bg-primary (shadcn) and bg-primary-500 (legacy) render the same color; both surfaces remain valid during the transition. When adding new code, prefer the semantic tokens. Don't write dark: overrides for shadcn classes — --background etc. switch automatically via .dark.
Fonts: --font-sans resolves to 'Google Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'. Google Sans is loaded via Google Fonts CSS (<link> in layout.tsx head, with preconnect to fonts.gstatic.com — font-src in next.config.js CSP must include it). Local Inter @font-face stays in globals.css as a deeper fallback so the page never FOUCs. tailwind.config.js fontFamily.primary mirrors the same stack so the legacy font-primary utility picks up Google Sans too.
yarn build is the standard Vercel build. yarn export produces a static site — but Next.js can't static-export a tree with API routes, so the export pipeline temporarily moves src/app/api/ out:
scripts/export-prepare.jscopiessrc/app/api/→temp_api_backup/and deletes the original.next buildruns withSTATIC_EXPORT=true(read innext.config.jsto flipoutput: 'export',images.unoptimized: true, and applyBASE_PATHif set).scripts/export-cleanup.jsrestores the API directory from the backup.
If a build crashes mid-export, temp_api_backup/ may be left behind — restore it manually before the next run, otherwise the API routes are gone.
yarn export:university is the same flow with .env.development.static.local, which sets a BASE_PATH for hosting under a sub-path.
ISR endpoint at GET /api/revalidate?secret=<SECRET>&slug=/blog/<slug>. Secret is NEXT_PUBLIC_SECRET or SECRET_KEY. The slug parameter is the actual path (e.g. /blog/local-llm-pdf-ocr), not the route template.
custom-next-sitemap.js runs as the generate:sitemap step, chained explicitly at the end of build. Do not move it back to a postbuild lifecycle hook: Yarn (used by CI and Vercel) does not run npm-style pre/post scripts, so the sitemap silently never generates and /sitemap.xml serves the HTML 404 page. yarn validate:sitemap is the CI gate that catches this. The config allowlists AI/LLM crawlers (GPTBot, Claude-Web, anthropic-ai, etc.), assigns per-route priorities, and skips robots.txt generation under STATIC_EXPORT=true. Edit this file (not a separate sitemap config) when adding new top-level routes.
scripts/generate-llms-txt.js writes two files, chained into build as generate:llms:
public/llms.txt— curated index: site metadata, the API/RSS surface, and one entry per blog post, research entry, and portfolio project (title, URL, summary, topics).public/llms-full.txt— every document's MDX body inlined verbatim under an H1 and aURL:line, so an assistant can answer from one fetch instead of crawling each page. MDX bodies carry no H1 of their own (the page template renders the frontmatter title), which is what keeps the concatenated heading hierarchy intact.
Both are tracked in git and regenerated at build time. The committed copies are the fallback; the build output is what actually ships. CI runs git diff --exit-code on them after the build, so a content edit without a regenerate fails the run. Run yarn generate:llms and commit the result whenever you add or edit an MDX entry.
- Wrapped in both
withAxiom(logs) andwithPWA(@ducanh2912/next-pwa— the maintained next-pwa fork, Workbox 7, disabled in dev). Options nest underworkboxOptions(skipWaiting, buildExcludes);extendDefaultRuntimeCaching: truepreserves the legacy next-pwa@5 cache strategies. Note:@serwist/nextwas evaluated but doesn't generatesw.jsunder Next 16's Turbopack-default build (tracking serwist#54) — revisit when stable. - Strict CSP including
*.youtube.com,giscus.app,*.googletagmanager.com,cdn.jsdelivr.net. - Image
remotePatternsallowlist — when adding a new external image host, register it here ornext/imagewill reject it. transpilePackages: ['mermaid', 'stylis']— leave it; mermaid ships ESM that Next can't consume otherwise.- Several legacy URL redirects (
/projects→/portfolio,/cv→/AhnafAnNafeeResume.pdf, etc.) live inredirects().
-
Prettier: no semicolons, 2-space indent, single quotes, 120-col print width,
trailingComma: 'none'. Import sorting via@ianvs/prettier-plugin-sort-imports; tailwind class sorting viaprettier-plugin-tailwindcss. ESLint extendsnext/core-web-vitals+@typescript-eslint/recommended;no-explicit-anyis intentionally off. -
shadcn rules: forms compose
Field+FieldLabel+FieldDescription(no rawdiv+Label); inputs with leading/trailing icons useInputGroup+InputGroupAddon; icons insideButtongetdata-icon="inline-start"/inline-end(nosize-*classes); usecn()for conditional classes; reach for<Empty>,<Skeleton>,<Alert>,<Badge>,<Separator>instead of styleddivs. Toasts viasonner(toast()fromsonner,<Toaster>from@/components/ui/sonner). -
Commits go through
commitizenwithcz-conventional-changelog— runyarn commitfor the prompt.lint-stagedruns Prettier on stagedjs/jsx/ts/tsx/mdx/md/cssfiles. -
Frontmatter
publishedfor blog and research isMM/DD/YYYY(portfolio usesdate).featured: truesurfaces an entry on the home page. Optionalupdated: "MM/DD/YYYY"overridespublishedas the freshness signal — it flows intodateModified(BlogPosting / ScholarlyArticle JSON-LD),<meta property="article:modified_time">, and the sitemaplastmod. Set it whenever you make a substantive content edit; leave it off for typo fixes. -
Authoring convention: open every blog post with a 60–120 word TL;DR paragraph immediately under the H1. LLM crawlers (ChatGPT, Perplexity, AI Overviews) draw ~44% of their citations from the first 30% of an article, so front-loading the answer compounds visibility.
-
Research authoring: use the YAML folded scalar (
abstract: >-) — block scalar (|) preserves newlines and the abstract renders with broken lines. Authors use a structured array;affiliationsindices are 1-based and reference the entry'saffiliationsarray.sectiondrives listing-page grouping (top-tier/conferences/journals/workshops/others).new: truerenders a NEW badge inline with the title on the listing card.comingSoon: trueswaps the listing thumbnail for theComingSoonImagepastel placeholder (no thumbnail/teaser required). Authors matchingSITE_AUTHOR.nameare auto-bolded acrossHeadingResearchandResearchItem. -
Author / affiliation superscripts in
HeadingResearchare conditional. Each marker only renders when it actually disambiguates something:*(corresponding author) —corresponding: trueon the author. Shows when 2+ authors AND at least one is corresponding.†(equal contribution) —equalContribution: trueon the author. Shows when 2+ authors AND at least 2 are flagged. Renders on every flagged author.‡(principal investigator) —principalInvestigator: trueon the author. Shows when 2+ authors AND at least one is PI.¹ ² ³(affiliation indices) — derived fromaffiliations: [...]on each author. Shows when 2+ affiliations exist on the entry.
Marker order on each author follows academic convention:
*†‡then numeric indices. The affiliation legend below the author line drops its leading<sup>under the sameshowAffSuprule. Single-author / single-affiliation entries collapse to clean text — no orphan markers. Each<sup>hascursor-help+ a nativetitletooltip (affiliation index sups resolve to the full affiliation name; symbol sups resolve to their caption text). A combined caption line appears below the affiliation row when any symbol marker is shown — e.g.*Corresponding author · †Equal contribution · ‡Principal investigator— joined by·.
How to write a new blog / portfolio / research entry without re-deriving conventions from scratch. Read this before any "write me a blog post about X" task.
-
File locations. Blog →
src/data/blog/*.mdx. Portfolio →src/data/portfolio/*.mdx. Research →src/data/research/*.mdx. Filename (minus.mdx) becomes the URL slug — kebab-case, short, topical (3–4 words max, no date prefix). -
Tone anchors. First-person, conversational-technical, title-case headings, 850–1,400 words. Before drafting, read 1–2 thematically adjacent posts in full to calibrate voice. Reference posts:
local-llm-pdf-ocr.mdx(deep technical, ~1,400w),autograder-architecture.mdx(architecture, ~850w),doi-paper-scraper.mdx(tooling),modelfile-syntax-extension.mdx(developer tool launch). Calibrate voice, not punctuation: the anchor posts lean heavily on em dashes, but new posts must NOT (see #11). -
TL;DR rule. Open every blog post with a 60–120 word TL;DR paragraph immediately under the frontmatter (no
# Titlemarkdown — the page template renders frontmattertitleas H1). LLM crawlers pull ~44% of citations from the first 30%, so front-loading the punch is non-negotiable. -
Frontmatter shape (Blog). Mirror existing posts:
title: 'Bold sentence-case title' summary: "Single-sentence summary, doubles as meta description." featured: true # surfaces on home page author_name: 'Ahnaf An Nafee' github_username: 'ahnafnafee' published: 'MM/DD/YYYY' # today's date topics: ['Topic 1', 'Topic 2', ...] # 4–6 broad categories keywords: ['kw1', 'kw2', ...] # 30–40 lowercase entries thumbnail: null # or ImageKit URL — see #7 related: [] # other slugs to cross-link
Research entries are richer (
authors[],affiliations[],venue,links,identifiers, structured author flags) — seeResearchinsrc/types/index.d.tsand existing entries insrc/data/research/. Research abstracts MUST use the YAML folded scalarabstract: >-(block scalar|preserves newlines and renders broken). -
Body structure. Title-case headings. One Mermaid diagram per major architectural section is on-brand (use
```mermaidfences — handled byMermaid.tsx). Code blocks framed by prose, not by intro text ("Here's an example:" — avoid). Tables fine for reference data. Raw<img src=... className='w-full rounded-lg' />for in-body images — the default<img>MDX mapping was intentionally removed. -
MDX components available. From
src/components/content/mdx/:Pre,Code,Mermaid,ContentImage,MDXLink,Blockquote,Table,Headings. Plugin set: remark-gfm, remark-math + rehype-katex ($...$and$$...$$), rehype-prism-plus, rehype-slug. Prism languages include js/ts/tsx/jsx/py/bash/json/yaml/css/html/sql/rust/go/java/c/cpp — unknown languages render as plain text. -
Thumbnail image generation (canonical theme). The
thumbnail:field is either an ImageKit URL athttps://ik.imagekit.io/8ieg70pvks/blog/<slug>.{jpg|jpeg|png}ornullto fall back to/api/og. For AI-generated thumbnails (ChatGPT Images 2.0 / GPT Image / DALL·E 3), use the "Editorial Tech Illustration" theme:-
Style anchor: Flat editorial tech illustration — Stripe blog / Vercel changelog / Linear docs feel. Geometric, clean, modern. No hand-drawn squiggles, no sticker / doodle aesthetic, no 3D renders, no photorealism, no whiteboard textures.
-
Background: Warm off-white
#FAF9F6with a barely-visible dotted grid for subtle texture. -
Composition: Title top-left in bold sans-serif (
#1F2937, ≤ 2 lines, 8% padding from edges); bylineAhnaf An Nafeedirectly below in regular weight (#6B7280, 70% title size); hero illustration occupying the center 60% horizontally centered; bottom 10% breathing room. -
Palette (strict): only these colors —
Hex Role #FAF9F6Background #3B82F6Blue primary (main brand accent) #8B5CF6Purple accent #F59E0BOrange accent #10B981Green accent (optional — "growth" / "success" themes) #EF4444Warning red (sparingly — error / warning semantics only) #1F2937Dark text #6B7280Muted text / connector lines #FFFFFFCard surfaces #E5E7EBCard borders -
Draw style: Flat shapes, no gradients, no glow, subtle drop shadows (max 8% opacity), 1.5–2px line weights, 8px rounded corners, generous whitespace.
-
Aspect ratio: 16:9 at 1200×630. The listing-card and post-detail thumbnail containers crop non-16:9 images on narrow viewports (mobile), so tighter aspect ratios like 4:3 / 3:2 lose top/bottom content. Note: 16:9 also matches the OG card spec, but thumbnails and OG cards are still separate concerns — when
thumbnail:isnullthe OG image is generated programmatically via/api/og(seesrc/libs/metapagegenerateOgImage); when set, the same image is used for both the listing card and the OG card. -
Universal template prompt (fill in
{TITLE}and{HERO}):Create a 1200×630 wide thumbnail image (16:9 aspect ratio) for a developer blog post titled "{TITLE}" by Ahnaf An Nafee. Style: flat editorial tech illustration in the spirit of Stripe blog, Vercel changelog, or Linear docs — geometric, clean, modern. Background: warm off-white #FAF9F6 with a faint dotted-grid texture. Title text in bold sans-serif (#1F2937), top-left aligned with 8% padding, 2 lines maximum. Byline "Ahnaf An Nafee" directly below the title in regular-weight sans-serif (#6B7280), 70% the title size. Hero illustration occupying the center-right 60% of the canvas (16:9 is wide — use the horizontal space): {HERO}. Strict palette — only these colors: #FAF9F6 (bg), #3B82F6 (blue), #8B5CF6 (purple), #F59E0B (orange), #10B981 (green, only for growth themes), #EF4444 (red, only for warning semantics), #1F2937 (dark text), #6B7280 (muted/lines), #FFFFFF with #E5E7EB borders (cards). Flat shapes only — no gradients, no glow, subtle drop shadows (max 8% opacity), 1.5–2px line weights, 8px rounded corners, generous whitespace. Avoid: hand-drawn squiggles, sticker / doodle style, 3D depth, photorealism, real readable code, whiteboard textures, brand logos other than abstract representations.
-
Retroactive application: The earlier thumbnails (
local-llm-pdf-ocr,doi-paper-scraper,autograder-architecture) are in an older hand-drawn whiteboard style and should be re-generated under this theme when bandwidth allows. Workflow: generate the new image → upload to ImageKit atblog/<slug>.{jpg|png}→ update the post'sthumbnail:field with the new URL (include?updatedAt=<unix-ms>for cache-bust) → optionally setupdated: 'MM/DD/YYYY'to bump the freshness signal in the sitemap anddateModifiedJSON-LD.
-
-
Related posts.
related: []is the default. When adding a new post, optionally scan for natural backlinks and update older posts'related:arrays — polish step, don't gate on it. -
Pre-write checklist.
Glob src/data/blog/*.mdx→ read closest anchor post → confirmBlogtype insrc/types/index.d.ts→ fetch repo README if applicable (gh api repos/<owner>/<repo>/readme --jq '.content' | base64 -d) → confirm today's date forpublished. -
Verify checklist.
yarn type-check→yarn lint→yarn dev→ visit/blog/<slug>→ confirm TL;DR renders under H1, headings have anchors, Mermaid renders, code blocks colored →yarn build→yarn validate:json-ld→yarn audit:alt-textif any<img>/<ContentImage>→ word count in 850–1,400 range → TL;DR is 60–120 words (count it). -
What NOT to do. No
# Titlemarkdown (page template handles H1). No "In this post, I'll explain..." filler — open with the punch. No keyword stuffing without body coverage. No fabricated stats / version numbers / model names — verify against linked sources. No paragraph-length comments inside code blocks — let prose do the work. No em dashes (—, U+2014) and no AI-writing tells. Avoid dramatic reveal-colons, "that's the point", "the clever bit", "refreshingly X", "isn't just X, it's Y", rule-of-three flourishes, and anthropomorphizing the code. Draft in plain punctuation (commas, periods, parentheses, the occasional functional colon), then scan the finished.mdxfor the em-dash codepoint (U+2014) and rewrite any that slipped in. The anchor posts in #2 use em dashes heavily; do not copy that habit.
indexing/sendIndexingRequest.ts reads URLs from the live sitemap, checks Search Console for index status, and submits unindexed URLs to the Google Indexing API. Requires indexing/service_account.json (gitignored) with Indexing API + Search Console API enabled and the service account added as an Owner in Search Console. The indexing/ directory is excluded from the TypeScript build (tsconfig.json).
There is per-project memory at C:\Users\ahnaf\.claude\projects\D--GitHub-ahnafnafee-dev\memory\ — check MEMORY.md there at the start of a session for any user feedback or environment notes (e.g. gh CLI is installed and preferred over curl/WebFetch for any GitHub work).