refactor: moved to tanstack start
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
npx commitlint --edit "$1"
|
||||
bunx commitlint --edit "$1"
|
||||
|
||||
@@ -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
|
||||
# -----------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
};
|
||||
+32
-9
@@ -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
-1
@@ -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(),
|
||||
+10
-6
@@ -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;
|
||||
+16
-10
@@ -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");
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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`);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
+19
-5
@@ -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 };
|
||||
+40
-69
@@ -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;
|
||||
}
|
||||
};
|
||||
+41
-22
@@ -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
@@ -0,0 +1,3 @@
|
||||
declare module "bullmq/dist/cjs/index.js" {
|
||||
export * from "bullmq";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -13,12 +13,15 @@
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
# framework build output
|
||||
/.next/
|
||||
/.output/
|
||||
/.tanstack/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
/dist
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: "/dashboard" });
|
||||
},
|
||||
});
|
||||
+15
-2
@@ -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"} />
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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>>
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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") {
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user