diff --git a/.github/ISSUE_TEMPLATE/add-notes.yml b/.github/ISSUE_TEMPLATE/add-notes.yml new file mode 100644 index 0000000..946afcb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/add-notes.yml @@ -0,0 +1,96 @@ +name: Add notes +description: Share notes, a lab manual or a question paper. No coding needed. +title: "Add notes: " +labels: ["content"] +body: + - type: markdown + attributes: + value: | + Thanks for adding to Knotes Central. Fill this in and a maintainer will + put it on the site — usually within a few days. + + **Before you start:** upload your files to a Google Drive folder and set + sharing to *Anyone at RVCE with the link*, or use a public GitHub repo. + + - type: input + id: department + attributes: + label: Department + description: e.g. CSE / ISE / AIML, ECE, Mechanical + placeholder: Computer Science, Information Science, AIML Engineering + validations: + required: true + + - type: input + id: year + attributes: + label: Year + placeholder: "2" + validations: + required: true + + - type: input + id: semester + attributes: + label: Semester + placeholder: "3" + validations: + required: true + + - type: input + id: subject + attributes: + label: Subject name + placeholder: Data Structures and its Applications + validations: + required: true + + - type: input + id: subject_code + attributes: + label: Subject code + placeholder: CSE201 + + - type: dropdown + id: type + attributes: + label: What kind of material is it? + options: + - Theory notes + - Lab material + - Question papers + validations: + required: true + + - type: input + id: title + attributes: + label: Title to show on the site + placeholder: DSA Unit 1–5 notes (Prof. Sharma) + validations: + required: true + + - type: input + id: link + attributes: + label: Link + description: Google Drive folder, Google Doc or GitHub repo. + placeholder: https://drive.google.com/drive/folders/... + validations: + required: true + + - type: input + id: credit + attributes: + label: Name for the contributors page + description: Leave blank to stay anonymous. + + - type: checkboxes + id: confirm + attributes: + label: Confirm + options: + - label: This is my own work, or I have permission to share it. + required: true + - label: The link is accessible to RVCE students. + required: true diff --git a/.github/ISSUE_TEMPLATE/broken-link.yml b/.github/ISSUE_TEMPLATE/broken-link.yml new file mode 100644 index 0000000..13a8282 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/broken-link.yml @@ -0,0 +1,38 @@ +name: Broken or wrong link +description: A note does not open, or points at the wrong material. +title: "Broken link: " +labels: ["broken-link"] +body: + - type: input + id: page + attributes: + label: Page + description: The page on the site where you found it. + placeholder: /cse-ise-aiml/2/3 + validations: + required: true + + - type: input + id: note + attributes: + label: Which note? + placeholder: DMS Study Materials + validations: + required: true + + - type: dropdown + id: problem + attributes: + label: What happens? + options: + - Link is dead / 404 + - Asks for access I cannot get + - Opens the wrong material + - Something else + validations: + required: true + + - type: input + id: replacement + attributes: + label: Working link, if you have one diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..35321ac --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Ask the RVCE community + url: https://www.reddit.com/r/KnotesCentral/ + about: General questions about notes, subjects or the site. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..486f9f0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + pull_request: + push: + branches: [main, refactor/structure] + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # What's New reads dates from history + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - run: npm ci + + # Fast, contributor-friendly check first: a bad catalog entry fails here + # with a message that says how to fix it. + - name: Validate catalog data + run: npm run check:data + + - name: Typecheck + run: npm run typecheck + + - name: Lint + run: npm run lint + + - name: Unit tests + run: npm test + + - name: Build + run: npm run build diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..3c2b050 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,71 @@ +name: Deploy to GitHub Pages + +# The site is a static export, so publishing it is just "build and upload the +# out/ directory". The base path and the canonical URL are read back from the +# Pages API rather than hardcoded, so a rename of the repository — or moving it +# to a custom domain — needs no code change. + +on: + push: + branches: [main, refactor/structure] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# One deploy at a time; never cancel one halfway through. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # What's New and the sitemap read dates from history + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Configure Pages + id: pages + uses: actions/configure-pages@v5 + with: + enablement: true + + - run: npm ci + + - name: Validate the catalog + run: npm run check:data + + - name: Build the static export + env: + NEXT_PUBLIC_SITE_URL: ${{ steps.pages.outputs.base_url }} + NEXT_PUBLIC_BASE_PATH: ${{ steps.pages.outputs.base_path }} + run: npm run build + + # Pages runs Jekyll over the artifact unless told not to, and Jekyll + # drops every directory beginning with an underscore — including + # _next/, which is the whole build. + - name: Disable Jekyll + run: touch out/.nojekyll + + - uses: actions/upload-pages-artifact@v3 + with: + path: out + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml new file mode 100644 index 0000000..de5f79d --- /dev/null +++ b/.github/workflows/link-check.yml @@ -0,0 +1,85 @@ +name: Link check + +on: + schedule: + - cron: "0 3 * * 1" # Mondays, 03:00 UTC + workflow_dispatch: + +# Drive folders die when the RVCE account that owned them is purged, so the +# catalog rots silently. This finds the damage weekly and files it as an issue. +jobs: + check: + runs-on: ubuntu-latest + permissions: + contents: write # commits the updated link status and history + issues: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + + # --write updates src/data/generated/link-status.json (what the site + # flags today) and appends this run to link-history.json (what /rot + # charts). Without the commit below, the history would never grow. + - name: Check every resource link + run: node scripts/check-links.mjs --write > link-report.txt 2>/dev/null || true + + - name: Summarise + run: cat link-report.txt >> "$GITHUB_STEP_SUMMARY" + + - name: Commit the new snapshot + run: | + if git diff --quiet -- src/data/generated; then + echo "Nothing changed since the last check." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src/data/generated + git commit -m "Link check $(date -u +%Y-%m-%d): record this week's verdicts" + git push + + - name: File or update the tracking issue + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const report = fs.readFileSync('link-report.txt', 'utf8'); + const title = 'Broken links — automated weekly check'; + const body = [ + 'Automated check of every link in `src/data/departments/`.', + '', + '`needs-auth` is normal for RVCE-restricted Drive folders and is not a problem.', + '`probably-gone` means Drive returned 404 — the folder was almost certainly deleted.', + '', + '```', + report.trim().slice(0, 55000), + '```', + ].join('\n'); + + const { data: issues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'broken-link', + }); + + const existing = issues.find((issue) => issue.title === title); + + if (existing) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: existing.number, + body, + }); + } else { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + body, + labels: ['broken-link'], + }); + } diff --git a/.gitignore b/.gitignore index 5ef6a52..8ab17ca 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,10 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# generated at build time (added.json is committed so a fresh clone typechecks) +/public/icon-*.png +/public/apple-icon.png + +# `npm run check:links -- --json` scratch output +/link-report.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a182bae..504cf09 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,48 +1,100 @@ -# Contributing to Knotes Central - -Thank you for considering contributing to Knotes Central! Your contributions help us create a comprehensive resource for students. Please follow the guidelines below to ensure a smooth contribution process. - -## How to Contribute Notes - -### 1. Submit Your Contribution - -- **Use RVCE Mail ID:** Contributions will only be accepted from users with an RVCE mail ID. -- **Upload ID Card:** Please upload a clear image of your RVCE ID card through the Google form provided on the [Credits page](credits.html). - -### 2. Fill Out the Google Form - -1. Go to the [Credits page](https://developer1010x.github.io/KnotesCentral/main/credits.html). -2. Navigate to the "Report Issues and Send Content" section. -3. Fill out the Google form with the following details: - - Your full name - - Your RVCE mail ID - - Link to your Google Drive folder containing the notes/materials you wish to contribute - - Any additional comments or information - -### 3. Content Review - -- **Content Verification:** Once submitted, your content will be reviewed by our team for accuracy and relevance. -- **Approval:** If approved, your content will be added to the appropriate section of Knotes Central. - -## Important Guidelines - -- **Original Content:** Ensure that the content you submit is your own work or that you have the necessary permissions to share it. -- **File Format:** Preferred formats are PDF, DOCX, and PPTX for documents and ZIP for multiple files. -- **Quality:** Make sure the notes are clear, well-organized, and free from any inappropriate content. - -## Liability - -By submitting content, you agree that: - -- The content will be checked and verified by our team. -- If any issues arise, you will be liable for the content you submitted. - -## Contributing to the GitHub Repository - -We welcome contributions to the Knotes Central GitHub repository as well. Here are some general guidelines for contributing code, documentation, or other materials: - -### 1. Fork the Repository - -1. Fork the repository to your own GitHub account. -2. Clone the forked repository to your local machine: - git clone https://github.com/your-username/KnotesCentral.git +# Contributing to KnotesNeo + +Everything on the site was uploaded by a student a year or two ahead of whoever +is reading it. Adding to it should take a minute — here are the two ways. + +![The contribute page: a form that writes the catalog entry for you](docs/screenshots/contribute.png) + +## 1. The short form (no code) + +**[→ Add notes](https://github.com/Developer1010x/knotesneo/issues/new?template=add-notes.yml)** + +1. Upload your files to a Google Drive folder and share it with RVCE (or push + them to a public GitHub repo). +2. Open the form, pick the department / year / semester, paste the link. +3. Submit. A maintainer adds the entry, and your name goes on the + [contributors page](https://developer1010x.github.io/KnotesCentral-Source-Code/contributors/). + +You need a free GitHub account and nothing else. No Git, no cloning. + +Broken link instead? +**[Report it](https://github.com/Developer1010x/knotesneo/issues/new?template=broken-link.yml)** — +that counts as contributing too. + +## 2. Edit the department file directly + +The whole catalog is plain data: one file per department under +`src/data/departments/`. Open the file for your branch on GitHub, hit the pencil +icon, and add your entry. GitHub forks the repo and opens a pull request for +you — nothing can break. + +Find the right `semester`, then add to its `subjects` array: + +```ts +{ + name: "Data Structures and its Applications", + subject_code: "CSE201", + notes: [ + { + title: "DSA Unit 1-5 notes", + type: "theory", // "theory" | "lab" | "question-paper" + link: "https://drive.google.com/drive/folders/...", + }, + ], +} +``` + +Keep the shape exactly as above. The site reads these files directly, so a typo +fails the build on your pull request rather than breaking a page. + +New department? Add `src/data/departments/.ts` following any existing +file, then register it in `src/data/departments/index.ts`. + +Adding yourself to the contributors page: append an entry to +`src/data/contributors.ts`. + +## Guidelines + +- **Original content.** Submit your own work, or material you have permission to + share. +- **Access.** Links must be openable by RVCE students — share Drive folders with + *Anyone at RVCE with the link*. +- **Formats.** PDF, DOCX, PPTX for documents; ZIP for bundles; a public repo for + code. +- **Quality.** Clear, organised, correctly labelled with the subject and, where + it matters, the professor or year. + +By submitting content you confirm it is yours to share, and that you are +responsible for what you submit. + +## Working on the site itself + +```bash +npm install +npm run dev # http://localhost:3000 +npm run check:data # validates the catalog — run this before opening a PR +npm test # unit tests over search, slugs, link health and the validator +npm run typecheck +npm run lint +npm run build # prerenders every department, year, semester and subject +npm run serve # serves the build at http://localhost:3000 +``` + +`check:data` is the one that matters for content PRs. It catches unbalanced +braces, invalid `type` values, links missing `https://`, empty titles, and +departments missing from `index.ts` — and says how to fix each one. CI runs it +on every pull request, so a bad entry fails the check instead of shipping a +broken card. + +- `src/data/` — the catalog (departments, contributors) and its types. +- `src/lib/` — lookups, search, note-type metadata, theme, site links. +- `src/components/ui/` — the shared design-system pieces. +- `src/app/` — routes; the dynamic ones prerender via `generateStaticParams`. +- `scripts/` — build-time helpers: catalog validation, PWA icons, the What's + New dates (read from git history, so no manual changelog), and the weekly + link checker that feeds `/gaps` and `/rot`. +- `tests/` — Vitest. `npm test` runs them; CI runs them on every pull request. + +Colours are CSS custom properties in `src/app/globals.css`, exposed to Tailwind +as semantic names (`bg-surface`, `text-muted`, `border-line`, `text-brand`). +Prefer those over raw palette classes so both themes stay correct. diff --git a/HISTORY.md b/HISTORY.md new file mode 100644 index 0000000..dec05d1 --- /dev/null +++ b/HISTORY.md @@ -0,0 +1,44 @@ +# History of KnotesCentral + +Four versions, one idea: the notes a batch collects should still be there for +the batch behind it. + +## V4 — KnotesNeo (current) + +- **Contributors**: S Prajwall Narayana, Krishna Tejaswi +- **What changed**: a full rewrite from hand-written HTML to Next.js, with the + catalog moved into typed data files that anyone can edit through the GitHub + web editor. +- **Features**: + - Department → year → semester → subject browsing, every page prerendered. + - Search across subject names, codes and note titles, plus a ⌘K palette. + - Bookmarks, revision progress and a saved semester, stored on the device. + - An exam-prep view: a whole semester's material in one list. + - A weekly link checker that tells "deleted" apart from "needs an RVCE + login", a `/gaps` page listing subjects with nothing working left, and a + `/rot` report tracking the decay over time. + - Installable as an app, with offline browsing of pages already visited. +- **Source**: this repository. + +## V3 — Refactoring and updates + +- **Contributors**: Krishna Tejaswi, S Prajwall Narayana +- **Co-contributors (drives)**: Suhas P [ETE 26], Hemanth [CSE 26], + Skanda [CSE 26] +- **What changed**: a search bar, assorted UI improvements, and the private + department drives that make up most of the catalog today. The V3 site was + never given a public URL of its own; its content is what V4 inherited. + +## V2 — Refactoring and updates + +- **Contributor**: Krishna Tejaswi +- **What changed**: rebuilt navigation and faster page loads. +- **Site**: (archived) + +## V1 — The original + +- **Contributor**: S Prajwall Narayana +- **What it was**: one page per course with links to notes, question papers and + lab material, covering every branch. +- **Sites**: and + (both archived) diff --git a/Histroy.md b/Histroy.md deleted file mode 100644 index a41de28..0000000 --- a/Histroy.md +++ /dev/null @@ -1,39 +0,0 @@ -# History of Knotes Central -**Active V3 Link:** [KnotesCentralV2]() - -**Active V2 Link:** [KnotesCentralV2](https://knotes-central-v3.github.io/KnotesCentralV2/) - -## Version 3.0 - Refactoring and Updates -- **Contributor**: Krishna Tejaswi , S Prajwall Narayana -- **Co Contributors(drives)**: Suhas P [ETE 26], Hemanth [CSE 26] , Skanda [CSE 26] -- **Description**: Refactored the website with additional features and improvements based on user feedback. -- **Features**: - - Enhanced user interface for better navigation. - - Improved load times and performance of the website. - - Added search bar and other UI imporovemts - - Private Drives - -**V3.0 Website Link:** - - - -## Version 2.0 - Refactoring and Updates -- **Contributor**: Krishna Tejaswi -- **Description**: Refactored the website with additional features and improvements based on user feedback. -- **Features**: - - Enhanced user interface for better navigation. - - Improved load times and performance of the website. - -**V2.0 Website Link:** https://knotes-central-v3.github.io/KnotesCentralV2/ - -## Version 1.0 -- **Contributors**: S Prajwall Narayana -- **Description**: The initial version of the website was created to serve as a comprehensive repository for storing and sharing academic notes and materials across all branches. -- **Features**: - - Homepage with an introduction to the website's purpose and features. - - Specific links for each course, where students can access notes, previous years' question papers, and other pertinent materials. -- **Old Website Link**: https://developer1100x.github.io/KnotesCentralV1.0/ -- **Legacy System**: https://knotes-central-v3.github.io/KnotesCentralV1/ - - - diff --git a/README.md b/README.md index e8d13b7..32f5daa 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,180 @@ -# Knotes-Central -https://knotes-central-v3.github.io/KnotesCentralV2/ +# KnotesNeo -A humble attempt to create a collaborative platform for students to share notes and resources. +Notes, lab manuals and previous-year question papers for RVCE — every +department, year and semester in one place, and a pipeline that measures how +fast that material is disappearing. -## Website Developers: +**Site:** — +a static export published by +[`.github/workflows/deploy.yml`](.github/workflows/deploy.yml) on every push. -- [Developer1010x](https://developer1010x.github.io/PORTFOLIO/) V1 & Idea S Prajwall N +**Contribute:** [add notes in about a minute](https://github.com/Developer1010x/knotesneo/issues/new?template=add-notes.yml) +· [report a broken link](https://github.com/Developer1010x/knotesneo/issues/new?template=broken-link.yml) +· [CONTRIBUTING.md](CONTRIBUTING.md) -- [KTS-o7](https://kts-o7.github.io) Refactoring and Updates Krishna Tejaswi +![The home page: 16 departments, 175 subjects, 202 resources](docs/screenshots/home-dark.png) -## Overview +## The problem this exists for -Knotes-Central is a collaborative platform designed to help students share notes, resources, and past papers. The platform aims to make it easier for students to access the study materials they need and contribute their own resources to help their peers. +RVCE deletes a student's Workspace account after they graduate, and every +Google Drive folder they owned goes with it. The notes a batch collects are +therefore gone about two years after they are collected — and the site linking +to them has no way of knowing. -## Features +**56 of the 190 links in this catalog (29%) are already confirmed dead, and 55 +of 175 subjects have nothing working left at all.** Those numbers are measured, +not estimated, they are on the site itself, and they are the reason the rest of +this repository is shaped the way it is. -- **User-Friendly Interface:** An intuitive design to easily navigate through different courses and resources. -- **Collaborative Sharing:** Users can upload and share notes, question papers, and other study materials. -- **Course Categorization:** Materials are organized based on courses, making it easy to find relevant resources. -- **Search Functionality:** Quickly find specific notes or documents using the search feature. -- **Open Drive Storage:** All materials are stored in an open drive for easy access. +| | | +|---|---| +| ![The link rot report](docs/screenshots/rot-report.png) | ![Subjects with nothing left](docs/screenshots/gaps.png) | +| [`/rot`](src/app/rot/page.tsx) — what the catalog is losing, and how fast | [`/gaps`](src/app/gaps/page.tsx) — subjects with nothing working, grouped by department | -## Getting Started -- Use RVCE EMail to access +## The link-rot pipeline + +This is the part worth reading the code for. + +``` +scripts/check-links.mjs weekly in CI (link-check.yml), and on demand + ├─ fetches all 190 links, classifies each one + ├─ link-status.json what is broken right now → dead badges, /gaps + └─ link-history.json one record per run, appended → /rot + ↓ + src/lib/linkHealth.ts isGone / isUsable — used by every listing + src/lib/linkRot.ts deadSince, rot by host, rot by department +``` + +**Telling "deleted" apart from "needs a login" is the whole difficulty.** An +anonymous request to a Drive folder shared only with RVCE accounts looks +exactly like a request to a folder that was deleted: both answer with a sign-in +wall. Get that wrong in either direction and the site either cries wolf about +most of its own catalog, or never notices real losses. + +So the checker never calls a Drive link dead on a sign-in page alone. That is +recorded as `needs-auth` — the normal, healthy state for most of this material — +and is never surfaced. Only a hard 404 from Drive (`probably-gone`) or from a +GitHub repository (`dead`) counts, which makes the published numbers a floor +rather than a guess. + +Each run is appended rather than overwritten, so the site can say **"dead since +August 2026"** on the note itself, and [`/rot`](src/app/rot/page.tsx) can show +whether the rot is accelerating, which hosts survive (Drive folders die; public +repositories do not), and which departments have been hollowed out. + +![A subject page with a dead link](docs/screenshots/subject-dead-links.png) + +## What it does + +- **Browse** by department → year → semester → subject, with note counts and + coverage badges at every level, so you can see what exists before you click. +- **Search** subjects by name, code or note title — from the search page, or + with ⌘K / Ctrl+K anywhere on the site. +- **Filter** by material type: theory notes, lab material, question papers. + +![Search with the PYQP filter](docs/screenshots/search.png) + +- **Know where a link goes** before opening it — Drive (RVCE login), GitHub, or + the library server that only answers on campus wifi. +- **See what is broken**, since when, and what to send instead. +- **Set your semester once** and land on your own subjects every visit. +- **Save notes and tick off subjects** — bookmarks and revision progress, stored + on your device, no account. +- **Exam prep view** — a whole semester's material in one list, grouped by type. +- **Installable** on Android/iOS, with offline browsing of pages you have seen. +- **What's new** feed (plus [RSS](https://developer1010x.github.io/KnotesCentral-Source-Code/feed.xml)), dated automatically from git + history. +- [Light and dark themes](docs/screenshots/home-light.png), mobile-first + layout, and every page prerendered as static HTML — 538 of them. + +Most material lives in RVCE Workspace Drive, so you will usually need to be +signed in with your college account. + +## How the catalog works + +The catalog is plain TypeScript data, not a database — one file per department: + +``` +src/data/departments/.ts one Department each, registered in index.ts +src/data/contributors.ts the contributors page +src/data/types.ts the shape everything must match +src/data/generated/ built, then committed: git dates, link verdicts +``` + +Adding notes means adding an object to a `subjects` array. Anyone can do it +through the GitHub editor; see [CONTRIBUTING.md](CONTRIBUTING.md). A file's name +is also its URL slug, so `maths.ts` is `/maths` — `npm test` enforces that, +because the "edit this file" button on `/contribute` is built from it. + +`npm run check:data` is the gate on content pull requests. It catches unbalanced +braces, invalid `type` values, out-of-range years and semesters, links without a +scheme, empty titles and departments missing from `index.ts` — each with a +message written for someone who has never opened a terminal. + +## Running it + +```bash +npm install +npm run dev # http://localhost:3000 + +npm run check:data # validate the catalog +npm test # 49 unit tests over the pure logic +npm run typecheck +npm run lint +npm run build # 538 static pages into out/ +npm run serve # serve out/ at http://localhost:3000 + +npm run check:links # ping every link, human-readable report +npm run check:links:write # …and record the verdict in src/data/generated/ +``` + +CI runs `check:data`, `test`, `typecheck`, `lint` and `build` on every pull +request. The link check runs weekly on its own schedule and commits what it +found, which is how the history in `/rot` grows. + +## Deployment + +The site is `output: "export"` — a directory of HTML with no server behind it. +`deploy.yml` builds it and publishes to GitHub Pages, taking both the canonical +URL and the base path from the Pages API (`actions/configure-pages`) rather than +hardcoding them, so a repository rename or a custom domain needs no code change. +The first run enables Pages by itself. + +Everything that has to carry the base path does: the manifest's `start_url`, the +service worker's scope and precache list, the sitemap, the RSS feed and the +per-subject Open Graph images. `npm run build` alone (no environment) produces a +root-relative build for local use. + +## Architecture, briefly + +Next.js App Router + Tailwind, React 19, TypeScript strict. No backend, no +database, no runtime dependencies beyond `next` and `react`. + +That is a deliberate trade. It buys free hosting, sub-second page loads, working +offline, and a catalog a non-programmer can edit through GitHub's web UI with CI +checking the result. It costs the ability to store anything: bookmarks and +progress live in `localStorage`, and **the project cannot host material, only +link to it** — which is exactly why link rot is the central problem here and not +a footnote. + +- `src/data/` — the catalog (departments, contributors) and its types. +- `src/lib/` — lookups, search, link health, rot history, note metadata, theme. +- `src/components/ui/` — the shared design-system pieces. +- `src/app/` — routes; the dynamic ones prerender via `generateStaticParams`. +- `scripts/` — build-time helpers: catalog validation, PWA icons, What's New + dates read from git history, and the link checker. +- `tests/` — Vitest over the logic that is easy to break quietly: slug + collisions, search ranking, cross-department subject matching, the palette's + paths, and the validator's regex parser (against fixture catalogs). + +Colours are CSS custom properties in `src/app/globals.css`, exposed to Tailwind +as semantic names (`bg-surface`, `text-muted`, `border-line`, `text-brand`). +Prefer those over raw palette classes so both themes stay correct. + +## Credits + +- [Developer1010x](https://developer1010x.github.io/PORTFOLIO/) — V1 and the idea (S Prajwall N) +- [KTS-o7](https://kts-o7.github.io) — refactoring and updates (Krishna Tejaswi) +- Everyone on the [contributors page](https://developer1010x.github.io/KnotesCentral-Source-Code/contributors/) + +Previous versions and what changed: [HISTORY.md](HISTORY.md). diff --git a/SECURITY.md b/SECURITY.md index 026358f..9cc8c6a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,14 +1,47 @@ # Security Policy -## Supported Versions +## What is in scope -project that are currently being supported with security updates. +KnotesNeo is a static site. It has no backend, no database and no accounts, so +there is nothing to log into and nothing of yours stored on a server — +bookmarks, revision progress and your saved semester live in your own browser's +`localStorage` and never leave the device. -| Version | Supported | -| ------- | ------------------ | -| [2.1.x](https://developer1010x.github.io/KnotesCentral/) | :white_check_mark: | -| [1.0.x ](https://developer1100x.github.io/KnotesCentralV1.0/) | :x: | +That leaves a small but real surface, and reports about any of it are welcome: +- **The site itself** — the deployed build, its service worker, or a + cross-site-scripting hole in how catalog data is rendered. +- **The build pipeline** — the GitHub Actions workflows in `.github/workflows/` + and the Node scripts in `scripts/`. +- **The catalog** — a link in `src/data/departments/` that points somewhere + malicious, or material that should not be shared publicly. +| Version | Supported | +| ------- | --------- | +| KnotesNeo (this repository, `main`) | :white_check_mark: | +| V2 and V1 (archived, unmaintained) | :x: | +The archived versions are static HTML kept for history at +[V2](https://knotes-central-v3.github.io/KnotesCentralV2/) and +[V1](https://knotes-central-v3.github.io/KnotesCentralV1/). They receive no +updates of any kind. +## Reporting + +Email **knotescentral@gmail.com** with the URL or file, what you did, and what +happened. If it is not sensitive, a +[GitHub issue](https://github.com/Developer1010x/knotesneo/issues/new/choose) +is fine and usually faster. + +Please do not open a public issue for anything that exposes someone's private +material — mail it instead, and it gets taken down first and discussed after. + +Expect a reply within a week. This is maintained by students between semesters, +not a security team. + +## Taking material down + +If something here is yours and you want it removed — or it was uploaded without +your permission — say so by email and the entry is deleted, no questions asked. +Nothing in this repository hosts files: every entry is a link to a Google Drive +folder or a GitHub repository owned by whoever uploaded it. diff --git a/docs/screenshots/contribute.png b/docs/screenshots/contribute.png new file mode 100644 index 0000000..8fdb2a4 Binary files /dev/null and b/docs/screenshots/contribute.png differ diff --git a/docs/screenshots/gaps.png b/docs/screenshots/gaps.png new file mode 100644 index 0000000..4d0208b Binary files /dev/null and b/docs/screenshots/gaps.png differ diff --git a/docs/screenshots/home-dark.png b/docs/screenshots/home-dark.png new file mode 100644 index 0000000..17d375c Binary files /dev/null and b/docs/screenshots/home-dark.png differ diff --git a/docs/screenshots/home-light.png b/docs/screenshots/home-light.png new file mode 100644 index 0000000..806645c Binary files /dev/null and b/docs/screenshots/home-light.png differ diff --git a/docs/screenshots/rot-report.png b/docs/screenshots/rot-report.png new file mode 100644 index 0000000..60d5f09 Binary files /dev/null and b/docs/screenshots/rot-report.png differ diff --git a/docs/screenshots/search.png b/docs/screenshots/search.png new file mode 100644 index 0000000..4f7e3df Binary files /dev/null and b/docs/screenshots/search.png differ diff --git a/docs/screenshots/subject-dead-links.png b/docs/screenshots/subject-dead-links.png new file mode 100644 index 0000000..ec81228 Binary files /dev/null and b/docs/screenshots/subject-dead-links.png differ diff --git a/next.config.ts b/next.config.ts index e9ffa30..9487b99 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,26 @@ import type { NextConfig } from "next"; +/** + * The site is a static export published to GitHub Pages, which serves a + * project site from a repository sub-path (…github.io/). The deploy + * workflow passes that sub-path in; a local build gets "" and serves from the + * root, so `npm run dev` and `npx serve out` both work unchanged. + * + * GitHub Pages returns "/" as the base path for a user/org site — Next rejects + * that, so it is normalised to "" here. + */ +const rawBasePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; +const basePath = rawBasePath === "/" ? "" : rawBasePath.replace(/\/$/, ""); + const nextConfig: NextConfig = { - /* config options here */ + // Everything the site shows is known at build time; there is no server. + output: "export", + basePath, + // A static host has no rewrite layer, so /gaps has to exist on disk as + // gaps/index.html rather than gaps.html. + trailingSlash: true, + // The image optimiser needs a running server, which an export does not have. + images: { unoptimized: true }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 323ca61..4915b2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,14 @@ { - "name": "knotes-central-v3", + "name": "knotesneo", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "knotes-central-v3", + "name": "knotesneo", "version": "0.1.0", "dependencies": { - "next": "15.1.6", + "next": "^15.1.6", "react": "^19.0.0", "react-dom": "^19.0.0" }, @@ -19,10 +19,12 @@ "@types/react-dom": "^19", "autoprefixer": "^10.4.20", "eslint": "^9", - "eslint-config-next": "15.1.6", + "eslint-config-next": "^15.1.6", "postcss": "^8.5.1", + "serve": "^14.2.6", "tailwindcss": "^3.4.17", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.10" } }, "node_modules/@alloc/quick-lru": { @@ -657,9 +659,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, @@ -866,6 +868,16 @@ "node": ">=12.4.0" } }, + "node_modules/@oxc-project/types": { + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", + "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -877,6 +889,251 @@ "node": ">=14" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", + "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", + "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", + "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", + "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", + "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", + "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", + "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", + "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", + "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", + "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", + "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", + "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", + "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -891,6 +1148,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -906,6 +1170,24 @@ "tslib": "^2.8.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -1193,6 +1475,99 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@zeit/schemas": { + "version": "2.36.0", + "resolved": "https://registry.npmjs.org/@zeit/schemas/-/schemas-2.36.0.tgz", + "integrity": "sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==", + "dev": true, + "license": "MIT" + }, "node_modules/acorn": { "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", @@ -1233,6 +1608,61 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", @@ -1283,6 +1713,27 @@ "node": ">= 8" } }, + "node_modules/arch": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", + "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -1464,6 +1915,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -1575,6 +2036,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/boxen": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.0.0.tgz", + "integrity": "sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^7.0.0", + "chalk": "^5.0.1", + "cli-boxes": "^3.0.0", + "string-width": "^5.1.2", + "type-fest": "^2.13.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.0.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -1643,6 +2140,16 @@ "node": ">=10.16.0" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -1703,6 +2210,19 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz", + "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -1733,6 +2253,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1750,6 +2280,22 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chalk-template": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/chalk-template/-/chalk-template-0.4.0.tgz", + "integrity": "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/chalk-template?sponsor=1" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -1788,12 +2334,43 @@ "node": ">= 6" } }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clipboardy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/clipboardy/-/clipboardy-3.0.0.tgz", + "integrity": "sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "arch": "^2.2.0", + "execa": "^5.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -1849,6 +2426,55 @@ "node": ">= 6" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1856,6 +2482,23 @@ "dev": true, "license": "MIT" }, + "node_modules/content-disposition": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz", + "integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1970,6 +2613,16 @@ } } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2017,8 +2670,8 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "devOptional": true, "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=8" } @@ -2214,6 +2867,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -2740,14 +3400,65 @@ "node": ">=4.0" } }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, - "license": "BSD-2-Clause", + "license": "Apache-2.0", "engines": { - "node": ">=0.10.0" + "node": ">=12.0.0" } }, "node_modules/fast-deep-equal": { @@ -2801,6 +3512,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.19.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.0.tgz", @@ -3017,6 +3745,19 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -3259,6 +4000,16 @@ "node": ">= 0.4" } }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3296,6 +4047,13 @@ "node": ">=0.8.19" } }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -3476,6 +4234,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3584,6 +4358,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-port-reachable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-port-reachable/-/is-port-reachable-4.0.0.tgz", + "integrity": "sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -3632,6 +4419,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-string": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", @@ -3729,6 +4529,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", @@ -3837,68 +4650,329 @@ "dependencies": { "minimist": "^1.2.0" }, - "bin": { - "json5": "lib/cli.js" + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/jsx-ast-utils": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", - "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flat": "^1.3.1", - "object.assign": "^4.1.4", - "object.values": "^1.1.6" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=4.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/language-subtag-registry": { - "version": "0.3.23", - "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", - "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/language-tags": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", - "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "language-subtag-registry": "^0.3.20" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=0.10" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.8.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/lilconfig": { @@ -3964,6 +5038,16 @@ "dev": true, "license": "ISC" }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3974,6 +5058,13 @@ "node": ">= 0.4" } }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3998,10 +5089,53 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.18.tgz", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "~1.33.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -4051,9 +5185,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -4075,6 +5209,16 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/next": { "version": "15.1.6", "resolved": "https://registry.npmjs.org/next/-/next-15.1.6.tgz", @@ -4184,6 +5328,19 @@ "node": ">=0.10.0" } }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4316,6 +5473,46 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4414,6 +5611,13 @@ "node": ">=8" } }, + "node_modules/path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -4448,6 +5652,20 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-to-regexp": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4498,9 +5716,9 @@ } }, "node_modules/postcss": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz", - "integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -4518,7 +5736,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -4700,6 +5918,42 @@ ], "license": "MIT" }, + "node_modules/range-parser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", + "integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react": { "version": "19.0.0", "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", @@ -4795,6 +6049,40 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/registry-auth-token": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-3.3.2.tgz", + "integrity": "sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "^1.1.6", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/registry-url": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-3.1.0.tgz", + "integrity": "sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "rc": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -4847,6 +6135,39 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", + "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.144.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.4", + "@rolldown/binding-darwin-arm64": "1.2.4", + "@rolldown/binding-darwin-x64": "1.2.4", + "@rolldown/binding-freebsd-x64": "1.2.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", + "@rolldown/binding-linux-arm64-gnu": "1.2.4", + "@rolldown/binding-linux-arm64-musl": "1.2.4", + "@rolldown/binding-linux-ppc64-gnu": "1.2.4", + "@rolldown/binding-linux-s390x-gnu": "1.2.4", + "@rolldown/binding-linux-x64-gnu": "1.2.4", + "@rolldown/binding-linux-x64-musl": "1.2.4", + "@rolldown/binding-openharmony-arm64": "1.2.4", + "@rolldown/binding-win32-arm64-msvc": "1.2.4", + "@rolldown/binding-win32-x64-msvc": "1.2.4" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -4891,6 +6212,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -4945,6 +6287,95 @@ "node": ">=10" } }, + "node_modules/serve": { + "version": "14.2.6", + "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.6.tgz", + "integrity": "sha512-QEjUSA+sD4Rotm1znR8s50YqA3kYpRGPmtd5GlFxbaL9n/FdUNbqMhxClqdditSk0LlZyA/dhud6XNRTOC9x2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@zeit/schemas": "2.36.0", + "ajv": "8.18.0", + "arg": "5.0.2", + "boxen": "7.0.0", + "chalk": "5.0.1", + "chalk-template": "0.4.0", + "clipboardy": "3.0.0", + "compression": "1.8.1", + "is-port-reachable": "4.0.0", + "serve-handler": "6.1.7", + "update-check": "1.5.4" + }, + "bin": { + "serve": "build/main.js" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/serve-handler": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/serve-handler/-/serve-handler-6.1.7.tgz", + "integrity": "sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.0.0", + "content-disposition": "0.5.2", + "mime-types": "2.1.18", + "minimatch": "3.1.5", + "path-is-inside": "1.0.2", + "path-to-regexp": "3.3.0", + "range-parser": "1.2.0" + } + }, + "node_modules/serve-handler/node_modules/bytes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.0.0.tgz", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/serve/node_modules/chalk": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.0.1.tgz", + "integrity": "sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/serve/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -5133,6 +6564,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -5172,6 +6610,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -5407,6 +6859,16 @@ "node": ">=4" } }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -5593,6 +7055,81 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5658,6 +7195,19 @@ "node": ">= 0.8.0" } }, + "node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -5807,6 +7357,17 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/update-check": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/update-check/-/update-check-1.5.4.tgz", + "integrity": "sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "registry-auth-token": "3.3.2", + "registry-url": "3.1.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -5824,6 +7385,224 @@ "dev": true, "license": "MIT" }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5928,6 +7707,39 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/widest-line": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", + "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^5.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 377b7e2..508a0b6 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,25 @@ { - "name": "knotes-central-v3", + "name": "knotesneo", "version": "0.1.0", "private": true, "scripts": { + "predev": "node scripts/build-changelog.mjs", "dev": "next dev", + "prebuild": "node scripts/build-changelog.mjs && node scripts/build-icons.mjs && node scripts/validate-data.mjs", "build": "next build", - "start": "next start", + "postbuild": "node scripts/fix-export-images.mjs", + "serve": "serve out -l 3000", + "start": "npm run serve", "lint": "next lint", - "export": "next export" + "test": "vitest run", + "typecheck": "tsc --noEmit", + "icons": "node scripts/build-icons.mjs", + "check:data": "node scripts/validate-data.mjs", + "check:links": "node scripts/check-links.mjs", + "check:links:write": "node scripts/check-links.mjs --write" }, "dependencies": { - "next": "latest", + "next": "^15.1.6", "react": "^19.0.0", "react-dom": "^19.0.0" }, @@ -21,10 +30,11 @@ "@types/react-dom": "^19", "autoprefixer": "^10.4.20", "eslint": "^9", - "eslint-config-next": "latest", + "eslint-config-next": "^15.1.6", "postcss": "^8.5.1", + "serve": "^14.2.6", "tailwindcss": "^3.4.17", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.10" } } - diff --git a/postcss.config.js b/postcss.config.js deleted file mode 100644 index 4230d3e..0000000 --- a/postcss.config.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - "postcss-nested": {}, - }, -}; diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..f651d4b --- /dev/null +++ b/public/sw.js @@ -0,0 +1,91 @@ +/* + * Offline support for the catalog. Pages are network-first (so notes stay + * current on college wifi) with a cached fallback; static assets are + * cache-first. Nothing here caches Drive or GitHub — only this origin. + */ +const VERSION = "knotes-v2"; +const SHELL = `${VERSION}-shell`; +const PAGES = `${VERSION}-pages`; + +// The site is a static export that may be served from a repository sub-path +// (…github.io//). The worker's own scope is the only reliable source of +// that prefix, so every path below is built from it rather than hardcoded. +const BASE = new URL(self.registration.scope).pathname.replace(/\/$/, ""); +const path = (route) => `${BASE}${route}`; + +const OFFLINE_URL = path("/offline/"); + +const PRECACHE = [ + path("/"), + path("/search/"), + path("/saved/"), + OFFLINE_URL, + path("/manifest.webmanifest"), +]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches + .open(SHELL) + .then((cache) => cache.addAll(PRECACHE)) + .then(() => self.skipWaiting()) + .catch(() => self.skipWaiting()) + ); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter((key) => !key.startsWith(VERSION)) + .map((key) => caches.delete(key)) + ) + ) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener("fetch", (event) => { + const { request } = event; + if (request.method !== "GET") return; + + const url = new URL(request.url); + if (url.origin !== self.location.origin) return; + + // Immutable build output — cache first. + if (url.pathname.startsWith(path("/_next/static/"))) { + event.respondWith( + caches.match(request).then( + (hit) => + hit || + fetch(request).then((response) => { + const copy = response.clone(); + caches.open(SHELL).then((cache) => cache.put(request, copy)); + return response; + }) + ) + ); + return; + } + + // Pages — network first, fall back to whatever we have. + if (request.mode === "navigate") { + event.respondWith( + fetch(request) + .then((response) => { + const copy = response.clone(); + caches.open(PAGES).then((cache) => cache.put(request, copy)); + return response; + }) + .catch(() => + caches + .match(request) + .then((hit) => hit || caches.match(OFFLINE_URL)) + .then((hit) => hit || Response.error()) + ) + ); + } +}); diff --git a/scripts/build-changelog.mjs b/scripts/build-changelog.mjs new file mode 100644 index 0000000..aa3160a --- /dev/null +++ b/scripts/build-changelog.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +/** + * Derives "when was this note added" from git history, so the What's New feed + * needs no manual bookkeeping — the commit that first introduced a link is its + * publication date. + * + * Writes src/data/generated/added.json: { [note link]: ISO date }. + * Degrades to an empty map when git history is unavailable (shallow CI clones), + * which simply means nothing is badged as new. + */ +import { execFileSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const OUT = resolve(root, "src/data/generated/added.json"); + +const git = (...args) => + execFileSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: 64 << 20 }); + +const isDataFile = (path) => + path.startsWith("src/data/") && path.endsWith(".ts"); + +const LINK = /link:\s*"([^"]+)"/g; + +function linksIn(text) { + return new Set(Array.from(text.matchAll(LINK), (match) => match[1])); +} + +function build() { + const log = git( + "log", + "--reverse", + "--format=%H\t%cI", + "--", + "src/data" + ).trim(); + + if (!log) return {}; + + const added = {}; + const seen = new Set(); + + for (const line of log.split("\n")) { + const [sha, date] = line.split("\t"); + + const files = git("ls-tree", "-r", "--name-only", sha) + .split("\n") + .filter(isDataFile); + + for (const file of files) { + let contents = ""; + try { + contents = git("show", `${sha}:${file}`); + } catch { + continue; + } + + for (const link of linksIn(contents)) { + if (seen.has(link)) continue; + seen.add(link); + added[link] = date; + } + } + } + + return added; +} + +let added = {}; +try { + added = build(); + console.log( + `build-changelog: dated ${Object.keys(added).length} links from git history` + ); +} catch (error) { + console.warn( + `build-changelog: no git history available (${error.message.split("\n")[0]}) — What's New will be empty` + ); +} + +mkdirSync(dirname(OUT), { recursive: true }); +writeFileSync(OUT, `${JSON.stringify(added, null, 2)}\n`); diff --git a/scripts/build-icons.mjs b/scripts/build-icons.mjs new file mode 100644 index 0000000..4231b88 --- /dev/null +++ b/scripts/build-icons.mjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +/** + * Renders the PWA icons from one source description, so the manifest can point + * at stable file names (Next's generated icon routes are content-hashed). + */ +import { ImageResponse } from "next/og.js"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +const BRAND = "#4f46e5"; + +function icon(size, maskable) { + const pad = maskable ? size * 0.14 : 0; + const radius = maskable ? size / 2 : size * 0.22; + + return { + type: "div", + props: { + style: { + width: size, + height: size, + display: "flex", + alignItems: "center", + justifyContent: "center", + background: BRAND, + borderRadius: maskable ? 0 : radius, + color: "#fff", + fontSize: size * (maskable ? 0.42 : 0.5), + fontWeight: 700, + letterSpacing: "-0.05em", + padding: pad, + }, + children: "K", + }, + }; +} + +const TARGETS = [ + ["public/icon-192.png", 192, false], + ["public/icon-512.png", 512, false], + ["public/icon-maskable.png", 512, true], + ["public/apple-icon.png", 180, false], +]; + +for (const [file, size, maskable] of TARGETS) { + const response = new ImageResponse(icon(size, maskable), { + width: size, + height: size, + }); + const buffer = Buffer.from(await response.arrayBuffer()); + const out = resolve(root, file); + mkdirSync(dirname(out), { recursive: true }); + writeFileSync(out, buffer); + console.log(`build-icons: ${file} (${buffer.length} bytes)`); +} diff --git a/scripts/check-links.mjs b/scripts/check-links.mjs new file mode 100644 index 0000000..c0774d1 --- /dev/null +++ b/scripts/check-links.mjs @@ -0,0 +1,306 @@ +#!/usr/bin/env node +/** + * Pings every resource link and classifies what came back. + * + * npm run check:links # human-readable report + * npm run check:links -- --json # machine-readable, for CI + * + * IMPORTANT CAVEAT about Google Drive: an anonymous request cannot tell + * "deleted" apart from "shared only with RVCE accounts" — both look like a + * sign-in wall or a 404. Drive results are therefore reported as `needs-auth` + * or `probably-gone`, never as a confident "dead". GitHub links are checkable + * properly and are reported as ok/dead. + */ +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const DIR = resolve(root, "src/data/departments"); +const JSON_OUT = process.argv.includes("--json"); +const WRITE = process.argv.includes("--write"); +const STATUS_OUT = resolve(root, "src/data/generated/link-status.json"); +const HISTORY_OUT = resolve(root, "src/data/generated/link-history.json"); +const CONCURRENCY = 8; +const TIMEOUT_MS = 15000; + +/** Pull every note link out of the data files, with where it came from. */ +function collect() { + const links = []; + + for (const file of readdirSync(DIR).filter( + (name) => name.endsWith(".ts") && name !== "index.ts" + )) { + const text = readFileSync(resolve(DIR, file), "utf8"); + const entries = text.matchAll( + /title:\s*"((?:[^"\\]|\\.)*)",\s*\n\s*type:\s*"([^"]*)",\s*\n\s*link:\s*"([^"]*)"/g + ); + + for (const [, title, type, link] of entries) { + links.push({ file, title, type, link }); + } + } + + return links; +} + +function classifyDrive(response, body) { + // Drive serves a sign-in interstitial for anything not public. + if (response.status === 404) return "probably-gone"; + if (response.status === 403) return "needs-auth"; + if (response.url.includes("accounts.google.com")) return "needs-auth"; + if (/Sign in|Request access|You need access/i.test(body)) return "needs-auth"; + if (/(no longer exists|item has been (deleted|removed)|Page not found)/i.test(body)) { + return "probably-gone"; + } + if (response.ok) return "ok"; + return "unknown"; +} + +async function check(entry) { + if (!entry.link.trim()) return { ...entry, status: "missing" }; + + const isDrive = /(drive|docs)\.google\.com/i.test(entry.link); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + + try { + const response = await fetch(entry.link, { + redirect: "follow", + signal: controller.signal, + headers: { + // Drive returns very different bodies to non-browser agents. + "user-agent": + "Mozilla/5.0 (compatible; KnotesCentralLinkCheck/1.0; +https://github.com/Developer1010x/knotesneo)", + }, + }); + + const body = isDrive ? (await response.text()).slice(0, 20000) : ""; + const status = isDrive + ? classifyDrive(response, body) + : response.status === 404 + ? "dead" + : response.ok + ? "ok" + : "unknown"; + + return { ...entry, status, code: response.status }; + } catch (error) { + return { + ...entry, + status: "unreachable", + code: 0, + error: error.name === "AbortError" ? "timeout" : error.message, + }; + } finally { + clearTimeout(timer); + } +} + +const DEAD_VERDICTS = ["probably-gone", "dead"]; + +/** + * The site only ever knew *that* a link was broken, never since when — each run + * overwrote the last. This keeps the time series instead: one record per check, + * and per link the date it was first seen dead, so a note can say "dead since + * March" and /rot can show whether the rot is getting worse. + * + * The first run of a repository that already has a link-status.json is seeded + * from that snapshot, so the history starts at the date of the real check that + * produced it rather than pretending the damage happened today. + */ +function loadHistory() { + if (existsSync(HISTORY_OUT)) { + try { + const parsed = JSON.parse(readFileSync(HISTORY_OUT, "utf8")); + return { + runs: Array.isArray(parsed.runs) ? parsed.runs : [], + links: parsed.links && typeof parsed.links === "object" ? parsed.links : {}, + }; + } catch { + console.warn( + "check-links: link-history.json is not valid JSON — starting a new history." + ); + } + } + + const history = { runs: [], links: {} }; + if (!existsSync(STATUS_OUT)) return history; + + try { + const snapshot = JSON.parse(readFileSync(STATUS_OUT, "utf8")); + const checkedAt = snapshot.checkedAt; + const gone = snapshot.gone ?? {}; + if (!checkedAt) return history; + + history.runs.push({ + checkedAt, + gone: Object.keys(gone).length, + seeded: true, + }); + for (const [link, status] of Object.entries(gone)) { + history.links[link] = { status, firstDead: checkedAt, lastDead: checkedAt }; + } + console.log( + `check-links: seeded the history from the ${checkedAt.slice(0, 10)} snapshot.` + ); + } catch { + console.warn("check-links: could not read link-status.json — history starts empty."); + } + + return history; +} + +/** + * One record per resource, not per note: a Drive folder listed under two + * departments is one link, and counting it twice would inflate every number on + * the rot report. Notes with no link at all stay separate — each is a real gap. + */ +function uniqueResources(results) { + const seen = new Set(); + return results.filter((result) => { + const key = result.link || `unlinked:${result.file}:${result.title}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function updateHistory(history, allResults, checkedAt) { + const results = uniqueResources(allResults); + + for (const result of results) { + if (!result.link) continue; + const dead = DEAD_VERDICTS.includes(result.status); + const record = history.links[result.link]; + + if (dead) { + if (record) { + record.status = result.status; + record.lastDead = checkedAt; + // It was back at some point and has died again; the run history keeps + // the full story, so only the current spell is tracked here. + if (record.recoveredAt) { + record.firstDead = checkedAt; + delete record.recoveredAt; + } + } else { + history.links[result.link] = { + status: result.status, + firstDead: checkedAt, + lastDead: checkedAt, + }; + } + } else if (record && !record.recoveredAt) { + // Someone re-uploaded it, or the check was wrong the first time. + record.recoveredAt = checkedAt; + } + } + + const previous = history.runs[history.runs.length - 1]; + if (!previous || previous.checkedAt !== checkedAt) { + history.runs.push({ + checkedAt, + checked: results.length, + gone: results.filter((result) => DEAD_VERDICTS.includes(result.status)) + .length, + byStatus: results.reduce((counts, result) => { + counts[result.status] = (counts[result.status] ?? 0) + 1; + return counts; + }, {}), + }); + } + + history.runs.sort((a, b) => a.checkedAt.localeCompare(b.checkedAt)); + writeFileSync(HISTORY_OUT, `${JSON.stringify(history, null, 2)}\n`); + + return history; +} + +async function run() { + const entries = collect(); + const results = []; + let index = 0; + + async function worker() { + while (index < entries.length) { + const entry = entries[index++]; + results.push(await check(entry)); + if (!JSON_OUT && results.length % 20 === 0) { + process.stderr.write(` checked ${results.length}/${entries.length}\n`); + } + } + } + + await Promise.all( + Array.from({ length: Math.min(CONCURRENCY, entries.length) }, worker) + ); + + const byStatus = results.reduce((counts, result) => { + counts[result.status] = (counts[result.status] ?? 0) + 1; + return counts; + }, {}); + + if (WRITE) { + const checkedAt = new Date().toISOString(); + // Read the history — or seed it from the previous snapshot — *before* that + // snapshot is overwritten, or the seed would just duplicate this run. + const history = loadHistory(); + + // Only record verdicts the site should act on. "needs-auth" is the normal, + // healthy state for RVCE-restricted material and must never be flagged. + const gone = {}; + for (const result of results) { + if (DEAD_VERDICTS.includes(result.status) && result.link) { + gone[result.link] = result.status; + } + } + writeFileSync(STATUS_OUT, `${JSON.stringify({ checkedAt, gone }, null, 2)}\n`); + console.log( + `check-links: recorded ${Object.keys(gone).length} broken links in src/data/generated/link-status.json` + ); + + updateHistory(history, results, checkedAt); + console.log( + `check-links: history now holds ${history.runs.length} check${ + history.runs.length === 1 ? "" : "s" + } and ${Object.keys(history.links).length} links that have been broken.` + ); + } + + if (JSON_OUT) { + writeFileSync( + resolve(root, "link-report.json"), + `${JSON.stringify({ checkedAt: new Date().toISOString(), byStatus, results }, null, 2)}\n` + ); + console.log(JSON.stringify(byStatus, null, 2)); + return; + } + + console.log("\n=== link check ==="); + for (const [status, count] of Object.entries(byStatus).sort( + (a, b) => b[1] - a[1] + )) { + console.log(`${String(count).padStart(4)} ${status}`); + } + + const problems = results.filter((result) => + ["dead", "probably-gone", "unreachable", "missing"].includes(result.status) + ); + + if (problems.length) { + console.log("\nNeeds attention:"); + for (const problem of problems) { + console.log( + ` [${problem.status}] ${problem.file}: ${problem.title}\n ${problem.link || "(no link)"}` + ); + } + } + + console.log( + "\nNote: Drive links shared only with RVCE accounts look identical to deleted ones\n" + + "from an anonymous check. Verify `needs-auth` entries while signed in.\n" + ); +} + +run(); diff --git a/scripts/fix-export-images.mjs b/scripts/fix-export-images.mjs new file mode 100644 index 0000000..c71774d --- /dev/null +++ b/scripts/fix-export-images.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node +/** + * Gives the exported Open Graph images a file extension. + * + * `next build` with `output: "export"` writes each metadata image route as an + * extensionless file — out/cse-ise-aiml/2/3/dms/opengraph-image — and a static + * host has no way to know that is a PNG, so it serves it as a download and + * every WhatsApp / Twitter / Slack preview of a shared subject link breaks. + * + * This renames each one to .png and rewrites the references in the + * exported HTML and RSC payloads to match. Runs as `postbuild`. + */ +import { readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const OUT = resolve(root, "out"); + +/** Metadata image routes Next generates without an extension. */ +const IMAGE_ROUTES = /^(opengraph-image|twitter-image)(-[\w-]+)?$/; +const REWRITABLE = /\.(html|txt|json|xml)$/; +const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + +function walk(dir, files = []) { + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + if (statSync(path).isDirectory()) walk(path, files); + else files.push(path); + } + return files; +} + +let out; +try { + out = walk(OUT); +} catch { + console.log("fix-export-images: no out/ directory — nothing to do."); + process.exit(0); +} + +const renamed = []; + +for (const file of out) { + const name = file.slice(file.lastIndexOf("/") + 1); + if (!IMAGE_ROUTES.test(name)) continue; + + // Only touch it if it really is a PNG; ImageResponse could be swapped later. + const head = readFileSync(file).subarray(0, 4); + if (!head.equals(PNG_MAGIC)) continue; + + renameSync(file, `${file}.png`); + renamed.push(name); +} + +if (renamed.length === 0) { + console.log("fix-export-images: no extensionless metadata images found."); + process.exit(0); +} + +// The generated markup points at "…/opengraph-image?"; every reference is +// followed by a query string or a closing quote, which is what anchors this. +const reference = /(opengraph-image|twitter-image)(?=[?"'\\])/g; +let patched = 0; + +for (const file of out) { + if (!REWRITABLE.test(file)) continue; + const text = readFileSync(file, "utf8"); + if (!reference.test(text)) continue; + reference.lastIndex = 0; + writeFileSync(file, text.replace(reference, "$1.png")); + patched += 1; +} + +console.log( + `fix-export-images: renamed ${renamed.length} metadata images and updated ${patched} files.` +); diff --git a/scripts/validate-data.mjs b/scripts/validate-data.mjs new file mode 100644 index 0000000..7621a51 --- /dev/null +++ b/scripts/validate-data.mjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node +/** + * Catches the mistakes a first-time contributor actually makes, with a message + * that says how to fix it. Runs in CI on every pull request, so a bad entry + * fails the check instead of shipping a broken card. + * + * npm run check:data + * node scripts/validate-data.mjs --dir=path/to/departments # used by tests + */ +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +// The directory is an argument so the checks can be tested against fixtures of +// deliberately broken data; without it, nothing here is verifiable except by +// breaking the real catalog. +const dirArgument = process.argv + .slice(2) + .find((argument) => argument.startsWith("--dir=")); + +const DIR = dirArgument + ? resolve(root, dirArgument.slice("--dir=".length)) + : resolve(root, "src/data/departments"); + +/** Paths in messages stay relative to the repository root. */ +const relative = (file) => `${DIR.replace(`${root}/`, "")}/${file}`; + +const VALID_TYPES = new Set(["theory", "lab", "question-paper"]); + +const problems = []; +const warnings = []; + +const fail = (file, message) => problems.push({ file, message }); +const warn = (file, message) => warnings.push({ file, message }); + +/** Reads the exported object literals without executing TypeScript. */ +function fields(text, key) { + return Array.from( + text.matchAll(new RegExp(`${key}:\\s*"((?:[^"\\\\]|\\\\.)*)"`, "g")), + (match) => match[1] + ); +} + +/** Same, for unquoted numeric fields like `year: 2` and `number: 3`. */ +function numberFields(text, key) { + return Array.from( + text.matchAll(new RegExp(`\\b${key}:\\s*(-?\\d+)`, "g")), + (match) => Number(match[1]) + ); +} + +const files = readdirSync(DIR).filter( + (file) => file.endsWith(".ts") && file !== "index.ts" +); + +const index = readFileSync(resolve(DIR, "index.ts"), "utf8"); +const seenLinks = new Map(); +const seenSlugs = new Set(); + +for (const file of files) { + const path = relative(file); + const text = readFileSync(resolve(DIR, file), "utf8"); + + // Registered in the barrel file? + const moduleName = file.replace(/\.ts$/, ""); + if (!index.includes(`"./${moduleName}"`)) { + fail( + path, + `not imported in index.ts — add it there or the department will not appear on the site` + ); + } + + // Balanced braces catch the classic "pasted one brace too few". + const open = (text.match(/{/g) ?? []).length; + const close = (text.match(/}/g) ?? []).length; + if (open !== close) { + fail(path, `unbalanced braces (${open} "{" vs ${close} "}")`); + } + + const links = fields(text, "link"); + const types = fields(text, "type"); + const titles = fields(text, "title"); + + const deptLink = links.find((link) => link.startsWith("/")); + if (!deptLink) { + fail(path, `no department link — needs a top-level link like "/cse-ise-aiml"`); + } else { + const slug = deptLink.replace(/^\//, ""); + if (seenSlugs.has(slug.toLowerCase())) { + fail(path, `duplicate department link "${deptLink}"`); + } + seenSlugs.add(slug.toLowerCase()); + } + + // Out-of-range years and semesters used to ship as real, sitemap-indexed + // URLs (/MegaAccess/1/12340). 0 is the deliberate "not tied to a semester" + // value used by /pyqp, /miscellaneous and /all-engineering. + for (const year of numberFields(text, "year")) { + if (!Number.isInteger(year) || year < 0 || year > 4) { + fail( + path, + `year ${year} is out of range — use 1-4, or 0 for material that is not tied to a year` + ); + } + } + + for (const semester of numberFields(text, "number")) { + if (!Number.isInteger(semester) || semester < 0 || semester > 8) { + fail( + path, + `semester ${semester} is out of range — use 1-8, or 0 for material that is not tied to a semester` + ); + } + } + + for (const type of types) { + if (!VALID_TYPES.has(type)) { + fail( + path, + `type "${type}" is not valid — use ${[...VALID_TYPES] + .map((value) => `"${value}"`) + .join(", ")}` + ); + } + } + + for (const title of titles) { + if (!title.trim()) fail(path, `a note has an empty title`); + } + + for (const link of links) { + if (link.startsWith("/")) continue; // department route, not a resource + + if (!link.trim()) { + warn(path, `a note has no link yet — it shows as "not uploaded yet" on the site`); + continue; + } + + if (!/^https?:\/\//i.test(link)) { + fail(path, `link "${link}" must start with http:// or https://`); + continue; + } + + if (/^https?:\/\/(drive|docs)\.google\.com\/?$/i.test(link)) { + fail(path, `link "${link}" points at Google Drive's home page, not a file`); + } + + const previous = seenLinks.get(link); + if (previous && previous !== path) { + warn(path, `link also used in ${previous} — is that intentional?`); + } + seenLinks.set(link, path); + } + + // Notes without a matching title count usually mean a malformed entry. + if (titles.length !== types.length) { + fail( + path, + `${titles.length} titles but ${types.length} types — every note needs both` + ); + } +} + +for (const { file, message } of warnings) { + console.warn(`warning ${file}: ${message}`); +} + +if (problems.length) { + console.error(""); + for (const { file, message } of problems) { + console.error(`error ${file}: ${message}`); + } + console.error( + `\n${problems.length} problem${problems.length === 1 ? "" : "s"} found. ` + + `See CONTRIBUTING.md for the expected shape.` + ); + process.exit(1); +} + +console.log( + `check:data — ${files.length} departments, ${seenLinks.size} links, no problems.` +); diff --git a/src/app/[department]/[year]/[semester]/[subject]/opengraph-image.tsx b/src/app/[department]/[year]/[semester]/[subject]/opengraph-image.tsx new file mode 100644 index 0000000..1981061 --- /dev/null +++ b/src/app/[department]/[year]/[semester]/[subject]/opengraph-image.tsx @@ -0,0 +1,113 @@ +import { ImageResponse } from "next/og"; +import { departments } from "@/data/departments"; +import { + countNotes, + departmentSlug, + subjectSlug, + findDepartment, + findSemester, + findSubject, + findYear, +} from "@/lib/catalog"; +import { SITE } from "@/lib/site"; + +/* Generated once at build time — the export has no server. */ +export const dynamic = "force-static"; + +export const alt = "Subject on KnotesNeo"; +export const size = { width: 1200, height: 630 }; +export const contentType = "image/png"; + +export function generateStaticParams() { + return departments.flatMap((department) => + department.years.flatMap((year) => + year.semesters.flatMap((semester) => + semester.subjects.map((subject) => ({ + department: departmentSlug(department), + year: String(year.year), + semester: String(semester.number), + subject: subjectSlug(subject, semester), + })) + ) + ) + ); +} + +/** + * Per-subject share card: what a WhatsApp link preview shows when a junior + * forwards "DSA notes" into a batch group. + */ +export default async function Image({ + params, +}: { + params: Promise<{ + department: string; + year: string; + semester: string; + subject: string; + }>; +}) { + const { department: deptSlug, year: y, semester: s, subject: sub } = await params; + const department = findDepartment(deptSlug); + const year = findYear(department, y); + const semester = findSemester(year, s); + const subject = findSubject(semester, sub); + + const counts = subject ? countNotes(subject.notes) : null; + + return new ImageResponse( + ( +
+
+
+ K +
+
{SITE.name}
+
+ +
+
+ {(department?.name ?? "RVCE").toUpperCase()} +
+
+ {subject?.name ?? "Subject"} +
+
+ {subject?.subject_code ?
{subject.subject_code}
: null} +
Year {year?.year ?? "?"}
+
Semester {semester?.number ?? "?"}
+
+
+ +
+ {counts?.total ?? 0} resources · notes, labs and papers +
+
+ ), + size + ); +} diff --git a/src/app/[department]/[year]/[semester]/[subject]/page.tsx b/src/app/[department]/[year]/[semester]/[subject]/page.tsx new file mode 100644 index 0000000..81149a5 --- /dev/null +++ b/src/app/[department]/[year]/[semester]/[subject]/page.tsx @@ -0,0 +1,286 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { departments } from "@/data/departments"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { CoverageBadges } from "@/components/ui/Badge"; +import { NoteLink } from "@/components/NoteLink"; +import { ShareButton } from "@/components/ShareButton"; +import { DoneToggle } from "@/components/Progress"; +import { TrackVisit } from "@/components/RecentlyViewed"; +import { AddNotesButton } from "@/components/ContributeCTA"; +import { Icon } from "@/components/ui/icons"; +import { + countNotes, + departmentSlug, + findDepartment, + findSemester, + findSubject, + findYear, + sameSubjectElsewhere, + subjectPath, + subjectSlug, +} from "@/lib/catalog"; +import { isNew } from "@/lib/changelog"; +import { countUsable, isGone, isUsable } from "@/lib/linkHealth"; +import { deadSince } from "@/lib/linkRot"; +import { reportIssueUrl } from "@/lib/site"; +import { JsonLd, breadcrumbLd, courseLd } from "@/components/StructuredData"; + +type Params = Promise<{ + department: string; + year: string; + semester: string; + subject: string; +}>; + +export function generateStaticParams() { + return departments.flatMap((department) => + department.years.flatMap((year) => + year.semesters.flatMap((semester) => + semester.subjects.map((subject) => ({ + department: departmentSlug(department), + year: String(year.year), + semester: String(semester.number), + subject: subjectSlug(subject, semester), + })) + ) + ) + ); +} + +async function resolve(params: Params) { + const { + department: deptSlug, + year: yearParam, + semester: semesterParam, + subject: subjectParam, + } = await params; + + const department = findDepartment(deptSlug); + const year = findYear(department, yearParam); + const semester = findSemester(year, semesterParam); + const subject = findSubject(semester, subjectParam); + + return { department, year, semester, subject }; +} + +export async function generateMetadata({ + params, +}: { + params: Params; +}): Promise { + const { department, year, semester, subject } = await resolve(params); + if (!department || !year || !semester || !subject) { + return { title: "Subject not found" }; + } + + const title = `${subject.name}${ + subject.subject_code ? ` (${subject.subject_code})` : "" + }`; + const description = `${subject.notes.length} resources for ${subject.name} — ${department.name}, year ${year.year}, semester ${semester.number} at RVCE.`; + + return { + title, + description, + openGraph: { title, description }, + }; +} + +export default async function SubjectPage({ params }: { params: Params }) { + const { department, year, semester, subject } = await resolve(params); + if (!department || !year || !semester || !subject) notFound(); + + const semesterPath = `${department.link}/${year.year}/${semester.number}`; + const path = `${semesterPath}/${subjectSlug(subject, semester)}`; + const counts = countNotes(subject.notes); + const siblings = semester.subjects.filter((other) => other !== subject); + const { usable, broken } = countUsable(subject.notes); + + // When a subject's own links have died, the same course in another branch is + // usually the fastest rescue. + const elsewhere = + usable === 0 + ? sameSubjectElsewhere( + { department, year, semester, subject }, + isUsable + ).slice(0, 4) + : []; + + const trail = [ + { label: "Departments", href: "/" }, + { label: department.name, href: department.link }, + { label: `Year ${year.year}`, href: `${department.link}/${year.year}` }, + { label: `Semester ${semester.number}`, href: semesterPath }, + { label: subject.name }, + ]; + + return ( +
+ + + + + +
+ {subject.subject_code && ( + + {subject.subject_code} + + )} + + + {counts.total} {counts.total === 1 ? "resource" : "resources"} + +
+ +
+ + + +
+
+ +
+

+ Resources +

+ + {subject.notes.length ? ( +
+ {subject.notes.map((note) => ( + + ))} +
+ ) : ( +
+ Nothing uploaded for this subject yet. +
+ )} + +

+ A link not opening?{" "} + + Report it + {" "} + and it gets fixed. +

+
+ + {elsewhere.length > 0 && ( +
+

+ + {broken > 0 + ? "These links are dead — but another branch still has this subject" + : "This subject is also taught elsewhere"} +

+

+ Same course, different department. The material is usually close + enough to revise from. +

+
    + {elsewhere.map((match) => ( +
  • + + + + {match.department.name} + + + {match.subject.name} · Year {match.year.year} · Sem{" "} + {match.semester.number} + + + + +
  • + ))} +
+
+ )} + + {siblings.length > 0 && ( +
+

+ Also in semester {semester.number} +

+
+ {siblings.map((other) => ( + + {other.name} + + ))} +
+ + + Exam prep for this semester + +
+ )} +
+ ); +} diff --git a/src/app/[department]/[year]/[semester]/exam-prep/page.tsx b/src/app/[department]/[year]/[semester]/exam-prep/page.tsx new file mode 100644 index 0000000..08d6009 --- /dev/null +++ b/src/app/[department]/[year]/[semester]/exam-prep/page.tsx @@ -0,0 +1,191 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { departments } from "@/data/departments"; +import type { Note, NoteType } from "@/data/types"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { NoteLink } from "@/components/NoteLink"; +import { ShareButton } from "@/components/ShareButton"; +import { SemesterSwitcher } from "@/components/SemesterSwitcher"; +import { CopyLinks } from "@/components/CopyLinks"; +import { SemesterProgress } from "@/components/Progress"; +import { EmptyState } from "@/components/ui/EmptyState"; +import { Icon } from "@/components/ui/icons"; +import { + countNotes, + departmentSlug, + findDepartment, + findSemester, + findYear, + semesterNotes, + subjectSlug, +} from "@/lib/catalog"; +import { isNew } from "@/lib/changelog"; +import { isGone } from "@/lib/linkHealth"; +import { deadSince } from "@/lib/linkRot"; +import { NOTE_TYPE_ORDER, noteTypeMeta } from "@/lib/noteTypes"; + +type Params = Promise<{ department: string; year: string; semester: string }>; + +export function generateStaticParams() { + return departments.flatMap((department) => + department.years.flatMap((year) => + year.semesters.map((semester) => ({ + department: departmentSlug(department), + year: String(year.year), + semester: String(semester.number), + })) + ) + ); +} + +async function resolve(params: Params) { + const { + department: deptSlug, + year: yearParam, + semester: semesterParam, + } = await params; + const department = findDepartment(deptSlug); + const year = findYear(department, yearParam); + const semester = findSemester(year, semesterParam); + return { department, year, semester }; +} + +export async function generateMetadata({ + params, +}: { + params: Params; +}): Promise { + const { department, year, semester } = await resolve(params); + if (!department || !year || !semester) return { title: "Not found" }; + + return { + title: `Exam prep · ${department.name} Semester ${semester.number}`, + description: `Every note, lab manual and question paper for semester ${semester.number} of ${department.name}, grouped for revision.`, + }; +} + +export default async function ExamPrepPage({ params }: { params: Params }) { + const { department, year, semester } = await resolve(params); + if (!department || !year || !semester) notFound(); + + const semesterPath = `${department.link}/${year.year}/${semester.number}`; + const counts = countNotes(semesterNotes(semester)); + + // Everything in the semester, regrouped by what it is rather than by subject. + const byType = new Map>(); + for (const subject of semester.subjects) { + const path = `${semesterPath}/${subjectSlug(subject, semester)}`; + for (const note of subject.notes) { + const bucket = byType.get(note.type) ?? []; + bucket.push({ note, subject: subject.name, path }); + byType.set(note.type, bucket); + } + } + + const subjectPaths = semester.subjects.map( + (subject) => `${semesterPath}/${subjectSlug(subject, semester)}` + ); + + const groups = NOTE_TYPE_ORDER.filter((type) => byType.get(type)?.length); + + return ( +
+ +
+ + ({ + title: note.title, + link: note.link, + }))} + /> + + + Browse by subject + +
+ +
+ ({ + year: y.year, + semesters: y.semesters.map((s) => s.number), + }))} + current={{ year: year.year, semester: semester.number }} + /> +
+
+ + + + {counts.total === 0 ? ( + + ) : ( + groups.map((type) => { + const meta = noteTypeMeta(type); + const items = byType.get(type) ?? []; + + return ( +
+
+ +

+ {meta.label} +

+ {items.length} +
+ +
+ {items.map(({ note, subject, path }) => ( +
+ + + {subject} + +
+ ))} +
+
+ ); + }) + )} +
+ ); +} diff --git a/src/app/[department]/[year]/[semester]/page.tsx b/src/app/[department]/[year]/[semester]/page.tsx index 81e3f25..f6f91aa 100644 --- a/src/app/[department]/[year]/[semester]/page.tsx +++ b/src/app/[department]/[year]/[semester]/page.tsx @@ -1,117 +1,172 @@ -'use client'; -import { departments } from "../../../../data/mockData"; +import type { Metadata } from "next"; import { notFound } from "next/navigation"; -import { use } from "react"; +import { departments } from "@/data/departments"; +import { CardGrid } from "@/components/ui/Card"; +import { CoverageBadges } from "@/components/ui/Badge"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { SubjectCard } from "@/components/SubjectCard"; +import { EmptyState } from "@/components/ui/EmptyState"; +import { TrackVisit } from "@/components/RecentlyViewed"; +import { SemesterProgress } from "@/components/Progress"; +import { ShareButton } from "@/components/ShareButton"; +import { SemesterSwitcher } from "@/components/SemesterSwitcher"; +import Link from "next/link"; +import { Icon } from "@/components/ui/icons"; +import { ContributeBanner } from "@/components/ContributeCTA"; +import { + countNotes, + departmentSlug, + findDepartment, + findSemester, + findYear, + semesterNotes, + subjectSlug, +} from "@/lib/catalog"; -export default function SemesterPage({ +export function generateStaticParams() { + return departments.flatMap((department) => + department.years.flatMap((year) => + year.semesters.map((semester) => ({ + department: departmentSlug(department), + year: String(year.year), + semester: String(semester.number), + })) + ) + ); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ department: string; year: string; semester: string }>; +}): Promise { + const { + department: slug, + year: yearParam, + semester: semesterParam, + } = await params; + const department = findDepartment(slug); + const year = findYear(department, yearParam); + const semester = findSemester(year, semesterParam); + if (!department || !year || !semester) return { title: "Not found" }; + + const subjects = semester.subjects.map((s) => s.name).join(", "); + + return { + title: `${department.name} · Semester ${semester.number}`, + description: `Notes, lab material and question papers for semester ${semester.number}: ${subjects}.`, + }; +} + +export default async function SemesterPage({ params, }: { params: Promise<{ department: string; year: string; semester: string }>; }) { - const resolvedParams = use(params); - - const department = departments.find( - (dept) => dept.link === `/${resolvedParams.department}` - ); - const year = department?.years.find( - (y) => y.year === parseInt(resolvedParams.year) - ); - const semester = year?.semesters.find( - (s) => s.number === parseInt(resolvedParams.semester) - ); + const { + department: slug, + year: yearParam, + semester: semesterParam, + } = await params; - if (!department || !year || !semester) { - notFound(); - } + const department = findDepartment(slug); + const year = findYear(department, yearParam); + const semester = findSemester(year, semesterParam); + + if (!department || !year || !semester) notFound(); + + const counts = countNotes(semesterNotes(semester)); + const semesterPath = `${department.link}/${year.year}/${semester.number}`; + const subjectPaths = semester.subjects.map( + (subject) => `${semesterPath}/${subjectSlug(subject, semester)}` + ); return (
- {/* Header with Breadcrumb */} -
-
- - {department.name} - - → - - Year {year.year} - - → - Semester {semester.number} + + + +
+ + + {semester.subjects.length} subjects · {counts.total} resources +
-

- Semester {semester.number} -

-
- {/* Subjects Grid */} -
- {semester.subjects.map((subject) => ( -
+ - {/* Subject Header */} -
-
-
- - - -
-
-

- {subject.name} -

- - {subject.subject_code} - -
-
-
+ + Exam prep view + + +
+ +
+ ({ + year: y.year, + semesters: y.semesters.map((s) => s.number), + }))} + current={{ year: year.year, semester: semester.number }} + /> +
+ + + + + {semester.subjects.length ? ( + + {semester.subjects.map((subject) => ( + + ))} + + ) : ( + + )} - {/* Notes Grid */} - -
- ))} -
+
); -} \ No newline at end of file +} diff --git a/src/app/[department]/[year]/page.tsx b/src/app/[department]/[year]/page.tsx index 55d1f63..b2c7bcd 100644 --- a/src/app/[department]/[year]/page.tsx +++ b/src/app/[department]/[year]/page.tsx @@ -1,96 +1,94 @@ -import { departments } from "../../../data/mockData"; +import type { Metadata } from "next"; import { notFound } from "next/navigation"; -import { use } from "react"; +import { departments } from "@/data/departments"; +import { CardGrid } from "@/components/ui/Card"; +import { CoverageBadges } from "@/components/ui/Badge"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { TileLink } from "@/components/ui/TileLink"; +import { EmptyState } from "@/components/ui/EmptyState"; +import { + countNotes, + departmentSlug, + findDepartment, + findYear, + semesterNotes, + yearNotes, +} from "@/lib/catalog"; -export default function YearPage({ +export function generateStaticParams() { + return departments.flatMap((department) => + department.years.map((year) => ({ + department: departmentSlug(department), + year: String(year.year), + })) + ); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ department: string; year: string }>; +}): Promise { + const { department: slug, year: yearParam } = await params; + const department = findDepartment(slug); + const year = findYear(department, yearParam); + if (!department || !year) return { title: "Not found" }; + + return { + title: `${department.name} · Year ${year.year}`, + description: `Semesters and subjects for year ${year.year} of ${department.name} at RVCE.`, + }; +} + +export default async function YearPage({ params, }: { params: Promise<{ department: string; year: string }>; }) { - const resolvedParams = use(params); - - const department = departments.find( - (dept) => dept.link === `/${resolvedParams.department}` - ); - const year = department?.years.find( - (y) => y.year === parseInt(resolvedParams.year) - ); + const { department: slug, year: yearParam } = await params; + const department = findDepartment(slug); + const year = findYear(department, yearParam); - if (!department || !year) { - notFound(); - } + if (!department || !year) notFound(); return (
- {/* Header */} -
-
- - {department.name} - - → - Year {year.year} -
-

Year {year.year}

-
- - {/* Semesters Grid */} - + {year.semesters.length ? ( + + {year.semesters.map((semester) => ( + + + + ))} + + ) : ( + + )}
); -} \ No newline at end of file +} diff --git a/src/app/[department]/page.tsx b/src/app/[department]/page.tsx index e8ea853..a1fc864 100644 --- a/src/app/[department]/page.tsx +++ b/src/app/[department]/page.tsx @@ -1,91 +1,94 @@ -import { departments } from "../../data/mockData"; +import type { Metadata } from "next"; import { notFound } from "next/navigation"; -import { use } from "react"; +import { departments } from "@/data/departments"; +import { CardGrid } from "@/components/ui/Card"; +import { CoverageBadges } from "@/components/ui/Badge"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { TileLink } from "@/components/ui/TileLink"; +import { EmptyState } from "@/components/ui/EmptyState"; +import { + countNotes, + departmentNotes, + departmentSlug, + findDepartment, + subjectCount, + yearNotes, +} from "@/lib/catalog"; -export default function DepartmentPage({ +export function generateStaticParams() { + return departments.map((department) => ({ + department: departmentSlug(department), + })); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ department: string }>; +}): Promise { + const { department: slug } = await params; + const department = findDepartment(slug); + if (!department) return { title: "Department not found" }; + + return { + title: department.name, + description: department.description, + openGraph: { title: department.name, description: department.description }, + }; +} + +export default async function DepartmentPage({ params, }: { params: Promise<{ department: string }>; }) { - // Unwrap params using React.use() - const resolvedParams = use(params); - const departmentSlug = resolvedParams.department; + const { department: slug } = await params; + const department = findDepartment(slug); - // Find the department from the URL parameter - const department = departments.find( - (dept) => dept.link === `/${departmentSlug}` - ); + if (!department) notFound(); - // If department not found, show 404 - if (!department) { - notFound(); - } + const counts = countNotes(departmentNotes(department)); return (
- {/* Department Header */} -
-

{department.name}

-

{department.description}

-
- - {/* Years Grid */} - + {department.years.length ? ( + + {department.years.map((year) => ( + + + + ))} + + ) : ( + + )}
); } diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index ecfaa0c..bddf7f4 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -1,162 +1,138 @@ -import React from "react"; +import type { Metadata } from "next"; import Link from "next/link"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { Icon } from "@/components/ui/icons"; +import { SITE } from "@/lib/site"; +import { AddNotesButton } from "@/components/ContributeCTA"; + +export const metadata: Metadata = { + title: "About", + description: + "What KnotesNeo is, how the notes are stored, and how RVCE students can keep it going.", +}; + +const STEPS = [ + { + title: "Upload and share", + body: "Put your files in a Google Drive folder and share it with RVCE, or push them to a public GitHub repo.", + }, + { + title: "Open the form", + body: "Pick the department, year and semester, paste the link, and submit. It is a GitHub issue form — no Git knowledge needed.", + }, + { + title: "It goes live", + body: "A maintainer adds the entry to the department file, and your name goes on the contributors page.", + }, +]; + export default function About() { return ( -
-

About Knotes Central

+
+ -

- Knotes Central is a comprehensive collection of academic resources for - RVCE. Our mission is to provide students with easy access to a wide - range of study materials, including notes, question papers, and lab - manuals, to help them excel in their academic pursuits. Our main Motto ,{" "} - "For all For Always!" -

- -
-

Our Mission

-

- Knotes Central aims to offer a comprehensive method of accessing - notes, ensuring our community members no longer need to search - extensively for study materials. All documents and notes are - exclusively from RVCE, making this a centralized resource for our - students. +

+

+ KnotesNeo is a comprehensive collection of academic resources for + RVCE — notes, question papers and lab manuals, gathered in one place + so nobody has to hunt for them again. Our motto:{" "} + "{SITE.motto}"

-
-
-

Features

-
    -
  • Wide range of courses covered
  • -
  • Easy access to notes and question papers
  • -
  • Accessible via RVCE Email ID
  • -
  • No running around for notes
  • -
-
+

How to access the material

+

+ All notes are stored in RVCE Workspace Drive, so most links require + you to be signed in with your college account. Some are made public + during beta. +

-
-

How to Access

-

- All notes are stored in RVCE Workspace Drive, requiring login to view - the content.But sometimes made public for beta versions. +

Declaration

+

+ This website is intended for the RVCE community. Its use is free and + fair, strictly for educational purposes.

-
-
-

Declaration

-

- This website is intended exclusively for RVCE students and is not - accessible to individuals outside this community. We declare that its - use is free and fair, strictly for educational purposes. +

Keeping it alive

+

+ The site is open source, and the catalog is just a set of files in + the repository — one per department. Anyone can add to it: either + through a short form, or by editing the department file directly on + GitHub. As each batch graduates, the next one keeps it current. That + is the only reason anything here exists.

-
+
-
-

- Continuous Updates and Contributions +
+

+ How to contribute

-

- This website connects to shared drives where all the notes, previous - years' question papers, and other resources are stored. As we - near graduation, we want to ensure that this resource remains valuable - and up-to-date for future batches. -

-
-
-

Batch Contributions

-

- After we graduate, we encourage our batch mates, as well as - juniors, to continue updating the drives with new notes and - resources. This collaborative effort will benefit everyone. -

-
-
-

List of Contributors

-

- We maintain a list of contributors who have editor access to the - drive. If you need to update the content, please contact the - nearest senior or junior from this list to gain editor access. The - list will be regularly updated to reflect active contributors. -

-
-
-

How to Contribute

-
    -
  • - Contact Us: To become a contributor, reach out - to a current contributor from the list. They will guide you on - gaining editor access and the guidelines for updating the - content. -
  • -
  • - Add New Content: When adding new content, - please follow the existing structure to maintain consistency. - Label your notes and resources clearly with course names and - relevant details. -
  • -
-
+
    + {STEPS.map((step, i) => ( +
  1. + + {i + 1} + +

    + {step.title} +

    +

    {step.body}

    +
  2. + ))} +
+ +
+ + + See both ways to contribute + +
-
-

Additional Notes

-

- Feel free to clone the drive (whose link will be in the sheet) and - create your own if the website is not updated. Additionally, you can - clone the website too. This project is still in its early stages and - has a long way to go. We are looking for your contributions to make - this project better. -

-
-
-

Previous Versions

-

- As we continue to improve the Knotes Central platform, we have had - several iterations. Below are links to the previous versions for your - reference: -

-
    +
    +

    Previous versions

    +
    • - Version 1:{" "} - - Knotes Central V1.0 - + KnotesNeo V1 +
    • - Version 2:{" "} - - Knotes Central V2 - + KnotesNeo V2 +
    -
    -

    Contact Us

    -

    - If you have any questions, suggestions, or feedback, please feel free - to{" "} - - contact us - - . We are always looking for ways to improve and would love to hear - from you. -

    -
    +

    + Questions, suggestions or corrections?{" "} + + Get in touch + + . +

); } diff --git a/src/app/contact/page.tsx b/src/app/contact/page.tsx index cdccaa2..5b2f2da 100644 --- a/src/app/contact/page.tsx +++ b/src/app/contact/page.tsx @@ -1,72 +1,105 @@ +import type { Metadata } from "next"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { Icon, type IconName } from "@/components/ui/icons"; +import { + CONTACT_EMAIL, + CONTACT_FORM_URL, + REDDIT, + addNotesIssueUrl, + reportIssueUrl, +} from "@/lib/site"; + +export const metadata: Metadata = { + title: "Contact", + description: + "Questions, corrections or notes to contribute — how to reach the KnotesNeo maintainers.", +}; + +const CHANNELS: Array<{ + icon: IconName; + title: string; + body: string; + href: string; + cta: string; +}> = [ + { + icon: "sparkles", + title: "Add notes", + body: "Paste a Drive or GitHub link into a short form. A maintainer puts it on the site.", + href: addNotesIssueUrl(), + cta: "Open the form", + }, + { + icon: "inbox", + title: "Report a broken link", + body: "A note that will not open, or points at the wrong material.", + href: reportIssueUrl(), + cta: "Report it", + }, + { + icon: "check", + title: "Anything else", + body: "Requests, corrections and general feedback through our form.", + href: CONTACT_FORM_URL, + cta: "Open the form", + }, + { + icon: "mail", + title: "Email", + body: CONTACT_EMAIL, + href: `mailto:${CONTACT_EMAIL}`, + cta: "Send an email", + }, + { + icon: "users", + title: "Reddit", + body: "Ask the wider RVCE community on r/rvce, or follow r/KnotesCentral for updates.", + href: REDDIT.knotes, + cta: "Open r/KnotesCentral", + }, +]; + export default function Contact() { return ( -
-

Contact Us

-
-
-

- Have questions, suggestions, or want to contribute? We'd love - to hear from you. Click the button below to fill out our contact - form. -

- +
+ + + - - -
-

You can also reach us at:

- - knotescentral@gmail.com - -

Follow us on Reddit:

- - RVCE Reddit - - - Knotes Reddit - -
- - - - - - - - - - - - - - - - - - - + ))}
+ +

+ Most notes live on RVCE Workspace Drive, so you may need to be signed in + with your college account to open them. +

); } diff --git a/src/app/contribute/page.tsx b/src/app/contribute/page.tsx new file mode 100644 index 0000000..a162f34 --- /dev/null +++ b/src/app/contribute/page.tsx @@ -0,0 +1,165 @@ +import type { Metadata } from "next"; +import { departments } from "@/data/departments"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { AddNotesButton } from "@/components/ContributeCTA"; +import { Icon } from "@/components/ui/icons"; +import { ContributeWizard } from "@/components/ContributeWizard"; +import { departmentOptions, departmentSlug } from "@/lib/catalog"; +import { githubEditUrl, REPO_URL, reportIssueUrl } from "@/lib/site"; + +export const metadata: Metadata = { + title: "Contribute notes", + description: + "Two ways to add notes to KnotesNeo: a short GitHub form, or editing the department file directly.", +}; + +const SNIPPET = `{ + name: "Data Structures and its Applications", + subject_code: "CSE201", + notes: [ + { + title: "DSA Unit 1-5 notes", + type: "theory", // "theory" | "lab" | "question-paper" + link: "https://drive.google.com/drive/folders/...", + }, + ], +}`; + +export default function ContributePage() { + return ( +
+ + +
+

+ Build your entry +

+

+ Fill this in and it writes the catalog entry for you — then send it + either way below. +

+
+ +
+
+ +
+
+ + Easiest + +

+ Fill a short form on GitHub +

+

+ Pick the department, year and semester, paste your Google Drive or + GitHub link, and submit. A maintainer adds it to the site. You need + a free GitHub account and nothing else — no Git, no code. +

+
    + {[ + "Upload your files to Drive and share them with RVCE.", + "Open the form and paste the link.", + "Add your name if you want the credit.", + ].map((step, i) => ( +
  1. + + {i + 1} + + {step} +
  2. + ))} +
+
+ +
+
+ +
+ + Direct + +

+ Edit the department file yourself +

+

+ Each department is a single file. Click edit, add your entry in the + right semester, and GitHub opens a pull request for you — it forks + the repo automatically, so you cannot break anything. +

+
+            {SNIPPET}
+          
+

+ Keep the shape exactly as above — the site reads these files + directly, and a typo shows up as a build failure on your pull + request rather than a broken page. +

+
+
+ +
+

+ Edit a department directly +

+

+ Opens GitHub's editor on that department's file. +

+
+ {departments.map((department) => ( + + + {department.name} + + + ))} +
+
+ +
+
+

+ Found a dead link instead? +

+

+ Report it and it gets fixed — that is a contribution too. +

+
+ +
+
+ ); +} diff --git a/src/app/contributors/page.tsx b/src/app/contributors/page.tsx index 3bfad61..967fb34 100644 --- a/src/app/contributors/page.tsx +++ b/src/app/contributors/page.tsx @@ -1,17 +1,36 @@ -import { contributors } from "../../data/mockData"; -import ContributorCard from "../../components/contributorCard"; +import type { Metadata } from "next"; +import { contributors } from "@/data/contributors"; +import ContributorCard from "@/components/ContributorCard"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { CardGrid } from "@/components/ui/Card"; +import { AddNotesButton } from "@/components/ContributeCTA"; + +export const metadata: Metadata = { + title: "Contributors", + description: + "The RVCE students who collected, uploaded and maintain the notes on KnotesNeo.", +}; export default function ContributorsPage() { return ( -
-

- Contributors -

-
- {contributors.map((contributor, index) => ( - +
+ + + + + + {contributors.map((contributor) => ( + ))} -
+
); } diff --git a/src/app/feed.xml/route.ts b/src/app/feed.xml/route.ts new file mode 100644 index 0000000..508f0b9 --- /dev/null +++ b/src/app/feed.xml/route.ts @@ -0,0 +1,49 @@ +import { recentNotes } from "@/lib/changelog"; +import { SITE } from "@/lib/site"; + +export const dynamic = "force-static"; + +const escape = (value: string) => + value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + +/** RSS of newly added material, for anyone who wants updates without checking. */ +export function GET() { + const items = recentNotes(50) + .map((item) => { + // Trailing slash: the export writes every route as /index.html. + const link = `${SITE.url}${item.path}/`; + const where = `${item.location.department.name} · Year ${item.location.year.year} · Semester ${item.location.semester.number}`; + + return ` + ${escape(item.note.title)} — ${escape(item.location.subject.name)} + ${escape(link)} + ${escape(item.note.link)} + ${new Date(item.date).toUTCString()} + ${escape(where)} + `; + }) + .join("\n"); + + const xml = ` + + + ${escape(SITE.name)} — what's new + ${SITE.url} + ${escape(SITE.description)} + en +${items} + + +`; + + return new Response(xml, { + headers: { + "content-type": "application/rss+xml; charset=utf-8", + "cache-control": "public, max-age=3600", + }, + }); +} diff --git a/src/app/gaps/page.tsx b/src/app/gaps/page.tsx new file mode 100644 index 0000000..23ee917 --- /dev/null +++ b/src/app/gaps/page.tsx @@ -0,0 +1,135 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { Icon } from "@/components/ui/icons"; +import { AddNotesButton } from "@/components/ContributeCTA"; +import { EmptyState } from "@/components/ui/EmptyState"; +import { + catalogStats, + subjectPath, + subjectsNeedingHelp, + type SubjectLocation, +} from "@/lib/catalog"; +import { goneCount, isUsable, LINK_CHECKED_AT } from "@/lib/linkHealth"; +import { SITE, addNotesIssueUrl } from "@/lib/site"; + +export const metadata: Metadata = { + title: "Help needed", + description: + "Subjects on KnotesNeo with no working material — the fastest way to help the batches behind you.", +}; + +const DATE = new Intl.DateTimeFormat("en-GB", { + day: "numeric", + month: "long", + year: "numeric", + timeZone: "UTC", +}); + +export default function GapsPage() { + const gaps = subjectsNeedingHelp(isUsable); + const stats = catalogStats(); + const broken = goneCount(); + + // Group by department so someone can scan straight to their own branch. + const byDepartment = new Map(); + for (const gap of gaps) { + const key = gap.department.name; + byDepartment.set(key, [...(byDepartment.get(key) ?? []), gap]); + } + + const departments = Array.from(byDepartment.entries()).sort( + (a, b) => b[1].length - a[1].length + ); + + return ( +
+ +
+ + + + How the rot is tracked + + + {gaps.length} of {stats.subjects} subjects affected + {LINK_CHECKED_AT && + ` · links last checked ${DATE.format(new Date(LINK_CHECKED_AT))}`} + +
+
+ + {gaps.length === 0 ? ( + + ) : ( + departments.map(([department, items]) => ( +
+
+

+ {department} +

+ + {items.length} {items.length === 1 ? "subject" : "subjects"} + +
+ +
    + {items.map((gap) => ( +
  • + + + {gap.subject.name} + + + {gap.subject.subject_code + ? `${gap.subject.subject_code} · ` + : ""} + Year {gap.year.year} · Semester {gap.semester.number} + + + + + I have this + + +
  • + ))} +
+
+ )) + )} +
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index eda258f..a7f8dfd 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,201 +2,160 @@ @tailwind components; @tailwind utilities; +/* + * Colour tokens are RGB triplets so Tailwind's `/opacity` modifiers work + * (see tailwind.config.ts). Light is the default; `.dark` on flips them. + * Every foreground/background pair below clears WCAG AA at body size. + */ :root { - /* Base colors */ - --background: #ffffff; - --foreground: #171717; - - /* Neon Colors */ - --neon-blue: #00ffff; /* Cyan */ - --neon-purple: #ff00ff; /* Magenta */ - --neon-pink: #ff69b4; /* Hot Pink */ - --neon-green: #39ff14; /* Neon Green */ - - /* Glow effects */ - --neon-blue-glow: 0 0 10px rgba(0, 255, 255, 0.5); - --neon-purple-glow: 0 0 10px rgba(255, 0, 255, 0.5); - --neon-text-glow: 0 0 5px rgba(255, 255, 255, 0.5); - - /* Card and UI */ - --card-bg: rgba(255, 255, 255, 0.05); - --card-border: rgba(255, 255, 255, 0.1); - --card-hover: rgba(255, 255, 255, 0.1); - - color-scheme: light dark; -} + --page: 248 250 252; /* slate-50 */ + --surface: 255 255 255; + --raised: 241 245 249; /* slate-100 */ + --line: 226 232 240; /* slate-200 */ + --fg: 15 23 42; /* slate-900 */ + --muted: 71 85 105; /* slate-600 — 7.2:1 on --surface */ -/* Dark mode overrides */ -.dark { - --background: #191919; - --foreground: #bbbbbb; - --card-bg: rgba(0, 0, 0, 0.02); - --card-border: rgba(0, 0, 0, 0.1); - --card-hover: rgba(0, 0, 0, 0.05); -} + --brand: 79 70 229; /* indigo-600 */ + --brand-soft: 238 242 255; /* indigo-50 */ + --brand-contrast: 255 255 255; -/* Base styles */ -body { - color: var(--foreground); - background: var(--background); - min-height: 100vh; - background-image: radial-gradient( - circle at 15% 50%, - rgba(0, 255, 255, 0.08) 0%, - transparent 25% - ), - radial-gradient( - circle at 85% 50%, - rgba(255, 0, 255, 0.08) 0%, - transparent 25% - ); - background-attachment: fixed; -} + --accent: 13 148 136; /* teal-600 */ -/* Smooth transitions */ -* { - transition: all 0.2s ease-in-out; -} + --theory: 37 99 235; /* blue-600 */ + --lab: 5 150 105; /* emerald-600 */ + --paper: 217 119 6; /* amber-600 */ -/* Mobile-first card styles */ -.card { - @apply rounded-xl p-4 md:p-6 backdrop-blur-sm; - background: var(--card-bg); - border: 1px solid var(--card-border); + color-scheme: light; } -.card:hover { - background: var(--card-hover); - box-shadow: var(--neon-purple-glow); -} +.dark { + --page: 2 6 23; /* slate-950 */ + --surface: 15 23 42; /* slate-900 */ + --raised: 30 41 59; /* slate-800 */ + --line: 51 65 85; /* slate-700 */ + --fg: 241 245 249; /* slate-100 */ + --muted: 148 163 184; /* slate-400 — 7.4:1 on --surface */ -/* Neon text effects */ -.neon-text { - text-shadow: var(--neon-text-glow); -} + --brand: 129 140 248; /* indigo-400 */ + --brand-soft: 30 27 75; /* indigo-950 */ + --brand-contrast: 15 23 42; -.neon-text-blue { - color: var(--neon-blue); - text-shadow: var(--neon-blue-glow); -} + --accent: 45 212 191; /* teal-400 */ -.neon-text-purple { - color: var(--neon-purple); - text-shadow: var(--neon-purple-glow); -} + --theory: 96 165 250; /* blue-400 */ + --lab: 52 211 153; /* emerald-400 */ + --paper: 251 191 36; /* amber-400 */ -/* Mobile-friendly container */ -.container { - @apply px-4 mx-auto w-full; - max-width: min(100%, 1280px); + color-scheme: dark; } -/* Mobile navigation adjustments */ -@media (max-width: 768px) { - .nav-items { - @apply flex-wrap justify-center gap-2; +@layer base { + html { + -webkit-text-size-adjust: 100%; + scroll-behavior: smooth; } - .nav-link { - @apply text-sm py-1 px-2; + body { + @apply bg-page text-fg antialiased; } -} -/* Gradient borders */ -.gradient-border { - position: relative; - border-radius: 0.75rem; - background: var(--card-bg); -} + /* One visible focus treatment for every interactive element. */ + :where(a, button, input, select, textarea, [tabindex]):focus-visible { + @apply outline-none ring-2 ring-brand ring-offset-2 ring-offset-page rounded-md; + } -.gradient-border::before { - content: ""; - position: absolute; - top: -1px; - left: -1px; - right: -1px; - bottom: -1px; - border-radius: 0.75rem; - padding: 1px; - background: linear-gradient(45deg, var(--neon-blue), var(--neon-purple)); - -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); - mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); - -webkit-mask-composite: xor; - mask-composite: exclude; + ::selection { + @apply bg-brand/20; + } } -/* Animation keyframes */ -@keyframes glow { - 0%, - 100% { - text-shadow: var(--neon-text-glow); +@layer components { + /* Page gutter. Wide enough to breathe, capped so lines stay readable. */ + .container { + @apply w-full mx-auto px-4 sm:px-6 lg:px-8; + max-width: 80rem; } - 50% { - text-shadow: var(--neon-purple-glow); + + .card { + @apply bg-surface border border-line rounded-card shadow-card; } -} -.animate-glow { - animation: glow 2s ease-in-out infinite; -} + .card-interactive { + @apply card transition duration-200 hover:border-brand/40 hover:shadow-lift focus-within:border-brand/40 motion-safe:hover:-translate-y-0.5; + } -/* Responsive typography */ -h1 { - @apply text-2xl md:text-3xl lg:text-4xl font-bold; -} + .prose-page { + @apply max-w-prose text-[0.975rem] leading-7 text-muted; + } -h2 { - @apply text-xl md:text-2xl lg:text-3xl font-bold; -} + .prose-page h2 { + @apply text-xl font-semibold text-fg mt-10 mb-3; + } -h3 { - @apply text-lg md:text-xl font-semibold; -} + .prose-page h3 { + @apply text-base font-semibold text-fg mt-6 mb-2; + } -/* Custom scrollbar */ -::-webkit-scrollbar { - width: 8px; -} + .prose-page p + p { + @apply mt-4; + } -::-webkit-scrollbar-track { - background: var(--background); -} + .prose-page a { + @apply text-brand font-medium underline underline-offset-4 decoration-brand/30 hover:decoration-brand; + } -::-webkit-scrollbar-thumb { - background: var(--card-border); - border-radius: 4px; -} + .prose-page ul { + @apply mt-4 space-y-2 list-disc pl-5 marker:text-brand/60; + } -::-webkit-scrollbar-thumb:hover { - background: var(--card-hover); -} + .skeleton { + @apply relative overflow-hidden bg-raised rounded-md; + } -/* Update the nav-link styles */ -.nav-link { - @apply px-3 py-2 rounded-md text-sm font-medium transition-colors flex items-center; + /* animate-shimmer, not a raw `animation: shimmer`: Tailwind only emits the + @keyframes when the utility that owns them is actually used. */ + .skeleton::after { + content: ""; + @apply absolute inset-0 -translate-x-full animate-shimmer; + background: linear-gradient( + 90deg, + transparent, + rgb(var(--fg) / 0.06), + transparent + ); + } } -/* Add mobile menu transition */ -@media (max-width: 768px) { - nav { - transition: all 0.3s ease-in-out; +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; } - .nav-link { - @apply w-full justify-between; + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; } } -/* Smooth theme transitions */ -html { - transition: background-color 0.3s ease; +/* Scrollbar tuned to the surface tokens rather than fixed greys. */ +::-webkit-scrollbar { + width: 10px; + height: 10px; } -html.dark { - color-scheme: dark; +::-webkit-scrollbar-track { + background: rgb(var(--page)); } -/* Prevent transition flash */ -.no-transitions * { - transition: none !important; +::-webkit-scrollbar-thumb { + background: rgb(var(--line)); + border-radius: 6px; + border: 2px solid rgb(var(--page)); +} + +::-webkit-scrollbar-thumb:hover { + background: rgb(var(--muted) / 0.5); } diff --git a/src/app/icon.svg b/src/app/icon.svg new file mode 100644 index 0000000..0243782 --- /dev/null +++ b/src/app/icon.svg @@ -0,0 +1,5 @@ + + + + diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3d3f444..ee0de46 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,36 +1,52 @@ +import type { Metadata, Viewport } from "next"; import { Inter } from "next/font/google"; -import "./globals.css"; // Make sure this import is present +import "./globals.css"; +import { SiteHeader } from "@/components/SiteHeader"; +import { Notice } from "@/components/Notice"; +import { SiteFooter } from "@/components/SiteFooter"; +import { InstallApp } from "@/components/InstallApp"; import { ThemeProvider } from "@/components/ThemeProvider"; -import { ThemeToggle } from "@/components/ThemeToggle"; -import Link from "next/link"; +import { THEME_INIT_SCRIPT } from "@/lib/theme"; +import { SITE, SITE_ORIGIN, asset } from "@/lib/site"; +import { JsonLd, siteLd } from "@/components/StructuredData"; -const inter = Inter({ subsets: ["latin"] }); +const inter = Inter({ subsets: ["latin"], display: "swap" }); -// This script runs before React hydration to prevent flash of wrong theme -// It causes a hydration warning in development, which is expected and can be ignored -const themeInitScript = ` - (function() { - const theme = localStorage.getItem('theme') ?? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); - document.documentElement.classList[theme === 'dark' ? 'add' : 'remove']('dark'); - })() -`; +export const metadata: Metadata = { + metadataBase: new URL(SITE_ORIGIN), + title: { + default: `${SITE.name} — RVCE notes, labs and question papers`, + template: `%s · ${SITE.name}`, + }, + description: SITE.description, + keywords: [ + "RVCE", + "RV College of Engineering", + "notes", + "question papers", + "lab manuals", + "VTU", + "study material", + ], + openGraph: { + type: "website", + siteName: SITE.name, + title: `${SITE.name} — RVCE notes, labs and question papers`, + description: SITE.description, + }, + twitter: { card: "summary_large_image" }, + manifest: asset("/manifest.webmanifest"), + appleWebApp: { capable: true, title: SITE.name, statusBarStyle: "default" }, + icons: { apple: asset("/apple-icon.png") }, + alternates: { types: { "application/rss+xml": asset("/feed.xml") } }, +}; -// Add this with other icon components at the top -const SearchIcon = () => ( - - - -); +export const viewport: Viewport = { + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#f8fafc" }, + { media: "(prefers-color-scheme: dark)", color: "#020617" }, + ], +}; export default function RootLayout({ children, @@ -38,105 +54,22 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - + - -