refactor: moved to tanstack start

This commit is contained in:
2026-06-18 13:29:13 +02:00
parent 4df568aff3
commit a662cb7966
105 changed files with 3378 additions and 1677 deletions
+1 -1
View File
@@ -1 +1 @@
npx commitlint --edit "$1"
bunx commitlint --edit "$1"
+35
View File
@@ -1,11 +1,46 @@
.PHONY: default
default: help
COMPOSE ?= docker compose
MYSQL_BACKUP ?= var/volumes/backups/basango.mysql.gz
MYSQL_BACKUP_IN_CONTAINER ?= /var/www/var/basango.mysql.gz
MYSQL_DATABASE ?= app
MYSQL_SERVICE ?= mariadb
MYSQL_ROOT_USER ?= root
POSTGRES_DATABASE ?= app
POSTGRES_SERVICE ?= postgres
POSTGRES_USER ?= postgres
SYNC_TABLES ?= user source article
.PHONY: help
help:
@echo Tasks:
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
# -----------------------------------
# Local data
# -----------------------------------
.PHONY: db-reload-from-backup
db-reload-from-backup: ## Reset local MariaDB/Postgres, load basango.mysql.gz, migrate, and sync data
@test -f "$(MYSQL_BACKUP)" || (echo "Missing backup: $(MYSQL_BACKUP)" >&2; exit 1)
$(COMPOSE) up -d $(MYSQL_SERVICE) $(POSTGRES_SERVICE)
@echo "Waiting for MariaDB..."
@until $(COMPOSE) exec -T $(MYSQL_SERVICE) sh -c 'mariadb-admin ping -u$(MYSQL_ROOT_USER) -p"$${MARIADB_ROOT_PASSWORD}" --silent'; do sleep 1; done
@echo "Waiting for Postgres..."
@until $(COMPOSE) exec -T $(POSTGRES_SERVICE) pg_isready -U $(POSTGRES_USER) -d postgres >/dev/null; do sleep 1; done
@echo "Resetting MariaDB database $(MYSQL_DATABASE)..."
$(COMPOSE) exec -T $(MYSQL_SERVICE) sh -c 'mariadb -u$(MYSQL_ROOT_USER) -p"$${MARIADB_ROOT_PASSWORD}" -e "DROP DATABASE IF EXISTS \`$(MYSQL_DATABASE)\`; CREATE DATABASE \`$(MYSQL_DATABASE)\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"'
@echo "Loading $(MYSQL_BACKUP_IN_CONTAINER) into MariaDB database $(MYSQL_DATABASE)..."
$(COMPOSE) exec -T $(MYSQL_SERVICE) sh -c 'gzip -dc "$(MYSQL_BACKUP_IN_CONTAINER)" | mariadb -u$(MYSQL_ROOT_USER) -p"$${MARIADB_ROOT_PASSWORD}" "$(MYSQL_DATABASE)"'
@echo "Resetting Postgres database $(POSTGRES_DATABASE)..."
$(COMPOSE) exec -T $(POSTGRES_SERVICE) psql -U $(POSTGRES_USER) -d postgres -v ON_ERROR_STOP=1 -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$(POSTGRES_DATABASE)' AND pid <> pg_backend_pid();" -c "DROP DATABASE IF EXISTS \"$(POSTGRES_DATABASE)\";" -c "CREATE DATABASE \"$(POSTGRES_DATABASE)\";"
@echo "Running Postgres migrations..."
bun run migrate
@echo "Synchronizing legacy data into Postgres..."
cd packages/db && bun run sync:data -- $(SYNC_TABLES)
@echo "Synchronizing categories..."
cd packages/db && bun run sync:categories
# -----------------------------------
# Deployment
# -----------------------------------
+4 -3
View File
@@ -11,7 +11,7 @@ The Basango Crawler is designed to systematically crawl news websites and extrac
- **Configurable Sources**: JSON-based configuration for different website sources
- **HTML & WordPress Support**: Built-in parsers for HTML websites and WordPress APIs
- **Rate Limiting**: Respects website rate limits and implements backoff strategies
- **Data Persistence**: JSONL output format for processed articles
- **Data Persistence**: SQLite outbox for processed articles and retryable forwarding
- **Worker Management**: Distributed worker system for parallel processing
- **Type Safety**: Full TypeScript implementation with Zod schema validation
@@ -51,6 +51,7 @@ BASANGO_CRAWLER_FETCH_USER_AGENT=Basango/0.1 (+https://github.com/bernard-ng/bas
# Crawler behavior
BASANGO_CRAWLER_UPDATE_DIRECTION=forward
BASANGO_CRAWLER_SQLITE_PATH=/var/lib/basango-crawler/crawler.db
# TTL settings (in seconds)
BASANGO_CRAWLER_ASYNC_TTL_FAILURE=3600
@@ -105,8 +106,8 @@ bun run crawler:sync -- --sourceId radiookapi.net --dateRange 2024-01-01:2024-01
bun run crawler:sync -- --sourceId example.com --category politics
```
Crawled data will be saved in the `data/` directory as JSONL files.
and can be push to the database using the `bun run crawler:push -- --sourceId radiookapi.net`.
Crawled articles are saved in the local SQLite outbox and forwarded to the backend. Pending
or failed articles can be retried with `bun run crawler:push -- --sourceId radiookapi.net`.
### Asynchronous Crawling
+29
View File
@@ -0,0 +1,29 @@
NODE_ENV=production
BASANGO_LOGGER_LEVEL=info
BASANGO_LOGGER_PRETTY=false
# Basango API used by crawler workers to read update windows and forward articles.
BASANGO_API_CRAWLER_ENDPOINT=https://api.example.com
BASANGO_API_CRAWLER_TOKEN=change-me
# Central Redis shared by scheduler and worker instances.
BASANGO_CRAWLER_ASYNC_REDIS_URL=redis://redis.example.com:6379/0
BASANGO_CRAWLER_ASYNC_QUEUE_LISTING=listing
BASANGO_CRAWLER_ASYNC_QUEUE_DETAILS=details
BASANGO_CRAWLER_ASYNC_QUEUE_PROCESSING=processing
BASANGO_CRAWLER_ASYNC_TTL_FAILURE=3600
BASANGO_CRAWLER_ASYNC_TTL_RESULT=3600
# Comma-separated source shard assigned to this machine.
BASANGO_CRAWLER_SOURCE_IDS=radiookapi.net,7sur7.cd
# Local data written by sync/push workflows. SQLite path defaults to <data>/crawler.db.
BASANGO_CRAWLER_ROOT_PATH=/opt/basango-crawler
BASANGO_CRAWLER_DATA_PATH=/var/lib/basango-crawler
BASANGO_CRAWLER_SQLITE_PATH=/var/lib/basango-crawler/crawler.db
# Fetch behavior.
BASANGO_CRAWLER_UPDATE_DIRECTION=forward
BASANGO_CRAWLER_FETCH_MAX_RETRIES=3
BASANGO_CRAWLER_FETCH_RESPECT_RETRY_AFTER=true
BASANGO_CRAWLER_FETCH_USER_AGENT=Basango/0.1 (+https://github.com/bernard-ng/basango)
+56
View File
@@ -0,0 +1,56 @@
# Basango Crawler Binary Deployment
This deployment runs one standalone `basango-crawler` binary with external `.env` config.
Crawler results are stored in a local SQLite outbox at `BASANGO_CRAWLER_SQLITE_PATH`
and forwarded to the shared Basango API.
## Build
```bash
bun run build:crawler:arm64
bun run build:crawler:x64
```
Use `dist/crawler/basango-crawler-linux-arm64` for 64-bit Raspberry Pi 4B/ARM Ubuntu, and `dist/crawler/basango-crawler-linux-x64` for x64 Ubuntu.
## Install On A Node
```bash
sudo useradd --system --home /opt/basango-crawler --shell /usr/sbin/nologin basango
sudo mkdir -p /opt/basango-crawler /var/lib/basango-crawler
sudo cp basango-crawler-linux-arm64 /opt/basango-crawler/basango-crawler
sudo cp .env /opt/basango-crawler/.env
sudo chown -R basango:basango /opt/basango-crawler /var/lib/basango-crawler
sudo chmod 0755 /opt/basango-crawler/basango-crawler
sudo chmod 0640 /opt/basango-crawler/.env
```
Copy the systemd files:
```bash
sudo cp basango-crawler-worker.service /etc/systemd/system/
sudo cp basango-crawler-schedule.service /etc/systemd/system/
sudo cp basango-crawler-schedule.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now basango-crawler-worker.service
sudo systemctl enable --now basango-crawler-schedule.timer
```
## Configure Shards
Set a different source list per node:
```bash
BASANGO_CRAWLER_SOURCE_IDS=radiookapi.net,7sur7.cd
```
The scheduler reads this list when `basango-crawler schedule` runs. Repeated `--sourceId` flags override the env shard for manual runs.
## Operate
```bash
sudo journalctl -u basango-crawler-worker -f
sudo journalctl -u basango-crawler-schedule -n 100
sudo systemctl list-timers basango-crawler-schedule.timer
/opt/basango-crawler/basango-crawler push --limit 100
```
@@ -0,0 +1,13 @@
[Unit]
Description=Schedule Basango crawler jobs for this node
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
WorkingDirectory=/opt/basango-crawler
EnvironmentFile=/opt/basango-crawler/.env
ExecStart=/opt/basango-crawler/basango-crawler schedule
User=basango
Group=basango
StateDirectory=basango-crawler
@@ -0,0 +1,12 @@
[Unit]
Description=Run Basango crawler scheduler periodically
[Timer]
OnBootSec=2min
OnUnitActiveSec=30min
RandomizedDelaySec=5min
Persistent=true
Unit=basango-crawler-schedule.service
[Install]
WantedBy=timers.target
@@ -0,0 +1,18 @@
[Unit]
Description=Basango crawler worker
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/basango-crawler
EnvironmentFile=/opt/basango-crawler/.env
ExecStart=/opt/basango-crawler/basango-crawler worker --queue listing --queue details
Restart=always
RestartSec=10
User=basango
Group=basango
StateDirectory=basango-crawler
[Install]
WantedBy=multi-user.target
+8 -6
View File
@@ -20,13 +20,15 @@
"name": "@basango/crawler",
"private": true,
"scripts": {
"build:binary": "bun run build:binary:arm64 && bun run build:binary:x64",
"build:binary:arm64": "bun build --compile --define BASANGO_CRAWLER_BINARY=true --external pino --target=bun-linux-arm64 src/cli.ts ../../node_modules/bullmq/dist/cjs/commands/*.lua ../../node_modules/bullmq/dist/cjs/commands/includes/*.lua --outfile ../../dist/crawler/basango-crawler-linux-arm64",
"build:binary:x64": "bun build --compile --define BASANGO_CRAWLER_BINARY=true --external pino --target=bun-linux-x64 src/cli.ts ../../node_modules/bullmq/dist/cjs/commands/*.lua ../../node_modules/bullmq/dist/cjs/commands/includes/*.lua --outfile ../../dist/crawler/basango-crawler-linux-x64",
"clean": "rm -rf .turbo node_modules",
"crawler:async": "bun run src/scripts/async.ts",
"crawler:push": "bun run src/scripts/push.ts",
"crawler:sync": "bun run src/scripts/sync.ts",
"crawler:worker": "bun run src/scripts/worker.ts",
"dev": "bun run src/scripts/worker.ts",
"test": "vitest --run",
"crawler:async": "bun run src/cli.ts schedule",
"crawler:push": "bun run src/cli.ts push",
"crawler:sync": "bun run src/cli.ts sync",
"crawler:worker": "bun run src/cli.ts worker",
"dev": "bun run src/cli.ts worker",
"typecheck": "tsc --noEmit"
},
"type": "module"
@@ -0,0 +1,11 @@
import type { ArticleMetadata } from "@basango/domain/models";
export interface ArticleDraft {
body: string;
categories?: string[];
link: string;
metadata?: ArticleMetadata;
publishedAt: Date;
sourceId: string;
title: string;
}
@@ -0,0 +1,86 @@
import { config } from "@basango/domain/config";
import type { Article } from "@basango/domain/models";
import { logger } from "@basango/logger";
import { HttpError, SyncHttpClient } from "#crawler/http/http-client";
export interface ForwardResult {
ok: boolean;
retryable: boolean;
status?: number;
message?: string;
}
const isRetryableStatus = (status: number): boolean => {
return status === 408 || status === 425 || status === 429 || status >= 500;
};
const stringifyResponseBody = (data: unknown): string | undefined => {
if (!data) return undefined;
if (typeof data === "string") return data;
try {
return JSON.stringify(data);
} catch {
return undefined;
}
};
export class ArticleForwarder {
private readonly client: SyncHttpClient;
private readonly endpoint: string;
private readonly token: string;
constructor() {
this.client = new SyncHttpClient(config.crawler.fetch.client);
this.endpoint = config.crawler.backend.endpoint;
this.token = config.crawler.backend.token;
}
async forward(payload: Partial<Article>): Promise<ForwardResult> {
try {
const response = await this.client.post(`${this.endpoint}/articles`, {
headers: {
Authorization: this.token,
...(payload.hash ? { "Idempotency-Key": payload.hash } : {}),
},
json: payload,
});
if (response.ok) {
const data = await response.json();
logger.info({ ...data }, "Article forwarded");
return { ok: true, retryable: false, status: response.status };
}
logger.error({ status: response.status, url: payload.link }, "Forwarding failed");
return {
message: `Forwarding failed with HTTP ${response.status}`,
ok: false,
retryable: isRetryableStatus(response.status),
status: response.status,
};
} catch (error) {
if (error instanceof HttpError) {
const data = await error.response.json().catch(() => ({}));
logger.error({ ...data, url: payload.link }, "Error forwarding article");
const body = stringifyResponseBody(data);
return {
message: body
? `Forwarding failed with HTTP ${error.status}: ${body}`
: `Forwarding failed with HTTP ${error.status}`,
ok: false,
retryable: isRetryableStatus(error.status),
status: error.status,
};
}
logger.error({ err: error, url: payload.link }, "Error forwarding article");
return {
message: error instanceof Error ? error.message : "Error forwarding article",
ok: false,
retryable: true,
};
}
}
}
+424
View File
@@ -0,0 +1,424 @@
import { Database } from "bun:sqlite";
import fs from "node:fs";
import path from "node:path";
import type { Article } from "@basango/domain/models";
export type ArticleOutboxStatus = "pending" | "forwarded" | "failed";
export interface ArticleOutboxSaveResult {
status?: ArticleOutboxStatus;
}
export interface OutboxArticle {
attempts: number;
body: string;
categories: string[];
claimedAt: Date | undefined;
claimedBy: string | undefined;
createdAt: Date;
forwardedAt: Date | undefined;
hash: string;
lastError: string | undefined;
link: string;
metadata: Article["metadata"] | undefined;
payload: Partial<Article>;
publishedAt: Date;
retryable: boolean;
sourceId: string;
status: ArticleOutboxStatus;
title: string;
updatedAt: Date;
}
export interface ClaimArticleBatchOptions {
claimedBy: string;
claimTtlMs?: number;
limit?: number;
sourceId?: string;
}
export interface ArticleOutboxOptions {
filePath: string;
create?: boolean;
}
interface ArticleRow {
hash: string;
source_id: string;
link: string;
title: string;
body: string;
categories: string;
metadata: string | null;
published_at: string;
payload: string;
status: ArticleOutboxStatus;
attempts: number;
retryable: number;
last_error: string | null;
created_at: string;
updated_at: string;
forwarded_at: string | null;
claimed_at: string | null;
claimed_by: string | null;
}
export interface ListOutboxArticlesOptions {
sourceId?: string;
limit?: number;
}
const isoDate = (value: Date | string | number | undefined): string => {
if (value === undefined) {
throw new Error("Article publishedAt is required for SQLite outbox storage");
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
throw new Error("Article publishedAt must be a valid date");
}
return date.toISOString();
};
const now = (): string => new Date().toISOString();
const serializeArticle = (article: Partial<Article>): string => {
return JSON.stringify({
...article,
publishedAt: isoDate(article.publishedAt),
});
};
const parseJson = <T>(value: string | null, fallback: T): T => {
if (!value) return fallback;
try {
return JSON.parse(value) as T;
} catch {
return fallback;
}
};
const rowToOutboxArticle = (row: ArticleRow): OutboxArticle => {
const payload = parseJson<Partial<Article> & { publishedAt?: string }>(row.payload, {});
return {
attempts: row.attempts,
body: row.body,
categories: parseJson<string[]>(row.categories, []),
claimedAt: row.claimed_at ? new Date(row.claimed_at) : undefined,
claimedBy: row.claimed_by ?? undefined,
createdAt: new Date(row.created_at),
forwardedAt: row.forwarded_at ? new Date(row.forwarded_at) : undefined,
hash: row.hash,
lastError: row.last_error ?? undefined,
link: row.link,
metadata: parseJson<Article["metadata"] | undefined>(row.metadata, undefined),
payload: {
...payload,
publishedAt: payload.publishedAt ? new Date(payload.publishedAt) : new Date(row.published_at),
},
publishedAt: new Date(row.published_at),
retryable: row.retryable === 1,
sourceId: row.source_id,
status: row.status,
title: row.title,
updatedAt: new Date(row.updated_at),
};
};
const resolveHash = (article: Partial<Article> | string): string | undefined => {
return typeof article === "string" ? article : article.hash;
};
export class ArticleOutbox {
private readonly db: Database;
constructor(options: ArticleOutboxOptions) {
const filePath = options.filePath.trim();
if (!filePath) {
throw new Error("ArticleOutbox requires a non-empty file path");
}
const create = options.create ?? true;
if (!create && !fs.existsSync(filePath)) {
throw new Error(`SQLite outbox does not exist: ${filePath}`);
}
if (create) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
}
this.db = new Database(filePath, { create });
this.migrate();
}
static exists(filePath: string): boolean {
return fs.existsSync(filePath);
}
save(article: Partial<Article>): ArticleOutboxSaveResult {
return {
status: this.upsertArticle(article),
};
}
private upsertArticle(article: Partial<Article>): ArticleOutboxStatus {
if (!article.hash || !article.sourceId || !article.link || !article.title || !article.body) {
throw new Error("Cannot save incomplete article to SQLite outbox");
}
const publishedAt = isoDate(article.publishedAt);
const timestamp = now();
const categories = JSON.stringify(article.categories ?? []);
const metadata = article.metadata ? JSON.stringify(article.metadata) : null;
const payload = serializeArticle(article);
this.db
.prepare(`
INSERT INTO articles (
hash,
source_id,
link,
title,
body,
categories,
metadata,
published_at,
payload,
status,
attempts,
retryable,
last_error,
created_at,
updated_at,
forwarded_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, 1, NULL, ?, ?, NULL)
ON CONFLICT(hash) DO UPDATE SET
source_id = excluded.source_id,
link = excluded.link,
title = excluded.title,
body = excluded.body,
categories = excluded.categories,
metadata = excluded.metadata,
published_at = excluded.published_at,
payload = excluded.payload,
status = CASE
WHEN articles.status = 'forwarded' THEN 'forwarded'
ELSE 'pending'
END,
last_error = CASE
WHEN articles.status = 'forwarded' THEN articles.last_error
ELSE NULL
END,
retryable = CASE
WHEN articles.status = 'forwarded' THEN articles.retryable
ELSE 1
END,
updated_at = excluded.updated_at,
forwarded_at = CASE
WHEN articles.status = 'forwarded' THEN articles.forwarded_at
ELSE NULL
END
`)
.run(
article.hash,
article.sourceId,
article.link,
article.title,
article.body,
categories,
metadata,
publishedAt,
payload,
timestamp,
timestamp,
);
const row = this.db
.prepare("SELECT status FROM articles WHERE hash = ?")
.get(article.hash) as Pick<ArticleRow, "status"> | null;
return row?.status ?? "pending";
}
listPending(options: ListOutboxArticlesOptions = {}): OutboxArticle[] {
const limit = options.limit ?? 100;
if (options.sourceId) {
const rows = this.db
.prepare(`
SELECT * FROM articles
WHERE status IN ('pending', 'failed') AND source_id = ?
AND retryable = 1
ORDER BY created_at ASC
LIMIT ?
`)
.all(options.sourceId, limit) as ArticleRow[];
return rows.map(rowToOutboxArticle);
}
const rows = this.db
.prepare(`
SELECT * FROM articles
WHERE status IN ('pending', 'failed')
AND retryable = 1
ORDER BY created_at ASC
LIMIT ?
`)
.all(limit) as ArticleRow[];
return rows.map(rowToOutboxArticle);
}
claim(options: ClaimArticleBatchOptions): OutboxArticle[] {
const limit = options.limit ?? 100;
const claimedAt = now();
const expiresBefore = new Date(
Date.now() - (options.claimTtlMs ?? 15 * 60 * 1000),
).toISOString();
if (options.sourceId) {
const rows = this.db
.prepare(`
UPDATE articles
SET claimed_at = ?,
claimed_by = ?,
updated_at = ?
WHERE hash IN (
SELECT hash FROM articles
WHERE status IN ('pending', 'failed')
AND retryable = 1
AND source_id = ?
AND (claimed_at IS NULL OR claimed_at < ?)
ORDER BY created_at ASC
LIMIT ?
)
RETURNING *
`)
.all(
claimedAt,
options.claimedBy,
claimedAt,
options.sourceId,
expiresBefore,
limit,
) as ArticleRow[];
return rows.map(rowToOutboxArticle);
}
const rows = this.db
.prepare(`
UPDATE articles
SET claimed_at = ?,
claimed_by = ?,
updated_at = ?
WHERE hash IN (
SELECT hash FROM articles
WHERE status IN ('pending', 'failed')
AND retryable = 1
AND (claimed_at IS NULL OR claimed_at < ?)
ORDER BY created_at ASC
LIMIT ?
)
RETURNING *
`)
.all(claimedAt, options.claimedBy, claimedAt, expiresBefore, limit) as ArticleRow[];
return rows.map(rowToOutboxArticle);
}
markForwarded(article: Partial<Article> | string): void {
const hash = resolveHash(article);
if (!hash) return;
const timestamp = now();
this.db
.prepare(`
UPDATE articles
SET status = 'forwarded',
last_error = NULL,
retryable = 0,
updated_at = ?,
forwarded_at = ?,
claimed_at = NULL,
claimed_by = NULL
WHERE hash = ?
`)
.run(timestamp, timestamp, hash);
}
markFailed(article: Partial<Article> | string, error: unknown, retryable = true): void {
const hash = resolveHash(article);
if (!hash) return;
const message = error instanceof Error ? error.message : String(error);
this.db
.prepare(`
UPDATE articles
SET status = 'failed',
attempts = attempts + 1,
retryable = ?,
last_error = ?,
updated_at = ?,
claimed_at = NULL,
claimed_by = NULL
WHERE hash = ?
`)
.run(retryable ? 1 : 0, message, now(), hash);
}
getArticle(hash: string): OutboxArticle | undefined {
const row = this.db
.prepare("SELECT * FROM articles WHERE hash = ?")
.get(hash) as ArticleRow | null;
return row ? rowToOutboxArticle(row) : undefined;
}
close(): void {
this.db.close();
}
private migrate(): void {
this.db.exec("PRAGMA journal_mode = WAL");
this.db.exec("PRAGMA synchronous = NORMAL");
this.db.exec("PRAGMA busy_timeout = 5000");
this.db.exec("PRAGMA foreign_keys = ON");
this.db.exec(`
CREATE TABLE IF NOT EXISTS articles (
hash TEXT PRIMARY KEY,
source_id TEXT NOT NULL,
link TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
body TEXT NOT NULL,
categories TEXT NOT NULL DEFAULT '[]',
metadata TEXT,
published_at TEXT NOT NULL,
payload TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'forwarded', 'failed')),
attempts INTEGER NOT NULL DEFAULT 0,
retryable INTEGER NOT NULL DEFAULT 1,
last_error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
forwarded_at TEXT,
claimed_at TEXT,
claimed_by TEXT
)
`);
this.db.exec(
"CREATE INDEX IF NOT EXISTS articles_status_created_at_idx ON articles(status, created_at)",
);
this.db.exec(
"CREATE INDEX IF NOT EXISTS articles_source_status_idx ON articles(source_id, status)",
);
this.db.exec(
"CREATE INDEX IF NOT EXISTS articles_claimed_at_created_at_idx ON articles(claimed_at, created_at)",
);
}
}
@@ -0,0 +1,83 @@
import type { Article } from "@basango/domain/models";
import { md5 } from "@basango/encryption";
import { logger } from "@basango/logger";
import { type ArticleDraft } from "#crawler/articles/article-draft";
import { ArticleForwarder } from "#crawler/articles/article-forwarder";
import type { ArticleOutbox } from "#crawler/articles/article-outbox";
const sanitize = (text: string): string => {
if (!text) return text;
let s = text.replace(/\u00A0/g, " ");
s = s.replace(" ", " ");
s = s.replace("", " ");
s = s.replace(/\u200B/g, "");
s = s.replace(/\u200C/g, "");
s = s.replace(/\u200D/g, "");
s = s.replace(/\uFEFF/g, "");
s = s.replace(/\r\n/g, "\n");
s = s.replace(/\n{2,}/g, "\n");
return s.trim();
};
export const normalizeArticle = (payload: Partial<Article> | ArticleDraft): Article => {
if (!payload.body || !payload.link || !payload.title) {
throw new Error("Cannot ingest incomplete article record");
}
if (!payload.publishedAt || Number.isNaN(new Date(payload.publishedAt).getTime())) {
throw new Error("Cannot ingest article record without a valid publishedAt date");
}
const hash = "hash" in payload && payload.hash ? payload.hash : md5(payload.link);
return {
...payload,
body: sanitize(payload.body),
categories: (payload.categories ?? []).map(sanitize),
hash,
title: sanitize(payload.title),
} as Article;
};
export interface IngestArticleOptions {
articleOutbox: ArticleOutbox;
}
export const ingestArticle = async (
payload: Partial<Article> | ArticleDraft,
options: IngestArticleOptions,
): Promise<Article> => {
const article = normalizeArticle(payload);
let alreadyForwarded = false;
try {
const result = options.articleOutbox.save(article);
if (result?.status === "forwarded") {
alreadyForwarded = true;
}
} catch (error) {
logger.error({ err: error }, "Failed to save article to SQLite outbox");
throw new Error("Failed to save article to outbox");
}
if (alreadyForwarded) {
logger.info({ url: article.link }, "article already forwarded");
return article;
}
const articleForwarder = new ArticleForwarder();
const result = await articleForwarder.forward(article);
if (!result.ok) {
const error = new Error(result.message ?? "Failed to forward article");
options.articleOutbox.markFailed(article, error, result.retryable);
throw error;
}
options.articleOutbox.markForwarded(article);
logger.info({ url: article.link }, "article successfully ingested");
return article;
};
+288
View File
@@ -0,0 +1,288 @@
#!/usr/bin/env bun
import os from "node:os";
import { parseArgs } from "node:util";
import { logger } from "@basango/logger";
import { ArticleForwarder } from "#crawler/articles/article-forwarder";
import { ArticleOutbox } from "#crawler/articles/article-outbox";
import { resolveCrawlerSqlitePath } from "#crawler/config/paths";
import { createQueueManager } from "#crawler/execution/async/queue-manager";
import { scheduleAsyncCrawl } from "#crawler/execution/async/scheduler";
import { startWorker } from "#crawler/execution/async/worker";
import type { CrawlingOptions } from "#crawler/execution/crawl-runtime";
import { runSyncCrawl } from "#crawler/execution/sync-crawl-runner";
const VERSION = "0.0.0";
const USAGE = `
Usage: basango-crawler <command> [options]
Commands:
worker Process crawler queues
schedule Schedule async crawls for one or more sources
sync Run a synchronous crawl for one source
push Push pending/failed SQLite articles to the backend
version Print version information
Common crawl options:
--sourceId <id> Source identifier. Can be repeated for schedule.
--pageRange <range> Optional page range filter (e.g. 1:5)
--dateRange <range> Optional date range filter (e.g. 2024-01-01:2024-01-31)
--category <slug> Optional category to crawl
Worker options:
--queue, -q <name> Queue to process. Can be repeated.
Push options:
--sourceId <id> Optional source filter
--limit <count> Max articles to push in one run (default: 100)
`;
interface ScheduleOptions extends Omit<CrawlingOptions, "sourceId"> {
sourceIds: string[];
}
interface PushOptions {
sourceId?: string;
limit: number;
}
const splitSourceIds = (value: string | undefined): string[] => {
if (!value) return [];
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
};
export const resolveScheduleSourceIds = (explicit: string[] = []): string[] => {
if (explicit.length > 0) {
return explicit;
}
return splitSourceIds(process.env.BASANGO_CRAWLER_SOURCE_IDS);
};
export const parseScheduleOptions = (args: string[]): ScheduleOptions => {
const { values } = parseArgs({
allowPositionals: false,
args,
options: {
category: { type: "string" },
dateRange: { type: "string" },
pageRange: { type: "string" },
sourceId: { multiple: true, type: "string" },
},
});
const sourceIdValues = values.sourceId;
const sourceIds = resolveScheduleSourceIds(
Array.isArray(sourceIdValues)
? sourceIdValues
: typeof sourceIdValues === "string"
? [sourceIdValues]
: [],
);
return {
category: values.category,
dateRange: values.dateRange,
pageRange: values.pageRange,
sourceIds,
};
};
const parseCrawlingOptions = (args: string[]): CrawlingOptions => {
const { values } = parseArgs({
allowPositionals: false,
args,
options: {
category: { type: "string" },
dateRange: { type: "string" },
pageRange: { type: "string" },
sourceId: { type: "string" },
},
});
if (!values.sourceId) {
throw new Error("--sourceId is required");
}
return {
category: values.category,
dateRange: values.dateRange,
pageRange: values.pageRange,
sourceId: values.sourceId,
};
};
const parseWorkerOptions = (args: string[]): { queue?: string[] } => {
const { values } = parseArgs({
allowPositionals: false,
args,
options: {
queue: { multiple: true, short: "q", type: "string" },
},
});
return {
queue: values.queue?.length ? values.queue : undefined,
};
};
export const parsePushOptions = (args: string[]): PushOptions => {
const { values } = parseArgs({
allowPositionals: false,
args,
options: {
limit: { type: "string" },
sourceId: { type: "string" },
},
});
const limit = values.limit ? Number(values.limit) : 100;
if (!Number.isInteger(limit) || limit < 1) {
throw new Error("--limit must be a positive integer");
}
return {
limit,
sourceId: values.sourceId,
};
};
const runWorker = async (args: string[]): Promise<void> => {
const options = parseWorkerOptions(args);
const manager = createQueueManager();
const handle = startWorker({
queueManager: manager,
queueNames: options.queue,
});
const shutdown = async (signal: NodeJS.Signals) => {
logger.info({ signal }, "Received shutdown signal, draining workers");
try {
await handle.close();
} finally {
await manager.close();
process.exit(0);
}
};
process.once("SIGINT", (signal) => void shutdown(signal));
process.once("SIGTERM", (signal) => void shutdown(signal));
logger.info({ queueNames: options.queue }, "Crawler workers started");
await new Promise(() => undefined);
};
const runSchedule = async (args: string[]): Promise<void> => {
const options = parseScheduleOptions(args);
if (options.sourceIds.length === 0) {
throw new Error(
"No sources provided. Pass --sourceId or set BASANGO_CRAWLER_SOURCE_IDS=source-a,source-b",
);
}
for (const sourceId of options.sourceIds) {
const id = await scheduleAsyncCrawl({
category: options.category,
dateRange: options.dateRange,
pageRange: options.pageRange,
sourceId,
});
logger.info({ id, sourceId }, "Scheduled asynchronous crawl job");
}
};
const runPush = async (args: string[]): Promise<void> => {
const options = parsePushOptions(args);
const sqlitePath = resolveCrawlerSqlitePath();
if (!ArticleOutbox.exists(sqlitePath)) {
throw new Error(`SQLite outbox does not exist: ${sqlitePath}`);
}
const outbox = new ArticleOutbox({ create: false, filePath: sqlitePath });
const forwarder = new ArticleForwarder();
let forwardedCount = 0;
let failedCount = 0;
try {
const claimId = `${os.hostname()}:${process.pid}:${Date.now()}`;
const articles = outbox.claim({
...options,
claimedBy: claimId,
});
logger.info(
{ claimed: articles.length, claimId, sourceId: options.sourceId, sqlitePath },
"Pushing articles from SQLite outbox",
);
for (const article of articles) {
const forwarded = await forwarder.forward(article.payload);
if (forwarded.ok) {
outbox.markForwarded(article.hash);
forwardedCount += 1;
} else {
outbox.markFailed(
article.hash,
new Error(forwarded.message ?? "Failed to forward article"),
forwarded.retryable,
);
failedCount += 1;
}
}
} finally {
outbox.close();
}
logger.info(
{ failed: failedCount, forwarded: forwardedCount, sourceId: options.sourceId },
"Push completed",
);
if (failedCount > 0) {
throw new Error(`Failed to push ${failedCount} article(s)`);
}
};
export const runCli = async (args: string[] = process.argv.slice(2)): Promise<number> => {
const [command, ...rest] = args;
try {
switch (command) {
case "worker":
await runWorker(rest);
return 0;
case "schedule":
await runSchedule(rest);
return 0;
case "sync":
await runSyncCrawl(parseCrawlingOptions(rest));
return 0;
case "push":
await runPush(rest);
return 0;
case "version":
console.log(`basango-crawler ${VERSION}`);
return 0;
case undefined:
case "help":
case "--help":
case "-h":
console.log(USAGE);
return command === undefined ? 1 : 0;
default:
throw new Error(`Unknown command: ${command}`);
}
} catch (error) {
logger.error({ err: error }, "Crawler command failed");
return 1;
}
};
if (import.meta.main) {
process.exitCode = await runCli();
}
+23
View File
@@ -0,0 +1,23 @@
import path from "node:path";
import { config } from "@basango/domain/config";
export const resolveCrawlerDataPath = (): string => {
const dataPath = config.crawler.paths.data?.trim();
if (dataPath) {
return path.resolve(dataPath);
}
const rootPath = config.crawler.paths.root?.trim();
return path.resolve(rootPath || process.cwd(), "data");
};
export const resolveCrawlerSqlitePath = (): string => {
const sqlitePath =
process.env.BASANGO_CRAWLER_SQLITE_PATH?.trim() || config.crawler.paths.sqlite?.trim();
if (sqlitePath) {
return path.resolve(sqlitePath);
}
return path.join(resolveCrawlerDataPath(), "crawler.db");
};
+75
View File
@@ -0,0 +1,75 @@
import { DEFAULT_DATE_FORMAT } from "@basango/domain/constants";
import {
DateSpecSchema,
type PageRange,
PageRangeSchema,
PageSpecSchema,
type TimestampRange,
TimestampRangeSchema,
} from "@basango/domain/models";
import { format, fromUnixTime, getUnixTime, isMatch, parse } from "date-fns";
const parseDate = (value: string, format: string): Date => {
if (!isMatch(value, format)) {
throw new Error(`Invalid date '${value}' for format '${format}'`);
}
const parsed = parse(value, format, new Date());
if (Number.isNaN(parsed.getTime())) {
throw new Error(`Invalid date '${value}' for format '${format}'`);
}
return parsed;
};
export const createPageRange = (spec: string | undefined): PageRange | undefined => {
if (!spec) return undefined;
const parsed = PageSpecSchema.parse(spec);
return PageRangeSchema.parse(parsed);
};
export const createTimestampRange = (
spec: string | undefined,
options: {
format?: string;
separator?: string;
} = {},
): TimestampRange | undefined => {
if (!spec) return undefined;
const { format = DEFAULT_DATE_FORMAT, separator = ":" } = options;
if (!separator) {
throw new Error("Separator cannot be empty");
}
const normalized = spec.replace(separator, ":");
const parsedSpec = DateSpecSchema.parse(normalized);
const startDate = parseDate(parsedSpec.startRaw, format);
const endDate = parseDate(parsedSpec.endRaw, format);
return TimestampRangeSchema.parse({
end: getUnixTime(endDate),
start: getUnixTime(startDate),
});
};
export const formatTimestampRange = (range: TimestampRange, fmt = DEFAULT_DATE_FORMAT): string => {
const start = format(fromUnixTime(range.start), fmt);
const end = format(fromUnixTime(range.end), fmt);
return `${start}:${end}`;
};
export const formatPageRange = (range: PageRange): string => {
return `${range.start}:${range.end}`;
};
export const isTimestampInRange = (range: TimestampRange, timestamp: number): boolean => {
return range.start <= timestamp && timestamp <= range.end;
};
export const createAbsoluteUrl = (base: string, href: string): string => {
try {
return new URL(href, base.endsWith("/") ? base : `${base}/`).toString();
} catch {
return href;
}
};
+15
View File
@@ -0,0 +1,15 @@
import type { RedisOptions } from "ioredis";
export const parseRedisUrl = (url: string): RedisOptions => {
if (!url.startsWith("redis://")) {
return {};
}
const parsed = new URL(url);
return {
db: Number(parsed.pathname?.replace("/", "") || 0),
host: parsed.hostname,
password: parsed.password || undefined,
port: Number(parsed.port || 6379),
};
};
+18
View File
@@ -0,0 +1,18 @@
import {
type AnySourceOptions,
type HtmlSourceOptions,
type WordPressSourceOptions,
config,
} from "@basango/domain/config";
export const resolveSourceConfig = (id: string): AnySourceOptions => {
const source =
config.crawler.sources.html.find((s: HtmlSourceOptions) => s.sourceId === id) ||
config.crawler.sources.wordpress.find((s: WordPressSourceOptions) => s.sourceId === id);
if (source === undefined) {
throw new Error(`Source '${id}' not found in configuration`);
}
return source;
};
@@ -0,0 +1,15 @@
import type { JobsOptions as BullJobsOptions, Job } from "bullmq";
import * as bullmq from "bullmq/dist/cjs/index.js";
type BullMqModule = typeof import("bullmq");
const cjsBullmq = bullmq as unknown as BullMqModule;
export const Queue: BullMqModule["Queue"] = cjsBullmq.Queue;
export const QueueEvents: BullMqModule["QueueEvents"] = cjsBullmq.QueueEvents;
export const Worker: BullMqModule["Worker"] = cjsBullmq.Worker;
export type JobInstance = Job;
export type JobsOptions = BullJobsOptions;
export type QueueEventsInstance = InstanceType<BullMqModule["QueueEvents"]>;
export type QueueInstance = InstanceType<BullMqModule["Queue"]>;
export type WorkerInstance = InstanceType<BullMqModule["Worker"]>;
@@ -0,0 +1,54 @@
import logger from "@basango/logger";
import { formatPageRange, formatTimestampRange } from "#crawler/config/ranges";
import { resolveSourceConfig } from "#crawler/config/sources";
import {
ArticleOutOfDateRangeError,
InvalidArticleError,
UnsupportedSourceKindError,
} from "#crawler/errors";
import { DetailsTaskPayload } from "#crawler/execution/async/queue-schemas";
import {
closeArticleOutbox,
createArticleOutbox,
resolveCrawlerConfig,
} from "#crawler/execution/crawl-runtime";
import { HtmlCrawler } from "#crawler/sources/html/html-crawler";
import { WordPressCrawler } from "#crawler/sources/wordpress/wordpress-crawler";
export const collectArticle = async (payload: DetailsTaskPayload): Promise<unknown> => {
const source = resolveSourceConfig(payload.sourceId);
const settings = resolveCrawlerConfig(source, {
category: payload.category,
dateRange: payload.dateRange ? formatTimestampRange(payload.dateRange) : undefined,
pageRange: payload.pageRange ? formatPageRange(payload.pageRange) : undefined,
sourceId: payload.sourceId,
});
const articleOutbox = createArticleOutbox(source);
try {
if (source.sourceKind === "html") {
const crawler = new HtmlCrawler(settings, { articleOutbox });
const html = await crawler.crawl(payload.url);
return await crawler.fetchOne(html, settings.dateRange, payload.url);
}
if (source.sourceKind === "wordpress") {
const crawler = new WordPressCrawler(settings, { articleOutbox });
return await crawler.fetchOne(payload.data ?? {}, settings.dateRange);
}
throw new UnsupportedSourceKindError(`Unsupported source kind`);
} catch (error) {
if (error instanceof ArticleOutOfDateRangeError || error instanceof InvalidArticleError) {
logger.info({ error, url: payload.url }, "Skipping article");
return { reason: error.name, skipped: true, url: payload.url };
}
throw error;
} finally {
closeArticleOutbox(articleOutbox);
}
};
@@ -0,0 +1,61 @@
import logger from "@basango/logger";
import { createTimestampRange } from "#crawler/config/ranges";
import { resolveSourceConfig } from "#crawler/config/sources";
import { collectWordPressListing } from "#crawler/execution/async/handlers/collect-wordpress-listing";
import { QueueManager, createQueueManager } from "#crawler/execution/async/queue-manager";
import { DetailsTaskPayload, ListingTaskPayload } from "#crawler/execution/async/queue-schemas";
import { resolveCrawlerConfig } from "#crawler/execution/crawl-runtime";
import { HtmlCrawler } from "#crawler/sources/html/html-crawler";
import { resolveSourceUpdateDates } from "#crawler/sources/source-update-window";
import type { HtmlSourceOptions } from "#domain/config";
export const collectHtmlListing = async (
payload: ListingTaskPayload,
queueManager?: QueueManager,
): Promise<number> => {
const manager = queueManager ?? createQueueManager();
const shouldCloseManager = queueManager === undefined;
try {
const source = resolveSourceConfig(payload.sourceId) as HtmlSourceOptions;
if (source.sourceKind !== "html") {
return await collectWordPressListing(payload, manager);
}
const settings = resolveCrawlerConfig(source, payload);
await resolveSourceUpdateDates(settings);
const crawler = new HtmlCrawler(settings);
const pageRange = settings.pageRange ?? (await crawler.getPagination());
let queued = 0;
for (let page = pageRange.start; page <= pageRange.end; page += 1) {
const target = crawler.buildEndpointUrl(page) ?? `${source.sourceUrl}`;
try {
const items = await crawler.fetchLinks(target, source.sourceSelectors.articles);
for (const node of items) {
const url = crawler.extractLink(node);
if (!url) continue;
await manager.enqueueArticle({
category: payload.category,
dateRange: createTimestampRange(payload.dateRange),
sourceId: payload.sourceId,
url,
} as DetailsTaskPayload);
queued += 1;
}
} catch (error) {
logger.error({ error, target }, "Failed to crawl page");
}
}
return queued;
} finally {
if (shouldCloseManager) {
await manager.close();
}
}
};
@@ -0,0 +1,62 @@
import logger from "@basango/logger";
import { createTimestampRange } from "#crawler/config/ranges";
import { resolveSourceConfig } from "#crawler/config/sources";
import { collectHtmlListing } from "#crawler/execution/async/handlers/collect-html-listing";
import { QueueManager, createQueueManager } from "#crawler/execution/async/queue-manager";
import { DetailsTaskPayload, ListingTaskPayload } from "#crawler/execution/async/queue-schemas";
import { resolveCrawlerConfig } from "#crawler/execution/crawl-runtime";
import { resolveSourceUpdateDates } from "#crawler/sources/source-update-window";
import { WordPressCrawler } from "#crawler/sources/wordpress/wordpress-crawler";
import type { WordPressSourceOptions } from "#domain/config";
export const collectWordPressListing = async (
payload: ListingTaskPayload,
queueManager?: QueueManager,
): Promise<number> => {
const manager = queueManager ?? createQueueManager();
const shouldCloseManager = queueManager === undefined;
try {
const source = resolveSourceConfig(payload.sourceId) as WordPressSourceOptions;
if (source.sourceKind !== "wordpress") {
return await collectHtmlListing(payload, manager);
}
const settings = resolveCrawlerConfig(source, payload);
await resolveSourceUpdateDates(settings);
const crawler = new WordPressCrawler(settings);
const pageRange = settings.pageRange ?? (await crawler.getPagination());
let queued = 0;
for (let page = pageRange.start; page <= pageRange.end; page += 1) {
const url = crawler.buildEndpointUrl(page);
try {
const entries = await crawler.fetchLinks(url);
for (const data of entries) {
const url = data.link;
if (!url) continue;
await manager.enqueueArticle({
category: payload.category,
data,
dateRange: createTimestampRange(payload.dateRange),
sourceId: payload.sourceId,
url,
} as DetailsTaskPayload);
queued += 1;
}
} catch (error) {
logger.error({ error, page }, "Failed to fetch WordPress page");
}
}
return queued;
} finally {
if (shouldCloseManager) {
await manager.close();
}
}
};
@@ -1,19 +1,20 @@
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { type CrawlerAsyncOptions, config } from "@basango/domain/config";
import { JobsOptions, Queue } from "bullmq";
import IORedis from "ioredis";
import { parseRedisUrl } from "#crawler/config/redis";
import { type JobsOptions, Queue } from "#crawler/execution/async/bullmq";
import {
DetailsTaskPayload,
DetailsTaskPayloadSchema,
ListingTaskPayload,
ListingTaskPayloadSchema,
} from "#crawler/process/async/schemas";
import { parseRedisUrl } from "#crawler/utils";
} from "#crawler/execution/async/queue-schemas";
export interface QueueBackend<T = unknown> {
add: (name: string, data: T, opts?: JobsOptions) => Promise<{ id: string }>;
close?: () => Promise<void>;
}
export type QueueFactory = (
@@ -37,15 +38,21 @@ const defaultQueueFactory: QueueFactory = (queueName, options, connection) => {
return {
add: async (name, data, opts) => {
const job = await queue.add(name, data, {
removeOnComplete: options.ttl.result === 0 ? true : undefined,
removeOnFail: options.ttl.failure === 0 ? true : undefined,
removeOnComplete: options.ttl.result === 0 ? true : { age: options.ttl.result },
removeOnFail: options.ttl.failure === 0 ? true : { age: options.ttl.failure },
...opts,
});
return { id: job.id ?? randomUUID() };
},
close: () => queue.close(),
};
};
const createStableJobId = (prefix: string, payload: unknown): string => {
const hash = createHash("sha1").update(JSON.stringify(payload)).digest("hex");
return `${prefix}-${hash}`;
};
export interface CreateQueueManagerOptions {
queueFactory?: QueueFactory;
connection?: IORedis;
@@ -71,23 +78,39 @@ export const createQueueManager = (options: CreateQueueManagerOptions = {}): Que
maxRetriesPerRequest: null,
});
const factory = options.queueFactory ?? defaultQueueFactory;
const queues = new Map<string, QueueBackend>();
const ensureQueue = (queueName: string) => factory(queueName, asyncOptions, connection);
const ensureQueue = (queueName: string) => {
const existing = queues.get(queueName);
if (existing) return existing;
const queue = factory(queueName, asyncOptions, connection);
queues.set(queueName, queue);
return queue;
};
return {
close: async () => {
await Promise.all([...queues.values()].map((queue) => queue.close?.()));
await connection.quit();
},
connection,
enqueueArticle: (payload) => {
const data = DetailsTaskPayloadSchema.parse(payload);
const queue = ensureQueue(asyncOptions.queues.details);
return queue.add("collect_article", data);
return queue.add("collect_article", data, {
jobId: createStableJobId("article", {
sourceId: data.sourceId,
url: data.url,
}),
});
},
enqueueListing: (payload) => {
const data = ListingTaskPayloadSchema.parse(payload);
const queue = ensureQueue(asyncOptions.queues.listing);
return queue.add("collect_listing", data);
return queue.add("collect_listing", data, {
jobId: createStableJobId("listing", data),
});
},
iterQueueNames: () => [asyncOptions.queues.listing, asyncOptions.queues.details],
options: asyncOptions,
@@ -1,5 +1,5 @@
import { PageRangeSchema, TimestampRangeSchema } from "@basango/domain/models";
import { z } from "zod";
import z from "zod";
export const ListingTaskPayloadSchema = z.object({
category: z.string().optional(),
@@ -1,15 +1,19 @@
import { logger } from "@basango/logger";
import * as handlers from "#crawler/process/async/handlers";
import { createQueueManager } from "#crawler/process/async/queue";
import { DetailsTaskPayloadSchema, ListingTaskPayloadSchema } from "#crawler/process/async/schemas";
import { CrawlingOptions } from "#crawler/process/crawler";
import { collectArticle as collectArticle1 } from "#crawler/execution/async/handlers/collect-article";
import { collectHtmlListing } from "#crawler/execution/async/handlers/collect-html-listing";
import { createQueueManager } from "#crawler/execution/async/queue-manager";
import {
DetailsTaskPayloadSchema,
ListingTaskPayloadSchema,
} from "#crawler/execution/async/queue-schemas";
import { CrawlingOptions } from "#crawler/execution/crawl-runtime";
export const collectListing = async (payload: unknown): Promise<number> => {
const data = ListingTaskPayloadSchema.parse(payload);
logger.debug({ data }, "Collecting listing");
const count = await handlers.collectHtmlListing(data);
const count = await collectHtmlListing(data);
logger.info({ count }, "Listing collection completed");
return count;
@@ -19,7 +23,7 @@ export const collectArticle = async (payload: unknown): Promise<unknown> => {
const data = DetailsTaskPayloadSchema.parse(payload);
logger.info({ data }, "Collecting article");
const result = await handlers.collectArticle(data);
const result = await collectArticle1(data);
logger.info({ url: data.url }, "Article collection completed");
return result;
@@ -1,8 +1,14 @@
import { QueueEvents, Worker } from "bullmq";
import IORedis from "ioredis";
import { QueueFactory, QueueManager } from "#crawler/process/async/queue";
import { collectArticle, collectListing } from "#crawler/process/async/tasks";
import {
type JobInstance,
QueueEvents,
type QueueEventsInstance,
Worker,
type WorkerInstance,
} from "#crawler/execution/async/bullmq";
import { QueueFactory, QueueManager } from "#crawler/execution/async/queue-manager";
import { collectArticle, collectListing } from "#crawler/execution/async/scheduler";
export interface WorkerOptions {
queueNames?: string[];
@@ -14,23 +20,23 @@ export interface WorkerOptions {
}
export interface WorkerHandle {
readonly workers: Worker[];
readonly events: QueueEvents[];
readonly workers: WorkerInstance[];
readonly events: QueueEventsInstance[];
close: () => Promise<void>;
}
export const startWorker = (options: WorkerOptions): WorkerHandle => {
const manager = options.queueManager;
const queueNames = options.queueNames ?? manager.iterQueueNames();
const workers: Worker[] = [];
const events: QueueEvents[] = [];
const workers: WorkerInstance[] = [];
const events: QueueEventsInstance[] = [];
const connection = manager.connection;
for (const queueName of queueNames) {
const worker = new Worker(
queueName,
async (job) => {
async (job: JobInstance) => {
switch (job.name) {
case "collect_listing":
return collectListing(job.data);
@@ -48,8 +54,8 @@ export const startWorker = (options: WorkerOptions): WorkerHandle => {
);
if (options.onError) {
worker.on("failed", (_, err) => options.onError?.(err as Error));
worker.on("error", (err) => options.onError?.(err as Error));
worker.on("failed", (_: JobInstance | undefined, err: Error) => options.onError?.(err));
worker.on("error", (err: Error) => options.onError?.(err));
}
const queueEvents = new QueueEvents(queueName, {
@@ -1,8 +1,9 @@
import { AnySourceOptions, CrawlerFetchingOptions, config } from "@basango/domain/config";
import logger from "@basango/logger";
import { logger } from "@basango/logger";
import { JsonlPersistor, Persistor } from "#crawler/process/persistence";
import { createPageRange, createTimestampRange } from "#crawler/utils";
import { ArticleOutbox } from "#crawler/articles/article-outbox";
import { resolveCrawlerSqlitePath } from "#crawler/config/paths";
import { createPageRange, createTimestampRange } from "#crawler/config/ranges";
export interface CrawlingOptions {
sourceId: string;
@@ -24,21 +25,16 @@ export const resolveCrawlerConfig = (
};
};
export const createPersistors = (source: AnySourceOptions): Persistor[] => {
return [
new JsonlPersistor({
directory: config.crawler.paths.data,
sourceId: source.sourceId,
}),
];
export const createArticleOutbox = (_source: AnySourceOptions): ArticleOutbox => {
return new ArticleOutbox({
filePath: resolveCrawlerSqlitePath(),
});
};
export const closePersistors = async (persistors: Persistor[]): Promise<void> => {
for (const persistor of persistors) {
try {
await persistor.close();
} catch (error) {
logger.warn({ error }, "Failed to close persistor");
}
export const closeArticleOutbox = (articleOutbox: ArticleOutbox): void => {
try {
articleOutbox.close();
} catch (error) {
logger.warn({ err: error }, "Failed to close SQLite article outbox");
}
};
@@ -0,0 +1,32 @@
import { logger } from "@basango/logger";
import { resolveSourceConfig } from "#crawler/config/sources";
import {
CrawlingOptions,
closeArticleOutbox,
createArticleOutbox,
resolveCrawlerConfig,
} from "#crawler/execution/crawl-runtime";
import { HtmlCrawler } from "#crawler/sources/html/html-crawler";
import { resolveSourceUpdateDates } from "#crawler/sources/source-update-window";
import { WordPressCrawler } from "#crawler/sources/wordpress/wordpress-crawler";
export const runSyncCrawl = async (options: CrawlingOptions): Promise<void> => {
const source = resolveSourceConfig(options.sourceId);
const settings = resolveCrawlerConfig(source, options);
const articleOutbox = createArticleOutbox(source);
await resolveSourceUpdateDates(settings);
const crawler =
source.sourceKind === "wordpress"
? new WordPressCrawler(settings, { articleOutbox })
: new HtmlCrawler(settings, { articleOutbox });
try {
await crawler.fetch();
} finally {
closeArticleOutbox(articleOutbox);
}
logger.info({ ...options }, "Synchronous crawl completed");
};
+1 -1
View File
@@ -3,9 +3,9 @@ import { DEFAULT_OPEN_GRAPH_USER_AGENT } from "@basango/domain/constants";
import { ArticleMetadata } from "@basango/domain/models";
import { parse } from "node-html-parser";
import { createAbsoluteUrl } from "#crawler/config/ranges";
import { SyncHttpClient } from "#crawler/http/http-client";
import { UserAgents } from "#crawler/http/user-agent";
import { createAbsoluteUrl } from "#crawler/utils";
/**
* Picks the first non-empty value from the provided array.
-125
View File
@@ -1,125 +0,0 @@
import type { HtmlSourceOptions, WordPressSourceOptions } from "@basango/domain/config";
import { logger } from "@basango/logger";
import { UnsupportedSourceKindError } from "#crawler/errors";
import { QueueManager, createQueueManager } from "#crawler/process/async/queue";
import { DetailsTaskPayload, ListingTaskPayload } from "#crawler/process/async/schemas";
import { createPersistors, resolveCrawlerConfig } from "#crawler/process/crawler";
import { HtmlCrawler } from "#crawler/process/parsers/html";
import { WordPressCrawler } from "#crawler/process/parsers/wordpress";
import {
createTimestampRange,
formatPageRange,
formatTimestampRange,
resolveSourceConfig,
resolveSourceUpdateDates,
} from "#crawler/utils";
export const collectHtmlListing = async (
payload: ListingTaskPayload,
manager: QueueManager = createQueueManager(),
): Promise<number> => {
const source = resolveSourceConfig(payload.sourceId) as HtmlSourceOptions;
if (source.sourceKind !== "html") {
return await collectWordPressListing(payload, manager);
}
const settings = resolveCrawlerConfig(source, payload);
await resolveSourceUpdateDates(settings);
const crawler = new HtmlCrawler(settings);
const pageRange = settings.pageRange ?? (await crawler.getPagination());
let queued = 0;
for (let page = pageRange.start; page <= pageRange.end; page += 1) {
const target = crawler.buildEndpointUrl(page) ?? `${source.sourceUrl}`;
try {
const items = await crawler.fetchLinks(target, source.sourceSelectors.articles);
for (const node of items) {
const url = crawler.extractLink(node);
if (!url) continue;
await manager.enqueueArticle({
category: payload.category,
dateRange: createTimestampRange(payload.dateRange),
sourceId: payload.sourceId,
url,
} as DetailsTaskPayload);
queued += 1;
}
} catch (error) {
logger.error({ error, target }, "Failed to crawl page");
}
}
return queued;
};
export const collectWordPressListing = async (
payload: ListingTaskPayload,
manager: QueueManager = createQueueManager(),
): Promise<number> => {
const source = resolveSourceConfig(payload.sourceId) as WordPressSourceOptions;
if (source.sourceKind !== "wordpress") {
return await collectHtmlListing(payload, manager);
}
const settings = resolveCrawlerConfig(source, payload);
await resolveSourceUpdateDates(settings);
const crawler = new WordPressCrawler(settings);
const pageRange = settings.pageRange ?? (await crawler.getPagination());
let queued = 0;
for (let page = pageRange.start; page <= pageRange.end; page += 1) {
const url = crawler.buildEndpointUrl(page);
try {
const entries = await crawler.fetchLinks(url);
for (const data of entries) {
const url = data.link;
if (!url) continue;
await manager.enqueueArticle({
category: payload.category,
data,
dateRange: createTimestampRange(payload.dateRange),
sourceId: payload.sourceId,
url,
} as DetailsTaskPayload);
queued += 1;
}
} catch (error) {
logger.error({ error, page }, "Failed to fetch WordPress page");
}
}
return queued;
};
export const collectArticle = async (payload: DetailsTaskPayload): Promise<unknown> => {
const source = resolveSourceConfig(payload.sourceId);
const settings = resolveCrawlerConfig(source, {
category: payload.category,
dateRange: payload.dateRange ? formatTimestampRange(payload.dateRange) : undefined,
pageRange: payload.pageRange ? formatPageRange(payload.pageRange) : undefined,
sourceId: payload.sourceId,
});
const persistors = createPersistors(source);
if (source.sourceKind === "html") {
const crawler = new HtmlCrawler(settings, { persistors });
const html = await crawler.crawl(payload.url);
return await crawler.fetchOne(html, settings.dateRange);
}
if (source.sourceKind === "wordpress") {
const crawler = new WordPressCrawler(settings, { persistors });
return await crawler.fetchOne(payload.data ?? {}, settings.dateRange);
}
throw new UnsupportedSourceKindError(`Unsupported source kind`);
};
-162
View File
@@ -1,162 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { config } from "@basango/domain/config";
import type { Article, SourceUpdateDates } from "@basango/domain/models";
import { md5 } from "@basango/encryption";
import logger from "@basango/logger";
import { HttpError, SyncHttpClient } from "#crawler/http/http-client";
export interface Persistor {
persist(record: Partial<Article>): Promise<void> | void;
close: () => Promise<void> | void;
}
export interface PersistorOptions {
directory: string;
sourceId: string;
suffix?: string;
encoding?: BufferEncoding;
}
const sanitize = (text: string): string => {
if (!text) return text;
let s = text.replace(/\u00A0/g, " "); // remove NBSP
s = s.replace(" ", " "); // remove other NBSP
s = s.replace("", " "); // remove NARROW NO-BREAK SPACE
s = s.replace(/\u200B/g, ""); // remove ZERO WIDTH SPACE
s = s.replace(/\u200C/g, ""); // remove ZERO WIDTH NON-JOINER
s = s.replace(/\u200D/g, ""); // remove ZERO WIDTH JOINER
s = s.replace(/\uFEFF/g, ""); // remove ZERO WIDTH NO-BREAK SPACE
s = s.replace(/\r\n/g, "\n"); // normalize CRLF to LF
s = s.replace(/\n{2,}/g, "\n"); // collapse multiple newlines to one
// s = s.replace(/[ \t]{2,}/g, " "); // collapse multiple spaces/tabs
return s.trim();
};
export const persist = async (
payload: Partial<Article>,
persistors: Persistor[],
): Promise<Article> => {
const data = {
...payload,
body: sanitize(payload.body!),
categories: payload.categories!.map(sanitize),
title: sanitize(payload.title!),
};
const article = {
...data,
hash: md5(data.link!),
} as Article;
for (const persistor of persistors) {
try {
await persistor.persist(article);
} catch (error) {
logger.error({ error }, "Failed to persist article record");
}
}
forward(article).catch((error) => {
logger.error({ error }, "Failed to forward article");
});
logger.info({ url: article.link }, "article successfully persisted");
return article;
};
export const getSourceUpdateDates = async (sourceId: string): Promise<SourceUpdateDates> => {
const client = new SyncHttpClient(config.crawler.fetch.client);
const endpoint = config.crawler.backend.endpoint;
logger.info({ sourceId }, "Fetching source update dates");
const response = await client.post(`${endpoint}/sources/update-dates`, {
headers: {
Authorization: config.crawler.backend.token,
},
json: {
name: sourceId,
},
});
if (response.ok) {
const data = await response.json();
logger.info({ ...data }, "Retrieved source update dates");
return data;
}
logger.error({ sourceId, status: response.status }, "Failed to retrieve source update dates");
return { earliest: new Date(), latest: new Date() };
};
export const forward = async (payload: Partial<Article>): Promise<void> => {
const client = new SyncHttpClient(config.crawler.fetch.client);
const endpoint = config.crawler.backend.endpoint;
try {
const response = await client.post(`${endpoint}/articles`, {
headers: {
Authorization: config.crawler.backend.token,
},
json: payload,
});
if (response.ok) {
const data = await response.json();
logger.info({ ...data }, "Article forwarded");
return;
}
logger.error({ status: response.status, url: payload.link }, "Forwarding failed");
} catch (error) {
if (error instanceof HttpError) {
const data = await error.response.json();
logger.error({ ...data, url: payload.link }, "Error forwarding article");
return;
}
logger.error({ error, url: payload.link }, "Error forwarding article");
}
};
export class JsonlPersistor implements Persistor {
private readonly filePath: string;
private readonly encoding: BufferEncoding;
private pending: Promise<void> = Promise.resolve();
private closed = false;
constructor(options: PersistorOptions) {
const suffix = options.suffix ?? ".jsonl";
this.encoding = options.encoding ?? "utf-8";
fs.mkdirSync(options.directory, { recursive: true });
this.filePath = path.join(options.directory, `${options.sourceId}${suffix}`);
if (!fs.existsSync(this.filePath)) {
fs.writeFileSync(this.filePath, "", { encoding: this.encoding });
}
}
persist(payload: Partial<Article>): Promise<void> {
if (this.closed) {
return Promise.reject(new Error("Persistor has been closed"));
}
const record = `${JSON.stringify(payload)}\n`;
this.pending = this.pending.then(async () => {
fs.appendFileSync(this.filePath, record, { encoding: this.encoding });
});
return this.pending;
}
async close(): Promise<void> {
this.closed = true;
await this.pending;
}
}
-31
View File
@@ -1,31 +0,0 @@
import logger from "@basango/logger";
import {
CrawlingOptions,
closePersistors,
createPersistors,
resolveCrawlerConfig,
} from "#crawler/process/crawler";
import { HtmlCrawler } from "#crawler/process/parsers/html";
import { WordPressCrawler } from "#crawler/process/parsers/wordpress";
import { resolveSourceConfig, resolveSourceUpdateDates } from "#crawler/utils";
export const runSyncCrawl = async (options: CrawlingOptions): Promise<void> => {
const source = resolveSourceConfig(options.sourceId);
const settings = resolveCrawlerConfig(source, options);
const persistors = createPersistors(source);
await resolveSourceUpdateDates(settings);
const crawler =
source.sourceKind === "wordpress"
? new WordPressCrawler(settings, { persistors })
: new HtmlCrawler(settings, { persistors });
try {
await crawler.fetch();
} finally {
await closePersistors(persistors);
}
logger.info({ ...options }, "Synchronous crawl completed");
};
+23
View File
@@ -0,0 +1,23 @@
export type CrawlRunEventType =
| "crawler.heartbeat"
| "crawl.preparing"
| "crawl.started"
| "crawl.source.started"
| "crawl.article.persisted"
| "crawl.article.forwarded"
| "crawl.source.done"
| "crawl.done"
| "crawl.failed";
export interface CrawlRunEvent {
articlesForwarded?: number;
articlesPersisted?: number;
durationMs?: number;
error?: string;
event: CrawlRunEventType;
nodeId?: string;
runId?: string;
sourceId?: string;
sources?: string[];
timestamp?: Date;
}
@@ -0,0 +1,25 @@
import { logger } from "@basango/logger";
import type { CrawlRunEvent } from "#crawler/runs/crawl-run-events";
export interface CrawlRunReporter {
emit(event: CrawlRunEvent): Promise<void> | void;
}
export class NoopCrawlRunReporter implements CrawlRunReporter {
emit(): void {
// Intentionally empty until dashboard/API event transport is wired.
}
}
export class LoggingCrawlRunReporter implements CrawlRunReporter {
emit(event: CrawlRunEvent): void {
logger.info(
{
...event,
timestamp: (event.timestamp ?? new Date()).toISOString(),
},
"Crawler run event",
);
}
}
-27
View File
@@ -1,27 +0,0 @@
#! /usr/bin/env bun
import { logger } from "@basango/logger";
import { scheduleAsyncCrawl } from "#crawler/process/async/tasks";
import { CRAWLING_USAGE, parseCrawlingCliArgs } from "#crawler/scripts/utils";
const main = async (): Promise<void> => {
const options = parseCrawlingCliArgs();
if (options.sourceId === undefined) {
console.log(CRAWLING_USAGE);
process.exitCode = 1;
return;
}
try {
const id = await scheduleAsyncCrawl({ ...options });
logger.info({ id, options }, "Scheduled asynchronous crawl job");
} catch (error) {
logger.error({ error }, "Failed to schedule crawl job");
process.exitCode = 1;
}
};
void main();
-79
View File
@@ -1,79 +0,0 @@
#!/usr/bin/env bun
import fs from "node:fs";
import path from "node:path";
import { createInterface } from "node:readline";
import { parseArgs } from "node:util";
import { config } from "@basango/domain/config";
import type { Article } from "@basango/domain/models";
import { logger } from "@basango/logger";
import { forward } from "#crawler/process/persistence";
const USAGE = `
Usage: bun run crawler:sync -- --sourceId <id>
`;
const parseCliArgs = (): { sourceId?: string } => {
const { values } = parseArgs({
options: {
sourceId: { type: "string" },
},
});
return values as { sourceId?: string };
};
const main = async (): Promise<void> => {
const { sourceId } = parseCliArgs();
if (!sourceId) {
console.log(USAGE);
process.exitCode = 1;
return;
}
const filePath = path.join(config.crawler.paths.data, `${sourceId}.jsonl`);
if (!fs.existsSync(filePath)) {
logger.error({ filePath, sourceId }, "Source must be crawled first; JSONL not found");
process.exitCode = 1;
return;
}
const stat = fs.statSync(filePath);
if (stat.size === 0) {
logger.error({ filePath, sourceId }, "Source must be crawled first; JSONL is empty");
process.exitCode = 1;
return;
}
logger.info({ filePath, sourceId }, "Syncing articles from JSONL to backend");
const stream = fs.createReadStream(filePath, { encoding: "utf-8" });
const rl = createInterface({ crlfDelay: Infinity, input: stream });
let count = 0;
try {
for await (const raw of rl) {
const line = raw.trim();
if (!line) continue;
try {
const article = JSON.parse(line) as Article & { publishedAt: string };
await forward({
...article,
publishedAt: new Date(article.publishedAt),
});
count += 1;
} catch (error) {
logger.error({ error, linePreview: line.slice(0, 100) }, "Invalid JSONL line");
}
}
} finally {
rl.close();
}
logger.info({ forwarded: count, sourceId }, "Sync completed");
};
void main();
-25
View File
@@ -1,25 +0,0 @@
#!/usr/bin/env bun
import { logger } from "@basango/logger";
import { runSyncCrawl } from "#crawler/process/sync/tasks";
import { CRAWLING_USAGE, parseCrawlingCliArgs } from "#crawler/scripts/utils";
const main = async (): Promise<void> => {
const options = parseCrawlingCliArgs();
if (options.sourceId === undefined) {
console.log(CRAWLING_USAGE);
process.exitCode = 1;
return;
}
try {
await runSyncCrawl({ ...options });
} catch (error) {
logger.error({ error }, "Synchronous crawl failed");
process.exitCode = 1;
}
};
void main();
-39
View File
@@ -1,39 +0,0 @@
import { parseArgs } from "node:util";
import { CrawlingOptions } from "#crawler/process/crawler";
interface WorkerCliOptions {
queue?: string[];
}
export const CRAWLING_USAGE = `
Usage: bun run crawler:[async|sync] -- --sourceId <id> [options]
Options:
--pageRange <range> Optional page range filter (e.g. 1:5)
--dateRange <range> Optional date range filter (e.g. 2024-01-01:2024-01-31)
--category <slug> Optional category to crawl
`;
export const parseWorkerCliArgs = (): WorkerCliOptions => {
const { values } = parseArgs({
options: {
queue: { multiple: true, short: "q", type: "string" },
},
});
return values as WorkerCliOptions;
};
export const parseCrawlingCliArgs = (): CrawlingOptions => {
const { values } = parseArgs({
options: {
category: { type: "string" },
dateRange: { type: "string" },
pageRange: { type: "string" },
sourceId: { type: "string" },
},
});
return values as CrawlingOptions;
};
-35
View File
@@ -1,35 +0,0 @@
#!/usr/bin/env bun
import { logger } from "@basango/logger";
import { createQueueManager } from "#crawler/process/async/queue";
import { startWorker } from "#crawler/process/async/worker";
import { parseWorkerCliArgs } from "#crawler/scripts/utils";
const main = async (): Promise<void> => {
const options = parseWorkerCliArgs();
const manager = createQueueManager();
const queues = options.queue?.length ? options.queue : undefined;
const handle = startWorker({
queueManager: manager,
queueNames: queues,
});
const shutdown = async (signal: NodeJS.Signals) => {
logger.info({ signal }, "Received shutdown signal, draining workers");
try {
await handle.close();
} finally {
await manager.close();
process.exit(0);
}
};
process.once("SIGINT", (signal) => void shutdown(signal));
process.once("SIGTERM", (signal) => void shutdown(signal));
logger.info({ queueNames: queues }, "Crawler workers started");
};
void main();
@@ -2,19 +2,19 @@ import { AnySourceOptions, CrawlerFetchingOptions, config } from "@basango/domai
import { Article } from "@basango/domain/models";
import { HTMLElement, parse as parseHtml } from "node-html-parser";
import type { ArticleOutbox } from "#crawler/articles/article-outbox";
import { SyncHttpClient } from "#crawler/http/http-client";
import { OpenGraph } from "#crawler/http/open-graph";
import type { Persistor } from "#crawler/process/persistence";
export interface CrawlerOptions {
persistors?: Persistor[];
articleOutbox?: ArticleOutbox;
}
export abstract class BaseCrawler {
protected readonly options: CrawlerFetchingOptions;
protected readonly source: AnySourceOptions;
protected readonly http: SyncHttpClient;
protected readonly persistors: Persistor[];
protected readonly articleOutbox: ArticleOutbox | undefined;
protected readonly openGraph: OpenGraph;
protected constructor(options: CrawlerFetchingOptions, crawlerOptions: CrawlerOptions = {}) {
@@ -23,13 +23,21 @@ export abstract class BaseCrawler {
}
this.http = new SyncHttpClient(config.crawler.fetch.client);
this.persistors = crawlerOptions.persistors ?? [];
this.articleOutbox = crawlerOptions.articleOutbox;
this.openGraph = new OpenGraph();
this.options = options;
this.source = options.source as AnySourceOptions;
}
protected requireArticleOutbox(): ArticleOutbox {
if (!this.articleOutbox) {
throw new Error("Article ingestion requires a SQLite article outbox");
}
return this.articleOutbox;
}
/**
* Fetch and process articles from the source.
*/
@@ -96,13 +104,19 @@ export abstract class BaseCrawler {
* Enrich the record with Open Graph metadata from the given URL.
* @param record - The article record
* @param url - The URL to fetch Open Graph data from
* @param html - Optional existing HTML to avoid fetching the same page twice
*/
protected async enrichWithOpenGraph(
record: Partial<Article>,
url?: string,
html?: string,
): Promise<Partial<Article>> {
try {
const metadata = url ? await this.openGraph.consumeUrl(url) : undefined;
const metadata = url
? html
? OpenGraph.consumeHtml(html, url)
: await this.openGraph.consumeUrl(url)
: undefined;
return { ...record, metadata };
} catch {
return { ...record, metadata: undefined };
@@ -5,15 +5,17 @@ import { fromUnixTime, getUnixTime, isMatch as isDateMatch, parse } from "date-f
import { HTMLElement } from "node-html-parser";
import TurndownService from "turndown";
import type { ArticleOutbox } from "#crawler/articles/article-outbox";
import { ingestArticle } from "#crawler/articles/ingest-article";
import { createAbsoluteUrl, isTimestampInRange } from "#crawler/config/ranges";
import {
ArticleOutOfDateRangeError,
InvalidArticleError,
InvalidSourceSelectorsError,
UnsupportedSourceKindError,
} from "#crawler/errors";
import { BaseCrawler } from "#crawler/process/parsers/base";
import { Persistor, persist } from "#crawler/process/persistence";
import { createAbsoluteUrl, isTimestampInRange } from "#crawler/utils";
import { BaseCrawler } from "#crawler/sources/base-crawler";
import { buildHtmlEndpointUrl, resolveHtmlPagination } from "#crawler/sources/html/html-pagination";
const md = new TurndownService({
bulletListMarker: "-",
@@ -28,7 +30,7 @@ export class HtmlCrawler extends BaseCrawler {
readonly source: HtmlSourceOptions;
private currentNode: string | null = null;
constructor(settings: CrawlerFetchingOptions, options: { persistors?: Persistor[] } = {}) {
constructor(settings: CrawlerFetchingOptions, options: { articleOutbox?: ArticleOutbox } = {}) {
super(settings, options);
if (!settings.source || settings.source.sourceKind !== "html") {
@@ -83,14 +85,14 @@ export class HtmlCrawler extends BaseCrawler {
}
}
await this.fetchOne(nodeHtml, dateRange);
await this.fetchOne(nodeHtml, dateRange, this.currentNode ?? undefined);
} catch (error: unknown) {
if (error instanceof ArticleOutOfDateRangeError) {
logger.info(
{ url: this.currentNode },
"Article out of date range, stopping further processing",
);
process.exit(0); // stop further processing
return;
}
logger.error({ error, url: this.currentNode }, "Failed to process HTML article");
@@ -105,13 +107,18 @@ export class HtmlCrawler extends BaseCrawler {
* Fetch and process a single HTML article.
* @param html - The HTML content of the article
* @param dateRange - Optional date range for filtering
* @param articleUrl - Optional known article URL, useful when parsing detail pages
*/
async fetchOne(html: string, dateRange?: TimestampRange | null): Promise<Partial<Article>> {
async fetchOne(
html: string,
dateRange?: TimestampRange | null,
articleUrl?: string,
): Promise<Partial<Article>> {
const root = this.parseHtml(html);
const selectors = this.source.sourceSelectors;
const title = this.extractText(root, selectors.articleTitle);
const link = this.currentNode ?? this.extractLink(root);
const link = articleUrl ?? this.currentNode ?? this.extractLink(root);
if (!link || !title) {
throw new InvalidArticleError("Missing article link or title");
}
@@ -120,6 +127,9 @@ export class HtmlCrawler extends BaseCrawler {
const categories = this.extractCategories(root, selectors.articleCategories);
const date = this.extractText(root, selectors.articleDate);
const timestamp = this.computeTimestamp(date);
if (timestamp === null) {
throw new InvalidArticleError("Missing or invalid article date");
}
if (dateRange && !isTimestampInRange(dateRange, timestamp)) {
throw new ArticleOutOfDateRangeError("Article outside date range", {
@@ -140,9 +150,10 @@ export class HtmlCrawler extends BaseCrawler {
title,
},
link,
html,
);
return await persist(data, this.persistors);
return await ingestArticle(data, { articleOutbox: this.requireArticleOutbox() });
}
/**
@@ -160,40 +171,13 @@ export class HtmlCrawler extends BaseCrawler {
* Get the pagination range (start and end page numbers).
*/
async getPagination(): Promise<{ start: number; end: number }> {
return { end: await this.getLastPage(), start: 0 };
}
/**
* Determine the last page number from pagination links.
*/
private async getLastPage(): Promise<number> {
const template = this.applyCategory(this.source.paginationTemplate);
const url = `${this.source.sourceUrl}${template}`;
try {
const html = await this.crawl(url);
const root = this.parseHtml(html);
const links = this.extractAll(root, this.source.sourceSelectors.pagination);
if (!links.length) return 1;
const last = links[links.length - 1]!;
const href = last.getAttribute("href") as string | null;
if (!href) return 1;
// Heuristic: prefer a number in the href, else "page" query param
const numberMatch = href.match(/(\d+)/);
if (numberMatch) {
const page = Number.parseInt(numberMatch[1]!, 10);
return Number.isFinite(page) && page > 0 ? page : 1;
}
const urlObj = new URL(createAbsoluteUrl(this.source.sourceUrl, href));
const pageParam = urlObj.searchParams.get("page");
if (pageParam) {
const page = Number.parseInt(pageParam, 10);
return Number.isFinite(page) && page > 0 ? page : 1;
}
return 1;
} catch {
return 1;
}
return await resolveHtmlPagination({
category: this.options.category,
crawl: (url) => this.crawl(url),
extractAll: (root, selector) => this.extractAll(root, selector),
parseHtml: (html) => this.parseHtml(html),
source: this.source,
});
}
/**
@@ -201,26 +185,11 @@ export class HtmlCrawler extends BaseCrawler {
* @param page - The page number
*/
buildEndpointUrl(page: number): string {
let template = this.applyCategory(this.source.paginationTemplate);
if (template.includes("{page}")) {
template = template.replace("{page}", String(page));
} else if (page > 0) {
const sep = template.includes("?") ? "&" : "?";
template = `${template}${sep}page=${page}`;
}
return createAbsoluteUrl(this.source.sourceUrl, template);
}
/**
* Apply category replacement in the template if needed.
* @param template - The URL template
*/
private applyCategory(template: string): string {
if (template.includes("{category}")) {
const replacement = this.options.category ?? "";
return template.replace("{category}", replacement);
}
return template;
return buildHtmlEndpointUrl({
category: this.options.category,
page,
source: this.source,
});
}
/**
@@ -314,24 +283,26 @@ export class HtmlCrawler extends BaseCrawler {
* @param raw - Raw date string
* @private
*/
private computeTimestamp(raw?: string | null): number {
if (!raw) return Math.floor(Date.now() / 1000);
private computeTimestamp(raw?: string | null): number | null {
if (!raw) return null;
const value = raw.trim();
if (!value) return null;
const format = this.source.sourceDate.format;
if (format === "dd.MM.yyyy") {
const [day, month, year] = raw.split(".").map(Number);
const [day, month, year] = value.split(".").map(Number);
if (!day || !month || !year) return null;
const timestamp = getUnixTime(new Date(year!, month! - 1, day));
return Number.isFinite(timestamp) ? timestamp : Math.floor(Date.now() / 1000);
return Number.isFinite(timestamp) ? timestamp : null;
}
if (!isDateMatch(value, format)) {
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? Math.floor(Date.now() / 1000) : Math.floor(parsed / 1000);
return Number.isNaN(parsed) ? null : Math.floor(parsed / 1000);
}
const date = parse(value, format, new Date());
const timestamp = getUnixTime(date);
return Number.isFinite(timestamp) ? timestamp : Math.floor(Date.now() / 1000);
return Number.isFinite(timestamp) ? timestamp : null;
}
}
@@ -0,0 +1,67 @@
import type { HtmlSourceOptions } from "@basango/domain/config";
import type { PageRange } from "@basango/domain/models";
import type { HTMLElement } from "node-html-parser";
import { createAbsoluteUrl } from "#crawler/config/ranges";
export const buildHtmlEndpointUrl = (settings: {
category?: string;
page: number;
source: HtmlSourceOptions;
}): string => {
let template = settings.source.paginationTemplate;
if (template.includes("{category}")) {
template = template.replace("{category}", settings.category ?? "");
}
if (template.includes("{page}")) {
template = template.replace("{page}", String(settings.page));
} else if (settings.page > 0) {
const sep = template.includes("?") ? "&" : "?";
template = `${template}${sep}page=${settings.page}`;
}
return createAbsoluteUrl(settings.source.sourceUrl, template);
};
export const resolveHtmlPagination = async (settings: {
category?: string;
crawl: (url: string) => Promise<string>;
extractAll: (root: HTMLElement, selector?: string | null) => HTMLElement[];
parseHtml: (html: string) => HTMLElement;
source: HtmlSourceOptions;
}): Promise<PageRange> => {
const url = buildHtmlEndpointUrl({
category: settings.category,
page: 0,
source: settings.source,
});
try {
const html = await settings.crawl(url);
const root = settings.parseHtml(html);
const links = settings.extractAll(root, settings.source.sourceSelectors.pagination);
if (!links.length) return { end: 1, start: 0 };
const last = links[links.length - 1]!;
const href = last.getAttribute("href") as string | null;
if (!href) return { end: 1, start: 0 };
const numberMatch = href.match(/(\d+)/);
if (numberMatch) {
const page = Number.parseInt(numberMatch[1]!, 10);
return { end: Number.isFinite(page) && page > 0 ? page : 1, start: 0 };
}
const urlObj = new URL(createAbsoluteUrl(settings.source.sourceUrl, href));
const pageParam = urlObj.searchParams.get("page");
if (pageParam) {
const page = Number.parseInt(pageParam, 10);
return { end: Number.isFinite(page) && page > 0 ? page : 1, start: 0 };
}
return { end: 1, start: 0 };
} catch {
return { end: 1, start: 0 };
}
};
@@ -0,0 +1,67 @@
import { type AnySourceOptions, config } from "@basango/domain/config";
import type { SourceUpdateDates, TimestampRange } from "@basango/domain/models";
import { logger } from "@basango/logger";
import { getUnixTime } from "date-fns";
import { SyncHttpClient } from "#crawler/http/http-client";
export const getSourceUpdateDates = async (sourceId: string): Promise<SourceUpdateDates> => {
const client = new SyncHttpClient(config.crawler.fetch.client);
const endpoint = config.crawler.backend.endpoint;
logger.info({ sourceId }, "Fetching source update dates");
const response = await client.post(`${endpoint}/sources/update-dates`, {
headers: {
Authorization: config.crawler.backend.token,
},
json: {
name: sourceId,
},
});
if (response.ok) {
const data = await response.json();
logger.info({ ...data }, "Retrieved source update dates");
return data;
}
logger.error({ sourceId, status: response.status }, "Failed to retrieve source update dates");
return { earliest: new Date(), latest: new Date() };
};
export const resolveSourceUpdateDates = async (settings: {
dateRange?: TimestampRange;
direction: "forward" | "backward";
source?: AnySourceOptions;
}) => {
if (settings.dateRange !== undefined || !settings.source) {
return;
}
const dates = await getSourceUpdateDates(settings.source.sourceId);
switch (settings.direction) {
case "backward":
settings.dateRange = {
end: getUnixTime(new Date()),
start: getUnixTime(dates.earliest),
};
logger.info(
{ dateRange: settings.dateRange, sourceId: settings.source.sourceId },
"Set date range start from earliest published date",
);
break;
case "forward":
if (dates.latest) {
settings.dateRange = {
end: getUnixTime(new Date()),
start: getUnixTime(dates.latest),
};
logger.info(
{ dateRange: settings.dateRange, sourceId: settings.source.sourceId },
"Set date range start from latest published date",
);
}
break;
}
};
@@ -4,14 +4,20 @@ import { logger } from "@basango/logger";
import { fromUnixTime } from "date-fns";
import TurndownService from "turndown";
import type { ArticleOutbox } from "#crawler/articles/article-outbox";
import { ingestArticle } from "#crawler/articles/ingest-article";
import { isTimestampInRange } from "#crawler/config/ranges";
import {
ArticleOutOfDateRangeError,
InvalidArticleError,
UnsupportedSourceKindError,
} from "#crawler/errors";
import { BaseCrawler } from "#crawler/process/parsers/base";
import { Persistor, persist } from "#crawler/process/persistence";
import { isTimestampInRange } from "#crawler/utils";
import { BaseCrawler } from "#crawler/sources/base-crawler";
import {
type WordPressPost,
extractWordPressMetadata,
shouldFetchWordPressMetadata,
} from "#crawler/sources/wordpress/wordpress-metadata";
const md = new TurndownService({
bulletListMarker: "-",
@@ -19,15 +25,6 @@ const md = new TurndownService({
hr: "---",
});
interface WordPressPost {
link?: string;
slug?: string;
title?: { rendered?: string };
content?: { rendered?: string };
date?: string;
categories?: number[];
}
/**
* Crawler for WordPress sites using the REST API.
*/
@@ -36,13 +33,13 @@ export class WordPressCrawler extends BaseCrawler {
private categoryMap: Map<number, string> = new Map();
public static readonly POST_QUERY =
"_fields=date,slug,link,title.rendered,content.rendered,categories&orderby=date&order=desc";
"_fields=date,slug,link,title.rendered,content.rendered,excerpt.rendered,categories,yoast_head_json&orderby=date&order=desc";
public static readonly CATEGORY_QUERY =
"_fields=id,slug,count&orderby=count&order=desc&per_page=100";
public static readonly TOTAL_PAGES_HEADER = "x-wp-totalpages";
public static readonly TOTAL_POSTS_HEADER = "x-wp-total";
constructor(settings: CrawlerFetchingOptions, options: { persistors?: Persistor[] } = {}) {
constructor(settings: CrawlerFetchingOptions, options: { articleOutbox?: ArticleOutbox } = {}) {
super(settings, options);
if (!settings.source || settings.source.sourceKind !== "wordpress") {
@@ -76,7 +73,7 @@ export class WordPressCrawler extends BaseCrawler {
{ url: node.link },
"Article out of date range, stopping further processing",
);
process.exit(0); // stop further processing
return;
}
logger.error({ error, url: node.link }, "Failed to process WordPress article");
@@ -134,6 +131,9 @@ export class WordPressCrawler extends BaseCrawler {
this.textContent(this.parseHtml(data.title?.rendered ?? "")) ?? data.slug ?? "Untitled";
const body = md.turndown(data.content?.rendered ?? "");
const timestamp = this.computeTimestamp(data.date);
if (timestamp === null) {
throw new InvalidArticleError("Missing or invalid article date");
}
const categories = await this.mapCategories(data.categories ?? []);
if (dateRange && !isTimestampInRange(dateRange, timestamp)) {
@@ -144,7 +144,7 @@ export class WordPressCrawler extends BaseCrawler {
});
}
const article = await this.enrichWithOpenGraph(
const article = await this.enrichWithMetadata(
{
body,
categories,
@@ -153,10 +153,26 @@ export class WordPressCrawler extends BaseCrawler {
sourceId: this.source.sourceId,
title,
},
link,
data,
);
return await persist(article, this.persistors);
return await ingestArticle(article, { articleOutbox: this.requireArticleOutbox() });
}
private async enrichWithMetadata(
article: Partial<Article>,
post: WordPressPost,
): Promise<Partial<Article>> {
const strategy = this.source.metadataStrategy;
const metadata = extractWordPressMetadata(post, strategy, {
textFromHtml: (html) => this.textContent(this.parseHtml(html)),
});
if (shouldFetchWordPressMetadata(strategy, metadata)) {
return await this.enrichWithOpenGraph(article, article.link);
}
return { ...article, metadata };
}
/**
@@ -236,12 +252,15 @@ export class WordPressCrawler extends BaseCrawler {
* Compute UNIX timestamp from WordPress date string.
* @param raw - Raw date string
*/
private computeTimestamp(raw?: string | null): number {
if (!raw) return Math.floor(Date.now() / 1000);
private computeTimestamp(raw?: string | null): number | null {
if (!raw) return null;
const value = raw.trim();
if (!value) return null;
// Normalize WordPress Z into +00:00 for Date parsing robustness
const cleaned = raw.replace("Z", "+00:00");
const cleaned = value.replace("Z", "+00:00");
const parsed = Date.parse(cleaned);
if (!Number.isNaN(parsed)) return Math.floor(parsed / 1000);
return Math.floor(Date.now() / 1000);
return null;
}
}
@@ -0,0 +1,105 @@
import type { WordPressSourceOptions } from "@basango/domain/config";
import type { ArticleMetadata } from "@basango/domain/models";
import { createAbsoluteUrl } from "#crawler/config/ranges";
export interface WordPressPost {
categories?: number[];
content?: { rendered?: string };
date?: string;
excerpt?: { rendered?: string };
link?: string;
slug?: string;
title?: { rendered?: string };
yoast_head_json?: YoastHeadJson;
}
interface YoastHeadJson {
article_modified_time?: string;
article_published_time?: string;
author?: string;
description?: string;
og_description?: string;
og_image?: Array<{ url?: string }>;
og_title?: string;
og_url?: string;
title?: string;
}
const pick = (values: Array<string | null | undefined>): string | undefined => {
for (const value of values) {
const text = value?.trim();
if (text) return text;
}
return undefined;
};
const hasMetadata = (metadata: ArticleMetadata): boolean => {
return Boolean(metadata.title || metadata.description || metadata.image || metadata.url);
};
export const extractYoastMetadata = (post: WordPressPost): ArticleMetadata | undefined => {
const yoast = post.yoast_head_json;
if (!yoast) return undefined;
const image = pick([yoast.og_image?.find((item) => item.url)?.url]);
const url = pick([yoast.og_url, post.link]);
const metadata = {
author: pick([yoast.author]),
description: pick([yoast.og_description, yoast.description]),
image: image && post.link ? createAbsoluteUrl(post.link, image) : image,
publishedAt: pick([yoast.article_published_time, post.date]),
title: pick([yoast.og_title, yoast.title]),
updatedAt: pick([yoast.article_modified_time]),
url: url && post.link ? createAbsoluteUrl(post.link, url) : url,
} satisfies ArticleMetadata;
return hasMetadata(metadata) ? metadata : undefined;
};
export const extractRestMetadata = (
post: WordPressPost,
helpers: {
textFromHtml: (html: string) => string | null;
},
): ArticleMetadata | undefined => {
const title = helpers.textFromHtml(post.title?.rendered ?? "");
const description = helpers.textFromHtml(post.excerpt?.rendered ?? "");
const metadata = {
description: description ?? undefined,
publishedAt: post.date,
title: title ?? undefined,
url: post.link,
} satisfies ArticleMetadata;
return hasMetadata(metadata) ? metadata : undefined;
};
export const shouldFetchWordPressMetadata = (
strategy: WordPressSourceOptions["metadataStrategy"],
metadata: ArticleMetadata | undefined,
): boolean => {
return strategy === "fetch" || (strategy === "auto" && metadata === undefined);
};
export const extractWordPressMetadata = (
post: WordPressPost,
strategy: WordPressSourceOptions["metadataStrategy"],
helpers: {
textFromHtml: (html: string) => string | null;
},
): ArticleMetadata | undefined => {
switch (strategy) {
case "none":
return undefined;
case "yoast":
return extractYoastMetadata(post);
case "rest":
return extractRestMetadata(post, helpers);
case "fetch":
return undefined;
case "auto":
return extractYoastMetadata(post) ?? extractRestMetadata(post, helpers);
}
};
+3
View File
@@ -0,0 +1,3 @@
declare module "bullmq/dist/cjs/index.js" {
export * from "bullmq";
}
-188
View File
@@ -1,188 +0,0 @@
import {
AnySourceOptions,
HtmlSourceOptions,
WordPressSourceOptions,
config,
} from "@basango/domain/config";
import { DEFAULT_DATE_FORMAT } from "@basango/domain/constants";
import {
DateSpecSchema,
PageRange,
PageRangeSchema,
PageSpecSchema,
TimestampRange,
TimestampRangeSchema,
} from "@basango/domain/models";
import logger from "@basango/logger";
import { format, fromUnixTime, getUnixTime, isMatch, parse } from "date-fns";
import type { RedisOptions } from "ioredis";
import { getSourceUpdateDates } from "./process/persistence";
/**
* Resolve a source configuration by its ID.
* @param id - The source ID
*/
export const resolveSourceConfig = (id: string): AnySourceOptions => {
const source =
config.crawler.sources.html.find((s: HtmlSourceOptions) => s.sourceId === id) ||
config.crawler.sources.wordpress.find((s: WordPressSourceOptions) => s.sourceId === id);
if (source === undefined) {
throw new Error(`Source '${id}' not found in configuration`);
}
return source;
};
export const resolveSourceUpdateDates = async (settings: {
dateRange?: TimestampRange;
direction: "forward" | "backward";
source?: AnySourceOptions;
}) => {
if (settings.dateRange === undefined && settings.source) {
const dates = await getSourceUpdateDates(settings.source.sourceId);
switch (settings.direction) {
case "backward":
settings.dateRange = {
end: getUnixTime(dates.earliest),
start: getUnixTime(new Date()),
};
logger.info(
{ dateRange: settings.dateRange, sourceId: settings.source.sourceId },
"Set date range start from earliest published date",
);
break;
case "forward":
if (dates.latest) {
settings.dateRange = {
end: getUnixTime(new Date()),
start: getUnixTime(dates.latest),
};
logger.info(
{ dateRange: settings.dateRange, sourceId: settings.source.sourceId },
"Set date range start from latest published date",
);
}
break;
}
}
};
/**
* Parse a Redis URL into RedisOptions.
* @param url - The Redis URL (e.g., "redis://:password@localhost:6379/0")
*/
export const parseRedisUrl = (url: string): RedisOptions => {
if (!url.startsWith("redis://")) {
return {};
}
const parsed = new URL(url);
return {
db: Number(parsed.pathname?.replace("/", "") || 0),
host: parsed.hostname,
password: parsed.password || undefined,
port: Number(parsed.port || 6379),
};
};
/**
* Parse a date string using the specified format.
* @param value - The date string to parse
* @param format - The date format
*/
const parseDate = (value: string, format: string): Date => {
if (!isMatch(value, format)) {
throw new Error(`Invalid date '${value}' for format '${format}'`);
}
const parsed = parse(value, format, new Date());
if (Number.isNaN(parsed.getTime())) {
throw new Error(`Invalid date '${value}' for format '${format}'`);
}
return parsed;
};
/**
* Create a page range from a string specification.
* @param spec - The page range specification (e.g., "1:10")
*/
export const createPageRange = (spec: string | undefined): PageRange | undefined => {
if (!spec) return undefined;
const parsed = PageSpecSchema.parse(spec);
return PageRangeSchema.parse(parsed);
};
/**
* Create a date range from a string specification.
* @param spec - The date range specification (e.g., "2023-01-01:2023-12-31")
* @param options - Options for date range creation
*/
export const createTimestampRange = (
spec: string | undefined,
options: {
format?: string;
separator?: string;
} = {},
): TimestampRange | undefined => {
if (!spec) return undefined;
const { format = DEFAULT_DATE_FORMAT, separator = ":" } = options;
if (!separator) {
throw new Error("Separator cannot be empty");
}
const normalized = spec.replace(separator, ":");
const parsedSpec = DateSpecSchema.parse(normalized);
const startDate = parseDate(parsedSpec.startRaw, format);
const endDate = parseDate(parsedSpec.endRaw, format);
const range = {
end: getUnixTime(endDate),
start: getUnixTime(startDate),
};
return TimestampRangeSchema.parse(range);
};
/**
* Format a date range into a string representation.
* @param range - The date range
* @param fmt - The date format (default: DEFAULT_DATE_FORMAT)
*/
export const formatTimestampRange = (range: TimestampRange, fmt = DEFAULT_DATE_FORMAT): string => {
const start = format(fromUnixTime(range.start), fmt);
const end = format(fromUnixTime(range.end), fmt);
return `${start}:${end}`;
};
/**
* Format a page range into a string representation.
* @param range - The page range
*/
export const formatPageRange = (range: PageRange): string => {
return `${range.start}:${range.end}`;
};
/**
* Check if a timestamp is within a given date range.
* @param range - The date range
* @param timestamp - The timestamp to check
*/
export const isTimestampInRange = (range: TimestampRange, timestamp: number): boolean => {
return range.start <= timestamp && timestamp <= range.end;
};
/**
* Convert a relative URL to an absolute URL based on the base URL.
* @param base - The base URL
* @param href - The relative or absolute URL
*/
export const createAbsoluteUrl = (base: string, href: string): string => {
try {
// new URL handles relative paths with base
return new URL(href, base.endsWith("/") ? base : `${base}/`).toString();
} catch {
return href;
}
};
+4 -1
View File
@@ -13,12 +13,15 @@
# testing
/coverage
# next.js
# framework build output
/.next/
/.output/
/.tanstack/
/out/
# production
/build
/dist
# misc
.DS_Store
-22
View File
@@ -1,22 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
devIndicators: false,
async headers() {
return [
{
headers: [
{
key: "X-Frame-Options",
value: "DENY",
},
],
source: "/((?!api/proxy).*)",
},
];
},
poweredByHeader: false,
reactStrictMode: true,
transpilePackages: ["@basango/ui", "@basango/api", "@basango/domain"],
};
export default nextConfig;
+15 -10
View File
@@ -13,17 +13,17 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.8",
"@tanstack/react-router": "^1.170.15",
"@tanstack/react-router-with-query": "^1.130.17",
"@tanstack/react-start": "^1.168.25",
"@tanstack/react-table": "^8.21.3",
"@trpc/client": "^11.7.1",
"@trpc/react-query": "^11.7.1",
"@trpc/server": "^11.7.1",
"@trpc/tanstack-react-query": "^11.7.1",
"class-variance-authority": "^0.7.1",
"client-only": "^0.0.1",
"date-fns": "catalog:",
"lucide-react": "^0.554.0",
"next": "16.0.7",
"next-international": "^1.3.1",
"next-themes": "^0.4.6",
"nuqs": "^2.7.3",
"react": "catalog:",
@@ -31,7 +31,6 @@
"react-dom": "catalog:",
"react-hook-form": "^7.66.0",
"recharts": "^3.4.1",
"server-only": "^0.0.1",
"sonner": "^2.0.7",
"superjson": "^2.2.5",
"zod": "catalog:",
@@ -40,10 +39,14 @@
"devDependencies": {
"@basango/tsconfig": "workspace:*",
"@tailwindcss/postcss": "^4.1.17",
"@tailwindcss/vite": "^4.3.1",
"@types/bun": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"typescript": "catalog:"
"@vitejs/plugin-react": "^6.0.2",
"nitro": "^3.0.260610-beta",
"typescript": "catalog:",
"vite": "^8.0.16"
},
"imports": {
"#dashboard/*": "./src/*"
@@ -51,9 +54,11 @@
"name": "@basango/dashboard",
"private": true,
"scripts": {
"build": "next build",
"clean": "rm -rf .next node_modules",
"dev": "next dev",
"start": "NODE_ENV=production next start"
}
"build": "vite build",
"clean": "rm -rf .output .tanstack node_modules",
"dev": "vite dev",
"start": "NODE_ENV=production node .output/server/index.mjs",
"typecheck": "tsc --noEmit"
},
"type": "module"
}
@@ -1,35 +0,0 @@
import { Metadata } from "next";
import { ArticlesFeed } from "#dashboard/components/articles-feed";
import { CategoriesCarousel } from "#dashboard/components/categories-carousel";
import { PageHeader } from "#dashboard/components/shell/page-header";
import { PageLayout } from "#dashboard/components/shell/page-layout";
import { HydrateClient, batchPrefetch, trpc } from "#dashboard/trpc/server";
export const metadata: Metadata = {
title: "Articles | Basango Dashboard",
};
export default function Page() {
batchPrefetch([
trpc.articles.list.infiniteQueryOptions({ limit: 12 }),
trpc.categories.list.queryOptions(),
]);
return (
<HydrateClient>
<PageLayout
header={
<>
<PageHeader title="Articles" />
<CategoriesCarousel />
</>
}
headersNumber={2}
title="Articles"
>
<ArticlesFeed />
</PageLayout>
</HydrateClient>
);
}
@@ -1,33 +0,0 @@
import { Metadata } from "next";
import { PublicationGraphChart } from "#dashboard/components/charts/articles/publication-graph-chart";
import { SourceDistributionChart } from "#dashboard/components/charts/articles/source-distribution-chart";
import { DashboardOverviewCard } from "#dashboard/components/dashboard-overview-card";
import { PageLayout } from "#dashboard/components/shell/page-layout";
import { HydrateClient, batchPrefetch, trpc } from "#dashboard/trpc/server";
export const metadata: Metadata = {
title: "Dashboard | Basango",
};
export default async function Page() {
batchPrefetch([
trpc.reports.getDashboardOverview.queryOptions(),
trpc.articles.getPublications.queryOptions({}),
trpc.articles.getSourceDistribution.queryOptions({ limit: 8 }),
]);
return (
<HydrateClient>
<PageLayout title="Dashboard">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-4">
<div className="lg:col-span-3 gap-4 flex flex-col">
<DashboardOverviewCard />
<PublicationGraphChart />
</div>
<SourceDistributionChart />
</div>
</PageLayout>
</HydrateClient>
);
}
@@ -1,16 +0,0 @@
import { SidebarProvider } from "@basango/ui/components/sidebar";
import { AppSidebar } from "#dashboard/components/sidebar/app-sidebar";
import { HydrateClient } from "#dashboard/trpc/server";
export default async function Layout({ children }: { children: React.ReactNode }) {
return (
<HydrateClient>
<SidebarProvider>
<AppSidebar />
{children}
</SidebarProvider>
</HydrateClient>
);
}
@@ -1,52 +0,0 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@basango/ui/components/tabs";
import { Metadata } from "next";
import { ArticlesFeed } from "#dashboard/components/articles-feed";
import { CategorySharesChart } from "#dashboard/components/charts/sources/category-shares-chart";
import { PublicationGraphChart } from "#dashboard/components/charts/sources/publication-graph-chart";
import { PageLayout } from "#dashboard/components/shell/page-layout";
import { SourceDetailsTab } from "#dashboard/components/source-details-tab";
import { HydrateClient, batchPrefetch, getQueryClient, trpc } from "#dashboard/trpc/server";
export const metadata: Metadata = {
title: "Source Details | Basango Dashboard",
};
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const queryClient = getQueryClient();
batchPrefetch([
trpc.sources.getById.queryOptions({ id }),
trpc.sources.getCategoryShares.queryOptions({ id, limit: 10 }),
trpc.sources.getPublications.queryOptions({ id }),
trpc.categories.list.queryOptions(),
trpc.articles.list.infiniteQueryOptions({ limit: 12, sourceId: id }),
]);
const source = await queryClient.fetchQuery(trpc.sources.getById.queryOptions({ id }));
return (
<HydrateClient>
<PageLayout title={source.name}>
<Tabs className="space-y-4" defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="articles">Articles</TabsTrigger>
<TabsTrigger value="details">Details</TabsTrigger>
</TabsList>
<TabsContent className="space-y-4" value="overview">
<CategorySharesChart sourceId={source.id} />
<PublicationGraphChart sourceId={source.id} />
</TabsContent>
<TabsContent value="articles">
<ArticlesFeed sourceId={source.id} />
</TabsContent>
<TabsContent value="details">
<SourceDetailsTab source={source} />
</TabsContent>
</Tabs>
</PageLayout>
</HydrateClient>
);
}
@@ -1,47 +0,0 @@
import { Button } from "@basango/ui/components/button";
import { PlusIcon } from "lucide-react";
import { Metadata } from "next";
import Link from "next/link";
import { RouterOutputs } from "#api/trpc/routers/_app";
import { SourceCreateModal } from "#dashboard/components/modals/source-create-modal";
import { PageLayout } from "#dashboard/components/shell/page-layout";
import { SourceCard } from "#dashboard/components/source-card";
import { HydrateClient, getQueryClient, prefetch, trpc } from "#dashboard/trpc/server";
export const metadata: Metadata = {
title: "Sources | Basango Dashboard",
};
type Source = RouterOutputs["sources"]["list"][number];
export default async function Page() {
const queryClient = getQueryClient();
prefetch(trpc.sources.list.queryOptions());
const sources = await queryClient.fetchQuery(trpc.sources.list.queryOptions());
return (
<HydrateClient>
<PageLayout title="Sources">
<div className="flex justify-end">
<Link href="?createSource=true">
<Button type="button">
<PlusIcon className="mr-2 size-4" />
Add source
</Button>
</Link>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{sources.map((source: Source) => (
<Link href={`/sources/${source.id}`} key={source.id}>
<SourceCard source={source} />
</Link>
))}
</div>
<SourceCreateModal />
</PageLayout>
</HydrateClient>
);
}
-30
View File
@@ -1,30 +0,0 @@
"use client";
import { Button } from "@basango/ui/components/button";
import Link from "next/link";
export default function ErrorPage({ reset }: { reset: () => void }) {
return (
<div className="h-[calc(100vh-200px)] w-full">
<div className="mt-8 flex flex-col items-center justify-center h-full">
<div className="flex justify-between items-center flex-col mt-8 text-center mb-8">
<h2 className="font-medium mb-4">Something went wrong</h2>
<p className="text-sm text-[#878787]">
An unexpected error has occurred. Please try again
<br /> or contact support if the issue persists.
</p>
</div>
<div className="flex space-x-4">
<Button onClick={() => reset()} variant="outline">
Try again
</Button>
<Link href="/account/support">
<Button>Contact us</Button>
</Link>
</div>
</div>
</div>
);
}
@@ -1,56 +0,0 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "@basango/ui/globals.css";
import { Toaster } from "@basango/ui/components/sonner";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import { Providers } from "./providers";
const geistSans = Geist({
subsets: ["latin"],
variable: "--font-geist-sans",
});
const geistMono = Geist_Mono({
subsets: ["latin"],
variable: "--font-geist-mono",
});
export const metadata: Metadata = {
description: "Basango : The intelligent news curation platform.",
metadataBase: new URL("https://dashboard.basango.com"),
title: "Basango | AI-powered news curation dashboard",
};
export const viewport = {
initialScale: 1,
maximumScale: 1,
themeColor: [
{ media: "(prefers-color-scheme: light)" },
{ media: "(prefers-color-scheme: dark)" },
],
userScalable: false,
width: "device-width",
};
export default async function RootLayout({
params,
children,
}: Readonly<{
params: Promise<{ locale: string }>;
children: React.ReactNode;
}>) {
const { locale } = await params;
return (
<html lang="en" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<NuqsAdapter>
<Providers locale={locale}>{children}</Providers>
<Toaster />
</NuqsAdapter>
</body>
</html>
);
}
@@ -1,13 +0,0 @@
import Link from "next/link";
export default function NotFound() {
return (
<div className="h-screen flex flex-col items-center justify-center text-center text-sm text-[#606060]">
<h2 className="text-xl font-semibold mb-2">Not Found</h2>
<p className="mb-4">Could not find requested resource</p>
<Link className="underline" href="/">
Return Home
</Link>
</div>
);
}
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from "next/navigation";
export default function Page() {
redirect("/dashboard");
}
@@ -1,30 +0,0 @@
"use client";
import { ThemeProvider } from "next-themes";
import type { ReactNode } from "react";
import { I18nProviderClient } from "#dashboard/locales/client";
import { TRPCReactProvider } from "#dashboard/trpc/client";
type ProviderProps = {
locale: string;
children: ReactNode;
};
export function Providers({ locale, children }: ProviderProps) {
return (
<TRPCReactProvider>
<I18nProviderClient locale={locale}>
<ThemeProvider
attribute="class"
defaultTheme="system"
disableTransitionOnChange
enableColorScheme
enableSystem
>
{children}
</ThemeProvider>
</I18nProviderClient>
</TRPCReactProvider>
);
}
+70
View File
@@ -0,0 +1,70 @@
import "@basango/ui/globals.css";
import { Toaster } from "@basango/ui/components/sonner";
import { HeadContent, Outlet, Scripts, createRootRouteWithContext } from "@tanstack/react-router";
import { ThemeProvider } from "next-themes";
import { NuqsAdapter } from "nuqs/adapters/tanstack-router";
import type { ReactNode } from "react";
import type { RouterContext } from "#dashboard/router-context";
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootComponent,
head: () => ({
meta: [
{ charSet: "utf-8" },
{ content: "width=device-width, initial-scale=1, maximum-scale=1", name: "viewport" },
{ title: "Basango | AI-powered news curation dashboard" },
{
content: "Basango : The intelligent news curation platform.",
name: "description",
},
],
}),
notFoundComponent: NotFound,
});
function RootComponent() {
return (
<RootDocument>
<NuqsAdapter>
<ThemeProvider
attribute="class"
defaultTheme="system"
disableTransitionOnChange
enableColorScheme
enableSystem
>
<Outlet />
</ThemeProvider>
<Toaster />
</NuqsAdapter>
</RootDocument>
);
}
function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<HeadContent />
</head>
<body>
{children}
<Scripts />
</body>
</html>
);
}
function NotFound() {
return (
<div className="flex h-screen flex-col items-center justify-center text-center text-sm text-[#606060]">
<h2 className="mb-2 font-semibold text-xl">Not Found</h2>
<p className="mb-4">Could not find requested resource</p>
<a className="underline" href="/dashboard">
Return Home
</a>
</div>
);
}
@@ -0,0 +1,36 @@
import { createFileRoute } from "@tanstack/react-router";
import { ArticlesFeed } from "#dashboard/components/articles-feed";
import { CategoriesCarousel } from "#dashboard/components/categories-carousel";
import { PageHeader } from "#dashboard/components/shell/page-header";
import { PageLayout } from "#dashboard/components/shell/page-layout";
export const Route = createFileRoute("/_authenticated/articles")({
component: ArticlesPage,
head: () => ({
meta: [{ title: "Articles | Basango Dashboard" }],
}),
loader: ({ context }) => {
void context.queryClient.prefetchInfiniteQuery(
context.trpc.articles.list.infiniteQueryOptions({ limit: 12 }),
);
void context.queryClient.prefetchQuery(context.trpc.categories.list.queryOptions());
},
});
function ArticlesPage() {
return (
<PageLayout
header={
<>
<PageHeader title="Articles" />
<CategoriesCarousel />
</>
}
headersNumber={2}
title="Articles"
>
<ArticlesFeed />
</PageLayout>
);
}
@@ -0,0 +1,36 @@
import { createFileRoute } from "@tanstack/react-router";
import { PublicationGraphChart } from "#dashboard/components/charts/articles/publication-graph-chart";
import { SourceDistributionChart } from "#dashboard/components/charts/articles/source-distribution-chart";
import { DashboardOverviewCard } from "#dashboard/components/dashboard-overview-card";
import { PageLayout } from "#dashboard/components/shell/page-layout";
export const Route = createFileRoute("/_authenticated/dashboard")({
component: DashboardPage,
head: () => ({
meta: [{ title: "Dashboard | Basango" }],
}),
loader: ({ context }) => {
void context.queryClient.prefetchQuery(
context.trpc.reports.getDashboardOverview.queryOptions(),
);
void context.queryClient.prefetchQuery(context.trpc.articles.getPublications.queryOptions({}));
void context.queryClient.prefetchQuery(
context.trpc.articles.getSourceDistribution.queryOptions({ limit: 8 }),
);
},
});
function DashboardPage() {
return (
<PageLayout title="Dashboard">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-4">
<div className="flex flex-col gap-4 lg:col-span-3">
<DashboardOverviewCard />
<PublicationGraphChart />
</div>
<SourceDistributionChart />
</div>
</PageLayout>
);
}
@@ -0,0 +1,57 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@basango/ui/components/tabs";
import { createFileRoute } from "@tanstack/react-router";
import { ArticlesFeed } from "#dashboard/components/articles-feed";
import { CategorySharesChart } from "#dashboard/components/charts/sources/category-shares-chart";
import { PublicationGraphChart } from "#dashboard/components/charts/sources/publication-graph-chart";
import { PageLayout } from "#dashboard/components/shell/page-layout";
import { SourceDetailsTab } from "#dashboard/components/source-details-tab";
export const Route = createFileRoute("/_authenticated/sources/$id")({
component: SourceDetailsPage,
head: () => ({
meta: [{ title: "Source Details | Basango Dashboard" }],
}),
loader: async ({ context, params }) => {
const sourceQuery = context.trpc.sources.getById.queryOptions({ id: params.id });
void context.queryClient.prefetchQuery(
context.trpc.sources.getCategoryShares.queryOptions({ id: params.id, limit: 10 }),
);
void context.queryClient.prefetchQuery(
context.trpc.sources.getPublications.queryOptions({ id: params.id }),
);
void context.queryClient.prefetchQuery(context.trpc.categories.list.queryOptions());
void context.queryClient.prefetchInfiniteQuery(
context.trpc.articles.list.infiniteQueryOptions({ limit: 12, sourceId: params.id }),
);
return context.queryClient.ensureQueryData(sourceQuery);
},
});
function SourceDetailsPage() {
const source = Route.useLoaderData();
return (
<PageLayout title={source.name}>
<Tabs className="space-y-4" defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="articles">Articles</TabsTrigger>
<TabsTrigger value="details">Details</TabsTrigger>
</TabsList>
<TabsContent className="space-y-4" value="overview">
<CategorySharesChart sourceId={source.id} />
<PublicationGraphChart sourceId={source.id} />
</TabsContent>
<TabsContent value="articles">
<ArticlesFeed sourceId={source.id} />
</TabsContent>
<TabsContent value="details">
<SourceDetailsTab source={source} />
</TabsContent>
</Tabs>
</PageLayout>
);
}
@@ -0,0 +1,47 @@
import type { RouterOutputs } from "@basango/api/trpc/routers/_app";
import { Button } from "@basango/ui/components/button";
import { Link, createFileRoute } from "@tanstack/react-router";
import { PlusIcon } from "lucide-react";
import { SourceCreateModal } from "#dashboard/components/modals/source-create-modal";
import { PageLayout } from "#dashboard/components/shell/page-layout";
import { SourceCard } from "#dashboard/components/source-card";
type Source = RouterOutputs["sources"]["list"][number];
export const Route = createFileRoute("/_authenticated/sources")({
component: SourcesPage,
head: () => ({
meta: [{ title: "Sources | Basango Dashboard" }],
}),
loader: ({ context }) =>
context.queryClient.ensureQueryData(context.trpc.sources.list.queryOptions()),
validateSearch: (search): { createSource?: boolean } =>
search.createSource === true || search.createSource === "true" ? { createSource: true } : {},
});
function SourcesPage() {
const sources = Route.useLoaderData();
return (
<PageLayout title="Sources">
<div className="flex justify-end">
<Link search={{ createSource: true }} to="/sources">
<Button type="button">
<PlusIcon className="mr-2 size-4" />
Add source
</Button>
</Link>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{sources.map((source: Source) => (
<Link key={source.id} params={{ id: source.id }} search={{}} to="/sources/$id">
<SourceCard source={source} />
</Link>
))}
</div>
<SourceCreateModal />
</PageLayout>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { SidebarProvider } from "@basango/ui/components/sidebar";
import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
import { AppSidebar } from "#dashboard/components/sidebar/app-sidebar";
import { getClientAccessToken, getClientRefreshToken } from "#dashboard/utils/auth/client";
export const Route = createFileRoute("/_authenticated")({
beforeLoad: ({ location }) => {
if (typeof document === "undefined") {
return;
}
if (!getClientAccessToken() && !getClientRefreshToken()) {
throw redirect({
search: {
return_to: location.href,
},
to: "/login",
});
}
},
component: AuthenticatedLayout,
});
function AuthenticatedLayout() {
return (
<SidebarProvider>
<AppSidebar />
<Outlet />
</SidebarProvider>
);
}
@@ -0,0 +1,73 @@
import { createFileRoute } from "@tanstack/react-router";
import { getCookie } from "@tanstack/react-start/server";
import {
DEFAULT_ACCESS_TOKEN_COOKIE,
DEFAULT_REFRESH_TOKEN_COOKIE,
getSessionCookieOptions,
refreshSession,
} from "#dashboard/utils/auth/session";
export const Route = createFileRoute("/api/session/refresh")({
server: {
handlers: {
POST: async ({ request }) => {
const refreshToken =
getCookie(DEFAULT_REFRESH_TOKEN_COOKIE) ?? (await getRefreshTokenFromBody(request));
if (!refreshToken) {
return Response.json({ error: "Missing refresh token" }, { status: 401 });
}
const tokens = await refreshSession(refreshToken);
if (!tokens) {
return Response.json({ error: "Invalid refresh token" }, { status: 401 });
}
const response = Response.json(tokens);
response.headers.append(
"Set-Cookie",
serializeCookie(
DEFAULT_ACCESS_TOKEN_COOKIE,
tokens.accessToken,
getSessionCookieOptions(tokens.accessTokenExpiresAt, request.url),
),
);
response.headers.append(
"Set-Cookie",
serializeCookie(
DEFAULT_REFRESH_TOKEN_COOKIE,
tokens.refreshToken,
getSessionCookieOptions(tokens.refreshTokenExpiresAt, request.url),
),
);
return response;
},
},
},
});
async function getRefreshTokenFromBody(request: Request) {
try {
const body = await request.json();
if (typeof body?.refreshToken === "string") {
return body.refreshToken;
}
} catch {
// Ignore malformed bodies.
}
return undefined;
}
function serializeCookie(
name: string,
value: string,
options: ReturnType<typeof getSessionCookieOptions>,
) {
const secure = options.secure ? "; Secure" : "";
return `${name}=${encodeURIComponent(value)}; Expires=${options.expires.toUTCString()}; Path=${options.path}; SameSite=Lax${secure}`;
}
@@ -1,49 +0,0 @@
import type { AppRouter } from "@basango/api/trpc/routers/_app";
import { DEFAULT_REFRESH_TOKEN_COOKIE } from "@basango/domain/constants";
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import superjson from "superjson";
const client = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
transformer: superjson,
url: `${process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3080"}/trpc`,
}),
],
});
export async function POST(request: NextRequest) {
const cookieStore = await cookies();
const refreshToken =
cookieStore.get(DEFAULT_REFRESH_TOKEN_COOKIE)?.value ??
(await getRefreshTokenFromBody(request));
if (!refreshToken) {
return NextResponse.json({ error: "Missing refresh token" }, { status: 401 });
}
try {
const tokens = await client.auth.refresh.mutate({
refreshToken,
});
return NextResponse.json(tokens);
} catch {
return NextResponse.json({ error: "Invalid refresh token" }, { status: 401 });
}
}
async function getRefreshTokenFromBody(request: NextRequest) {
try {
const body = await request.json();
if (typeof body?.refreshToken === "string") {
return body.refreshToken;
}
} catch {
// Ignore malformed bodies
}
return undefined;
}
-13
View File
@@ -1,13 +0,0 @@
"use client";
import NextError from "next/error";
export default function GlobalError() {
return (
<html lang="en">
<body>
<NextError statusCode={0} />
</body>
</html>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/")({
beforeLoad: () => {
throw redirect({ to: "/dashboard" });
},
});
@@ -1,12 +1,25 @@
import { createFileRoute } from "@tanstack/react-router";
import { LoginForm } from "#dashboard/components/forms/login-form";
export default function Page() {
export const Route = createFileRoute("/login")({
component: LoginPage,
head: () => ({
meta: [{ title: "Login | Basango Dashboard" }],
}),
validateSearch: (search): { return_to?: string } =>
typeof search.return_to === "string" ? { return_to: search.return_to } : {},
});
function LoginPage() {
const { return_to: returnTo } = Route.useSearch();
return (
<div className="grid min-h-svh lg:grid-cols-2">
<div className="flex flex-col gap-4 p-6 md:p-10">
<div className="flex flex-1 items-center justify-center">
<div className="w-full max-w-xs">
<LoginForm />
<LoginForm returnTo={returnTo} />
</div>
</div>
</div>
@@ -12,7 +12,6 @@ import {
} from "@basango/ui/components/dropdown-menu";
import { Skeleton } from "@basango/ui/components/skeleton";
import { ExternalLink, Link2, MoreHorizontal } from "lucide-react";
import Link from "next/link";
import * as React from "react";
import { formatDate, formatRelativeTime } from "#dashboard/utils/utils";
@@ -68,10 +67,10 @@ export function ArticleCard({ article }: ArticleCardProps) {
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={article.link} rel="noreferrer" target="_blank">
<a href={article.link} rel="noreferrer" target="_blank">
<ExternalLink className="mr-2 h-4 w-4" />
Open original
</Link>
</a>
</DropdownMenuItem>
<DropdownMenuItem onClick={copyLink}>
<Link2 className="mr-2 h-4 w-4" />
@@ -84,14 +83,14 @@ export function ArticleCard({ article }: ArticleCardProps) {
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-3 p-4">
<CardTitle className="text-base leading-tight">
<Link
<a
className="transition hover:text-primary hover:underline"
href={article.link}
rel="noreferrer"
target="_blank"
>
{article.title}
</Link>
</a>
</CardTitle>
<p className="text-sm text-muted-foreground line-clamp-3">
{article.metadata?.description ??
@@ -1,5 +1,6 @@
"use client";
import { RouterOutputs } from "@basango/api/trpc/routers/_app";
import {
Card,
CardContent,
@@ -10,7 +11,6 @@ import {
import { useQuery } from "@tanstack/react-query";
import { Bar, BarChart, Legend, ResponsiveContainer, XAxis, YAxis } from "recharts";
import { RouterOutputs } from "#api/trpc/routers/_app";
import { ChartLimitToggle, useChartLimitFilter } from "#dashboard/components/charts/chart-filters";
import { useTRPC } from "#dashboard/trpc/client";
import { getColorFromName } from "#dashboard/utils/categories";
@@ -12,7 +12,7 @@ import { Input } from "@basango/ui/components/input";
import { SubmitButton } from "@basango/ui/components/submit-button";
import { cn } from "@basango/ui/lib/utils";
import { useMutation } from "@tanstack/react-query";
import { useRouter, useSearchParams } from "next/navigation";
import { useNavigate, useRouter } from "@tanstack/react-router";
import { Controller } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -24,9 +24,13 @@ import { persistSessionTokens } from "#dashboard/utils/auth/client";
type LoginValues = z.infer<typeof loginSchema>;
export function LoginForm({ className, ...props }: React.ComponentProps<"form">) {
type LoginFormProps = React.ComponentProps<"form"> & {
returnTo?: string;
};
export function LoginForm({ className, returnTo, ...props }: LoginFormProps) {
const router = useRouter();
const searchParams = useSearchParams();
const navigate = useNavigate();
const trpc = useTRPC();
const setUser = useUserStore((state) => state.setUser);
@@ -53,8 +57,8 @@ export function LoginForm({ className, ...props }: React.ComponentProps<"form">)
toast.success("Successfully logged in.");
form.reset();
router.push(searchParams?.get("return_to") ?? `/dashboard`);
router.refresh();
await navigate({ to: returnTo ?? "/dashboard" });
await router.invalidate();
},
}),
);
@@ -15,8 +15,8 @@ import {
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@basango/ui/components/sidebar";
import { Link, useLocation } from "@tanstack/react-router";
import { ChevronRight, type LucideIcon } from "lucide-react";
import { usePathname } from "next/navigation";
type ParentItem = {
title: string;
@@ -37,7 +37,9 @@ type Props = {
};
export function AppSidebarContent({ items }: Props) {
const pathname = usePathname();
const pathname = useLocation({
select: (location) => location.pathname,
});
return (
<SidebarGroup>
@@ -66,9 +68,9 @@ export function AppSidebarContent({ items }: Props) {
asChild
isActive={subItem.url === pathname || pathname.includes(subItem.url)}
>
<a href={subItem.url}>
<Link to={subItem.url}>
<span>{subItem.title}</span>
</a>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
@@ -2,8 +2,10 @@
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "@basango/ui/components/sidebar";
import { getPublicVersion } from "#dashboard/utils/environment";
export function AppSidebarInfo() {
const version = process.env.NEXT_PUBLIC_VERSION || "0.0.0";
const version = getPublicVersion();
return (
<SidebarMenu>
@@ -15,8 +15,8 @@ import {
SidebarMenuItem,
useSidebar,
} from "@basango/ui/components/sidebar";
import { useNavigate, useRouter } from "@tanstack/react-router";
import { ChevronsUpDown, LogOut } from "lucide-react";
import { useRouter } from "next/navigation";
import { useUser } from "#dashboard/hooks/use-user";
import { clearSessionTokens } from "#dashboard/utils/auth/client";
@@ -25,13 +25,14 @@ import { getInitials } from "#dashboard/utils/utils";
export function AppSidebarUser() {
const { isMobile } = useSidebar();
const router = useRouter();
const navigate = useNavigate();
const { user, setUser } = useUser();
const handleLogout = () => {
const handleLogout = async () => {
clearSessionTokens();
setUser(null);
router.push(`/login`);
router.refresh();
await navigate({ search: {}, to: "/login" });
await router.invalidate();
};
return (
@@ -1,5 +1,6 @@
"use client";
import { RouterOutputs } from "@basango/api/trpc/routers/_app";
import {
Card,
CardContent,
@@ -16,7 +17,6 @@ import {
} from "@basango/ui/components/chart";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { RouterOutputs } from "#api/trpc/routers/_app";
import { formatDate, formatNumber } from "#dashboard/utils/utils";
const chartConfig = {
@@ -1,5 +1,6 @@
"use client";
import { RouterOutputs } from "@basango/api/trpc/routers/_app";
import {
Card,
CardContent,
@@ -7,10 +8,8 @@ import {
CardHeader,
CardTitle,
} from "@basango/ui/components/card";
import Link from "next/link";
import type { ReactNode } from "react";
import { RouterOutputs } from "#api/trpc/routers/_app";
import { SourceEditForm } from "#dashboard/components/forms/source-edit-form";
type Props = {
@@ -34,13 +33,14 @@ export function SourceDetailsTab({ source }: Props) {
<DetailItem
label="Website"
value={
<Link
<a
className="text-primary underline underline-offset-4"
href={source.url}
rel="noreferrer"
target="_blank"
>
{source.url}
</Link>
</a>
}
/>
<DetailItem label="Description" value={source.description ?? "Not provided"} />
-10
View File
@@ -1,10 +0,0 @@
"use client";
import { createI18nClient } from "next-international/client";
// NOTE: Also update middleware.ts to support locale
export const languages = ["en"];
export const { I18nProviderClient, useCurrentLocale } = createI18nClient({
en: () => import("./translations/en"),
});
-5
View File
@@ -1,5 +0,0 @@
import { createI18nServer } from "next-international/server";
export const { getStaticParams } = createI18nServer({
en: () => import("./translations/en"),
});
@@ -1,3 +0,0 @@
export default {
app: "Basango Dashboard",
} as const;
-127
View File
@@ -1,127 +0,0 @@
import {
DEFAULT_ACCESS_TOKEN_COOKIE,
DEFAULT_REFRESH_TOKEN_COOKIE,
} from "@basango/domain/constants";
import { type NextRequest, NextResponse } from "next/server";
import { createI18nMiddleware } from "next-international/middleware";
const SUPPORTED_LOCALES = ["en"] as const;
const DEFAULT_LOCALE = SUPPORTED_LOCALES[0];
const I18nMiddleware = createI18nMiddleware({
defaultLocale: DEFAULT_LOCALE,
locales: SUPPORTED_LOCALES as unknown as string[],
urlMappingStrategy: "rewrite",
});
const PUBLIC_PATHS = new Set(["/login"]);
type SessionTokens = {
accessToken: string;
refreshToken: string;
accessTokenExpiresAt: string;
refreshTokenExpiresAt: string;
};
export default async function proxy(request: NextRequest) {
const { locale, pathname } = extractLocaleAndPath(request);
let accessToken = request.cookies.get(DEFAULT_ACCESS_TOKEN_COOKIE)?.value;
const refreshToken = request.cookies.get(DEFAULT_REFRESH_TOKEN_COOKIE)?.value;
const isPublicRoute = PUBLIC_PATHS.has(pathname);
let refreshedTokens: SessionTokens | null = null;
if (!accessToken && refreshToken) {
refreshedTokens = await refreshSession(request);
accessToken = refreshedTokens?.accessToken;
}
if (!isPublicRoute && !accessToken) {
return redirectToLogin(request, locale);
}
if (accessToken && pathname === "/login") {
const redirectUrl = new URL(`/${locale}/dashboard`, request.url);
return NextResponse.redirect(redirectUrl);
}
const i18nResponse = await I18nMiddleware(request);
if (refreshedTokens) {
setSessionCookies(i18nResponse, refreshedTokens, request);
}
return i18nResponse;
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|api).*)"],
};
function extractLocaleAndPath(request: NextRequest) {
const segments = request.nextUrl.pathname.split("/").filter(Boolean);
const maybeLocale = segments[0];
const localeFromPath =
maybeLocale && SUPPORTED_LOCALES.find((supportedLocale) => supportedLocale === maybeLocale);
const locale = localeFromPath ?? DEFAULT_LOCALE;
const pathSegments = localeFromPath ? segments.slice(1) : segments;
const pathname = `/${pathSegments.join("/")}`.replace(/\/+/g, "/") || "/";
return { locale, pathname };
}
function redirectToLogin(request: NextRequest, locale: string) {
const target = new URL(`/${locale}/login`, request.url);
const returnTo = buildReturnToParam(request);
if (returnTo) {
target.searchParams.set("return_to", returnTo);
}
return NextResponse.redirect(target);
}
function buildReturnToParam(request: NextRequest) {
const path = `${request.nextUrl.pathname}${request.nextUrl.search}`;
return path !== "/" ? path : null;
}
async function refreshSession(request: NextRequest): Promise<SessionTokens | null> {
try {
const response = await fetch(new URL("/api/session/refresh", request.url), {
headers: {
cookie: request.headers.get("cookie") ?? "",
},
method: "POST",
});
if (!response.ok) {
return null;
}
return (await response.json()) as SessionTokens;
} catch {
return null;
}
}
function setSessionCookies(response: NextResponse, tokens: SessionTokens, request: NextRequest) {
const secure = request.nextUrl.protocol === "https:";
response.cookies.set({
expires: new Date(tokens.accessTokenExpiresAt),
name: DEFAULT_ACCESS_TOKEN_COOKIE,
path: "/",
sameSite: "lax",
secure,
value: tokens.accessToken,
});
response.cookies.set({
expires: new Date(tokens.refreshTokenExpiresAt),
name: DEFAULT_REFRESH_TOKEN_COOKIE,
path: "/",
sameSite: "lax",
secure,
value: tokens.refreshToken,
});
}
+234
View File
@@ -0,0 +1,234 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './app/__root'
import { Route as LoginRouteImport } from './app/login'
import { Route as AuthenticatedRouteImport } from './app/_authenticated'
import { Route as IndexRouteImport } from './app/index'
import { Route as AuthenticatedSourcesRouteImport } from './app/_authenticated.sources'
import { Route as AuthenticatedDashboardRouteImport } from './app/_authenticated.dashboard'
import { Route as AuthenticatedArticlesRouteImport } from './app/_authenticated.articles'
import { Route as ApiSessionRefreshRouteImport } from './app/api.session.refresh'
import { Route as AuthenticatedSourcesIdRouteImport } from './app/_authenticated.sources.$id'
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const AuthenticatedRoute = AuthenticatedRouteImport.update({
id: '/_authenticated',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const AuthenticatedSourcesRoute = AuthenticatedSourcesRouteImport.update({
id: '/sources',
path: '/sources',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedDashboardRoute = AuthenticatedDashboardRouteImport.update({
id: '/dashboard',
path: '/dashboard',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedArticlesRoute = AuthenticatedArticlesRouteImport.update({
id: '/articles',
path: '/articles',
getParentRoute: () => AuthenticatedRoute,
} as any)
const ApiSessionRefreshRoute = ApiSessionRefreshRouteImport.update({
id: '/api/session/refresh',
path: '/api/session/refresh',
getParentRoute: () => rootRouteImport,
} as any)
const AuthenticatedSourcesIdRoute = AuthenticatedSourcesIdRouteImport.update({
id: '/$id',
path: '/$id',
getParentRoute: () => AuthenticatedSourcesRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/articles': typeof AuthenticatedArticlesRoute
'/dashboard': typeof AuthenticatedDashboardRoute
'/sources': typeof AuthenticatedSourcesRouteWithChildren
'/sources/$id': typeof AuthenticatedSourcesIdRoute
'/api/session/refresh': typeof ApiSessionRefreshRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/articles': typeof AuthenticatedArticlesRoute
'/dashboard': typeof AuthenticatedDashboardRoute
'/sources': typeof AuthenticatedSourcesRouteWithChildren
'/sources/$id': typeof AuthenticatedSourcesIdRoute
'/api/session/refresh': typeof ApiSessionRefreshRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/_authenticated': typeof AuthenticatedRouteWithChildren
'/login': typeof LoginRoute
'/_authenticated/articles': typeof AuthenticatedArticlesRoute
'/_authenticated/dashboard': typeof AuthenticatedDashboardRoute
'/_authenticated/sources': typeof AuthenticatedSourcesRouteWithChildren
'/_authenticated/sources/$id': typeof AuthenticatedSourcesIdRoute
'/api/session/refresh': typeof ApiSessionRefreshRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/login'
| '/articles'
| '/dashboard'
| '/sources'
| '/sources/$id'
| '/api/session/refresh'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/login'
| '/articles'
| '/dashboard'
| '/sources'
| '/sources/$id'
| '/api/session/refresh'
id:
| '__root__'
| '/'
| '/_authenticated'
| '/login'
| '/_authenticated/articles'
| '/_authenticated/dashboard'
| '/_authenticated/sources'
| '/_authenticated/sources/$id'
| '/api/session/refresh'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
LoginRoute: typeof LoginRoute
ApiSessionRefreshRoute: typeof ApiSessionRefreshRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/_authenticated': {
id: '/_authenticated'
path: ''
fullPath: '/'
preLoaderRoute: typeof AuthenticatedRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/_authenticated/sources': {
id: '/_authenticated/sources'
path: '/sources'
fullPath: '/sources'
preLoaderRoute: typeof AuthenticatedSourcesRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/dashboard': {
id: '/_authenticated/dashboard'
path: '/dashboard'
fullPath: '/dashboard'
preLoaderRoute: typeof AuthenticatedDashboardRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/articles': {
id: '/_authenticated/articles'
path: '/articles'
fullPath: '/articles'
preLoaderRoute: typeof AuthenticatedArticlesRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/api/session/refresh': {
id: '/api/session/refresh'
path: '/api/session/refresh'
fullPath: '/api/session/refresh'
preLoaderRoute: typeof ApiSessionRefreshRouteImport
parentRoute: typeof rootRouteImport
}
'/_authenticated/sources/$id': {
id: '/_authenticated/sources/$id'
path: '/$id'
fullPath: '/sources/$id'
preLoaderRoute: typeof AuthenticatedSourcesIdRouteImport
parentRoute: typeof AuthenticatedSourcesRoute
}
}
}
interface AuthenticatedSourcesRouteChildren {
AuthenticatedSourcesIdRoute: typeof AuthenticatedSourcesIdRoute
}
const AuthenticatedSourcesRouteChildren: AuthenticatedSourcesRouteChildren = {
AuthenticatedSourcesIdRoute: AuthenticatedSourcesIdRoute,
}
const AuthenticatedSourcesRouteWithChildren =
AuthenticatedSourcesRoute._addFileChildren(AuthenticatedSourcesRouteChildren)
interface AuthenticatedRouteChildren {
AuthenticatedArticlesRoute: typeof AuthenticatedArticlesRoute
AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute
AuthenticatedSourcesRoute: typeof AuthenticatedSourcesRouteWithChildren
}
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedArticlesRoute: AuthenticatedArticlesRoute,
AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
AuthenticatedSourcesRoute: AuthenticatedSourcesRouteWithChildren,
}
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
AuthenticatedRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AuthenticatedRoute: AuthenticatedRouteWithChildren,
LoginRoute: LoginRoute,
ApiSessionRefreshRoute: ApiSessionRefreshRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { startInstance } from './start.ts'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
config: Awaited<ReturnType<typeof startInstance.getOptions>>
}
}
+8
View File
@@ -0,0 +1,8 @@
import type { QueryClient } from "@tanstack/react-query";
import type { createTRPCOptions } from "#dashboard/trpc/options";
export type RouterContext = {
queryClient: QueryClient;
trpc: ReturnType<typeof createTRPCOptions>;
};
+32
View File
@@ -0,0 +1,32 @@
import { createRouter } from "@tanstack/react-router";
import { routerWithQueryClient } from "@tanstack/react-router-with-query";
import { TRPCReactProvider } from "#dashboard/trpc/client";
import { createTRPCOptions } from "#dashboard/trpc/options";
import { makeQueryClient } from "#dashboard/trpc/query-client";
import { routeTree } from "./routeTree.gen";
export function getRouter() {
const queryClient = makeQueryClient();
const router = createRouter({
context: {
queryClient,
trpc: createTRPCOptions(queryClient),
},
routeTree,
scrollRestoration: true,
});
return routerWithQueryClient(router, queryClient, {
WrapProvider: ({ children }) => (
<TRPCReactProvider queryClient={queryClient}>{children}</TRPCReactProvider>
),
});
}
declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof getRouter>;
}
}
+103
View File
@@ -0,0 +1,103 @@
import { createMiddleware, createStart } from "@tanstack/react-start";
import { getCookie, setCookie } from "@tanstack/react-start/server";
import {
DEFAULT_ACCESS_TOKEN_COOKIE,
DEFAULT_REFRESH_TOKEN_COOKIE,
getSessionCookieOptions,
refreshSession,
} from "#dashboard/utils/auth/session";
const PUBLIC_PATHS = new Set(["/login", "/api/session/refresh"]);
const authMiddleware = createMiddleware({ type: "request" }).server(
async ({ next, request, pathname, handlerType }) => {
if (handlerType !== "router" || isStaticAsset(pathname)) {
return next();
}
const localeRedirect = getLocaleRedirect(request);
if (localeRedirect) {
return localeRedirect;
}
if (pathname.startsWith("/api/")) {
return next();
}
let accessToken = getCookie(DEFAULT_ACCESS_TOKEN_COOKIE);
const refreshToken = getCookie(DEFAULT_REFRESH_TOKEN_COOKIE);
if (!accessToken && refreshToken) {
const tokens = await refreshSession(refreshToken);
if (tokens) {
accessToken = tokens.accessToken;
setSessionCookies(tokens, request.url);
}
}
if (pathname === "/login" && accessToken) {
return Response.redirect(new URL("/dashboard", request.url));
}
if (!PUBLIC_PATHS.has(pathname) && !accessToken) {
const loginUrl = new URL("/login", request.url);
const returnTo = `${pathname}${new URL(request.url).search}`;
if (returnTo !== "/") {
loginUrl.searchParams.set("return_to", returnTo);
}
return Response.redirect(loginUrl);
}
const result = await next();
result.response.headers.set("X-Frame-Options", "DENY");
return result;
},
);
export const startInstance = createStart(() => ({
requestMiddleware: [authMiddleware],
}));
function getLocaleRedirect(request: Request) {
const url = new URL(request.url);
if (url.pathname !== "/en" && !url.pathname.startsWith("/en/")) {
return null;
}
const nextPath = url.pathname.replace(/^\/en\/?/, "/") || "/dashboard";
url.pathname = nextPath === "/" ? "/dashboard" : nextPath;
return Response.redirect(url);
}
function isStaticAsset(pathname: string) {
return (
pathname.startsWith("/assets/") ||
pathname.startsWith("/_build/") ||
pathname.startsWith("/__vite") ||
pathname === "/favicon.ico"
);
}
function setSessionCookies(
tokens: NonNullable<Awaited<ReturnType<typeof refreshSession>>>,
requestUrl: string,
) {
setCookie(
DEFAULT_ACCESS_TOKEN_COOKIE,
tokens.accessToken,
getSessionCookieOptions(tokens.accessTokenExpiresAt, requestUrl),
);
setCookie(
DEFAULT_REFRESH_TOKEN_COOKIE,
tokens.refreshToken,
getSessionCookieOptions(tokens.refreshTokenExpiresAt, requestUrl),
);
}
+6 -31
View File
@@ -2,45 +2,22 @@
import type { AppRouter } from "@basango/api/trpc/routers/_app";
import type { QueryClient } from "@tanstack/react-query";
import { QueryClientProvider } from "@tanstack/react-query";
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import { createTRPCContext } from "@trpc/tanstack-react-query";
import { useState } from "react";
import superjson from "superjson";
import { getClientAccessToken } from "#dashboard/utils/auth/client";
import { makeQueryClient } from "./query-client";
import { getPublicApiUrl } from "#dashboard/utils/environment";
export const { TRPCProvider, useTRPC } = createTRPCContext<AppRouter>();
let browserQueryClient: QueryClient;
function getQueryClient() {
if (typeof window === "undefined") {
// Server: always make a new query client
return makeQueryClient();
}
// Browser: make a new query client if we don't already have one
// This is very important, so we don't re-make a new client if React
// suspends during the initial render. This may not be needed if we
// have a suspense boundary BELOW the creation of the query client
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient;
}
export function TRPCReactProvider(
props: Readonly<{
children: React.ReactNode;
queryClient: QueryClient;
}>,
) {
// NOTE: Avoid useState when initializing the query client if you don't
// have a suspense boundary between this and the code that may
// suspend because React will throw away the client on the initial
// render if it suspends and there is no boundary
const queryClient = getQueryClient();
const [trpcClient] = useState(() =>
createTRPCClient<AppRouter>({
links: [
@@ -54,17 +31,15 @@ export function TRPCReactProvider(
: {};
},
transformer: superjson,
url: `${process.env.NEXT_PUBLIC_API_URL}/trpc`,
url: `${getPublicApiUrl()}/trpc`,
}),
],
}),
);
return (
<QueryClientProvider client={queryClient}>
<TRPCProvider queryClient={queryClient} trpcClient={trpcClient}>
{props.children}
</TRPCProvider>
</QueryClientProvider>
<TRPCProvider queryClient={props.queryClient} trpcClient={trpcClient}>
{props.children}
</TRPCProvider>
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { AppRouter } from "@basango/api/trpc/routers/_app";
import { DEFAULT_ACCESS_TOKEN_COOKIE } from "@basango/domain/constants";
import type { QueryClient } from "@tanstack/react-query";
import { createIsomorphicFn } from "@tanstack/react-start";
import { getCookie } from "@tanstack/react-start/server";
import { createTRPCClient, httpBatchLink, loggerLink } from "@trpc/client";
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
import superjson from "superjson";
import { getClientAccessToken } from "#dashboard/utils/auth/client";
import { getPublicApiUrl } from "#dashboard/utils/environment";
export function createTRPCOptions(queryClient: QueryClient) {
return createTRPCOptionsProxy<AppRouter>({
client: createTRPCClient<AppRouter>({
links: [
httpBatchLink({
async headers() {
const token = await getAccessToken();
return token ? { Authorization: `Bearer ${token}` } : {};
},
transformer: superjson,
url: `${getPublicApiUrl()}/trpc`,
}),
loggerLink({
enabled: (opts) =>
import.meta.env.DEV || (opts.direction === "down" && opts.result instanceof Error),
}),
],
}),
queryClient,
});
}
const getAccessToken = createIsomorphicFn()
.client(() => getClientAccessToken())
.server(() => getCookie(DEFAULT_ACCESS_TOKEN_COOKIE));
-81
View File
@@ -1,81 +0,0 @@
/** biome-ignore-all lint/suspicious/noExplicitAny: needed for tRPC type inference */
import "server-only";
import type { AppRouter } from "@basango/api/trpc/routers/_app";
import { HydrationBoundary, dehydrate } from "@tanstack/react-query";
import { createTRPCClient, httpBatchLink, loggerLink } from "@trpc/client";
import {
type TRPCInfiniteQueryOptions,
type TRPCQueryOptions,
createTRPCOptionsProxy,
} from "@trpc/tanstack-react-query";
import { cache } from "react";
import superjson from "superjson";
import { getServerAccessToken } from "#dashboard/utils/auth/server";
import { makeQueryClient } from "./query-client";
// IMPORTANT: Create a stable getter for the query client that
// will return the same client during the same request.
export const getQueryClient = cache(makeQueryClient);
export const trpc = createTRPCOptionsProxy<AppRouter>({
client: createTRPCClient({
links: [
httpBatchLink({
async headers() {
const token = await getServerAccessToken();
return token
? {
Authorization: `Bearer ${token}`,
// "x-user-country": await getCountryCode(),
// "x-user-locale": await getLocale(),
// "x-user-timezone": await getTimezone(),
}
: {};
},
transformer: superjson,
url: `${process.env.NEXT_PUBLIC_API_URL}/trpc`,
}),
loggerLink({
enabled: (opts) =>
process.env.NODE_ENV === "development" ||
(opts.direction === "down" && opts.result instanceof Error),
}),
],
}),
queryClient: getQueryClient,
});
export function HydrateClient(props: { children: React.ReactNode }) {
const queryClient = getQueryClient();
return <HydrationBoundary state={dehydrate(queryClient)}>{props.children}</HydrationBoundary>;
}
type AnyQueryOptions =
| ReturnType<TRPCQueryOptions<any>>
| ReturnType<TRPCInfiniteQueryOptions<any>>;
export function prefetch<T extends AnyQueryOptions>(queryOptions: T) {
const queryClient = getQueryClient();
const meta = queryOptions.queryKey[1];
if (!Array.isArray(meta) && meta?.type === "infinite") {
void queryClient.prefetchInfiniteQuery(queryOptions as any);
} else {
void queryClient.prefetchQuery(queryOptions as any);
}
}
export function batchPrefetch<T extends AnyQueryOptions>(queryOptionsArray: T[]) {
const queryClient = getQueryClient();
for (const queryOptions of queryOptionsArray) {
const meta = queryOptions.queryKey[1];
if (!Array.isArray(meta) && meta?.type === "infinite") {
void queryClient.prefetchInfiniteQuery(queryOptions as any);
} else {
void queryClient.prefetchQuery(queryOptions as any);
}
}
}
-15
View File
@@ -1,15 +0,0 @@
import "server-only";
import { cookies } from "next/headers";
import { DEFAULT_ACCESS_TOKEN_COOKIE, DEFAULT_REFRESH_TOKEN_COOKIE } from "#domain/constants";
export async function getServerAccessToken() {
const cookiesStore = await cookies();
return cookiesStore.get(DEFAULT_ACCESS_TOKEN_COOKIE)?.value;
}
export async function getServerRefreshToken() {
const cookiesStore = await cookies();
return cookiesStore.get(DEFAULT_REFRESH_TOKEN_COOKIE)?.value;
}
+44
View File
@@ -0,0 +1,44 @@
import type { AppRouter } from "@basango/api/trpc/routers/_app";
import {
DEFAULT_ACCESS_TOKEN_COOKIE,
DEFAULT_REFRESH_TOKEN_COOKIE,
} from "@basango/domain/constants";
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
import superjson from "superjson";
import { getPublicApiUrl } from "#dashboard/utils/environment";
export type SessionTokens = {
accessToken: string;
refreshToken: string;
accessTokenExpiresAt: string;
refreshTokenExpiresAt: string;
};
const client = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
transformer: superjson,
url: `${getPublicApiUrl()}/trpc`,
}),
],
});
export async function refreshSession(refreshToken: string): Promise<SessionTokens | null> {
try {
return await client.auth.refresh.mutate({ refreshToken });
} catch {
return null;
}
}
export function getSessionCookieOptions(expiresAt: string, requestUrl: string) {
return {
expires: new Date(expiresAt),
path: "/",
sameSite: "lax" as const,
secure: new URL(requestUrl).protocol === "https:",
};
}
export { DEFAULT_ACCESS_TOKEN_COOKIE, DEFAULT_REFRESH_TOKEN_COOKIE };
+18 -2
View File
@@ -1,6 +1,22 @@
export function getPublicApiUrl() {
return (
import.meta.env.VITE_PUBLIC_API_URL ??
process.env.VITE_PUBLIC_API_URL ??
"http://localhost:3080"
);
}
export function getPublicVersion() {
return import.meta.env.VITE_PUBLIC_VERSION ?? process.env.VITE_PUBLIC_VERSION ?? "0.0.0";
}
export function getUrl() {
if (process.env.NEXT_PUBLIC_URL) {
return process.env.NEXT_PUBLIC_URL;
if (import.meta.env.VITE_PUBLIC_URL) {
return import.meta.env.VITE_PUBLIC_URL;
}
if (process.env.VITE_PUBLIC_URL) {
return process.env.VITE_PUBLIC_URL;
}
if (process.env.VERCEL_TARGET_ENV === "preview") {
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+5 -8
View File
@@ -1,19 +1,16 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"noEmit": true,
"paths": {
"@basango/ui/*": ["../../packages/ui/src/*"],
"#api/*": ["../api/src/*"],
"#dashboard/*": ["./src/*"],
"#db/*": ["../../packages/db/src/*"],
"#domain/*": ["../../packages/domain/src/*"]
},
"plugins": [
{
"name": "next"
}
]
}
},
"exclude": ["node_modules"],
"extends": "@basango/tsconfig/nextjs.json",
"include": ["next-env.d.ts", "next.config.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"]
"extends": "@basango/tsconfig/base.json",
"include": ["vite.config.ts", "**/*.ts", "**/*.tsx"]
}
+22
View File
@@ -0,0 +1,22 @@
import tailwindcss from "@tailwindcss/vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { nitro } from "nitro/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
tanstackStart({
router: {
generatedRouteTree: "./routeTree.gen.ts",
routesDirectory: "./app",
},
}),
nitro(),
tailwindcss(),
viteReact(),
],
resolve: {
tsconfigPaths: true,
},
});
+7 -2
View File
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.6/schema.json",
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
"assist": {
"actions": {
"source": {
@@ -39,7 +39,12 @@
},
"files": {
"ignoreUnknown": true,
"includes": ["**", "!apps/mobile-legacy", "!apps/api-legacy"]
"includes": [
"**",
"!apps/mobile-legacy",
"!apps/api-legacy",
"!apps/dashboard/src/routeTree.gen.ts"
]
},
"formatter": {
"enabled": true,
+382 -53
View File
@@ -6,16 +6,16 @@
"name": "basango",
"devDependencies": {
"@basango/tsconfig": "workspace:*",
"@biomejs/biome": "^2.3.6",
"@commitlint/cli": "^20.1.0",
"@commitlint/config-conventional": "^20.0.0",
"@biomejs/biome": "^2.3.8",
"@commitlint/cli": "^20.2.0",
"@commitlint/config-conventional": "^20.2.0",
"@manypkg/cli": "^0.25.1",
"@types/bun": "^1.3.2",
"@types/bun": "^1.3.4",
"@types/node": "^24.10.1",
"commitizen": "^4.3.1",
"cz-conventional-changelog": "^3.3.0",
"husky": "^9.1.7",
"turbo": "^2.6.1",
"turbo": "^2.6.3",
"typescript": "^5.9.3",
},
},
@@ -72,17 +72,17 @@
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.8",
"@tanstack/react-router": "^1.170.15",
"@tanstack/react-router-with-query": "^1.130.17",
"@tanstack/react-start": "^1.168.25",
"@tanstack/react-table": "^8.21.3",
"@trpc/client": "^11.7.1",
"@trpc/react-query": "^11.7.1",
"@trpc/server": "^11.7.1",
"@trpc/tanstack-react-query": "^11.7.1",
"class-variance-authority": "^0.7.1",
"client-only": "^0.0.1",
"date-fns": "catalog:",
"lucide-react": "^0.554.0",
"next": "16.0.7",
"next-international": "^1.3.1",
"next-themes": "^0.4.6",
"nuqs": "^2.7.3",
"react": "catalog:",
@@ -90,7 +90,6 @@
"react-dom": "catalog:",
"react-hook-form": "^7.66.0",
"recharts": "^3.4.1",
"server-only": "^0.0.1",
"sonner": "^2.0.7",
"superjson": "^2.2.5",
"zod": "catalog:",
@@ -99,10 +98,14 @@
"devDependencies": {
"@basango/tsconfig": "workspace:*",
"@tailwindcss/postcss": "^4.1.17",
"@tailwindcss/vite": "^4.3.1",
"@types/bun": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@vitejs/plugin-react": "^6.0.2",
"nitro": "^3.0.260610-beta",
"typescript": "catalog:",
"vite": "^8.0.16",
},
},
"apps/mobile": {
@@ -275,7 +278,7 @@
"@asteasolutions/zod-to-openapi": ["@asteasolutions/zod-to-openapi@8.1.0", "", { "dependencies": { "openapi3-ts": "^4.1.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-tQFxVs05J/6QXXqIzj6rTRk3nj1HFs4pe+uThwE95jL5II2JfpVXkK+CqkO7aT0Do5AYqO6LDrKpleLUFXgY+g=="],
"@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="],
"@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="],
@@ -549,7 +552,11 @@
"@egjs/hammerjs": ["@egjs/hammerjs@2.0.17", "", { "dependencies": { "@types/hammerjs": "^2.0.36" } }, "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A=="],
"@emnapi/runtime": ["@emnapi/runtime@1.7.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q=="],
"@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="],
"@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
@@ -785,6 +792,8 @@
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="],
"@next/env": ["@next/env@16.0.7", "", {}, "sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.0.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LlDtCYOEj/rfSnEn/Idi+j1QKHxY9BJFmxx7108A6D8K0SB+bNgfYQATPk/4LqOl4C0Wo3LACg2ie6s7xqMpJg=="],
@@ -809,8 +818,18 @@
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="],
"@oozcitak/infra": ["@oozcitak/infra@2.0.2", "", { "dependencies": { "@oozcitak/util": "^10.0.0" } }, "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA=="],
"@oozcitak/url": ["@oozcitak/url@3.0.0", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/util": "^10.0.0" } }, "sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ=="],
"@oozcitak/util": ["@oozcitak/util@10.0.0", "", {}, "sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@oxc-project/types": ["@oxc-project/types@0.135.0", "", {}, "sha512-wR+xRdFkUBMvcAjBJ2q2kcZM6d+DKu2NgoOyxZgYwZdLhmiv6+rnO8PZ/P68kMiZtIKm+pW7zyEJ4kSOs0vo+Q=="],
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
@@ -949,6 +968,38 @@
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.10.1", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^10.2.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-/U17EXQ9Do9Yx4DlNGU6eVNfZvFJfYpUtRRdLf19PbPjdWBxNlxGZXywQZ1p1Nz8nMkWplTI7iD/23m07nolDA=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.1", "", { "os": "android", "cpu": "arm64" }, "sha512-BLf9Wak/gfwVb7NQTQW4wBgL3oAfPy7ArEkhwV543OVw/uY6B47z5xYsqPSZ9PDOorvURPinws6ThaFuNgGLgA=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rRZRPy/Ynb+Mxu0O6tfPldHeDgAn0sRij+IOUy6sFdUlv3hArGW/DloE3GfAxtqpOJuRNgF74Nr5gM4xBeU2jQ=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/MtefPxhKPyWWFM8L45OWiEqRf+eSU2Qv9ZAyTaoZOoGcoPKxbbhjTJO2/U2IThv0uDZ4NWHc3/oTsR6IEOtww=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-202K+cpIi1kx/Zn7AtxBi4LTXSY67Aszb2K9rNsuW7FeBeh0nqoNmYLOSZidV0p88VPBzMmTZcHAdPNo3kRYzQ=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.1", "", { "os": "linux", "cpu": "arm" }, "sha512-wl9NfeXNUwrXtUc063tddmZFUI6qiNs1CNOwni0OL4vC7MqVSYugra3ZgtDmtVy8e0DluJTENmzIv2BwqLzT4Q=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-at2EO4o7D/PJLC4Xik16bU4CcjQE2tSv1LfqMA0TRYQYQihRm3gZeDB8xaX28A9SFedibcAk5DeMCKt4REKG0A=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-5PUjZx366h9tkJTPJF5eibxOlK3sGoeRiBJLLjjEB5/kLDuhr6qB3LkhqLz1smXNgsX+pBhnbcJBrPE30HznAA=="],
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1WK84XPeio3tjP1sM/TMXiC0G1i1iq1qGZ71KfNQjEFLU1kwD+Cv5T8nGySg/JUFwLbaScu6ve9DmeXlmqpkFA=="],
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-1nS1X5z1uMJ369RU25hTpKCFvUwXZp12dIzlzk4S+UxCTcSVGsAE6tzkOSufv/7jnmAtK0ZlrsJxh2fGmsnVSw=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.1", "", { "os": "linux", "cpu": "x64" }, "sha512-NwX/wspnq4vYyMFsqbYvzums3ki/Tk8FZbMzMAovPDp3OfLeYKby/D+9osokadXuYEV3OvpeHlwnr/bG8QMixA=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.1", "", { "os": "linux", "cpu": "x64" }, "sha512-+n46LhDrJFQM+229y4oXtVpj1G50U/+XuHMlpnisFTEXhrg9f/YIjp/HymX+PVJjBEr7XHRs3CFLelV464pqwA=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.1", "", { "os": "none", "cpu": "arm64" }, "sha512-qGwEu47zOWYo7LdRHhCWTNhzwGtxXpdY6CERs8QEOqC0PXGGics/e3vHnyEUKt8xK6YkbZXFUCeklrpB6js8ag=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.1", "", { "dependencies": { "@emnapi/core": "1.11.0", "@emnapi/runtime": "1.11.0", "@napi-rs/wasm-runtime": "^1.1.5" }, "cpu": "none" }, "sha512-qczfgEH8u0wHGGOXtA7UMAybNKuQjjEXairyQaw4WzjiMztfbgatG1h4OKays/smhtwbWltpKCRGtVhU6h40Sg=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-4psXSh63mSbwJF+mB8/9yfUUEzBiHYcUjxa32EO9ZwKy0Ypwjcg4F10D8SvVXgd+isy2UUUjF9HJJnDu1T/4Gg=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.1", "", { "os": "win32", "cpu": "x64" }, "sha512-MUvC/HLXVjzkQkWiExdVTEEWf0py+GfWm8WKSZsekG3ih6a21iy0BHPF07X3JIf3ifoklZXTIaHTLPBgH1C3dw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
"@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="],
"@sinonjs/commons": ["@sinonjs/commons@3.0.1", "", { "dependencies": { "type-detect": "4.0.8" } }, "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ=="],
@@ -991,14 +1042,54 @@
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.17", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.17", "@tailwindcss/oxide": "4.1.17", "postcss": "^8.4.41", "tailwindcss": "4.1.17" } }, "sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.1", "", { "dependencies": { "@tailwindcss/node": "4.3.1", "@tailwindcss/oxide": "4.3.1", "tailwindcss": "4.3.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ=="],
"@tanstack/history": ["@tanstack/history@1.162.0", "", {}, "sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.8", "", {}, "sha512-4E0RP/0GJCxSNiRF2kAqE/LQkTJVlL/QNU7gIJSptaseV9HP6kOuA+N11y4bZKZxa3QopK3ZuewwutHx6DqDXQ=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.8", "", { "dependencies": { "@tanstack/query-core": "5.90.8" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-/3b9QGzkf4rE5/miL6tyhldQRlLXzMHcySOm/2Tm2OLEFE9P1ImkH0+OviDBSvyAvtAOJocar5xhd7vxdLi3aQ=="],
"@tanstack/react-router": ["@tanstack/react-router@1.170.15", "", { "dependencies": { "@tanstack/history": "1.162.0", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.171.13", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-GawYz7HEjj8rTUUDoT/SemDEVm63pZUO+2mOcXHY9Jl3EwMS5gFBnPu/2UvcrwRm1jN1k79fokc0d4aFmrLatg=="],
"@tanstack/react-router-with-query": ["@tanstack/react-router-with-query@1.130.17", "", { "peerDependencies": { "@tanstack/react-query": ">=5.49.2", "@tanstack/react-router": ">=1.43.2", "@tanstack/router-core": ">=1.114.7", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-TNaSocW20KuPwUojEm130DLWTr9M5hsSzxiu4QqS2jNCnrGLuDrwMHyP+6fq13lG3YuU4u9O1qajxfJIGomZCg=="],
"@tanstack/react-start": ["@tanstack/react-start@1.168.25", "", { "dependencies": { "@tanstack/react-router": "1.170.15", "@tanstack/react-start-client": "1.168.13", "@tanstack/react-start-rsc": "0.1.24", "@tanstack/react-start-server": "1.167.19", "@tanstack/router-utils": "1.162.2", "@tanstack/start-client-core": "1.170.12", "@tanstack/start-plugin-core": "1.171.17", "@tanstack/start-server-core": "1.169.14", "pathe": "^2.0.3" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "@vitejs/plugin-rsc": "*", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "@vitejs/plugin-rsc", "vite"] }, "sha512-aHlg9YTSeL12gWrYIHAEzoncPHc5JUbQ60Sc26OQ7J1zcsXqdKwdcqaApG4YV12S/keFdbndHjxaiYkUcJlx7Q=="],
"@tanstack/react-start-client": ["@tanstack/react-start-client@1.168.13", "", { "dependencies": { "@tanstack/react-router": "1.170.15", "@tanstack/router-core": "1.171.13", "@tanstack/start-client-core": "1.170.12" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-enr4hL0Fifqz7jO8Zy4CuEpunEfH1LbvMw/mRjG49j699Bo3CaR7mPDcgN/9tSSjjUT5ZDj9M6TiTp9cSgehww=="],
"@tanstack/react-start-rsc": ["@tanstack/react-start-rsc@0.1.24", "", { "dependencies": { "@tanstack/react-router": "1.170.15", "@tanstack/router-core": "1.171.13", "@tanstack/router-utils": "1.162.2", "@tanstack/start-client-core": "1.170.12", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-plugin-core": "1.171.17", "@tanstack/start-server-core": "1.169.14", "@tanstack/start-storage-context": "1.167.15", "pathe": "^2.0.3" }, "peerDependencies": { "@rspack/core": ">=2.0.0-0", "@vitejs/plugin-rsc": ">=0.5.20", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "react-server-dom-rspack": ">=0.0.2" }, "optionalPeers": ["@rspack/core", "@vitejs/plugin-rsc", "react-server-dom-rspack"] }, "sha512-8zBLV68t6byrbtIyKYNTCpcc7qFbb0kQiu0yFtFIvsi70fpBeG3VP8bmkN95/Cqpvz1lLio+E4JApRyV52MpxQ=="],
"@tanstack/react-start-server": ["@tanstack/react-start-server@1.167.19", "", { "dependencies": { "@tanstack/react-router": "1.170.15", "@tanstack/router-core": "1.171.13", "@tanstack/start-server-core": "1.169.14" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-+eMpAwDreQvCwgX45MdUHTUCF/Wad36+PwQafe6W5wa3qVkGyN3P131ShGyRwT/0WwKa5EVGdW1zFgwby8UNqA=="],
"@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="],
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
"@tanstack/router-core": ["@tanstack/router-core@1.171.13", "", { "dependencies": { "@tanstack/history": "1.162.0", "cookie-es": "^3.0.0", "seroval": "^1.5.4", "seroval-plugins": "^1.5.4" } }, "sha512-+NOwEj1kO/6IGmpHRIZHasYxYWpyBQGNIZAST9aNrk9Q3YlU9SgqVnl1pbLa9qAKfeNdXQIRve0RQb/0kyDeDA=="],
"@tanstack/router-generator": ["@tanstack/router-generator@1.167.17", "", { "dependencies": { "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.13", "@tanstack/router-utils": "1.162.2", "@tanstack/virtual-file-routes": "1.162.0", "jiti": "^2.7.0", "magic-string": "^0.30.21", "prettier": "^3.5.0", "zod": "^4.4.3" } }, "sha512-xtB9tB2Ws0tWR6Pi7nc3Qk9IYgoh1mQCKWjHqIl9tf6BNUpKoqniJoPAQ4+LGrK8FeZYU0o0p/qlZEyj9FAulA=="],
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.168.18", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.13", "@tanstack/router-generator": "1.167.17", "@tanstack/router-utils": "1.162.2", "chokidar": "^5.0.0", "unplugin": "^3.0.0", "zod": "^4.4.3" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2 || ^2.0.0", "@tanstack/react-router": "^1.170.15", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-MofS28/axfnfnhOD2RSgJEaU882aX5RsAzhGz5Vc4XhAmvCjy919u9JrNs4QsTWFbTD1P7IJ8WFlFVsrg0pStg=="],
"@tanstack/router-utils": ["@tanstack/router-utils@1.162.2", "", { "dependencies": { "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ=="],
"@tanstack/start-client-core": ["@tanstack/start-client-core@1.170.12", "", { "dependencies": { "@tanstack/router-core": "1.171.13", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-storage-context": "1.167.15", "seroval": "^1.5.4" } }, "sha512-gwtZRMPUIAxmDV2AIQUhC0kSW262SV7BkHXEgy5B1woHQdrdsELuGOdJwdweLxrjyefORxk+9MYGqDY0Cxn0bw=="],
"@tanstack/start-fn-stubs": ["@tanstack/start-fn-stubs@1.162.0", "", {}, "sha512-QWfUZ3Yo923tdQn38LyKMU8rcTw69zc+T4dAvgTWV4O56SqFRsGfS0lSWIMhJRwXIx/bvdi7nTUBDdZtTHtpTQ=="],
"@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.171.17", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.13", "@tanstack/router-generator": "1.167.17", "@tanstack/router-plugin": "1.168.18", "@tanstack/router-utils": "1.162.2", "@tanstack/start-server-core": "1.169.14", "exsolve": "^1.0.7", "lightningcss": "^1.32.0", "pathe": "^2.0.3", "picomatch": "^4.0.3", "seroval": "^1.5.4", "source-map": "^0.7.6", "srvx": "^0.11.9", "tinyglobby": "^0.2.15", "ufo": "^1.5.4", "vitefu": "^1.1.1", "xmlbuilder2": "^4.0.3", "zod": "^4.4.3" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "vite"] }, "sha512-ngKkp3wn/U3nyeqZl7KcMzjbgTbcypC5ES7O92JpA5/tz4PufFOf5l+eX3pY+4Z6jE6Jb6ekQgnryG7XMjpK7Q=="],
"@tanstack/start-server-core": ["@tanstack/start-server-core@1.169.14", "", { "dependencies": { "@tanstack/history": "1.162.0", "@tanstack/router-core": "1.171.13", "@tanstack/start-client-core": "1.170.12", "@tanstack/start-storage-context": "1.167.15", "fetchdts": "^0.1.6", "h3-v2": "npm:h3@2.0.1-rc.20", "seroval": "^1.5.4" } }, "sha512-cSCTNbKARrkddPOfavF/soRFDxH+b+v3m4TeW6AvEy419R3E0ZsoZAm5UI6uNR1y4UU9WTOmaxLQ4nzIZPKmXg=="],
"@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.167.15", "", { "dependencies": { "@tanstack/router-core": "1.171.13" } }, "sha512-Jy0q4vdG6pv76N92+X+ag3fuOV2zINQagYyMN1/es7tPI1vzpKECIU8AqHqzI6ahkwaph7XDvmfUkiLJ3i4LOA=="],
"@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="],
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="],
"@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="],
"@trpc/client": ["@trpc/client@11.7.1", "", { "peerDependencies": { "@trpc/server": "11.7.1", "typescript": ">=5.7.2" } }, "sha512-uOnAjElKI892/U6aQMcBHYs3x7mme3Cvv1F87ytBL56rBvs7+DyK7r43zgaXKf13+GtPEI6ex5xjVUfyDW8XcQ=="],
@@ -1021,6 +1112,8 @@
"@turbo/workspaces": ["@turbo/workspaces@2.6.0", "", { "dependencies": { "commander": "^10.0.0", "execa": "5.1.1", "fast-glob": "^3.2.12", "fs-extra": "^10.1.0", "gradient-string": "^2.0.0", "inquirer": "^8.0.0", "js-yaml": "^4.1.0", "ora": "4.1.1", "picocolors": "1.0.1", "semver": "7.6.2", "update-check": "^1.5.4" }, "bin": { "workspaces": "dist/cli.js" } }, "sha512-Kh6KBcHgEUy+dPzePzGxhxDVY4QsxD7PKAJM3srbHESOILTh2LahU7IhwDFUvr1SzCQj2y0DSFUQ4h+Vu6fYKQ=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
@@ -1099,6 +1192,8 @@
"@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.2", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.0" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
"JSONStream": ["JSONStream@1.3.5", "", { "dependencies": { "jsonparse": "^1.2.0", "through": ">=2.2.7 <3" }, "bin": { "JSONStream": "./bin.js" } }, "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ=="],
@@ -1127,6 +1222,8 @@
"ansi-styles": ["ansi-styles@3.2.1", "", { "dependencies": { "color-convert": "^1.9.0" } }, "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA=="],
"ansis": ["ansis@4.3.1", "", {}, "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA=="],
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
@@ -1153,6 +1250,8 @@
"aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="],
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],
"babel-jest": ["babel-jest@29.7.0", "", { "dependencies": { "@jest/transform": "^29.7.0", "@types/babel__core": "^7.1.14", "babel-plugin-istanbul": "^6.1.1", "babel-preset-jest": "^29.6.3", "chalk": "^4.0.0", "graceful-fs": "^4.2.9", "slash": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.8.0" } }, "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg=="],
"babel-plugin-istanbul": ["babel-plugin-istanbul@6.1.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@istanbuljs/load-nyc-config": "^1.0.0", "@istanbuljs/schema": "^0.1.2", "istanbul-lib-instrument": "^5.0.4", "test-exclude": "^6.0.0" } }, "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA=="],
@@ -1237,6 +1336,8 @@
"chardet": ["chardet@0.7.0", "", {}, "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="],
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
"chrome-launcher": ["chrome-launcher@0.15.2", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0" }, "bin": { "print-chrome-path": "bin/print-chrome-path.js" } }, "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ=="],
@@ -1293,6 +1394,8 @@
"connect": ["connect@3.7.0", "", { "dependencies": { "debug": "2.6.9", "finalhandler": "1.1.2", "parseurl": "~1.3.3", "utils-merge": "1.0.1" } }, "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ=="],
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"constant-case": ["constant-case@2.0.0", "", { "dependencies": { "snake-case": "^2.1.0", "upper-case": "^1.1.1" } }, "sha512-eS0N9WwmjTqrOmR3o83F5vW8Z+9R1HnVz3xmzT2PMFug9ly+Au/fxRWlEBSb6LcZwspSsEn9Xs1uw9YgzAg1EQ=="],
"conventional-changelog-angular": ["conventional-changelog-angular@7.0.0", "", { "dependencies": { "compare-func": "^2.0.0" } }, "sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ=="],
@@ -1305,6 +1408,8 @@
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="],
"copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="],
"core-js-compat": ["core-js-compat@3.46.0", "", { "dependencies": { "browserslist": "^4.26.3" } }, "sha512-p9hObIIEENxSV8xIu+V68JjSeARg6UVMG5mR+JEUguG3sI6MsiS1njz2jHmyJDvA+8jX/sytkBHup6kxhM9law=="],
@@ -1323,6 +1428,8 @@
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"crossws": ["crossws@0.4.6", "", { "peerDependencies": { "srvx": ">=0.11.5" }, "optionalPeers": ["srvx"] }, "sha512-/Wxe9Z007EbJ496j88nToZEvyPZ8PY/wjZJ18Agh/GCA9cYHyLbxtrpdFlFzAw3TV20F0SUYGl0g6PzChbwUrg=="],
"crypto-random-string": ["crypto-random-string@2.0.0", "", {}, "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA=="],
"css-in-js-utils": ["css-in-js-utils@3.1.0", "", { "dependencies": { "hyphenate-style-name": "^1.0.3" } }, "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A=="],
@@ -1367,6 +1474,8 @@
"dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="],
"db0": ["db0@0.3.4", "", { "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", "sqlite3": "*" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "better-sqlite3", "drizzle-orm", "mysql2", "sqlite3"] }, "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
@@ -1401,7 +1510,7 @@
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"diff": ["diff@4.0.2", "", {}, "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A=="],
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="],
@@ -1451,6 +1560,8 @@
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"env-runner": ["env-runner@0.1.14", "", { "dependencies": { "crossws": "^0.4.5", "exsolve": "^1.0.8", "httpxy": "^0.5.3", "srvx": "^0.11.16" }, "peerDependencies": { "@netlify/runtime": "^4.1.23", "@vercel/queue": "^0.2.0", "miniflare": "^4.20260515.0", "wrangler": "^4.0.0" }, "optionalPeers": ["@netlify/runtime", "@vercel/queue", "miniflare", "wrangler"], "bin": { "env-runner": "dist/cli.mjs" } }, "sha512-qdk5mmgFsd+zPg3r1bkZ+IbvpfUfypyDvNhMGypSMRpz7kOa/kI6SpW8fgyukuEM4Lo24M65r+1Ne0DtT7vFBA=="],
"error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
"error-stack-parser": ["error-stack-parser@2.1.4", "", { "dependencies": { "stackframe": "^1.3.4" } }, "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ=="],
@@ -1527,6 +1638,8 @@
"exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
"external-editor": ["external-editor@3.1.0", "", { "dependencies": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", "tmp": "^0.0.33" } }, "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew=="],
"fast-copy": ["fast-copy@3.0.2", "", {}, "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ=="],
@@ -1551,6 +1664,8 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fetchdts": ["fetchdts@0.1.7", "", {}, "sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA=="],
"figures": ["figures@3.2.0", "", { "dependencies": { "escape-string-regexp": "^1.0.5" } }, "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
@@ -1623,6 +1738,10 @@
"gradient-string": ["gradient-string@2.0.2", "", { "dependencies": { "chalk": "^4.1.2", "tinygradient": "^1.1.5" } }, "sha512-rEDCuqUQ4tbD78TpzsMtt5OIf0cBCSDWSJtUDaF6JsAh+k0v9r++NzxNEG87oDZx9ZwGhD8DaezR2L/yrw0Jdw=="],
"h3": ["h3@2.0.1-rc.22", "", { "dependencies": { "rou3": "^0.8.1", "srvx": "^0.11.15" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"], "bin": { "h3": "bin/h3.mjs" } }, "sha512-Esv0DMIuPkCTSWCA0vO73vcTqwzH1wjSrAO1TXNu/K3up1sZHa9EKMapbmxCDYBeymC3fVTk4qxp7ogQWQ+KgA=="],
"h3-v2": ["h3@2.0.1-rc.20", "", { "dependencies": { "rou3": "^0.8.1", "srvx": "^0.11.13" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"], "bin": { "h3": "bin/h3.mjs" } }, "sha512-28ljodXuUp0fZovdiSRq4G9OgrxCztrJe5VdYzXAB7ueRvI7pIUqLU14Xi3XqdYJ/khXjfpUOOD2EQa6CmBgsg=="],
"handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="],
"has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="],
@@ -1647,6 +1766,8 @@
"hono-rate-limiter": ["hono-rate-limiter@0.4.2", "", { "peerDependencies": { "hono": "^4.1.1" } }, "sha512-AAtFqgADyrmbDijcRTT/HJfwqfvhalya2Zo+MgfdrMPas3zSMD8SU03cv+ZsYwRU1swv7zgVt0shwN059yzhjw=="],
"hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="],
"hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="],
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
@@ -1655,6 +1776,8 @@
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"httpxy": ["httpxy@0.5.3", "", {}, "sha512-SMS9V6Sn7VWaS11lYhoAr0ceoaiolTWf4jYdJn0NJhCdKMu9R2H9Fh0LBDWBHQF6HRLI1PmaePYsjanSpE5PEw=="],
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
"husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
@@ -1689,8 +1812,6 @@
"inquirer": ["inquirer@8.2.5", "", { "dependencies": { "ansi-escapes": "^4.2.1", "chalk": "^4.1.1", "cli-cursor": "^3.1.0", "cli-width": "^3.0.0", "external-editor": "^3.0.3", "figures": "^3.0.0", "lodash": "^4.17.21", "mute-stream": "0.0.8", "ora": "^5.4.1", "run-async": "^2.4.0", "rxjs": "^7.5.5", "string-width": "^4.1.0", "strip-ansi": "^6.0.0", "through": "^2.3.6", "wrap-ansi": "^7.0.0" } }, "sha512-QAgPDQMEgrDssk1XiwwHoOGYF9BAbUcc1+j+FhEvaOt8/cKRqyLn0U5qA6F74fGhTMGxf92pOvPBeh29jQJDTQ=="],
"international-types": ["international-types@0.8.1", "", {}, "sha512-tajBCAHo4I0LIFlmQ9ZWfjMWVyRffzuvfbXCd6ssFt5u1Zw15DN0UBpVTItXdNa1ls+cpQt3Yw8+TxsfGF8JcA=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="],
@@ -1743,6 +1864,8 @@
"isbinaryfile": ["isbinaryfile@4.0.10", "", {}, "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw=="],
"isbot": ["isbot@5.1.43", "", {}, "sha512-drJhFmibra4LO6Wd7D3Oi6UICRK9244vSZkmxzhlZP0TTdwCA2ueK4PEkUkzPYeuqug9+cqqdWPgihjk5+83Cg=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
@@ -1809,29 +1932,29 @@
"lighthouse-logger": ["lighthouse-logger@1.4.2", "", { "dependencies": { "debug": "^2.6.9", "marky": "^1.2.2" } }, "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g=="],
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
@@ -1983,10 +2106,12 @@
"next": ["next@16.0.7", "", { "dependencies": { "@next/env": "16.0.7", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.0.7", "@next/swc-darwin-x64": "16.0.7", "@next/swc-linux-arm64-gnu": "16.0.7", "@next/swc-linux-arm64-musl": "16.0.7", "@next/swc-linux-x64-gnu": "16.0.7", "@next/swc-linux-x64-musl": "16.0.7", "@next/swc-win32-arm64-msvc": "16.0.7", "@next/swc-win32-x64-msvc": "16.0.7", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A=="],
"next-international": ["next-international@1.3.1", "", { "dependencies": { "client-only": "^0.0.1", "international-types": "^0.8.1", "server-only": "^0.0.1" } }, "sha512-ydU9jQe+4MohMWltbZae/yuWeKhmp0QKQqJNNi8WCCMwrly03qfMAHw/tWbT2qgAlG++CxF5jMXmGQZgOHeVOw=="],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
"nf3": ["nf3@0.3.17", "", {}, "sha512-N9zEWySuJFw+gR0lhS5863YsvNeudOdqRyFvNb+jMXbeTJOdrjDqkCpDginIZfUm0LzT1t1nCRiDeqQm/8kirQ=="],
"nitro": ["nitro@3.0.260610-beta", "", { "dependencies": { "consola": "^3.4.2", "crossws": "^0.4.6", "db0": "^0.3.4", "env-runner": "^0.1.12", "h3": "2.0.1-rc.22", "hookable": "^6.1.1", "nf3": "^0.3.17", "ocache": "^0.1.5", "ofetch": "2.0.0-alpha.3", "ohash": "^2.0.11", "rolldown": "^1.1.0", "srvx": "^0.11.16", "unenv": "2.0.0-rc.24", "unstorage": "2.0.0-alpha.7" }, "peerDependencies": { "@vercel/queue": "^0.3.0", "dotenv": "*", "giget": "*", "jiti": "^2.7.0", "rollup": "^4.61.1", "vite": "^7 || ^8", "xml2js": "^0.6.2", "zephyr-agent": "^0.2.0" }, "optionalPeers": ["@vercel/queue", "dotenv", "giget", "jiti", "rollup", "vite", "xml2js", "zephyr-agent"], "bin": { "nitro": "dist/cli/index.mjs" } }, "sha512-KPb4L5yaF/Rx/xoGMpgHRJvZhbhGiqbRKOwwPLCH9jKTKTsEUHLjnJas85AeCzaswqa8Wi52eQBtRsODC4PS0Q=="],
"no-case": ["no-case@2.3.2", "", { "dependencies": { "lower-case": "^1.1.1" } }, "sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ=="],
"node-abort-controller": ["node-abort-controller@3.1.1", "", {}, "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="],
@@ -2025,6 +2150,12 @@
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"ocache": ["ocache@0.1.5", "", { "dependencies": { "ohash": "^2.0.11" } }, "sha512-kNNnkkVQup/QDvmTz8Q84wc2ntiyoVHDxa6eHWKt5qdGAmFRBIxy83rxgCYEjW0x06UJ9E3P6VgM2yY4rOBH4w=="],
"ofetch": ["ofetch@2.0.0-alpha.3", "", {}, "sha512-zpYTCs2byOuft65vI3z43Dd6iSdFbOZZLb9/d21aCpx2rGastVU9dOCv0lu4ykc1Ur1anAYjDi3SUvR0vq50JA=="],
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
@@ -2089,6 +2220,8 @@
"path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"pg": ["pg@8.16.3", "", { "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", "pg-protocol": "^1.10.3", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.2.7" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw=="],
"pg-cloudflare": ["pg-cloudflare@1.2.7", "", {}, "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg=="],
@@ -2107,7 +2240,7 @@
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@3.0.1", "", {}, "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"pino": ["pino@10.1.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w=="],
@@ -2135,6 +2268,8 @@
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
"prettier": ["prettier@3.8.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q=="],
"pretty-bytes": ["pretty-bytes@5.6.0", "", {}, "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg=="],
"pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="],
@@ -2219,6 +2354,8 @@
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
"recharts": ["recharts@3.4.1", "", { "dependencies": { "@reduxjs/toolkit": "1.x.x || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-35kYg6JoOgwq8sE4rhYkVWwa6aAIgOtT+Ob0gitnShjwUwZmhrmy7Jco/5kJNF4PnLXgt9Hwq+geEMS+WrjU1g=="],
@@ -2275,6 +2412,10 @@
"rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],
"rolldown": ["rolldown@1.1.1", "", { "dependencies": { "@oxc-project/types": "=0.135.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.1", "@rolldown/binding-darwin-arm64": "1.1.1", "@rolldown/binding-darwin-x64": "1.1.1", "@rolldown/binding-freebsd-x64": "1.1.1", "@rolldown/binding-linux-arm-gnueabihf": "1.1.1", "@rolldown/binding-linux-arm64-gnu": "1.1.1", "@rolldown/binding-linux-arm64-musl": "1.1.1", "@rolldown/binding-linux-ppc64-gnu": "1.1.1", "@rolldown/binding-linux-s390x-gnu": "1.1.1", "@rolldown/binding-linux-x64-gnu": "1.1.1", "@rolldown/binding-linux-x64-musl": "1.1.1", "@rolldown/binding-openharmony-arm64": "1.1.1", "@rolldown/binding-wasm32-wasi": "1.1.1", "@rolldown/binding-win32-arm64-msvc": "1.1.1", "@rolldown/binding-win32-x64-msvc": "1.1.1" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-IN750c0p+s3jqJIsFLRZrQazmbAB1kkQDTtQjSt/gbS2ywLhlv4R5Shazer0FZKmuo/BsO3/w2UoYnUjuOZqHg=="],
"rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="],
"run-async": ["run-async@2.4.1", "", {}, "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
@@ -2305,6 +2446,10 @@
"serialize-error": ["serialize-error@2.1.0", "", {}, "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw=="],
"seroval": ["seroval@1.5.4", "", {}, "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw=="],
"seroval-plugins": ["seroval-plugins@1.5.4", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw=="],
"serve-static": ["serve-static@1.16.2", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "0.19.0" } }, "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw=="],
"server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="],
@@ -2351,7 +2496,7 @@
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="],
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
@@ -2365,6 +2510,8 @@
"sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="],
"srvx": ["srvx@0.11.16", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-bp07zRuycfTY43IjAvvTFnmnJi8ikW0VFiHwOhhYcVW/L4xQ1XY4PAd4Nuum1rsA17C39zL7x+CDhrn5AL32Rw=="],
"stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
"stackframe": ["stackframe@1.3.4", "", {}, "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="],
@@ -2415,7 +2562,7 @@
"tailwind-merge": ["tailwind-merge@3.3.1", "", {}, "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g=="],
"tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="],
"tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
@@ -2449,7 +2596,7 @@
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"tinygradient": ["tinygradient@1.1.5", "", { "dependencies": { "@types/tinycolor2": "^1.4.0", "tinycolor2": "^1.0.0" } }, "sha512-8nIfc2vgQ4TeLnk2lFj4tRLvvJwEfQuabdsmvDdQPT0xlk9TaNtpGd6nNRxXoK6vQhN6RSzj+Cnp5tTQmpxmbw=="],
@@ -2497,12 +2644,16 @@
"ua-parser-js": ["ua-parser-js@1.0.41", "", { "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug=="],
"ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
"uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="],
"undici": ["undici@6.22.0", "", {}, "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
"unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="],
"unicode-match-property-ecmascript": ["unicode-match-property-ecmascript@2.0.0", "", { "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" } }, "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q=="],
@@ -2519,6 +2670,10 @@
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="],
"unstorage": ["unstorage@2.0.0-alpha.7", "", { "peerDependencies": { "@azure/app-configuration": "^1.11.0", "@azure/cosmos": "^4.9.1", "@azure/data-tables": "^13.3.2", "@azure/identity": "^4.13.0", "@azure/keyvault-secrets": "^4.10.0", "@azure/storage-blob": "^12.31.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.13.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.36.2", "@vercel/blob": ">=0.27.3", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "chokidar": "^4 || ^5", "db0": ">=0.3.4", "idb-keyval": "^6.2.2", "ioredis": "^5.9.3", "lru-cache": "^11.2.6", "mongodb": "^6 || ^7", "ofetch": "*", "uploadthing": "^7.7.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "chokidar", "db0", "idb-keyval", "ioredis", "lru-cache", "mongodb", "ofetch", "uploadthing"] }, "sha512-ELPztchk2zgFJnakyodVY3vJWGW9jy//keJ32IOJVGUMyaPydwcA1FtVvWqT0TNRch9H+cMNEGllfVFfScImog=="],
"update-browserslist-db": ["update-browserslist-db@1.1.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A=="],
"update-check": ["update-check@1.5.4", "", { "dependencies": { "registry-auth-token": "3.3.2", "registry-url": "3.1.0" } }, "sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ=="],
@@ -2551,6 +2706,10 @@
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
"vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="],
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
"vlq": ["vlq@1.0.1", "", {}, "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w=="],
"walker": ["walker@1.0.8", "", { "dependencies": { "makeerror": "1.0.12" } }, "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ=="],
@@ -2561,6 +2720,8 @@
"webidl-conversions": ["webidl-conversions@5.0.0", "", {}, "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
@@ -2589,7 +2750,9 @@
"xml2js": ["xml2js@0.6.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w=="],
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"xmlbuilder2": ["xmlbuilder2@4.0.3", "", { "dependencies": { "@oozcitak/dom": "^2.0.2", "@oozcitak/infra": "^2.0.2", "@oozcitak/util": "^10.0.0", "js-yaml": "^4.1.1" } }, "sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA=="],
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
@@ -2607,7 +2770,7 @@
"yocto-queue": ["yocto-queue@1.2.1", "", {}, "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg=="],
"zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="],
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"zod-openapi": ["zod-openapi@5.4.3", "", { "peerDependencies": { "zod": "^3.25.74 || ^4.0.0" } }, "sha512-6kJ/gJdvHZtuxjYHoMtkl2PixCwRuZ/s79dVkEr7arHvZGXfx7Cvh53X3HfJ5h9FzGelXOXlnyjwfX0sKEPByw=="],
@@ -2615,8 +2778,6 @@
"zustand": ["zustand@5.0.8", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw=="],
"@babel/core/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
@@ -2629,12 +2790,6 @@
"@babel/plugin-transform-runtime/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/template/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@babel/traverse--for-generate-function-map/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@commitlint/format/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"@commitlint/load/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
@@ -2653,8 +2808,12 @@
"@expo/cli/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="],
"@expo/cli/picomatch": ["picomatch@3.0.1", "", {}, "sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag=="],
"@expo/cli/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"@expo/config/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="],
"@expo/config/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
"@expo/config-plugins/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@@ -2677,6 +2836,8 @@
"@expo/image-utils/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"@expo/json-file/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="],
"@expo/mcp-tunnel/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"@expo/mcp-tunnel/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
@@ -2685,22 +2846,28 @@
"@expo/metro/metro-source-map": ["metro-source-map@0.83.2", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.83.2", "nullthrows": "^1.1.1", "ob1": "0.83.2", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-5FL/6BSQvshIKjXOennt9upFngq2lFvDakZn5LfauIVq8+L4sxXewIlSTcxAtzbtjAIaXeOSVMtCJ5DdfCt9AA=="],
"@expo/metro-config/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"@expo/metro-config/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"@expo/metro-config/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="],
"@expo/metro-config/lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"@expo/metro-config/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="],
"@expo/package-manager/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"@expo/package-manager/ora": ["ora@3.4.0", "", { "dependencies": { "chalk": "^2.4.2", "cli-cursor": "^2.1.0", "cli-spinners": "^2.0.0", "log-symbols": "^2.2.0", "strip-ansi": "^5.2.0", "wcwidth": "^1.0.1" } }, "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg=="],
"@expo/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
"@expo/xcpretty/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="],
"@expo/xcpretty/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"@expo/xcpretty/find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.7.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
@@ -2723,6 +2890,8 @@
"@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"@manypkg/tools/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"@radix-ui/react-alert-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-alert-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
@@ -2817,6 +2986,10 @@
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@tailwindcss/node/lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"@tailwindcss/node/tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-pJdKGq/1iquWYtv1RRSljZklxHCOCAJFJrImO5ZLKPJVJlVUcs8yFwNQlqS0Lo8xT1VAXXTCZocF9n26FWEKsw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q=="],
@@ -2829,6 +3002,20 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@tailwindcss/postcss/tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="],
"@tailwindcss/vite/@tailwindcss/node": ["@tailwindcss/node@4.3.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.1" } }, "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A=="],
"@tailwindcss/vite/@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.1", "@tailwindcss/oxide-darwin-arm64": "4.3.1", "@tailwindcss/oxide-darwin-x64": "4.3.1", "@tailwindcss/oxide-freebsd-x64": "4.3.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", "@tailwindcss/oxide-linux-x64-musl": "4.3.1", "@tailwindcss/oxide-wasm32-wasi": "4.3.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" } }, "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA=="],
"@tanstack/router-generator/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"@tanstack/router-utils/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"@tanstack/start-plugin-core/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"@tanstack/start-plugin-core/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"@turbo/gen/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
"@turbo/gen/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
@@ -2953,8 +3140,6 @@
"jest-haste-map/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
"jest-message-util/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"jest-message-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"jest-mock/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
@@ -2977,8 +3162,6 @@
"log-symbols/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"metro/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"metro/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"metro/ci-info": ["ci-info@2.0.0", "", {}, "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ=="],
@@ -2991,12 +3174,18 @@
"metro/metro-symbolicate": ["metro-symbolicate@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.83.2", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-KoU9BLwxxED6n33KYuQQuc5bXkIxF3fSwlc3ouxrrdLWwhu64muYZNQrukkWzhVKRNFIXW7X2iM8JXpi2heIPw=="],
"metro/source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="],
"metro/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
"metro-babel-transformer/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="],
"metro-config/metro-runtime": ["metro-runtime@0.83.2", "", { "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" } }, "sha512-nnsPtgRvFbNKwemqs0FuyFDzXLl+ezuFsUXDbX8o0SXOfsOPijqiQrf3kuafO1Zx1aUWf4NOrKJMAQP5EEHg9A=="],
"metro-source-map/source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="],
"metro-symbolicate/source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="],
"metro-transform-worker/metro-source-map": ["metro-source-map@0.83.2", "", { "dependencies": { "@babel/traverse": "^7.25.3", "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", "@babel/types": "^7.25.2", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-symbolicate": "0.83.2", "nullthrows": "^1.1.1", "ob1": "0.83.2", "source-map": "^0.5.6", "vlq": "^1.0.0" } }, "sha512-5FL/6BSQvshIKjXOennt9upFngq2lFvDakZn5LfauIVq8+L4sxXewIlSTcxAtzbtjAIaXeOSVMtCJ5DdfCt9AA=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
@@ -3015,14 +3204,14 @@
"p-locate/p-limit": ["p-limit@4.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ=="],
"parse-json/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"pino-pretty/minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"pino-pretty/strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
"plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
"pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
"pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
@@ -3073,14 +3262,20 @@
"test-exclude/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"ts-node/arg": ["arg@4.1.3", "", {}, "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="],
"ts-node/diff": ["diff@4.0.2", "", {}, "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A=="],
"unplugin/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"update-check/registry-auth-token": ["registry-auth-token@3.3.2", "", { "dependencies": { "rc": "^1.1.6", "safe-buffer": "^5.0.1" } }, "sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ=="],
"update-check/registry-url": ["registry-url@3.1.0", "", { "dependencies": { "rc": "^1.0.1" } }, "sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA=="],
"vite/postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
"vite/rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="],
"whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
@@ -3089,7 +3284,7 @@
"xcode/uuid": ["uuid@7.0.3", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg=="],
"xml2js/xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"xmlbuilder2/js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="],
"zod-to-json-schema/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
@@ -3177,10 +3372,34 @@
"@expo/metro-config/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"@expo/metro-config/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
"@expo/metro-config/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
"@expo/metro-config/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
"@expo/metro-config/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
"@expo/metro-config/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
"@expo/metro-config/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
"@expo/metro-config/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
"@expo/metro-config/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
"@expo/metro-config/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
"@expo/metro-config/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
"@expo/metro-config/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
"@expo/metro/metro-source-map/metro-symbolicate": ["metro-symbolicate@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", "metro-source-map": "0.83.2", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "bin": { "metro-symbolicate": "src/index.js" } }, "sha512-KoU9BLwxxED6n33KYuQQuc5bXkIxF3fSwlc3ouxrrdLWwhu64muYZNQrukkWzhVKRNFIXW7X2iM8JXpi2heIPw=="],
"@expo/metro/metro-source-map/ob1": ["ob1@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-XlK3w4M+dwd1g1gvHzVbxiXEbUllRONEgcF2uEO0zm4nxa0eKlh41c6N65q1xbiDOeKKda1tvNOAD33fNjyvCg=="],
"@expo/metro/metro-source-map/source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="],
"@expo/package-manager/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"@expo/package-manager/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
@@ -3221,6 +3440,8 @@
"@jest/types/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"@manypkg/tools/tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-checkbox/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
@@ -3253,6 +3474,58 @@
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
"@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
"@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
"@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
"@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
"@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
"@tailwindcss/vite/@tailwindcss/node/enhanced-resolve": ["enhanced-resolve@5.21.6", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ=="],
"@tailwindcss/vite/@tailwindcss/node/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.1", "", { "os": "android", "cpu": "arm64" }, "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ=="],
"@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA=="],
"@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg=="],
"@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g=="],
"@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg=="],
"@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ=="],
"@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA=="],
"@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg=="],
"@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ=="],
"@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.1", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA=="],
"@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg=="],
"@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.1", "", { "os": "win32", "cpu": "x64" }, "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA=="],
"@tanstack/router-utils/tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"@turbo/workspaces/ora/chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="],
"@turbo/workspaces/ora/log-symbols": ["log-symbols@3.0.0", "", { "dependencies": { "chalk": "^2.4.2" } }, "sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ=="],
@@ -3321,6 +3594,8 @@
"metro-transform-worker/metro-source-map/ob1": ["ob1@0.83.2", "", { "dependencies": { "flow-enums-runtime": "^0.0.6" } }, "sha512-XlK3w4M+dwd1g1gvHzVbxiXEbUllRONEgcF2uEO0zm4nxa0eKlh41c6N65q1xbiDOeKKda1tvNOAD33fNjyvCg=="],
"metro-transform-worker/metro-source-map/source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="],
"metro/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"metro/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
@@ -3345,6 +3620,40 @@
"test-exclude/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"vite/postcss/nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
"vite/rolldown/@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="],
"vite/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="],
"vite/rolldown/@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="],
"vite/rolldown/@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg=="],
"vite/rolldown/@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g=="],
"vite/rolldown/@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw=="],
"vite/rolldown/@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw=="],
"vite/rolldown/@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q=="],
"vite/rolldown/@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg=="],
"vite/rolldown/@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg=="],
"vite/rolldown/@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg=="],
"vite/rolldown/@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow=="],
"vite/rolldown/@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg=="],
"vite/rolldown/@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.3", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg=="],
"vite/rolldown/@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g=="],
"vite/rolldown/@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA=="],
"@expo/cli/chalk/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"@expo/cli/ora/chalk/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="],
@@ -3383,6 +3692,20 @@
"@jest/types/chalk/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"@tailwindcss/vite/@tailwindcss/node/enhanced-resolve/tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="],
"@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="],
"@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="],
"@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
"@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@turbo/workspaces/ora/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"@turbo/workspaces/ora/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
@@ -3417,6 +3740,10 @@
"serve-static/send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"vite/rolldown/@rolldown/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"vite/rolldown/@rolldown/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
"@expo/cli/ora/cli-cursor/restore-cursor/onetime": ["onetime@2.0.1", "", { "dependencies": { "mimic-fn": "^1.0.0" } }, "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ=="],
"@expo/package-manager/ora/cli-cursor/restore-cursor/onetime": ["onetime@2.0.1", "", { "dependencies": { "mimic-fn": "^1.0.0" } }, "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ=="],
@@ -3431,6 +3758,8 @@
"node-plop/inquirer/chalk/supports-color/has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"vite/rolldown/@rolldown/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@expo/cli/ora/cli-cursor/restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="],
"@expo/package-manager/ora/cli-cursor/restore-cursor/onetime/mimic-fn": ["mimic-fn@1.2.0", "", {}, "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="],
+1 -1
View File
@@ -10,7 +10,7 @@ services:
- "3306:3306"
volumes:
- ./var/volumes/mariadb:/var/lib/mysql:rw
- ./var/volumes/api-legacy-var:/var/www/var
- ./var/volumes/backups:/var/www/var
networks:
- basango_network
+3
View File
@@ -44,6 +44,9 @@
"private": true,
"scripts": {
"build": "turbo run build",
"build:crawler": "bun run --cwd apps/crawler build:binary",
"build:crawler:arm64": "bun run --cwd apps/crawler build:binary:arm64",
"build:crawler:x64": "bun run --cwd apps/crawler build:binary:x64",
"build:dashboard": "turbo build --filter=@basango/dashboard",
"clean": "git clean -xdf node_modules",
"clean:workspaces": "turbo run clean",

Some files were not shown because too many files have changed in this diff Show More