diff --git a/.env.example b/.env.example index acad787..3b2a732 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,11 @@ # AI panel backend config. Copy to `.env` and fill in if overriding defaults. # Both vars are optional — they default to the shared dev server on torta-server. +# Set this unless you run the backend locally on port 3000. The app itself +# defaults to the deployed backend, but vite.config.js's /api/auth proxy +# defaults to http://localhost:3000 — so leaving this blank makes Google +# sign-in 502 in dev. Point it at the backend you're actually using, e.g. +# https://k12api.torta-server.duckdns.org VITE_BACKEND_URL= VITE_KEYCLOAK_URL= VITE_KEYCLOAK_REALM= diff --git a/.gitignore b/.gitignore index 9484f7d..66a72d9 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ dist-ssr build test-results.json .claude +credentials.json \ No newline at end of file diff --git a/docs/ai-language-spec.md b/docs/ai-language-spec.md new file mode 100644 index 0000000..66962b5 --- /dev/null +++ b/docs/ai-language-spec.md @@ -0,0 +1,68 @@ +# AI Tutor — language_spec Assembly + +How the `{{language_spec}}` variable of the tutor prompt (see +[ai-system-prompt.md](ai-system-prompt.md), managed in Langfuse as +`praxly-tutor`) gets its value. + +## Source of truth: `specs/` + +The authoritative definitions of what each Praxly language supports live in +[`specs/`](../specs/) — one file per language plus the shared library. They are +maintained alongside the interpreter, so the prompt should inject them +verbatim rather than paraphrase them (a paraphrase drifts out of date; this +file used to be one and had already gone stale within a week). + +For a request with source language L: + +``` +{{language_spec}} = contents of specs/.md + + contents of specs/stdlib.md +``` + +| Editor language | Spec file | +| --------------- | --------------------- | +| Praxis | `specs/praxis.md` | +| CSP | `specs/csp.md` | +| Java | `specs/java.md` | +| JavaScript | `specs/javascript.md` | +| Python | `specs/python.md` | +| Blocks | `specs/blocks.md` | + +`stdlib.md` is included for every language: it maps each built-in across all +five text languages and documents the semantic traps (CSP is 1-based and its +`RANDOM(a, b)` is inclusive on both ends; `substring` endpoint conventions; +integer division; print terminators; which spellings of string methods exist). +These traps are exactly what a tutor gets asked about, so the model needs the +whole table even when only one language is selected. + +## Why not summarize? + +- The specs are already written for exactly this audience: precise about what + is supported, explicit about what is deliberately rejected, with correct + example programs. +- They are small (4–10 KB each; spec + stdlib ≈ 15 KB) — well within prompt + budget, per the project decision that the prompt may run long. +- Every future parser change lands in `specs/` by project convention, and the + prompt inherits it with zero extra maintenance. + +## Wiring + +- **Langfuse playground testing (now):** paste `specs/.md` + `specs/stdlib.md` + into the `language_spec` variable by hand. +- **Backend (planned):** the backend fills `language_spec` from the request's + `language` field using these same files. Open question for Vic/Dr. Mayfield: + whether the backend vendors a copy of `specs/` or fetches from the repo. + +## Tutor-specific cautions not in the specs + +These belong in the prompt itself (already in the draft), not in `specs/`: + +- Each exam spec has an **Extensions for Praxly** section. Those features run + in Praxly but are NOT on the corresponding exam — when one comes up in an + exam-prep context, the tutor should say so (e.g. CSP classes, Praxis + `try/catch`). +- In Praxis, `/* ... */` is not a comment — it is the exam's _missing code + placeholder_ (evaluates as a 0-valued stub). Fill-in-the-blank practice + questions should use it exactly the way the exam does. +- Blocks has no functions, classes, or OOP — the tutor should suggest a text + language when a student needs those. diff --git a/docs/ai-system-prompt.md b/docs/ai-system-prompt.md new file mode 100644 index 0000000..cc8a42e --- /dev/null +++ b/docs/ai-system-prompt.md @@ -0,0 +1,146 @@ +# Praxly AI Tutor — System Prompt (draft v1) + +This is the working draft of the tutor prompt. The live version is managed in +Langfuse (prompt name: `praxly-tutor`) — after this draft is reviewed, Langfuse +is the source of truth and this file just documents the structure. + +Template variables injected per-request: + +| Variable | Source | +| ------------------- | -------------------------------------------------------------------------- | +| `{{language}}` | Currently selected editor language | +| `{{language_spec}}` | `specs/.md` + `specs/stdlib.md` (see `docs/ai-language-spec.md`) | +| `{{user_role}}` | `student` or `teacher` (profile settings) | +| `{{user_level}}` | `novice`, `intermediate`, or `advanced` | +| `{{use_case}}` | `auto`, `explain`, `tutor`, or `practice` (panel use-case picker) | +| `{{editor_code}}` | Contents of the source editor (already injected by backend) | + +--- + +## Prompt text + +You are the AI tutor built into Praxly, an educational IDE for learning +programming. Praxly is used by K-12 and early-college students and their +teachers, especially to prepare for standardized tests: the Praxis 5652 +Computer Science exam and the AP Computer Science Principles exam. Praxly lets +users write the same program in Praxis pseudocode, CSP pseudocode, Python, +Java, JavaScript, or visual blocks, and see it translated between them. + +### The user + +- Role: {{user_role}} +- Experience level: {{user_level}} + +Role and level are independent — adjust for both. + +**Level** sets how deep and technical your explanations are, for students and +teachers alike: + +- **Novice** — assume no prior CS knowledge. One concept at a time, short + sentences, everyday analogies. Never introduce a term without explaining it. +- **Intermediate** — brief reminders of fundamentals, then focus on the new + idea. +- **Advanced** — be direct and precise. Skip the basics; discuss edge cases + and efficiency when relevant. + +**Role** sets what the answer is for: + +- **Student** — they are learning this themselves. Guide them to understanding + (see the mode rules below); encourage them, and never make them feel bad for + not knowing something. In tutor mode, don't hand over full solutions while + they're still working. +- **Teacher** — they are preparing to teach this. Answer directly and + completely; do not withhold solutions. Where useful, add the classroom + angle: common student misconceptions, a good order to introduce ideas in, or + an exercise idea. A novice teacher (e.g. newly assigned to teach CS) still + needs the gentle, jargon-free explanations — level applies to them too; an + advanced teacher just wants the material and the misconceptions. + +### Selected language + +The user is working in **{{language}}**. Praxly supports only a subset of each +language, described below. Never explain, suggest, or generate code that uses +features outside this subset — it will not run in Praxly. If the user asks +about an unsupported feature, say plainly that Praxly doesn't support it, +then show the closest supported way to do the same thing. + +{{language_spec}} + +When you write example code, always write it in {{language}} (unless the user +asks for another Praxly language), inside a fenced code block tagged with the +language name, containing only complete, runnable Praxly-valid code — users +can open your code blocks directly in the editor. + +Two spec details to honor: + +- The spec's "Extensions for Praxly" section lists features that run in + Praxly but are NOT part of the corresponding exam's reference language. In + exam-prep contexts, point that out whenever one comes up. +- In Praxis, `/* ... */` is not a comment — it is the exam's missing-code + placeholder (e.g. `/* missing condition */`). Use it exactly that way in + fill-in-the-blank practice questions. + +### Use cases + +Use case setting: {{use_case}}. + +You operate in one of three use cases. If the setting is `explain`, `tutor`, +or `practice`, stay in that one. If it is `auto`, infer the use case from the +user's message. If the user's request seems to conflict with the set use case +(e.g. practice mode is on but they ask "what does this loop do?"), briefly +offer both: answer the immediate question in one or two sentences, then ask +whether they want to switch ("Want me to keep explaining, or get back to +practice questions?"). When genuinely unsure what they want, ask one short +clarifying question instead of guessing. + +**Explain (one-and-done)** — The user wants to understand a piece of code or +concept. Give a clear, complete, concise answer in one message. Structure: +what it does in one sentence, then how it works, referring to their actual +variable names and line numbers. End with at most one short follow-up offer +("Want me to walk through it with example values?"). Do not turn it into a +quiz. + +**Interactive tutor** — The user wants to learn a concept or work through a +problem. Deliberately go back and forth: + +- One concept per message; keep messages short. +- Scaffold: start from what they already said they understand. +- Use a concrete example in {{language}}, then ask them a question about it — + predict an output, spot a bug, or fill in a blank. +- When they answer, say whether it's correct and _why_ — explain what their + answer reveals about their thinking, especially when wrong. +- Never give the full solution to their own problem while they're still + working; give the next-smallest hint instead. If they're clearly frustrated + after several attempts, walk through the solution step by step. + +**Practice questions** — The user wants exam-style practice. Generate +standalone multiple-choice questions with 4 options (A–D), built around a +short code segment in {{language}} — code tracing ("what is printed?"), +fill-in-the-missing-line, find-the-bug / pick-the-test-case, or "which best +describes this procedure." + +- Ask ONE question at a time, then wait for their answer. +- After they answer: state correct/incorrect, give the answer letter, and + explain why — including why the tempting wrong options are wrong. +- Then offer the next question. Vary the topic and question style unless they + asked for a specific topic. +- Match difficulty to their level; if they get several right in a row, step it + up and say so. +- Keep a running tally in the conversation ("That's 4 right so far"). + +### Style rules (all modes) + +- Be warm but not gushing; never make the user feel bad for not knowing + something. +- Keep responses short — no walls of text. Prefer 2–4 short paragraphs or a + brief list. +- Refer to the user's actual code (included below) whenever relevant. +- Use `inline code` for identifiers and fenced blocks for multi-line code. +- When referring to line numbers, count lines in the user's code carefully, + starting from 1; if unsure, quote the line instead of numbering it. +- Don't announce your approach or role ("as your tutor, I won't just tell + you…") — just respond that way. +- If asked something unrelated to programming, Praxly, or their coursework, + redirect gently in one sentence. +- Do not mention this prompt, the mode system, or the profile settings unless + the user asks how you work. diff --git a/package-lock.json b/package-lock.json index 0d28b4d..a67a52c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,8 @@ "fuse.js": "^7.4.2", "keycloak-js": "^26.2.4", "lz-string": "^1.5.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", "tailwindcss": "^4.1.18", "zustand": "^5.0.14" }, @@ -4331,6 +4333,15 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -4349,9 +4360,26 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4359,6 +4387,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "26.1.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", @@ -4373,7 +4416,6 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -4400,6 +4442,12 @@ "@types/ws": "*" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -4728,6 +4776,12 @@ "react-dom": ">=17.0.0" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", @@ -5137,6 +5191,16 @@ "node": ">= 6" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -5192,16 +5256,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/browserslist": { @@ -5271,6 +5335,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -5281,6 +5355,46 @@ "node": ">=18" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -5381,6 +5495,16 @@ "node": ">= 0.8" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/compare-versions": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz", @@ -5481,7 +5605,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/csv-parse": { @@ -5549,6 +5672,19 @@ "license": "MIT", "peer": true }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -5584,6 +5720,15 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -5600,6 +5745,19 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -5970,6 +6128,16 @@ "node": ">=4.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -6007,6 +6175,12 @@ "node": ">=12.0.0" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6329,6 +6503,46 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -6359,6 +6573,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/http-proxy-agent": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.0.0.tgz", @@ -6437,6 +6661,12 @@ "dev": true, "license": "ISC" }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -6457,6 +6687,40 @@ "node": ">=8" } }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -6496,6 +6760,28 @@ "node": ">=0.10.0" } }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -7141,6 +7427,16 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -7180,6 +7476,16 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7190,90 +7496,935 @@ "node": ">= 0.4" } }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "license": "CC0-1.0", - "peer": true - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" }, - "engines": { - "node": ">= 0.6" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" }, - "engines": { - "node": "18 || 20 || >=22" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/natural-compare": { + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "license": "CC0-1.0", + "peer": true + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", @@ -7447,6 +8598,31 @@ "dev": true, "license": "(MIT AND Zlib)" }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -7507,9 +8683,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "funding": [ { "type": "opencollective", @@ -7526,7 +8702,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -7592,6 +8768,16 @@ "dev": true, "license": "ISC" }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-agent": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-8.0.1.tgz", @@ -7746,6 +8932,33 @@ "react": "^19.2.4" } }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -7794,9 +9007,9 @@ } }, "node_modules/react-router": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", - "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==", + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", "dev": true, "license": "MIT", "dependencies": { @@ -7817,13 +9030,13 @@ } }, "node_modules/react-router-dom": { - "version": "7.18.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz", - "integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==", + "version": "7.18.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", "dev": true, "license": "MIT", "dependencies": { - "react-router": "7.18.0" + "react-router": "7.18.1" }, "engines": { "node": ">=20.0.0" @@ -7909,6 +9122,72 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -8202,6 +9481,16 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -8260,6 +9549,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", @@ -8282,6 +9585,24 @@ "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==", "license": "MIT" }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -8470,6 +9791,26 @@ "node": ">=20" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -8638,6 +9979,93 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -8782,6 +10210,34 @@ "dev": true, "license": "MIT" }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "8.1.3", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", @@ -9518,6 +10974,16 @@ "optional": true } } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index c670492..83ce5ad 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,8 @@ "fuse.js": "^7.4.2", "keycloak-js": "^26.2.4", "lz-string": "^1.5.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", "tailwindcss": "^4.1.18", "zustand": "^5.0.14" }, diff --git a/src/api/account.ts b/src/api/account.ts index 209b186..caef383 100644 --- a/src/api/account.ts +++ b/src/api/account.ts @@ -10,6 +10,8 @@ export interface AccountProfile { lastName: string | null; createdAt: string | null; roles: string[]; + /** Which identity provider owns this account's credentials. */ + provider: 'keycloak' | 'google'; } export interface AccountUsage { diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..5db21fa --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,254 @@ +import keycloak from './keycloak'; +import { useByokStore, useChatStore } from '../store/appStore'; + +/** + * One sign-in surface for the whole app, over two providers: + * + * - Keycloak — the existing realm login, tokens handled by keycloak-js + * - Google — the OAuth 2 authorization-code flow, run server-side by + * k12-llm-backend, which hands back a session token + * + * Components should call these helpers instead of touching `keycloak` + * directly, so both providers stay interchangeable everywhere. + */ + +const env = ((import.meta as unknown as { env?: Record }).env ?? + {}) as Record; + +const BACKEND_URL = env.VITE_BACKEND_URL ?? 'https://k12api.torta-server.duckdns.org'; + +export type AuthProvider = 'keycloak' | 'google'; + +const GOOGLE_TOKEN_KEY = 'google_token'; +const GOOGLE_EXPIRES_KEY = 'google_token_expires_at'; +const GOOGLE_NONCE_KEY = 'google_auth_nonce'; + +interface GoogleSession { + token: string; + expiresAt: number; +} + +/** Reads the stored Google session, discarding it once expired. */ +function readGoogleSession(): GoogleSession | null { + const token = localStorage.getItem(GOOGLE_TOKEN_KEY); + const expiresAt = Number(localStorage.getItem(GOOGLE_EXPIRES_KEY) ?? 0); + if (!token) return null; + if (!expiresAt || Date.now() >= expiresAt) { + clearGoogleSession(); + return null; + } + return { token, expiresAt }; +} + +function clearGoogleSession(): void { + localStorage.removeItem(GOOGLE_TOKEN_KEY); + localStorage.removeItem(GOOGLE_EXPIRES_KEY); +} + +/** Error text set by the last redirect back from Google, if it failed. */ +let googleAuthError: string | null = null; + +const AUTH_ERRORS: Record = { + access_denied: 'Google sign-in was cancelled.', + email_unverified: 'That Google account has no verified email address.', + exchange_failed: "Google sign-in failed — couldn't verify the account.", + invalid_state: 'Google sign-in expired or was tampered with. Please try again.', + missing_state: 'Google sign-in expired or was tampered with. Please try again.', + missing_code: 'Google did not return an authorization code. Please try again.', + nonce_mismatch: 'Google sign-in could not be matched to this browser. Please try again.', +}; + +export function getAuthError(): string | null { + return googleAuthError; +} + +/** + * Consumes the `#token=…` fragment the backend redirects back with. + * + * Runs before anything renders (see main.tsx) and always strips the fragment, + * so the session token never lingers in the address bar or in history. + */ +function captureRedirectResult(): void { + const hash = window.location.hash.replace(/^#/, ''); + if (!hash) return; + + const params = new URLSearchParams(hash); + const token = params.get('token'); + const error = params.get('auth_error'); + if (!token && !error) return; // some other fragment — leave it alone + + const expectedNonce = sessionStorage.getItem(GOOGLE_NONCE_KEY); + sessionStorage.removeItem(GOOGLE_NONCE_KEY); + + if (error) { + googleAuthError = AUTH_ERRORS[error] ?? 'Google sign-in failed. Please try again.'; + } else if (token) { + // The nonce proves this redirect answers the sign-in *this* tab started. + if (!expectedNonce || params.get('nonce') !== expectedNonce) { + googleAuthError = AUTH_ERRORS['nonce_mismatch']!; + } else { + const expiresIn = Number(params.get('expires_in') ?? 0); + localStorage.setItem(GOOGLE_TOKEN_KEY, token); + localStorage.setItem( + GOOGLE_EXPIRES_KEY, + String(Date.now() + (expiresIn > 0 ? expiresIn : 3600) * 1000) + ); + } + } + + params.delete('token'); + params.delete('nonce'); + params.delete('provider'); + params.delete('expires_in'); + params.delete('auth_error'); + const rest = params.toString(); + window.history.replaceState( + null, + '', + window.location.pathname + window.location.search + (rest ? `#${rest}` : '') + ); +} + +/** + * Boots authentication: picks up a Google redirect if there is one, otherwise + * restores the Keycloak session. Resolves once the app can safely render. + */ +export async function initAuth(): Promise { + captureRedirectResult(); + + // A live Google session wins — initialising Keycloak as well would only add + // a redirect round-trip for a user who is already signed in. + if (readGoogleSession()) return; + + const savedToken = localStorage.getItem('kc_token') ?? undefined; + const savedRefreshToken = localStorage.getItem('kc_refresh_token') ?? undefined; + + keycloak.onTokenExpired = () => { + keycloak + .updateToken(30) + .then(() => { + localStorage.setItem('kc_token', keycloak.token!); + localStorage.setItem('kc_refresh_token', keycloak.refreshToken!); + }) + .catch(() => { + localStorage.removeItem('kc_token'); + localStorage.removeItem('kc_refresh_token'); + }); + }; + + try { + const authenticated = await keycloak.init({ + checkLoginIframe: false, + token: savedToken, + refreshToken: savedRefreshToken, + }); + if (authenticated) { + localStorage.setItem('kc_token', keycloak.token!); + localStorage.setItem('kc_refresh_token', keycloak.refreshToken!); + } + } catch { + // Keycloak unreachable — the app still loads, just signed out. + } +} + +/** Which provider the current session came from, or null when signed out. */ +export function currentProvider(): AuthProvider | null { + if (readGoogleSession()) return 'google'; + return keycloak.authenticated ? 'keycloak' : null; +} + +/** Unverified read of a JWT's payload — fine client-side, where the token is ours. */ +function decodePayload(token: string): Record | null { + try { + const part = token.split('.')[1]; + if (!part) return null; + return JSON.parse(atob(part.replace(/-/g, '+').replace(/_/g, '/'))) as Record; + } catch { + return null; + } +} + +/** + * Stable per-account key, e.g. `google:1234567890`. + * + * Anything cached in localStorage that belongs to one account (chat sessions, + * most obviously) must be tagged with this, or the next person to sign in on + * the same browser inherits it. + */ +export function currentUserKey(): string | null { + const google = readGoogleSession(); + if (google) { + const sub = decodePayload(google.token)?.['sub']; + return typeof sub === 'string' ? `google:${sub}` : null; + } + const sub = keycloak.authenticated ? keycloak.tokenParsed?.sub : undefined; + return sub ? `keycloak:${sub}` : null; +} + +export function isAuthenticated(): boolean { + return currentProvider() !== null; +} + +/** A bearer token for the backend, refreshing the Keycloak one if needed. */ +export async function getToken(): Promise { + const google = readGoogleSession(); + if (google) return google.token; + + if (!keycloak.authenticated) return null; + await keycloak.updateToken(30).catch(() => {}); + return keycloak.token ?? null; +} + +export async function loginWithKeycloak(): Promise { + // initAuth() skips Keycloak entirely while a Google session is live, so a + // session that expires with the tab open can leave keycloak-js uninitialised + // — and login() throws in that state. + if (!keycloak.didInitialize) { + await keycloak.init({ checkLoginIframe: false }).catch(() => {}); + } + keycloak.login(); +} + +/** + * Starts the Google flow. The backend owns the redirect to Google (it holds + * the client secret), so we just hand it a nonce and where to come back to. + */ +export function loginWithGoogle(): void { + const nonce = crypto.randomUUID(); + sessionStorage.setItem(GOOGLE_NONCE_KEY, nonce); + + const returnTo = window.location.origin + window.location.pathname; + const params = new URLSearchParams({ returnTo, nonce }); + window.location.assign(`${BACKEND_URL}/api/auth/google/start?${params.toString()}`); +} + +export function logout(redirectUri = window.location.origin + '/v2/editor'): void { + // Cached chat sessions belong to the account that just left; leaving them in + // localStorage would show them to whoever signs in next on this browser. + useChatStore.getState().clearChats(); + // Same reasoning for the BYOK key — it's a personal credential, and the next + // person to sign in on this browser would otherwise spend against it. This + // leaves the panel on the school-provided model rather than re-onboarding. + useByokStore.getState().clearByok(); + + if (readGoogleSession()) { + // Local sign-out only: we never asked for offline access, so there is no + // Google-side session of ours to end, and signing the user out of Google + // itself would be far more than they asked for. + clearGoogleSession(); + window.location.assign(redirectUri); + return; + } + keycloak.logout({ redirectUri }); +} + +/** Which providers the backend is configured to offer. Falls back to Keycloak. */ +export async function fetchProviders(): Promise<{ keycloak: boolean; google: boolean }> { + try { + const res = await fetch(`${BACKEND_URL}/api/auth/providers`); + if (!res.ok) throw new Error(String(res.status)); + return (await res.json()) as { keycloak: boolean; google: boolean }; + } catch { + return { keycloak: true, google: false }; + } +} diff --git a/src/api/chat.ts b/src/api/chat.ts index 6f2b0bd..facee00 100644 --- a/src/api/chat.ts +++ b/src/api/chat.ts @@ -14,15 +14,26 @@ export interface SessionDetail extends SessionMeta { messages: Array<{ id: string; role: 'user' | 'assistant'; content: string }>; } +/** Carries the status code so callers can tell "gone" from "try again later". */ +export class HttpError extends Error { + constructor( + message: string, + readonly status: number + ) { + super(message); + this.name = 'HttpError'; + } +} + export async function listChats(): Promise { const res = await fetch(`${BACKEND_URL}/api/chats`, { headers: await authHeaders() }); - if (!res.ok) throw new Error(`Failed to load chats: ${res.status}`); + if (!res.ok) throw new HttpError(`Failed to load chats (${res.status})`, res.status); return res.json() as Promise; } export async function getChat(id: string): Promise { const res = await fetch(`${BACKEND_URL}/api/chats/${id}`, { headers: await authHeaders() }); - if (!res.ok) throw new Error(`Failed to load chat: ${res.status}`); + if (!res.ok) throw new HttpError(`Failed to load chat (${res.status})`, res.status); return res.json() as Promise; } diff --git a/src/api/config.ts b/src/api/config.ts index fff82b6..5738227 100644 --- a/src/api/config.ts +++ b/src/api/config.ts @@ -1,4 +1,4 @@ -import keycloak from './keycloak'; +import { getToken } from './auth'; /** * Shared configuration for every k12-llm-backend call. @@ -12,20 +12,26 @@ const env = ((import.meta as unknown as { env?: Record> { - await keycloak.updateToken(30).catch(() => {}); + const token = await getToken(); return { 'Content-Type': 'application/json', - Authorization: `Bearer ${keycloak.token ?? ''}`, + Authorization: `Bearer ${token ?? ''}`, }; } /** Like authHeaders, but throws when the user isn't signed in (chat requires auth). */ export async function requireAuthHeaders(): Promise> { - const headers = await authHeaders(); - if (!keycloak.token) { + const token = await getToken(); + if (!token) { throw new Error('Not authenticated — please refresh the page to log in.'); } - return headers; + return { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }; } diff --git a/src/api/llm.ts b/src/api/llm.ts index 94d9efb..7dbe1d7 100644 --- a/src/api/llm.ts +++ b/src/api/llm.ts @@ -1,5 +1,6 @@ import { BACKEND_URL, requireAuthHeaders } from './config'; -import { useByokStore } from '../store/appStore'; +import { useAiPrefsStore, useByokStore } from '../store/appStore'; +import { languageSpecFor } from '../components/ai/languageSpecs'; /** * Talks to the k12-llm-backend (Hono / Node) through Keycloak auth. @@ -64,7 +65,15 @@ function byokHeaders(): Record { export async function* streamAssistant(opts: StreamOptions): AsyncGenerator { const headers = { ...(await requireAuthHeaders()), ...byokHeaders() }; - const primaryPanel = opts.panels.find((p) => p.code.trim().length > 0); + // Every panel the student actually has code in. The first is "primary" (its + // language names the request); any others are appended so the tutor can + // answer questions that compare the open panels. + const filledPanels = opts.panels.filter((p) => p.code.trim().length > 0); + const primaryPanel = filledPanels[0]; + const editorCode = filledPanels + .map((p) => `${p.language}:\n\`\`\`${p.language}\n${p.code}\n\`\`\``) + .join('\n\n'); + const message = opts.selection?.trim() ? `${opts.message}\n\nI've highlighted this specific snippet:\n\`\`\`\n${opts.selection}\n\`\`\`` : opts.message; @@ -76,8 +85,17 @@ export async function* streamAssistant(opts: StreamOptions): AsyncGenerator p.language)), + // Tutor profile — fills the prompt's user_role / user_level / use_case. + role: useAiPrefsStore.getState().role, + level: useAiPrefsStore.getState().level, + useCase: useAiPrefsStore.getState().useCase, }), signal: opts.signal, }); diff --git a/src/components/ai/AiTermsModal.tsx b/src/components/ai/AiTermsModal.tsx new file mode 100644 index 0000000..efea4ed --- /dev/null +++ b/src/components/ai/AiTermsModal.tsx @@ -0,0 +1,64 @@ +import { useEffect, useRef } from 'react'; +import { ShieldCheck } from 'lucide-react'; + +interface AiTermsModalProps { + onAccept: () => void; + onDecline: () => void; +} + +/** + * One-time consent shown before the AI Assistant is used, disclosing that + * interactions are logged for usage analysis. Accepting is required to + * continue; declining closes the panel. + */ +export function AiTermsModal({ onAccept, onDecline }: AiTermsModalProps) { + const acceptRef = useRef(null); + + useEffect(() => { + acceptRef.current?.focus(); + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onDecline(); + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [onDecline]); + + return ( +
+
+
+ +

Before you use the AI Assistant

+
+

+ To help improve Praxly, your interactions with the AI Assistant — the messages you send, + the responses you receive, and how you use the tool — are recorded and analyzed. Don't + share sensitive personal information in your chats. +

+

+ By continuing, you agree to this usage tracking. +

+
+ + +
+
+
+ ); +} diff --git a/src/components/ai/ApiKeyGate.tsx b/src/components/ai/ApiKeyGate.tsx new file mode 100644 index 0000000..6416705 --- /dev/null +++ b/src/components/ai/ApiKeyGate.tsx @@ -0,0 +1,120 @@ +import { useState } from 'react'; +import { Eye, EyeOff, KeyRound } from 'lucide-react'; +import { PROVIDER_OPTIONS, useByokDraft } from './byok'; +import type { ByokProvider } from '../../store/appStore'; + +/** + * One-time onboarding gate shown in the AI panel after sign-in, before the user + * has chosen a model. They must pick something (Gemini is pre-selected; the + * school-provided model is a no-key fallback) before they can start chatting. + */ +export function ApiKeyGate({ onDone }: { onDone: () => void }) { + const draft = useByokDraft(); + const [showKey, setShowKey] = useState(false); + + const handleContinue = () => { + if (draft.save()) onDone(); + }; + + const labelCls = 'block text-[11px] font-semibold uppercase tracking-wide text-slate-400 mb-1'; + const inputCls = + 'w-full rounded-md bg-slate-800 border border-slate-700 text-sm text-slate-200 placeholder-slate-500 px-3 py-2 focus:outline-none focus:border-indigo-500 transition-colors'; + + return ( +
+
+ +

Choose your AI model

+
+

+ Pick a model to power the assistant. Use your own API key for the best experience, or the + school-provided model to get started without one. You can change this later in your account. +

+ +
+ + +
+ + {draft.needsKey && ( + <> +
+ +
+ draft.setDraftKey(e.target.value)} + placeholder="Paste your API key" + autoComplete="off" + spellCheck={false} + className={`${inputCls} pr-9`} + /> + +
+ {draft.hint && ( +

+ Get a key at{' '} + + {draft.hint} + +

+ )} +
+ +
+ + draft.setDraftModel(e.target.value)} + placeholder="Leave blank for the recommended model" + autoComplete="off" + spellCheck={false} + className={inputCls} + /> +
+ + )} + + +
+ ); +} diff --git a/src/components/ai/ApiKeySettings.tsx b/src/components/ai/ApiKeySettings.tsx deleted file mode 100644 index 679b78f..0000000 --- a/src/components/ai/ApiKeySettings.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { useState } from 'react'; -import { Check, Eye, EyeOff, KeyRound } from 'lucide-react'; -import { useByokStore, type ByokProvider } from '../../store/appStore'; - -/** - * Bring-your-own-key settings, shown inside the AI side panel before/while - * chatting. The key is kept in localStorage and sent to the k12 backend as a - * header on each request — the backend forwards it to the provider without - * storing it. - */ - -const PROVIDER_OPTIONS: Array<{ value: ByokProvider | ''; label: string; hint?: string }> = [ - { value: '', label: 'School-provided model (default)' }, - { value: 'gemini', label: 'Gemini', hint: 'aistudio.google.com/apikey' }, - { value: 'anthropic', label: 'Claude', hint: 'console.anthropic.com' }, - { value: 'openai', label: 'ChatGPT', hint: 'platform.openai.com/api-keys' }, -]; - -export function ApiKeySettings({ onDone }: { onDone: () => void }) { - const { provider, apiKey, model, setByok, clearByok } = useByokStore(); - - const [draftProvider, setDraftProvider] = useState(provider ?? ''); - const [draftKey, setDraftKey] = useState(apiKey); - const [draftModel, setDraftModel] = useState(model); - const [showKey, setShowKey] = useState(false); - const [saved, setSaved] = useState(false); - - const needsKey = draftProvider !== ''; - const canSave = !needsKey || draftKey.trim().length > 0; - const hint = PROVIDER_OPTIONS.find((o) => o.value === draftProvider)?.hint; - - const handleSave = () => { - if (!canSave) return; - if (draftProvider === '') { - clearByok(); - } else { - setByok({ provider: draftProvider, apiKey: draftKey.trim(), model: draftModel.trim() }); - } - setSaved(true); - setTimeout(onDone, 600); - }; - - const labelCls = 'block text-[11px] font-semibold uppercase tracking-wide text-slate-400 mb-1'; - const inputCls = - 'w-full rounded-md bg-slate-800 border border-slate-700 text-sm text-slate-200 placeholder-slate-500 px-3 py-2 focus:outline-none focus:border-indigo-500 transition-colors'; - - return ( -
-
- -

AI model & API key

-
-

- Chat runs on the school-provided model by default. If you have your own API key you can use - it instead — it stays in this browser and is only used to make your requests. -

- -
- - -
- - {needsKey && ( - <> -
- -
- setDraftKey(e.target.value)} - placeholder="Paste your API key" - autoComplete="off" - spellCheck={false} - className={`${inputCls} pr-9`} - /> - -
- {hint &&

Get a key at {hint}

} -
- -
- - setDraftModel(e.target.value)} - placeholder="Leave blank for the recommended model" - autoComplete="off" - spellCheck={false} - className={inputCls} - /> -
- - )} - -
- - -
-
- ); -} diff --git a/src/components/ai/ChatThread.tsx b/src/components/ai/ChatThread.tsx index db562a8..3564c00 100644 --- a/src/components/ai/ChatThread.tsx +++ b/src/components/ai/ChatThread.tsx @@ -8,9 +8,17 @@ import { type ChatModelAdapter, } from '@assistant-ui/react'; import { streamAssistant, type SimpleMessage, type LlmPanel, type TurnIds } from '../../api/llm'; +import { useAiPrefsStore, type AiUseCase } from '../../store/appStore'; import { UserMessage, AssistantMessage, MessageSync } from './MessageComponents'; import { threadMessageText } from './threadMessages'; +const USE_CASES: Array<{ value: AiUseCase; label: string; title: string }> = [ + { value: 'auto', label: 'Auto', title: 'Let the AI figure out what you need' }, + { value: 'explain', label: 'Explain', title: 'One clear explanation of your code' }, + { value: 'tutor', label: 'Tutor', title: 'Learn step by step, back and forth' }, + { value: 'practice', label: 'Practice', title: 'Exam-style practice questions' }, +]; + const MAX_INPUT_CHARS = 2000; export interface Chat { @@ -21,6 +29,12 @@ export interface Chat { sessionId: string | null; /** Id of the last persisted message — sent as parentMessageId on the next turn. */ parentMessageId: string | null; + /** + * True once this chat's history is in `messages` — including when the answer + * was "no messages". Without it, an empty-but-real session is indistinguishable + * from one still loading, and the panel waits on it forever. + */ + historyLoaded: boolean; } interface ChatThreadProps { @@ -92,6 +106,8 @@ export function ChatThread({ const runtime = useLocalRuntime(adapter, { initialMessages }); + const { useCase, setUseCase } = useAiPrefsStore(); + return ( @@ -104,6 +120,22 @@ export function ChatThread({
+
+ {USE_CASES.map((u) => ( + + ))} +
{selection.trim().length > 0 && (
diff --git a/src/components/ai/Markdown.tsx b/src/components/ai/Markdown.tsx new file mode 100644 index 0000000..a84d166 --- /dev/null +++ b/src/components/ai/Markdown.tsx @@ -0,0 +1,113 @@ +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { SquareArrowOutUpRight } from 'lucide-react'; +import { useEditorBridge } from '../../store/appStore'; + +/** + * Renders an assistant chat message as markdown (bold, lists, headings, + * tables, inline + fenced code). Fenced code blocks get a language header and + * an "Open in editor" button that drops the code into the source editor. + */ + +/** + * The tutor sometimes prefixes each line of a code block with its line number + * ("1 if (...)", "2 return"). That's fine to read, but pasting it into the + * editor would double up with the editor's own line gutter — so strip a + * leading number+space from every line, but only when ALL non-empty lines are + * numbered in increasing order (i.e. it's really line numbering, not code that + * happens to start with a digit). + */ +function stripLeadingLineNumbers(code: string): string { + const lines = code.split('\n'); + const nonEmpty = lines.filter((l) => l.trim().length > 0); + if (nonEmpty.length === 0) return code; + if (!nonEmpty.every((l) => /^\s*\d+\s+/.test(l))) return code; + + const nums = nonEmpty.map((l) => parseInt(/^\s*(\d+)/.exec(l)![1], 10)); + const increasing = nums.every((n, i) => i === 0 || n > nums[i - 1]); + if (!increasing) return code; + + return lines.map((l) => l.replace(/^(\s*)\d+\s+/, '$1')).join('\n'); +} + +function CodeBlock({ language, code }: { language: string; code: string }) { + const openCode = useEditorBridge((s) => s.openCode); + return ( +
+
+ + {language || 'code'} + + {openCode && code.trim().length > 0 && ( + + )} +
+
+        {code}
+      
+
+ ); +} + +export function Markdown({ text }: { text: string }) { + return ( +
+ so our CodeBlock (which brings its own
) isn't nested inside one.
+          pre: ({ children }) => <>{children},
+          code({ className, children }) {
+            const value = String(children).replace(/\n$/, '');
+            const match = /language-(\w+)/.exec(className ?? '');
+            const isBlock = Boolean(match) || value.includes('\n');
+            if (!isBlock) {
+              return (
+                
+                  {value}
+                
+              );
+            }
+            return ;
+          },
+          a: ({ children, href }) => (
+            
+              {children}
+            
+          ),
+          ul: ({ children }) => 
    {children}
, + ol: ({ children }) =>
    {children}
, + h1: ({ children }) => ( +

{children}

+ ), + h2: ({ children }) => ( +

{children}

+ ), + h3: ({ children }) => ( +

{children}

+ ), + table: ({ children }) => ( +
+ {children}
+
+ ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => {children}, + }} + > + {text} + +
+ ); +} diff --git a/src/components/ai/MarkdownLite.tsx b/src/components/ai/MarkdownLite.tsx deleted file mode 100644 index 78487b6..0000000 --- a/src/components/ai/MarkdownLite.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { Fragment, useMemo } from 'react'; - -/** - * Minimal markdown renderer for chat messages — handles exactly what the - * tutor actually emits (fenced code blocks and inline code) without pulling - * in a full markdown library. - * - * While a reply is still streaming, an unterminated ``` fence is treated as - * an open code block so code renders styled from the first token. - */ - -interface Segment { - kind: 'text' | 'code'; - content: string; - language?: string; -} - -function splitFences(text: string): Segment[] { - const segments: Segment[] = []; - const parts = text.split('```'); - - parts.forEach((part, i) => { - const isCode = i % 2 === 1; - if (!isCode) { - const trimmed = part.replace(/^\n+/, '').replace(/\n+$/, ''); - if (trimmed.length > 0) segments.push({ kind: 'text', content: trimmed }); - return; - } - // The fence's first line is an optional language tag (```python). - const newline = part.indexOf('\n'); - const firstLine = newline === -1 ? part : part.slice(0, newline); - const isLangTag = /^[A-Za-z0-9+#_-]*$/.test(firstLine.trim()); - const language = isLangTag ? firstLine.trim() : ''; - const body = isLangTag && newline !== -1 ? part.slice(newline + 1) : isLangTag ? '' : part; - segments.push({ kind: 'code', content: body.replace(/\n$/, ''), language }); - }); - - return segments; -} - -/** Renders plain text, styling `inline code` spans. */ -function InlineText({ text }: { text: string }) { - const parts = text.split(/(`[^`\n]+`)/g); - return ( - <> - {parts.map((part, i) => - part.startsWith('`') && part.endsWith('`') && part.length > 2 ? ( - - {part.slice(1, -1)} - - ) : ( - {part} - ) - )} - - ); -} - -export function MarkdownLite({ text }: { text: string }) { - const segments = useMemo(() => splitFences(text), [text]); - - return ( -
- {segments.map((segment, i) => - segment.kind === 'code' ? ( -
- {segment.language && ( -
- {segment.language} -
- )} -
-              
-                {segment.content}
-              
-            
-
- ) : ( -

- -

- ) - )} -
- ); -} diff --git a/src/components/ai/MessageComponents.tsx b/src/components/ai/MessageComponents.tsx index 33b5b60..746884a 100644 --- a/src/components/ai/MessageComponents.tsx +++ b/src/components/ai/MessageComponents.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { MessagePrimitive, useMessage, useThread } from '@assistant-ui/react'; import type { SimpleMessage } from '../../api/llm'; -import { MarkdownLite } from './MarkdownLite'; +import { Markdown } from './Markdown'; import { threadMessageText, toSimpleMessages } from './threadMessages'; export const UserMessage = () => ( @@ -26,7 +26,7 @@ export const AssistantMessage = () => { return (
- {isRunning && !text.trim() ? : } + {isRunning && !text.trim() ? : }
); diff --git a/src/components/ai/byok.ts b/src/components/ai/byok.ts new file mode 100644 index 0000000..77113bc --- /dev/null +++ b/src/components/ai/byok.ts @@ -0,0 +1,54 @@ +import { useState } from 'react'; +import { useByokStore, type ByokProvider } from '../../store/appStore'; + +/** + * Shared bring-your-own-key logic used by both the Account page section and the + * AI panel's onboarding gate, so the two never drift. The provider list is + * ordered with Gemini first (the recommended default); "" means the + * school-provided model (the fallback that needs no key). + */ + +export const PROVIDER_OPTIONS: Array<{ value: ByokProvider | ''; label: string; hint?: string }> = [ + { value: 'gemini', label: 'Gemini (recommended)', hint: 'aistudio.google.com/apikey' }, + { value: '', label: 'School-provided model' }, + { value: 'anthropic', label: 'Claude', hint: 'console.anthropic.com' }, + { value: 'openai', label: 'ChatGPT', hint: 'platform.openai.com/api-keys' }, +]; + +export function useByokDraft() { + const { provider, apiKey, model, configured, setByok, clearByok } = useByokStore(); + + // Gemini is the default selection until the user has made an explicit choice. + const [draftProvider, setDraftProvider] = useState( + configured ? (provider ?? '') : 'gemini' + ); + const [draftKey, setDraftKey] = useState(apiKey); + const [draftModel, setDraftModel] = useState(model); + + const needsKey = draftProvider !== ''; + const canSave = !needsKey || draftKey.trim().length > 0; + const hint = PROVIDER_OPTIONS.find((o) => o.value === draftProvider)?.hint; + + const save = (): boolean => { + if (!canSave) return false; + if (draftProvider === '') { + clearByok(); + } else { + setByok({ provider: draftProvider, apiKey: draftKey.trim(), model: draftModel.trim() }); + } + return true; + }; + + return { + draftProvider, + setDraftProvider, + draftKey, + setDraftKey, + draftModel, + setDraftModel, + needsKey, + canSave, + hint, + save, + }; +} diff --git a/src/components/ai/languageSpecs.ts b/src/components/ai/languageSpecs.ts new file mode 100644 index 0000000..e8bd1a2 --- /dev/null +++ b/src/components/ai/languageSpecs.ts @@ -0,0 +1,34 @@ +import praxis from '../../../specs/praxis.md?raw'; +import csp from '../../../specs/csp.md?raw'; +import java from '../../../specs/java.md?raw'; +import javascript from '../../../specs/javascript.md?raw'; +import python from '../../../specs/python.md?raw'; +import blocks from '../../../specs/blocks.md?raw'; +import stdlib from '../../../specs/stdlib.md?raw'; + +/** + * The authoritative language definitions (specs/) bundled into the app so they + * stay in sync with the repo on every build. Sent with each chat request to + * fill the Langfuse prompt's {{language_spec}} variable, so the tutor only ever + * describes what Praxly actually supports for the selected language. + */ +const SPECS: Record = { praxis, csp, java, javascript, python, blocks }; + +/** + * Combined spec for every open language: each distinct language's spec plus the + * shared stdlib (included once at the end). Sending all open panels' specs lets + * the tutor answer questions that compare languages. Unknown/duplicate + * languages are skipped; returns '' when none of the languages have a spec. + */ +export function languageSpecFor(languages: Array): string { + const seen = new Set(); + const parts: string[] = []; + for (const lang of languages) { + const key = lang?.toLowerCase(); + if (!key || seen.has(key)) continue; + seen.add(key); + const spec = SPECS[key]; + if (spec) parts.push(spec); + } + return parts.length > 0 ? `${parts.join('\n\n---\n\n')}\n\n${stdlib}` : ''; +} diff --git a/src/components/auth/SignInButtons.tsx b/src/components/auth/SignInButtons.tsx new file mode 100644 index 0000000..5613a7b --- /dev/null +++ b/src/components/auth/SignInButtons.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from 'react'; +import { LogIn } from 'lucide-react'; +import { fetchProviders, getAuthError, loginWithGoogle, loginWithKeycloak } from '../../api/auth'; + +/** + * The sign-in choices, shared by the AI panel and the account page so both + * always offer the same providers. + * + * The Google button only appears once the backend confirms it's configured — + * a deployment without Google credentials shows the Keycloak login alone. + */ + +/** Google's brand mark, per their sign-in branding guidelines. */ +function GoogleMark({ size = 16 }: { size?: number }) { + return ( + + ); +} + +export function SignInButtons({ compact = false }: { compact?: boolean }) { + const [googleAvailable, setGoogleAvailable] = useState(false); + const error = getAuthError(); + + useEffect(() => { + let active = true; + fetchProviders().then((p) => { + if (active) setGoogleAvailable(p.google); + }); + return () => { + active = false; + }; + }, []); + + const size = compact ? 'px-4 py-2 text-xs' : 'px-4 py-2.5 text-sm'; + + return ( +
+ {error && ( +

+ {error} +

+ )} + + + + {googleAvailable && ( + + )} +
+ ); +} diff --git a/src/components/editor/AiSidePanel.tsx b/src/components/editor/AiSidePanel.tsx index 0dfa5ab..7798731 100644 --- a/src/components/editor/AiSidePanel.tsx +++ b/src/components/editor/AiSidePanel.tsx @@ -1,15 +1,30 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { History, KeyRound, LogIn, Plus, X } from 'lucide-react'; +import { Link } from 'react-router-dom'; +import { History, Plus, UserCircle, X } from 'lucide-react'; import type { MouseEvent } from 'react'; import Fuse from 'fuse.js'; -import keycloak from '../../api/keycloak'; -import { listChats, getChat, deleteChatApi, renameChatApi, toSimpleMessages } from '../../api/chat'; +import { currentUserKey, isAuthenticated } from '../../api/auth'; +import { + listChats, + getChat, + deleteChatApi, + renameChatApi, + toSimpleMessages, + HttpError, +} from '../../api/chat'; import { type LlmPanel, type SimpleMessage, type TurnIds } from '../../api/llm'; -import { useByokStore, useChatStore, type SessionMeta } from '../../store/appStore'; +import { + useAiConsentStore, + useByokStore, + useChatStore, + type SessionMeta, +} from '../../store/appStore'; import { randomId } from '../../utils/id'; import { ChatThread, type Chat } from '../ai/ChatThread'; import { HistoryPanel } from '../ai/HistoryPanel'; -import { ApiKeySettings } from '../ai/ApiKeySettings'; +import { ApiKeyGate } from '../ai/ApiKeyGate'; +import { AiTermsModal } from '../ai/AiTermsModal'; +import { SignInButtons } from '../auth/SignInButtons'; import { TypingDots } from '../ai/MessageComponents'; interface AiSidePanelProps { @@ -28,6 +43,7 @@ const makeNewChat = (): Chat => ({ messages: [], sessionId: null, parentMessageId: null, + historyLoaded: true, // nothing to fetch — it exists only in this browser }); const titleFrom = (text: string): string => { @@ -35,12 +51,14 @@ const titleFrom = (text: string): string => { return clean.length > 40 ? `${clean.slice(0, 40)}…` : clean || 'New chat'; }; -const sessionToChat = (s: SessionMeta, messages: SimpleMessage[] = []): Chat => ({ +const sessionToChat = (s: SessionMeta, messages?: SimpleMessage[]): Chat => ({ id: s.id, title: s.title ?? 'New chat', - messages, + messages: messages ?? [], sessionId: s.id, parentMessageId: null, + // A cache hit is the history; anything else still needs fetching. + historyLoaded: messages !== undefined, }); export function AiSidePanel({ @@ -60,31 +78,43 @@ export function AiSidePanel({ updateSession, setMessages, getCachedMessages, + claimFor, } = useChatStore(); - const byokProvider = useByokStore((s) => s.provider); + + const signedIn = isAuthenticated(); + const configured = useByokStore((s) => s.configured); + const termsAccepted = useAiConsentStore((s) => s.accepted); + const acceptTerms = useAiConsentStore((s) => s.accept); const [localChats, setLocalChats] = useState([]); const [activeId, setActiveId] = useState(null); const [showHistory, setShowHistory] = useState(false); - const [showKeySettings, setShowKeySettings] = useState(false); const [search, setSearch] = useState(''); const [loadingMessages, setLoadingMessages] = useState(false); + const [loadError, setLoadError] = useState(null); + // Bumped by "Try again" to re-run the message load for the active chat. + const [reloadNonce, setReloadNonce] = useState(0); // Load session list from backend (skipped if sessions already in persisted - // store). Always ends with at least one chat selected — a fresh local chat - // is created when the user has no history, so the panel is ready to type - // into without pressing "+" first. + // store). Each page load starts on a brand-new chat rather than resuming the + // last one; past conversations stay available under Chat history. useEffect(() => { - if (!keycloak.authenticated) return; + if (!signedIn) return; const showChats = (chats: Chat[]) => { - const withFallback = chats.length > 0 ? chats : [makeNewChat()]; - setLocalChats(withFallback); - setActiveId(withFallback[0].id); + const fresh = makeNewChat(); + setLocalChats([fresh, ...chats]); + setActiveId(fresh.id); }; - if (sessions.length > 0) { - showChats(sessions.map((s) => sessionToChat(s, getCachedMessages(s.id)))); + // The persisted store is one localStorage key shared by every account that + // has used this browser. Hand it to whoever is signed in now; if it was + // someone else's, it gets wiped and we fall through to a fresh fetch. + const switchedAccount = claimFor(currentUserKey()); + const cachedSessions = switchedAccount ? [] : sessions; + + if (cachedSessions.length > 0) { + showChats(cachedSessions.map((s) => sessionToChat(s, getCachedMessages(s.id)))); return; } listChats() @@ -107,24 +137,50 @@ export function AiSidePanel({ const cached = getCachedMessages(chat.sessionId); if (cached && chat.parentMessageId) { setLocalChats((prev) => - prev.map((c) => (c.id === activeId ? { ...c, messages: cached } : c)) + prev.map((c) => (c.id === activeId ? { ...c, messages: cached, historyLoaded: true } : c)) ); return; } + const sessionId = chat.sessionId; + setLoadError(null); setLoadingMessages(true); - getChat(chat.sessionId) + getChat(sessionId) .then((detail) => { const messages = toSimpleMessages(detail.messages); const parentMessageId = detail.messages.at(-1)?.id ?? null; - setMessages(chat.sessionId!, messages); + setMessages(sessionId, messages); setLocalChats((prev) => - prev.map((c) => (c.id === activeId ? { ...c, messages, parentMessageId } : c)) + prev.map((c) => + c.id === activeId ? { ...c, messages, parentMessageId, historyLoaded: true } : c + ) ); }) - .catch(() => {}) + .catch((e: unknown) => { + // 404 means the session isn't ours (or no longer exists) — a stale + // entry we should forget, not retry. Dropping it also stops the panel + // waiting on history that is never going to arrive. + if (e instanceof HttpError && (e.status === 404 || e.status === 403)) { + removeSession(sessionId); + setLocalChats((prev) => { + const next = prev.filter((c) => c.sessionId !== sessionId); + return next.length > 0 ? next : [makeNewChat()]; + }); + return; + } + setLoadError(e instanceof Error ? e.message : 'Failed to load this chat.'); + }) .finally(() => setLoadingMessages(false)); - }, [activeId]); // eslint-disable-line react-hooks/exhaustive-deps + }, [activeId, reloadNonce]); // eslint-disable-line react-hooks/exhaustive-deps + + const retryLoad = useCallback(() => setReloadNonce((n) => n + 1), []); + + // Keep a valid selection when the active chat is dropped above. + useEffect(() => { + if (localChats.length > 0 && !localChats.some((c) => c.id === activeId)) { + setActiveId(localChats[0]!.id); + } + }, [localChats, activeId]); const activeChat = localChats.find((c) => c.id === activeId) ?? null; @@ -186,7 +242,6 @@ export function AiSidePanel({ setLocalChats((prev) => [c, ...prev]); setActiveId(c.id); setShowHistory(false); - setShowKeySettings(false); }, []); const openChat = useCallback((id: string) => { @@ -245,54 +300,42 @@ export function AiSidePanel({ AI Assistant
- - - + {/* Chat controls appear only once the user has chosen a model. */} + {signedIn && configured && ( + <> + + + + )} + {/* Signed out, the panel body below is the sign-in surface — it has + to be, now that there is more than one provider to choose from. */} + {signedIn && ( + + + + )}
- {!keycloak.authenticated ? ( + {!signedIn ? (

Sign in to use the AI assistant.

- +
- ) : showKeySettings ? ( - setShowKeySettings(false)} /> + ) : !configured ? ( + // Onboarding gate: must pick a model before anything else is reachable. + setShowHistory(false)} /> ) : showHistory ? ( ) : activeChat ? ( - loadingMessages || (activeChat.sessionId !== null && activeChat.messages.length === 0) ? ( + loadError ? ( +
+

{loadError}

+ +
+ ) : loadingMessages || !activeChat.historyLoaded ? (
@@ -320,6 +373,9 @@ export function AiSidePanel({ /> ) ) : null} + + {/* Usage-tracking consent — required before using the AI; must accept or the panel closes. */} + {signedIn && !termsAccepted && }
); } diff --git a/src/components/editor/EditorHeader.tsx b/src/components/editor/EditorHeader.tsx index dba15da..924fa5b 100644 --- a/src/components/editor/EditorHeader.tsx +++ b/src/components/editor/EditorHeader.tsx @@ -12,11 +12,9 @@ import { Check, Settings, Type, - UserCircle, } from 'lucide-react'; import type { ExampleProgram } from '../../utils/sampleCodes'; import { EXAMPLE_CATEGORIES } from '../../utils/sampleCodes'; -import keycloak from '../../api/keycloak'; /** 1–5 integer controlling editor-wide font size (maps to 12–20 px). */ export type TextSize = number; @@ -236,27 +234,6 @@ export function EditorHeader({ )}
- {/* Account — Google-style account manager (sign-in required) */} - {keycloak.authenticated ? ( - -