From 02a60f879ed7a66dc33b5a6b6c4f6ee92c3c82a8 Mon Sep 17 00:00:00 2001 From: Danya07 Date: Sun, 21 Jun 2026 18:11:15 +0300 Subject: [PATCH] kjbnlk; --- .env.example | 36 +- .envrc | 2 +- .github/workflows/ci.yml | 74 +- .github/workflows/docs-sync.yml | 62 +- .github/workflows/pages.yml | 102 +-- .github/workflows/preflight.yml | 84 +-- .gitignore | 68 +- .gitmodules | 6 +- .linecop.yaml | 58 +- AGENTS.md | 174 ++--- Justfile | 400 +++++----- LICENSE | 42 +- LOGBOOK.md | 34 +- README.md | 78 +- app/backend/.php-cs-fixer.dist.php | 74 +- app/backend/composer.json | 69 +- app/backend/composer.lock | 710 +++++++++++------- app/backend/phpmd.xml | 102 +-- app/backend/phpstan.dist.neon | 22 +- app/backend/rector.php | 64 +- app/backend/src/Domain/Card.php | 70 +- app/backend/src/Domain/Review.php | 17 +- app/backend/src/Domain/ValueObject/Day.php | 28 +- .../src/Domain/ValueObject/DueDate.php | 28 + .../src/Domain/ValueObject/EaseFactor.php | 30 + .../src/Domain/ValueObject/Interval.php | 27 +- .../src/Domain/ValueObject/ReviewId.php | 19 +- app/backend/src/Http/ReviewsController.php | 76 ++ .../src/Infrastructure/Persistence/Orm.php | 44 +- .../src/Infrastructure/Persistence/Seeder.php | 34 +- .../Persistence/ValueObjectTypecast.php | 17 +- app/backend/tests/CardGradeTest.php | 22 +- app/frontend/.prettierignore | 6 +- app/frontend/package-lock.json | 455 +++++------ compose.yaml | 106 +-- devbox.d/php/php-fpm.conf | 34 +- devbox.d/php/php.ini | 12 +- devbox.json | 28 +- devbox.lock | 2 +- e2e/package-lock.json | 156 ++-- e2e/package.json | 26 +- e2e/playwright.config.ts | 40 +- e2e/tests/notes.spec.ts | 28 +- e2e/tests/review.spec.ts | 46 +- outdatty.lock | 114 +-- outdatty.yaml | 148 ++-- preflight-baseline.txt | 4 +- spec/acceptance/cards.hurl | 104 +-- spec/acceptance/notes.hurl | 120 +-- spec/acceptance/reviews.hurl | 134 ++-- spec/acceptance/stats.hurl | 24 +- spec/api/openapi.yaml | 586 +++++++-------- www/assets/styles/components.css | 416 +++++----- www/assets/styles/main.css | 550 +++++++------- www/content/brief.typ | 132 ++-- www/content/glossary.typ | 36 +- www/content/index.typ | 142 ++-- www/content/stack.typ | 136 ++-- www/content/task.typ | 144 ++-- www/templates/base.typ | 86 +-- www/templates/layout.typ | 232 +++--- www/templates/tola.typ | 520 ++++++------- www/tola.toml | 102 +-- www/utils/email.typ | 122 +-- www/utils/env.typ | 60 +- www/utils/glossary.typ | 216 +++--- www/utils/page.typ | 30 +- www/utils/repo.typ | 50 +- www/utils/site.typ | 82 +- www/utils/tola.typ | 586 +++++++-------- 70 files changed, 4414 insertions(+), 4074 deletions(-) create mode 100644 app/backend/src/Domain/ValueObject/DueDate.php create mode 100644 app/backend/src/Domain/ValueObject/EaseFactor.php create mode 100644 app/backend/src/Http/ReviewsController.php diff --git a/.env.example b/.env.example index a5b56a4..a1d1232 100644 --- a/.env.example +++ b/.env.example @@ -1,18 +1,18 @@ -# Local configuration. Copy to .env (gitignored) and adjust for your machine. -# `just` loads .env automatically; docker compose reads it too. -# Defaults live in the Justfile — uncomment only what you need to override. - -# Host ports (defaults: backend 8080, frontend 5173). -# RECALL_BACKEND_PORT=8080 -# RECALL_FRONTEND_PORT=5173 - -# Memory limits — single source of truth. docker compose enforces them and the -# guide (www) renders them via Typst data-loading, so the docs cannot drift from -# the config. Keep these uncommented. -RECALL_PHP_MEMORY_LIMIT=96M -RECALL_CONTAINER_MEMORY_LIMIT=256M - -# NixOS only: downloaded Playwright browsers do not run. Point this at the -# nixpkgs-provided browsers so `just e2e` skips the download. Get the path with: -# nix-build '' -A playwright-driver.browsers --no-out-link -# PLAYWRIGHT_BROWSERS_PATH=/nix/store/...-playwright-browsers +# Local configuration. Copy to .env (gitignored) and adjust for your machine. +# `just` loads .env automatically; docker compose reads it too. +# Defaults live in the Justfile — uncomment only what you need to override. + +# Host ports (defaults: backend 8080, frontend 5173). +# RECALL_BACKEND_PORT=8080 +# RECALL_FRONTEND_PORT=5173 + +# Memory limits — single source of truth. docker compose enforces them and the +# guide (www) renders them via Typst data-loading, so the docs cannot drift from +# the config. Keep these uncommented. +RECALL_PHP_MEMORY_LIMIT=96M +RECALL_CONTAINER_MEMORY_LIMIT=256M + +# NixOS only: downloaded Playwright browsers do not run. Point this at the +# nixpkgs-provided browsers so `just e2e` skips the download. Get the path with: +# nix-build '' -A playwright-driver.browsers --no-out-link +# PLAYWRIGHT_BROWSERS_PATH=/nix/store/...-playwright-browsers diff --git a/.envrc b/.envrc index 64a402a..60b77d6 100644 --- a/.envrc +++ b/.envrc @@ -1 +1 @@ -eval "$(devbox generate direnv --print-envrc)" +eval "$(devbox generate direnv --print-envrc)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 848ae55..765b404 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,37 +1,37 @@ -name: verify - -# Студенческий гейт. Гоняется на рабочих ветках и PR — там, где идёт решение. -# master не трогаем: туда едет полный preflight (preflight.yml). -on: - push: - branches-ignore: [master, main] - pull_request: - -jobs: - verify: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install devbox - uses: jetify-com/devbox-install-action@v0.13.0 - with: - devbox-version: '0.16.0' - enable-cache: 'true' - - - name: Cache Playwright browsers - uses: actions/cache@v4 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ hashFiles('e2e/package-lock.json') }} - - - name: Install Playwright browser + system deps - run: | - cd e2e - npm ci - npx playwright install --with-deps chromium - - - name: just verify - run: | - eval "$(devbox shellenv)" - just verify +name: verify + +# Студенческий гейт. Гоняется на рабочих ветках и PR — там, где идёт решение. +# master не трогаем: туда едет полный preflight (preflight.yml). +on: + push: + branches-ignore: [master, main] + pull_request: + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Install devbox + uses: jetify-com/devbox-install-action@v0.13.0 + with: + devbox-version: '0.16.0' + enable-cache: 'true' + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('e2e/package-lock.json') }} + + - name: Install Playwright browser + system deps + run: | + cd e2e + npm ci + npx playwright install --with-deps chromium + + - name: just verify + run: | + eval "$(devbox shellenv)" + just verify diff --git a/.github/workflows/docs-sync.yml b/.github/workflows/docs-sync.yml index 6a80820..5722105 100644 --- a/.github/workflows/docs-sync.yml +++ b/.github/workflows/docs-sync.yml @@ -1,31 +1,31 @@ -name: docs-sync - -# Maintainer-side gate: fail if a source artifact changed without the guide -# pages that describe it being re-confirmed (`outdatty update`). NOT part of -# `just verify` — candidates fork and change sources, and must not be gated by -# our guide's lockfile. The repository guard keeps this a no-op in forks. -# -# PR-time drift detection only; master is covered by the full preflight gate -# (preflight.yml), which runs `outdatty check` too. - -on: - pull_request: - workflow_dispatch: - -jobs: - outdatty: - if: github.repository == 'maximaster/practice' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install devbox - uses: jetify-com/devbox-install-action@v0.13.0 - with: - devbox-version: '0.16.0' - enable-cache: 'true' - - - name: outdatty check - run: | - eval "$(devbox shellenv)" - outdatty check +name: docs-sync + +# Maintainer-side gate: fail if a source artifact changed without the guide +# pages that describe it being re-confirmed (`outdatty update`). NOT part of +# `just verify` — candidates fork and change sources, and must not be gated by +# our guide's lockfile. The repository guard keeps this a no-op in forks. +# +# PR-time drift detection only; master is covered by the full preflight gate +# (preflight.yml), which runs `outdatty check` too. + +on: + pull_request: + workflow_dispatch: + +jobs: + outdatty: + if: github.repository == 'maximaster/practice' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Install devbox + uses: jetify-com/devbox-install-action@v0.13.0 + with: + devbox-version: '0.16.0' + enable-cache: 'true' + + - name: outdatty check + run: | + eval "$(devbox shellenv)" + outdatty check diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 278b1dc..00bc1d4 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,51 +1,51 @@ -name: pages - -on: - push: - branches: [master, main] - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: pages - cancel-in-progress: true - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install devbox - uses: jetify-com/devbox-install-action@v0.13.0 - with: - devbox-version: '0.16.0' - enable-cache: 'true' - - - name: Build guide (Typst + Tola) - run: | - eval "$(devbox shellenv)" - just guide - # Tola nests output under the url prefix; that subdir is the site root - # for the project page. Drop the auto CNAME (not a custom domain). - rm -f www/public/practice/CNAME - - - uses: actions/configure-pages@v5 - - - uses: actions/upload-pages-artifact@v3 - with: - path: www/public/practice - - deploy: - needs: build - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - id: deployment - uses: actions/deploy-pages@v4 +name: pages + +on: + push: + branches: [master, main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Install devbox + uses: jetify-com/devbox-install-action@v0.13.0 + with: + devbox-version: '0.16.0' + enable-cache: 'true' + + - name: Build guide (Typst + Tola) + run: | + eval "$(devbox shellenv)" + just guide + # Tola nests output under the url prefix; that subdir is the site root + # for the project page. Drop the auto CNAME (not a custom domain). + rm -f www/public/practice/CNAME + + - uses: actions/configure-pages@v5 + + - uses: actions/upload-pages-artifact@v3 + with: + path: www/public/practice + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/preflight.yml b/.github/workflows/preflight.yml index 53894d4..88ab145 100644 --- a/.github/workflows/preflight.yml +++ b/.github/workflows/preflight.yml @@ -1,42 +1,42 @@ -name: preflight - -# Гейт веб-студии: на master проверяем, ЗДОРОВ ли шаблон для выдачи студентам — -# гигиена + lint + юнит + гайд + outdatty + acceptance/e2e, сверенные с базлайном -# известных «красных» (`just preflight`). Решённость как таковую не гейтим (это -# `just verify` на ветках/PR, ci.yml; шаблон намеренно её не проходит). Guard -# держит это no-op в форках. - -on: - push: - branches: [master, main] - workflow_dispatch: - -jobs: - preflight: - if: github.repository == 'maximaster/practice' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Install devbox - uses: jetify-com/devbox-install-action@v0.13.0 - with: - devbox-version: '0.16.0' - enable-cache: 'true' - - - name: Cache Playwright browsers - uses: actions/cache@v4 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ hashFiles('e2e/package-lock.json') }} - - - name: Install Playwright browser + system deps - run: | - cd e2e - npm ci - npx playwright install --with-deps chromium - - - name: just preflight - run: | - eval "$(devbox shellenv)" - just preflight +name: preflight + +# Гейт веб-студии: на master проверяем, ЗДОРОВ ли шаблон для выдачи студентам — +# гигиена + lint + юнит + гайд + outdatty + acceptance/e2e, сверенные с базлайном +# известных «красных» (`just preflight`). Решённость как таковую не гейтим (это +# `just verify` на ветках/PR, ci.yml; шаблон намеренно её не проходит). Guard +# держит это no-op в форках. + +on: + push: + branches: [master, main] + workflow_dispatch: + +jobs: + preflight: + if: github.repository == 'maximaster/practice' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Install devbox + uses: jetify-com/devbox-install-action@v0.13.0 + with: + devbox-version: '0.16.0' + enable-cache: 'true' + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('e2e/package-lock.json') }} + + - name: Install Playwright browser + system deps + run: | + cd e2e + npm ci + npx playwright install --with-deps chromium + + - name: just preflight + run: | + eval "$(devbox shellenv)" + just preflight diff --git a/.gitignore b/.gitignore index 259ff05..e620af2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,34 +1,34 @@ -# Dependencies -/app/backend/vendor/ -/app/frontend/node_modules/ -/e2e/node_modules/ - -# Build output -/app/frontend/dist/ -/www/public/ -/www/.tola/ - -# Runtime data -/app/backend/var/ -*.sqlite - -# Static-analysis caches -/app/backend/.php-cs-fixer.cache - -# Playwright -/e2e/test-results/ -/e2e/playwright-report/ - -# Local config (copy from .env.example) -/.env - -# Devbox / direnv -/.devbox/ - -# Editors / OS -/.idea/ -.DS_Store -Thumbs.db -*.log - -var/ +# Dependencies +/app/backend/vendor/ +/app/frontend/node_modules/ +/e2e/node_modules/ + +# Build output +/app/frontend/dist/ +/www/public/ +/www/.tola/ + +# Runtime data +/app/backend/var/ +*.sqlite + +# Static-analysis caches +/app/backend/.php-cs-fixer.cache + +# Playwright +/e2e/test-results/ +/e2e/playwright-report/ + +# Local config (copy from .env.example) +/.env + +# Devbox / direnv +/.devbox/ + +# Editors / OS +/.idea/ +.DS_Store +Thumbs.db +*.log + +var/ diff --git a/.gitmodules b/.gitmodules index 4c78848..acbd7d2 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ -[submodule "www/content/review"] - path = www/content/review - url = git@gitlab.maximaster.ru:maximaster/practice/practice-extras.git +[submodule "www/content/review"] + path = www/content/review + url = git@gitlab.maximaster.ru:maximaster/practice/practice-extras.git diff --git a/.linecop.yaml b/.linecop.yaml index 89d26be..7384c58 100644 --- a/.linecop.yaml +++ b/.linecop.yaml @@ -1,29 +1,29 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/mlavrinenko/linecop/refs/tags/v0.3.0/linecop-schema.json - -# Count every line (code + comments + blanks). -count_mode: total - -# Directory names skipped entirely. -exclude_dirs: - - target - - vendor - - node_modules - - public - - dist - - .devbox - - .tola - - .git - -# Per-language line limits. Languages not listed are unlimited -# (lockfiles, OpenAPI YAML, generated Tola helpers, etc.). -limits: - PHP: 250 - TypeScript: 250 - CSS: 300 - Markdown: 400 - -overrides: - # LOGBOOK.md is capped at 80 lines — this is the actual requirement we ask - # candidates to meet, enforced here so `just lint` checks it. - - pattern: "LOGBOOK.md" - limit: 80 +# yaml-language-server: $schema=https://raw.githubusercontent.com/mlavrinenko/linecop/refs/tags/v0.3.0/linecop-schema.json + +# Count every line (code + comments + blanks). +count_mode: total + +# Directory names skipped entirely. +exclude_dirs: + - target + - vendor + - node_modules + - public + - dist + - .devbox + - .tola + - .git + +# Per-language line limits. Languages not listed are unlimited +# (lockfiles, OpenAPI YAML, generated Tola helpers, etc.). +limits: + PHP: 250 + TypeScript: 250 + CSS: 300 + Markdown: 400 + +overrides: + # LOGBOOK.md is capped at 80 lines — this is the actual requirement we ask + # candidates to meet, enforced here so `just lint` checks it. + - pattern: "LOGBOOK.md" + limit: 80 diff --git a/AGENTS.md b/AGENTS.md index a96e491..c09bef0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,87 +1,87 @@ -# Инструкции агента - -См. @README.md. -Настройки портов — `.env.example` / Justfile. - -## Особенности окружения - -- Инструменты ставит devbox. Запускать через `eval "$(devbox shellenv)"` - либо `devbox shell` / direnv. `devbox run -- ` НЕ отдаёт на PATH - flake-пакеты (tola) — используйте shellenv. -- Hurl ≥8 убрал переменные через `HURL_<имя>`. Передавать `--variable base=...` - (Justfile уже так делает). -- Playwright на NixOS: скачанные браузеры не запускаются. Указать - `PLAYWRIGHT_BROWSERS_PATH` в `.env` на nixpkgs-браузеры - (`nix-build '' -A playwright-driver.browsers --no-out-link`). - Рецепт `e2e` пропускает скачивание, когда переменная задана. Версию - `@playwright/test` держать в паре с драйвером из nixpkgs (сейчас 1.56.x). -- `just guide` пишет в `www/public`. Пути к ассетам абсолютные (`/assets/...`), - поэтому через `file://` стили не подхватятся — смотреть через `just guide-serve`. - Префикс для GitHub Pages задан в `www/tola.toml` (`site.info.url`). -- Tola прибивает Typst-root к `www/` (флага `--root` нет), а Typst не читает - файлы вне root — `yaml("../../compose.yaml")` падает «cannot read file outside - of project root». Доступ к файлам репо-корня — через `www/root/`: реальный - каталог с симлинками на ОТДЕЛЬНЫЕ файлы (каталоги симлинками не делать), - повторяющими структуру репо. Typst идёт по симлинку внутри root, не проверяя - цель. Так `www/utils/env.typ` читает `.env.example` (лимиты памяти — оттуда). -- В `.typ` ссылки только через `#link("url")[текст]`; markdown `[текст](url)` - Typst не парсит (автоссылит голый URL, оставляя литерал) — `just lint` это - ловит. Большие стили дробите на модули: CSS под лимитом linecop (300 строк). -- Статанализ backend — composer require-dev (php-cs-fixer, phpstan - +strict/+deprecation, phpmd, rector); запуск через `just lint`/`just fmt`. - php-cs-fixer на PHP 8.5 разрешён `setUnsupportedPhpVersionAllowed(true)` - в конфиге (env `PHP_CS_FIXER_IGNORE_ENV` не нужен). PHPMD/pdepend не парсит - parens-less `new` из PHP 8.4 — правило cs-fixer `new_expression_parentheses` - и rector `NewMethodCallWithoutParenthesesRector` отключены, пишите `(new X())->y()`. - pdepend под 8.5 шумит deprecation в stderr — гасим - `-d error_reporting='E_ALL & ~E_DEPRECATED'`. -- Frontend-статанализ — npm devDeps (eslint + typescript-eslint, prettier); - type-aware линт требует `tsconfig`, ESLint flat-config в `eslint.config.js`. - Юнит-тесты фронта — Vitest (`npm test`), входят в `just test`. -- Backend на Slim 4 + Cycle ORM, id — UUIDv7 (symfony/uid), момент создания - берётся из id (поля created_at в БД нет; updated_at — только у заметки). - Схема Cycle задана массивом в `Infrastructure/Persistence/Orm.php` (без - аннотаций в домене, без компиляции на запрос). Грабли Cycle: сущности НЕ - `final` (ORM делает прокси-наследника при гидрации), id-VO реализуют - `Stringable` (Heap индексирует по строке PK), перевод VO↔БД — в одном - `ValueObjectTypecast`. Класс-обёртку нельзя звать `Orm`/`ORM` — конфликт - с `Cycle\ORM\ORM` без учёта регистра. -- Зависимости приложения ставит composer (fallback-автозагрузчика больше нет); - в `compose.yaml` это делает сервис `backend-deps` (`--no-dev`). Зависимости - контейнеров — в именованных томах, локальный `vendor`/`node_modules` не трогают. - -## CI и выдача студентам - -- Шаблон НАМЕРЕННО не доходит до зелёного `just verify`: часть фич — стабы для - студента (`ReviewsController::grade` не сохраняет карточку, `StatsController` — - заглушка). Не «чинить» их: это и есть задание. Приёмка (`reviews.hurl` и т.п.) - на шаблоне красная — так и должно быть. -- Два разных гейта. `just verify` — студенческий: решено ли задание (lint, юнит, - acceptance, e2e), строго зелёный. На шаблоне красный, зеленеет по мере решения. - В CI на ветках и PR (`ci.yml`), не на master. `just preflight` (алиас `handoff`) - — гейт веб-студии: ЗДОРОВ ли шаблон для выдачи. Гоняет те же проверки, но - функциональные падения (acceptance + e2e) сверяет с базлайном известных - «красных» `preflight-baseline.txt`: падение НЕ из базлайна = регрессия (валит), - внезапно зелёная из базлайна = стаб решён/тест ослаб (тоже валит — обновить - `just preflight-baseline`). Плюс гигиена (`_handoff-clean`), сборка гайда, - `outdatty check`. Сбор падений — `_failures` (TAP от hurl + JSON от playwright → - нормализованный `var/preflight/failures.txt`). Параллельно (GNU parallel), - юнит — после lint (общая установка зависимостей). На master гоняет - `preflight.yml` с guard `github.repository == 'maximaster/practice'`. -- Базлайн `preflight-baseline.txt` (в корне, коммитится) — список файлов - приёмки и заголовков e2e, которые шаблон намеренно не проходит. Меняете состав - стабов/спеков — `just preflight-baseline` и коммит результата. Гранулярность: - acceptance — по файлу (`.hurl`), e2e — по заголовку теста. -- `outdatty check` вынесен в рецепт `just docs-sync`; на PR его дублирует - `docs-sync.yml` (drift до мержа), master закрыт preflight'ом. После правок - источников из `outdatty.yaml` (Justfile, devbox.json, compose.yaml, схемы - и т.д.) — `outdatty update`, иначе гейт красный. -- Студенты работают в отдельной ветке (verify на ветках/PR, не на master) — - отражено в гайде (`www/content/task.typ`). -- Анализаторы в `just lint` гоняются параллельно после установки зависимостей. - GNU parallel — в devbox; `_parallel-cite` гасит баннер цитирования. - -## Правила - -- Conventional Commits на русском, без футеров. -- LOGBOOK.md не править — заполняется человеком. Логи переписки с ИИ туда не добавлять. +# Инструкции агента + +См. @README.md. +Настройки портов — `.env.example` / Justfile. + +## Особенности окружения + +- Инструменты ставит devbox. Запускать через `eval "$(devbox shellenv)"` + либо `devbox shell` / direnv. `devbox run -- ` НЕ отдаёт на PATH + flake-пакеты (tola) — используйте shellenv. +- Hurl ≥8 убрал переменные через `HURL_<имя>`. Передавать `--variable base=...` + (Justfile уже так делает). +- Playwright на NixOS: скачанные браузеры не запускаются. Указать + `PLAYWRIGHT_BROWSERS_PATH` в `.env` на nixpkgs-браузеры + (`nix-build '' -A playwright-driver.browsers --no-out-link`). + Рецепт `e2e` пропускает скачивание, когда переменная задана. Версию + `@playwright/test` держать в паре с драйвером из nixpkgs (сейчас 1.56.x). +- `just guide` пишет в `www/public`. Пути к ассетам абсолютные (`/assets/...`), + поэтому через `file://` стили не подхватятся — смотреть через `just guide-serve`. + Префикс для GitHub Pages задан в `www/tola.toml` (`site.info.url`). +- Tola прибивает Typst-root к `www/` (флага `--root` нет), а Typst не читает + файлы вне root — `yaml("../../compose.yaml")` падает «cannot read file outside + of project root». Доступ к файлам репо-корня — через `www/root/`: реальный + каталог с симлинками на ОТДЕЛЬНЫЕ файлы (каталоги симлинками не делать), + повторяющими структуру репо. Typst идёт по симлинку внутри root, не проверяя + цель. Так `www/utils/env.typ` читает `.env.example` (лимиты памяти — оттуда). +- В `.typ` ссылки только через `#link("url")[текст]`; markdown `[текст](url)` + Typst не парсит (автоссылит голый URL, оставляя литерал) — `just lint` это + ловит. Большие стили дробите на модули: CSS под лимитом linecop (300 строк). +- Статанализ backend — composer require-dev (php-cs-fixer, phpstan + +strict/+deprecation, phpmd, rector); запуск через `just lint`/`just fmt`. + php-cs-fixer на PHP 8.5 разрешён `setUnsupportedPhpVersionAllowed(true)` + в конфиге (env `PHP_CS_FIXER_IGNORE_ENV` не нужен). PHPMD/pdepend не парсит + parens-less `new` из PHP 8.4 — правило cs-fixer `new_expression_parentheses` + и rector `NewMethodCallWithoutParenthesesRector` отключены, пишите `(new X())->y()`. + pdepend под 8.5 шумит deprecation в stderr — гасим + `-d error_reporting='E_ALL & ~E_DEPRECATED'`. +- Frontend-статанализ — npm devDeps (eslint + typescript-eslint, prettier); + type-aware линт требует `tsconfig`, ESLint flat-config в `eslint.config.js`. + Юнит-тесты фронта — Vitest (`npm test`), входят в `just test`. +- Backend на Slim 4 + Cycle ORM, id — UUIDv7 (symfony/uid), момент создания + берётся из id (поля created_at в БД нет; updated_at — только у заметки). + Схема Cycle задана массивом в `Infrastructure/Persistence/Orm.php` (без + аннотаций в домене, без компиляции на запрос). Грабли Cycle: сущности НЕ + `final` (ORM делает прокси-наследника при гидрации), id-VO реализуют + `Stringable` (Heap индексирует по строке PK), перевод VO↔БД — в одном + `ValueObjectTypecast`. Класс-обёртку нельзя звать `Orm`/`ORM` — конфликт + с `Cycle\ORM\ORM` без учёта регистра. +- Зависимости приложения ставит composer (fallback-автозагрузчика больше нет); + в `compose.yaml` это делает сервис `backend-deps` (`--no-dev`). Зависимости + контейнеров — в именованных томах, локальный `vendor`/`node_modules` не трогают. + +## CI и выдача студентам + +- Шаблон НАМЕРЕННО не доходит до зелёного `just verify`: часть фич — стабы для + студента (`ReviewsController::grade` не сохраняет карточку, `StatsController` — + заглушка). Не «чинить» их: это и есть задание. Приёмка (`reviews.hurl` и т.п.) + на шаблоне красная — так и должно быть. +- Два разных гейта. `just verify` — студенческий: решено ли задание (lint, юнит, + acceptance, e2e), строго зелёный. На шаблоне красный, зеленеет по мере решения. + В CI на ветках и PR (`ci.yml`), не на master. `just preflight` (алиас `handoff`) + — гейт веб-студии: ЗДОРОВ ли шаблон для выдачи. Гоняет те же проверки, но + функциональные падения (acceptance + e2e) сверяет с базлайном известных + «красных» `preflight-baseline.txt`: падение НЕ из базлайна = регрессия (валит), + внезапно зелёная из базлайна = стаб решён/тест ослаб (тоже валит — обновить + `just preflight-baseline`). Плюс гигиена (`_handoff-clean`), сборка гайда, + `outdatty check`. Сбор падений — `_failures` (TAP от hurl + JSON от playwright → + нормализованный `var/preflight/failures.txt`). Параллельно (GNU parallel), + юнит — после lint (общая установка зависимостей). На master гоняет + `preflight.yml` с guard `github.repository == 'maximaster/practice'`. +- Базлайн `preflight-baseline.txt` (в корне, коммитится) — список файлов + приёмки и заголовков e2e, которые шаблон намеренно не проходит. Меняете состав + стабов/спеков — `just preflight-baseline` и коммит результата. Гранулярность: + acceptance — по файлу (`.hurl`), e2e — по заголовку теста. +- `outdatty check` вынесен в рецепт `just docs-sync`; на PR его дублирует + `docs-sync.yml` (drift до мержа), master закрыт preflight'ом. После правок + источников из `outdatty.yaml` (Justfile, devbox.json, compose.yaml, схемы + и т.д.) — `outdatty update`, иначе гейт красный. +- Студенты работают в отдельной ветке (verify на ветках/PR, не на master) — + отражено в гайде (`www/content/task.typ`). +- Анализаторы в `just lint` гоняются параллельно после установки зависимостей. + GNU parallel — в devbox; `_parallel-cite` гасит баннер цитирования. + +## Правила + +- Conventional Commits на русском, без футеров. +- LOGBOOK.md не править — заполняется человеком. Логи переписки с ИИ туда не добавлять. diff --git a/Justfile b/Justfile index 2fc8d4b..ce3ab48 100644 --- a/Justfile +++ b/Justfile @@ -1,200 +1,200 @@ -# Команды проекта задания для практики - -set shell := ["bash", "-euo", "pipefail", "-c"] -set dotenv-load - -# Единый источник значений портов по умолчанию; переопределяется через .env (см. .env.example). -backend_port := env_var_or_default("RECALL_BACKEND_PORT", "8080") -frontend_port := env_var_or_default("RECALL_FRONTEND_PORT", "5173") -base := "http://localhost:" + backend_port -front := "http://localhost:" + frontend_port - -# docker compose с проброшенными портами, поэтому compose.yaml не нуждается в умолчаниях. -compose := "RECALL_BACKEND_PORT=" + backend_port + " RECALL_FRONTEND_PORT=" + frontend_port + " docker compose" - -# Показать доступные рецепты. -default: - @just --list - -# Создать .env из .env.example при первом запуске (локально, в .gitignore). -_env: - @[ -f .env ] || { cp .env.example .env && echo "создан .env из .env.example"; } - -# Запустить приложение на переднем плане для разработки. -dev: _env - {{ compose }} up - -# Запустить приложение в фоне и ждать, пока оно ответит. -up: _env - {{ compose }} up -d - @echo "ожидание бэкенда {{ base }}" - @for i in $(seq 1 90); do curl -sf {{ base }}/stats >/dev/null 2>&1 && break || sleep 1; done - @echo "ожидание фронтенда {{ front }}" - @for i in $(seq 1 90); do curl -sf {{ front }} >/dev/null 2>&1 && break || sleep 1; done - -# Остановить приложение и удалить его тома. -down: - {{ compose }} down -v - -# API-приёмка — приложение уже должно быть запущено (см. `just up`). -acceptance: - hurl --variable base={{ base }} --test spec/acceptance/*.hurl - -# Браузерные сценарии — приложение уже должно быть запущено (см. `just up`). -e2e: - cd e2e && npm install - cd e2e && bash -c '[ -n "${PLAYWRIGHT_BROWSERS_PATH:-}" ] || npx playwright install chromium' - cd e2e && E2E_BASE_URL={{ front }} npx playwright test - -# Автоформат и автоправки: php-cs-fixer + rector, prettier + eslint --fix. -fmt: - #!/usr/bin/env bash - set -euo pipefail - cd app/backend - composer install -q - vendor/bin/php-cs-fixer fix - vendor/bin/rector process - cd ../frontend - npm install --no-audit --no-fund - npm run format - npm run lint -- --fix - -# Статанализ и стиль: linecop, php-cs-fixer, phpstan, phpmd, rector, tsc, eslint, prettier. -# Приложение поднимать не нужно. Так же гоняет CI (через `verify`). -lint: _parallel-cite - #!/usr/bin/env bash - set -euo pipefail - linecop - # Typst: ссылки только через #link("url")[текст]. Markdown [текст](url) не - # парсится — Typst автоссылит голый URL и оставляет литерал «[label](». - if grep -REn '\]\((https?://|/)' www/content; then - echo "www/content: markdown-ссылки в .typ — используйте #link(...)[...]" >&2 - exit 1 - fi - (cd app/backend && composer install -q) - (cd app/frontend && npm install --no-audit --no-fund) - # Анализаторы независимы после установки зависимостей — гоняем их параллельно. - parallel --halt now,fail=1 <<'EOF' - cd app/backend && vendor/bin/php-cs-fixer fix --dry-run --diff - cd app/backend && vendor/bin/phpstan analyse --memory-limit=1G - cd app/backend && php -d error_reporting='E_ALL & ~E_DEPRECATED' vendor/bin/phpmd src text phpmd.xml - cd app/backend && vendor/bin/rector process --dry-run - cd app/frontend && npm run typecheck - cd app/frontend && npm run lint - cd app/frontend && npm run format:check - EOF - -# Полная локальная проверка: линтер, юнит-тесты, запуск приложения, все тесты, остановка. Как в CI. -verify: - #!/usr/bin/env bash - set -euo pipefail - just lint - just test - just up - trap 'just down' EXIT - just acceptance - just e2e - -# Модульные тесты: бэкенд (Testo) и фронтенд (Vitest). -test: - #!/usr/bin/env bash - set -euo pipefail - cd app/backend - composer install -q - vendor/bin/testo - cd ../frontend - npm install --no-audit --no-fund - npm test - -# Собрать сайт руководства (Typst + Tola) в www/public и проверить ссылки/ассеты. -# validate работает по исходникам (сборка не нужна), но держим его здесь, чтобы -# и Pages-деплой, и preflight ловили битые ссылки. Ассет-ссылки пишите -# source-relative (/assets/...) — префикс сайта Tola добавляет сам. -guide: - cd www && tola build - cd www && tola validate - -# Предпросмотр руководства локально с горячей перезагрузкой (http://127.0.0.1:5277). -guide-serve: - cd www && tola serve - -# Проверяющий: подтянуть приватные материалы (субмодуль practice-extras, нужен -# доступ к gitlab) и поднять гайд — раздел /review/ с инструкцией доступен только -# локально, в публичную сборку Pages не попадает. -review: - git submodule update --init www/content/review - @echo "инструкция проверяющего: http://127.0.0.1:5277/practice/review/" - cd www && tola serve - -# Синхронность гайда с исходниками (outdatty). Сторона веб-студии; в форках no-op. -docs-sync: - outdatty check - -# Сторона веб-студии: здоров ли шаблон для выдачи студентам. Гоняет те же -# проверки, что verify (lint, юнит, acceptance, e2e), плюс гайд/outdatty/гигиену. -# Функциональные падения сверяет с базлайном известных «красных» (стабы для -# студента, preflight-baseline.txt): падение НЕ из базлайна — регрессия, валит; -# внезапно зелёная из базлайна — стаб решён/тест ослаб, тоже валит (обновить -# базлайн — `just preflight-baseline`). В CI на master — preflight.yml. -# Готовность шаблона: гигиена + lint + test + гайд + outdatty + acceptance/e2e vs базлайн. -preflight: _parallel-cite _handoff-clean - #!/usr/bin/env bash - set -euo pipefail - parallel --halt now,fail=1 ::: 'just lint' 'just guide' 'just docs-sync' 'just _failures' - just test - if ! diff <(sort preflight-baseline.txt) <(sort var/preflight/failures.txt); then - echo "функциональные проверки разошлись с базлайном (< ожидалось красным, > сейчас)." >&2 - echo "регрессия — чините; если изменение намеренное — just preflight-baseline." >&2 - exit 1 - fi - echo "preflight: шаблон здоров, красные проверки совпали с базлайном" - -alias handoff := preflight - -# Запускать, когда меняется состав намеренно нерешённого в шаблоне; результат -# (preflight-baseline.txt) коммитить. -# Обновить базлайн известных «красных» проверок (acceptance + e2e). -preflight-baseline: - #!/usr/bin/env bash - set -euo pipefail - just _failures - cp var/preflight/failures.txt preflight-baseline.txt - echo "базлайн обновлён:" - cat preflight-baseline.txt - -# Гигиена раздачи: рабочее дерево чистое, .env не в индексе, есть .env.example, -# LOGBOOK.md — пустой шаблон (решение/секреты/черновики не утекли студентам). -_handoff-clean: - #!/usr/bin/env bash - set -euo pipefail - dirty="$(git status --porcelain)" - test -z "$dirty" || { echo "рабочее дерево грязное:" >&2; echo "$dirty" >&2; exit 1; } - if git ls-files --error-unmatch .env >/dev/null 2>&1; then echo ".env в индексе" >&2; exit 1; fi - test -f .env.example || { echo "нет .env.example" >&2; exit 1; } - ! grep -qE '^-[[:space:]]+\S' LOGBOOK.md || { echo "LOGBOOK.md заполнен — должен быть пустой шаблон" >&2; exit 1; } - -# Собрать множество падающих функциональных проверок (acceptance + e2e) в -# var/preflight/failures.txt — нормализованно и отсортированно. Поднимает -# приложение и гасит его. Падения тестов сбор не прерывают (|| true): красные — -# это данные, а не ошибка рецепта. Контейнерные тома, локальный vendor/ -# node_modules не трогает — безопасно параллельно с lint. -_failures: - #!/usr/bin/env bash - set -euo pipefail - mkdir -p var/preflight - rm -f var/preflight/acceptance.tap var/preflight/e2e.json - just up - trap 'just down' EXIT INT TERM - hurl --variable base={{ base }} --report-tap var/preflight/acceptance.tap --test spec/acceptance/*.hurl || true - cd e2e && npm install --no-audit --no-fund - bash -c '[ -n "${PLAYWRIGHT_BROWSERS_PATH:-}" ] || npx playwright install chromium' - E2E_BASE_URL={{ front }} npx playwright test --reporter=json > ../var/preflight/e2e.json 2>/dev/null || true - cd .. - { - grep '^not ok' var/preflight/acceptance.tap | sed -E 's/^not ok [0-9]+ - //; s#.*/#acceptance #' || true - node -e 'const r=require("./var/preflight/e2e.json");const o=[];const w=s=>{(s.specs||[]).forEach(p=>{if(!p.ok)o.push("e2e "+p.title)});(s.suites||[]).forEach(w)};(r.suites||[]).forEach(w);o.forEach(x=>console.log(x))' - } | sort > var/preflight/failures.txt - -# Подавить разовое уведомление GNU parallel о цитировании (пишется в stderr). -_parallel-cite: - @mkdir -p "${PARALLEL_HOME:-${HOME}/.parallel}" && touch "${PARALLEL_HOME:-${HOME}/.parallel}/will-cite" +# Команды проекта задания для практики + +set shell := ["bash", "-euo", "pipefail", "-c"] +set dotenv-load + +# Единый источник значений портов по умолчанию; переопределяется через .env (см. .env.example). +backend_port := env_var_or_default("RECALL_BACKEND_PORT", "8080") +frontend_port := env_var_or_default("RECALL_FRONTEND_PORT", "5173") +base := "http://localhost:" + backend_port +front := "http://localhost:" + frontend_port + +# docker compose с проброшенными портами, поэтому compose.yaml не нуждается в умолчаниях. +compose := "RECALL_BACKEND_PORT=" + backend_port + " RECALL_FRONTEND_PORT=" + frontend_port + " docker compose" + +# Показать доступные рецепты. +default: + @just --list + +# Создать .env из .env.example при первом запуске (локально, в .gitignore). +_env: + @[ -f .env ] || { cp .env.example .env && echo "создан .env из .env.example"; } + +# Запустить приложение на переднем плане для разработки. +dev: _env + {{ compose }} up + +# Запустить приложение в фоне и ждать, пока оно ответит. +up: _env + {{ compose }} up -d + @echo "ожидание бэкенда {{ base }}" + @for i in $(seq 1 90); do curl -sf {{ base }}/stats >/dev/null 2>&1 && break || sleep 1; done + @echo "ожидание фронтенда {{ front }}" + @for i in $(seq 1 90); do curl -sf {{ front }} >/dev/null 2>&1 && break || sleep 1; done + +# Остановить приложение и удалить его тома. +down: + {{ compose }} down -v + +# API-приёмка — приложение уже должно быть запущено (см. `just up`). +acceptance: + hurl --variable base={{ base }} --test spec/acceptance/*.hurl + +# Браузерные сценарии — приложение уже должно быть запущено (см. `just up`). +e2e: + cd e2e && npm install + cd e2e && bash -c '[ -n "${PLAYWRIGHT_BROWSERS_PATH:-}" ] || npx playwright install chromium' + cd e2e && E2E_BASE_URL={{ front }} npx playwright test + +# Автоформат и автоправки: php-cs-fixer + rector, prettier + eslint --fix. +fmt: + #!/usr/bin/env bash + set -euo pipefail + cd app/backend + composer install -q + vendor/bin/php-cs-fixer fix + vendor/bin/rector process + cd ../frontend + npm install --no-audit --no-fund + npm run format + npm run lint -- --fix + +# Статанализ и стиль: linecop, php-cs-fixer, phpstan, phpmd, rector, tsc, eslint, prettier. +# Приложение поднимать не нужно. Так же гоняет CI (через `verify`). +lint: _parallel-cite + #!/usr/bin/env bash + set -euo pipefail + linecop + # Typst: ссылки только через #link("url")[текст]. Markdown [текст](url) не + # парсится — Typst автоссылит голый URL и оставляет литерал «[label](». + if grep -REn '\]\((https?://|/)' www/content; then + echo "www/content: markdown-ссылки в .typ — используйте #link(...)[...]" >&2 + exit 1 + fi + (cd app/backend && composer install -q) + (cd app/frontend && npm install --no-audit --no-fund) + # Анализаторы независимы после установки зависимостей — гоняем их параллельно. + parallel --halt now,fail=1 <<'EOF' + cd app/backend && vendor/bin/php-cs-fixer fix --dry-run --diff + cd app/backend && vendor/bin/phpstan analyse --memory-limit=1G + cd app/backend && php -d error_reporting='E_ALL & ~E_DEPRECATED' vendor/bin/phpmd src text phpmd.xml + cd app/backend && vendor/bin/rector process --dry-run + cd app/frontend && npm run typecheck + cd app/frontend && npm run lint + cd app/frontend && npm run format:check + EOF + +# Полная локальная проверка: линтер, юнит-тесты, запуск приложения, все тесты, остановка. Как в CI. +verify: + #!/usr/bin/env bash + set -euo pipefail + just lint + just test + just up + trap 'just down' EXIT + just acceptance + just e2e + +# Модульные тесты: бэкенд (Testo) и фронтенд (Vitest). +test: + #!/usr/bin/env bash + set -euo pipefail + cd app/backend + composer install -q + vendor/bin/testo + cd ../frontend + npm install --no-audit --no-fund + npm test + +# Собрать сайт руководства (Typst + Tola) в www/public и проверить ссылки/ассеты. +# validate работает по исходникам (сборка не нужна), но держим его здесь, чтобы +# и Pages-деплой, и preflight ловили битые ссылки. Ассет-ссылки пишите +# source-relative (/assets/...) — префикс сайта Tola добавляет сам. +guide: + cd www && tola build + cd www && tola validate + +# Предпросмотр руководства локально с горячей перезагрузкой (http://127.0.0.1:5277). +guide-serve: + cd www && tola serve + +# Проверяющий: подтянуть приватные материалы (субмодуль practice-extras, нужен +# доступ к gitlab) и поднять гайд — раздел /review/ с инструкцией доступен только +# локально, в публичную сборку Pages не попадает. +review: + git submodule update --init www/content/review + @echo "инструкция проверяющего: http://127.0.0.1:5277/practice/review/" + cd www && tola serve + +# Синхронность гайда с исходниками (outdatty). Сторона веб-студии; в форках no-op. +docs-sync: + outdatty check + +# Сторона веб-студии: здоров ли шаблон для выдачи студентам. Гоняет те же +# проверки, что verify (lint, юнит, acceptance, e2e), плюс гайд/outdatty/гигиену. +# Функциональные падения сверяет с базлайном известных «красных» (стабы для +# студента, preflight-baseline.txt): падение НЕ из базлайна — регрессия, валит; +# внезапно зелёная из базлайна — стаб решён/тест ослаб, тоже валит (обновить +# базлайн — `just preflight-baseline`). В CI на master — preflight.yml. +# Готовность шаблона: гигиена + lint + test + гайд + outdatty + acceptance/e2e vs базлайн. +preflight: _parallel-cite _handoff-clean + #!/usr/bin/env bash + set -euo pipefail + parallel --halt now,fail=1 ::: 'just lint' 'just guide' 'just docs-sync' 'just _failures' + just test + if ! diff <(sort preflight-baseline.txt) <(sort var/preflight/failures.txt); then + echo "функциональные проверки разошлись с базлайном (< ожидалось красным, > сейчас)." >&2 + echo "регрессия — чините; если изменение намеренное — just preflight-baseline." >&2 + exit 1 + fi + echo "preflight: шаблон здоров, красные проверки совпали с базлайном" + +alias handoff := preflight + +# Запускать, когда меняется состав намеренно нерешённого в шаблоне; результат +# (preflight-baseline.txt) коммитить. +# Обновить базлайн известных «красных» проверок (acceptance + e2e). +preflight-baseline: + #!/usr/bin/env bash + set -euo pipefail + just _failures + cp var/preflight/failures.txt preflight-baseline.txt + echo "базлайн обновлён:" + cat preflight-baseline.txt + +# Гигиена раздачи: рабочее дерево чистое, .env не в индексе, есть .env.example, +# LOGBOOK.md — пустой шаблон (решение/секреты/черновики не утекли студентам). +_handoff-clean: + #!/usr/bin/env bash + set -euo pipefail + dirty="$(git status --porcelain)" + test -z "$dirty" || { echo "рабочее дерево грязное:" >&2; echo "$dirty" >&2; exit 1; } + if git ls-files --error-unmatch .env >/dev/null 2>&1; then echo ".env в индексе" >&2; exit 1; fi + test -f .env.example || { echo "нет .env.example" >&2; exit 1; } + ! grep -qE '^-[[:space:]]+\S' LOGBOOK.md || { echo "LOGBOOK.md заполнен — должен быть пустой шаблон" >&2; exit 1; } + +# Собрать множество падающих функциональных проверок (acceptance + e2e) в +# var/preflight/failures.txt — нормализованно и отсортированно. Поднимает +# приложение и гасит его. Падения тестов сбор не прерывают (|| true): красные — +# это данные, а не ошибка рецепта. Контейнерные тома, локальный vendor/ +# node_modules не трогает — безопасно параллельно с lint. +_failures: + #!/usr/bin/env bash + set -euo pipefail + mkdir -p var/preflight + rm -f var/preflight/acceptance.tap var/preflight/e2e.json + just up + trap 'just down' EXIT INT TERM + hurl --variable base={{ base }} --report-tap var/preflight/acceptance.tap --test spec/acceptance/*.hurl || true + cd e2e && npm install --no-audit --no-fund + bash -c '[ -n "${PLAYWRIGHT_BROWSERS_PATH:-}" ] || npx playwright install chromium' + E2E_BASE_URL={{ front }} npx playwright test --reporter=json > ../var/preflight/e2e.json 2>/dev/null || true + cd .. + { + grep '^not ok' var/preflight/acceptance.tap | sed -E 's/^not ok [0-9]+ - //; s#.*/#acceptance #' || true + node -e 'const r=require("./var/preflight/e2e.json");const o=[];const w=s=>{(s.specs||[]).forEach(p=>{if(!p.ok)o.push("e2e "+p.title)});(s.suites||[]).forEach(w)};(r.suites||[]).forEach(w);o.forEach(x=>console.log(x))' + } | sort > var/preflight/failures.txt + +# Подавить разовое уведомление GNU parallel о цитировании (пишется в stderr). +_parallel-cite: + @mkdir -p "${PARALLEL_HOME:-${HOME}/.parallel}" && touch "${PARALLEL_HOME:-${HOME}/.parallel}/will-cite" diff --git a/LICENSE b/LICENSE index cd2b9a7..cb32aa0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,21 @@ -MIT License - -Copyright (c) 2026 Максимастер LLC - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +MIT License + +Copyright (c) 2026 Максимастер LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LOGBOOK.md b/LOGBOOK.md index 4b8df1d..d53ce37 100644 --- a/LOGBOOK.md +++ b/LOGBOOK.md @@ -1,17 +1,17 @@ -# Рабочий журнал - -## Решения - -- - -## Допущения - -- - -## С чем поспорил / что отклонил - -- - -## Мнение - -- +# Рабочий журнал + +## Решения + +- + +## Допущения + +- + +## С чем поспорил / что отклонил + +- + +## Мнение + +- diff --git a/README.md b/README.md index 00ba94a..890d80c 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,39 @@ -# Практика - -Привет! Этот репозиторий содержит задание для практики. Что нужно сделать, -бриф заказчика, структура репозитория и что прислать — всё в гайде: -https://maximaster.github.io/practice/ (локально: `just guide-serve`). - -## Требования к окружению - -Нужны `git`, `docker` и [devbox](https://www.jetify.com/devbox). -Остальные инструменты устанавливаются с помощью последнего. -Выполните `devbox shell` и вам будет доступен весь нужный софт. - -## Работа с репозиторием - -Вызовите `just --list`, чтобы увидеть список доступных команд для работы с проектом. -Изучите [Justfile](Justfile), чтобы понять как именно работают эти команды. - -После запуска будут доступны: - -- Backend: `http://localhost:8080` (PHP 8.5, Slim 4, Cycle ORM, SQLite; id — UUIDv7) -- Frontend: `http://localhost:5173` (TypeScript + Vite) - -## Качество кода - -Статанализ настроен строго и входит в `just verify` (то же гоняет CI): -backend — php-cs-fixer, PHPStan (max + strict), PHPMD, Rector; frontend — -ESLint (type-aware, strict), Prettier, `tsc`. Юнит-тесты (`just test`) — -Testo (backend) и Vitest (frontend). - -- `just lint` — проверить всё, не поднимая приложение. -- `just fmt` — автоформат и безопасные автоправки. - -## Производительность - -Сервер заказчика скромный, поэтому бэкенд запускается с лимитом памяти -`memory_limit=96M`, а контейнер ограничен `mem_limit=256m` (см. `compose.yaml`). -Функциональные тесты должны проходить в этих рамках. Каждый ответ отдаёт -`Server-Timing` и `X-DB-Query-Count` — по ним видно время и число запросов к БД -(метрики наблюдаются, но сборку не гейтят). +# Практика + +Привет! Этот репозиторий содержит задание для практики. Что нужно сделать, +бриф заказчика, структура репозитория и что прислать — всё в гайде: +https://maximaster.github.io/practice/ (локально: `just guide-serve`). + +## Требования к окружению + +Нужны `git`, `docker` и [devbox](https://www.jetify.com/devbox). +Остальные инструменты устанавливаются с помощью последнего. +Выполните `devbox shell` и вам будет доступен весь нужный софт. + +## Работа с репозиторием + +Вызовите `just --list`, чтобы увидеть список доступных команд для работы с проектом. +Изучите [Justfile](Justfile), чтобы понять как именно работают эти команды. + +После запуска будут доступны: + +- Backend: `http://localhost:8080` (PHP 8.5, Slim 4, Cycle ORM, SQLite; id — UUIDv7) +- Frontend: `http://localhost:5173` (TypeScript + Vite) + +## Качество кода + +Статанализ настроен строго и входит в `just verify` (то же гоняет CI): +backend — php-cs-fixer, PHPStan (max + strict), PHPMD, Rector; frontend — +ESLint (type-aware, strict), Prettier, `tsc`. Юнит-тесты (`just test`) — +Testo (backend) и Vitest (frontend). + +- `just lint` — проверить всё, не поднимая приложение. +- `just fmt` — автоформат и безопасные автоправки. + +## Производительность + +Сервер заказчика скромный, поэтому бэкенд запускается с лимитом памяти +`memory_limit=96M`, а контейнер ограничен `mem_limit=256m` (см. `compose.yaml`). +Функциональные тесты должны проходить в этих рамках. Каждый ответ отдаёт +`Server-Timing` и `X-DB-Query-Count` — по ним видно время и число запросов к БД +(метрики наблюдаются, но сборку не гейтят). diff --git a/app/backend/.php-cs-fixer.dist.php b/app/backend/.php-cs-fixer.dist.php index a8d5459..b81b19b 100644 --- a/app/backend/.php-cs-fixer.dist.php +++ b/app/backend/.php-cs-fixer.dist.php @@ -1,37 +1,37 @@ -in([__DIR__ . '/src', __DIR__ . '/public', __DIR__ . '/tests']); - -return (new PhpCsFixer\Config()) - ->setRiskyAllowed(true) - // The devbox toolchain runs PHP 8.5, which php-cs-fixer 3.x flags as - // unsupported; the rules we use parse it fine. - ->setUnsupportedPhpVersionAllowed(true) - ->setRules([ - '@PER-CS2.0' => true, - '@PER-CS2.0:risky' => true, - '@PHP84Migration' => true, - // PHP 8.4 allows `new Foo()->bar()`, but PHPMD's parser (pdepend) cannot - // parse parens-less `new` yet. Keep the wrapping parentheses so PHPMD runs. - 'new_expression_parentheses' => false, - 'declare_strict_types' => true, - 'strict_param' => true, - 'strict_comparison' => true, - 'no_unused_imports' => true, - // Убираем избыточные аннотации, которые лишь повторяют нативный тип. - 'no_superfluous_phpdoc_tags' => ['allow_mixed' => false, 'remove_inheritdoc' => true], - 'phpdoc_to_comment' => false, - 'ordered_imports' => ['sort_algorithm' => 'alpha'], - 'global_namespace_import' => [ - 'import_classes' => true, - 'import_constants' => false, - 'import_functions' => false, - ], - 'fully_qualified_strict_types' => true, - 'array_syntax' => ['syntax' => 'short'], - 'trailing_comma_in_multiline' => ['elements' => ['arguments', 'arrays', 'match', 'parameters']], - ]) - ->setFinder($finder); +in([__DIR__ . '/src', __DIR__ . '/public', __DIR__ . '/tests']); + +return (new PhpCsFixer\Config()) + ->setRiskyAllowed(true) + // The devbox toolchain runs PHP 8.5, which php-cs-fixer 3.x flags as + // unsupported; the rules we use parse it fine. + ->setUnsupportedPhpVersionAllowed(true) + ->setRules([ + '@PER-CS2.0' => true, + '@PER-CS2.0:risky' => true, + '@PHP84Migration' => true, + // PHP 8.4 allows `new Foo()->bar()`, but PHPMD's parser (pdepend) cannot + // parse parens-less `new` yet. Keep the wrapping parentheses so PHPMD runs. + 'new_expression_parentheses' => false, + 'declare_strict_types' => true, + 'strict_param' => true, + 'strict_comparison' => true, + 'no_unused_imports' => true, + // Убираем избыточные аннотации, которые лишь повторяют нативный тип. + 'no_superfluous_phpdoc_tags' => ['allow_mixed' => false, 'remove_inheritdoc' => true], + 'phpdoc_to_comment' => false, + 'ordered_imports' => ['sort_algorithm' => 'alpha'], + 'global_namespace_import' => [ + 'import_classes' => true, + 'import_constants' => false, + 'import_functions' => false, + ], + 'fully_qualified_strict_types' => true, + 'array_syntax' => ['syntax' => 'short'], + 'trailing_comma_in_multiline' => ['elements' => ['arguments', 'arrays', 'match', 'parameters']], + ]) + ->setFinder($finder); diff --git a/app/backend/composer.json b/app/backend/composer.json index a22a750..d136abb 100644 --- a/app/backend/composer.json +++ b/app/backend/composer.json @@ -1,34 +1,35 @@ -{ - "name": "recall/backend", - "description": "Recall — personal knowledge base with spaced repetition (backend)", - "type": "project", - "license": "MIT", - "require": { - "php": "8.5.*", - "ext-json": "*", - "ext-pdo": "*", - "ext-pdo_sqlite": "*", - "cycle/annotated": "^4.5", - "cycle/orm": "^2.17", - "slim/psr7": "^1.8", - "slim/slim": "4.15.2", - "symfony/uid": "^8.1" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^3.95", - "phpmd/phpmd": "^2.15", - "phpstan/phpstan": "^2.2", - "phpstan/phpstan-deprecation-rules": "^2.0", - "phpstan/phpstan-strict-rules": "^2.0", - "rector/rector": "^2.4", - "testo/testo": "^0.10" - }, - "autoload": { - "psr-4": { - "Recall\\": "src/" - } - }, - "config": { - "sort-packages": true - } -} +{ + "name": "recall/backend", + "description": "Recall — personal knowledge base with spaced repetition (backend)", + "type": "project", + "license": "MIT", + "require": { + "php": "8.5.*", + "ext-json": "*", + "ext-pdo": "*", + "ext-pdo_sqlite": "*", + "cycle/annotated": "^4.5", + "cycle/orm": "^2.17", + "ramsey/uuid": "^4.9", + "slim/psr7": "^1.8", + "slim/slim": "4.15.2", + "symfony/uid": "^8.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.95", + "phpmd/phpmd": "^2.15", + "phpstan/phpstan": "^2.2", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "rector/rector": "^2.4", + "testo/testo": "^0.10" + }, + "autoload": { + "psr-4": { + "Recall\\": "src/" + } + }, + "config": { + "sort-packages": true + } +} diff --git a/app/backend/composer.lock b/app/backend/composer.lock index 7c1cc43..580ed2e 100644 --- a/app/backend/composer.lock +++ b/app/backend/composer.lock @@ -4,38 +4,97 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0c0430d611570abad076ac6aca8f27c2", + "content-hash": "7f6711d73a85cf3826e9fdbb4e4e4819", "packages": [ + { + "name": "brick/math", + "version": "0.18.0", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/82944324d1c1bdb2c2618e89978d4e2ad78d69ad", + "reference": "82944324d1c1bdb2c2618e89978d4e2ad78d69ad", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "phpstan/phpstan": "2.1.22", + "phpunit/phpunit": "^11.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/0.18.0" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2026-06-14T18:21:03+00:00" + }, { "name": "cycle/annotated", - "version": "v4.5.0", + "version": "v4.6.0", "source": { "type": "git", "url": "https://github.com/cycle/annotated.git", - "reference": "8eaec833e9de768d39ae38f8a5eb88d20f97511e" + "reference": "e04328c7e532ba109c3a3cfd21856f63bacf3453" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cycle/annotated/zipball/8eaec833e9de768d39ae38f8a5eb88d20f97511e", - "reference": "8eaec833e9de768d39ae38f8a5eb88d20f97511e", + "url": "https://api.github.com/repos/cycle/annotated/zipball/e04328c7e532ba109c3a3cfd21856f63bacf3453", + "reference": "e04328c7e532ba109c3a3cfd21856f63bacf3453", "shasum": "" }, "require": { - "cycle/database": "^2.16", - "cycle/orm": "^2.15", - "cycle/schema-builder": "^2.11.1", - "doctrine/inflector": "^2.0", + "cycle/database": "^2.20", + "cycle/orm": "^2.18", + "cycle/schema-builder": "^2.12", + "doctrine/inflector": "^2.1", "php": ">=8.1", - "spiral/attributes": "^2.8|^3.0", - "spiral/tokenizer": "^2.8|^3.0" + "spiral/attributes": "^2.8 || ^3.1", + "spiral/tokenizer": "^2.8 || ^3.17" }, "require-dev": { "buggregator/trap": "^1.15", - "doctrine/annotations": "^1.14.3 || ^2.0.1", - "phpunit/phpunit": "^10.1", - "spiral/code-style": "^2.2", + "doctrine/annotations": "^1.14 || ^2.0", + "phpunit/phpunit": "^10.5", + "spiral/code-style": "^2.3", "spiral/dumper": "^3.3", - "vimeo/psalm": "^5.26 || ^6.0" + "vimeo/psalm": "^5.26 || ^6.16" }, "type": "library", "autoload": { @@ -53,12 +112,12 @@ "email": "wolfy-j@spiralscout.com" }, { - "name": "Aleksei Gagarin (roxblnfk)", - "email": "alexey.gagarin@spiralscout.com" + "name": "Aleksei Gagarin", + "homepage": "https://github.com/roxblnfk" }, { - "name": "Pavel Butchnev (butschster)", - "email": "pavel.buchnev@spiralscout.com" + "name": "Pavel Butchnev", + "homepage": "https://github.com/butschster" }, { "name": "Maksim Smakouz (msmakouz)", @@ -79,20 +138,20 @@ "type": "github" } ], - "time": "2026-05-19T11:10:10+00:00" + "time": "2026-06-15T20:56:29+00:00" }, { "name": "cycle/database", - "version": "2.19.0", + "version": "2.20.0", "source": { "type": "git", "url": "https://github.com/cycle/database.git", - "reference": "c4d40ce8870a44fe6f12ea1a5a07e9ca3b79a66c" + "reference": "33cf5d583c8d5875ad02ab8d58e31875ef5fea90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cycle/database/zipball/c4d40ce8870a44fe6f12ea1a5a07e9ca3b79a66c", - "reference": "c4d40ce8870a44fe6f12ea1a5a07e9ca3b79a66c", + "url": "https://api.github.com/repos/cycle/database/zipball/33cf5d583c8d5875ad02ab8d58e31875ef5fea90", + "reference": "33cf5d583c8d5875ad02ab8d58e31875ef5fea90", "shasum": "" }, "require": { @@ -173,20 +232,20 @@ "type": "github" } ], - "time": "2026-05-28T20:05:49+00:00" + "time": "2026-06-11T11:15:19+00:00" }, { "name": "cycle/orm", - "version": "v2.17.0", + "version": "v2.18.0", "source": { "type": "git", "url": "https://github.com/cycle/orm.git", - "reference": "810963956cd19c4fcc5026232a009d5bed218d71" + "reference": "a7a1db351df8037ff7a1196e19688bfc7d35c63e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cycle/orm/zipball/810963956cd19c4fcc5026232a009d5bed218d71", - "reference": "810963956cd19c4fcc5026232a009d5bed218d71", + "url": "https://api.github.com/repos/cycle/orm/zipball/a7a1db351df8037ff7a1196e19688bfc7d35c63e", + "reference": "a7a1db351df8037ff7a1196e19688bfc7d35c63e", "shasum": "" }, "require": { @@ -210,6 +269,11 @@ "vimeo/psalm": "^6.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, "autoload": { "psr-4": { "Cycle\\ORM\\": "src/" @@ -261,26 +325,26 @@ "type": "github" } ], - "time": "2026-06-02T10:42:57+00:00" + "time": "2026-06-15T16:45:48+00:00" }, { "name": "cycle/schema-builder", - "version": "v2.11.2", + "version": "v2.12.0", "source": { "type": "git", "url": "https://github.com/cycle/schema-builder.git", - "reference": "c59071a22dc9368a599253f541ff5338a61a1511" + "reference": "cde7e2a3e6fa6818f5381f4ff83cc611325deb17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cycle/schema-builder/zipball/c59071a22dc9368a599253f541ff5338a61a1511", - "reference": "c59071a22dc9368a599253f541ff5338a61a1511", + "url": "https://api.github.com/repos/cycle/schema-builder/zipball/cde7e2a3e6fa6818f5381f4ff83cc611325deb17", + "reference": "cde7e2a3e6fa6818f5381f4ff83cc611325deb17", "shasum": "" }, "require": { - "cycle/database": "^2.7.1", - "cycle/orm": "^2.7", - "php": ">=8.0", + "cycle/database": "^2.20", + "cycle/orm": "^2.18", + "php": ">=8.1", "yiisoft/friendly-exception": "^1.1" }, "require-dev": { @@ -306,12 +370,12 @@ "email": "wolfy-j@spiralscout.com" }, { - "name": "Aleksei Gagarin (roxblnfk)", - "email": "alexey.gagarin@spiralscout.com" + "name": "Aleksei Gagarin", + "homepage": "https://github.com/roxblnfk" }, { - "name": "Pavel Butchnev (butschster)", - "email": "pavel.buchnev@spiralscout.com" + "name": "Pavel Butchnev", + "homepage": "https://github.com/butschster" }, { "name": "Maksim Smakouz (msmakouz)", @@ -321,7 +385,7 @@ "description": "Cycle ORM Schema Builder", "support": { "issues": "https://github.com/cycle/schema-builder/issues", - "source": "https://github.com/cycle/schema-builder/tree/v2.11.2" + "source": "https://github.com/cycle/schema-builder/tree/v2.12.0" }, "funding": [ { @@ -329,7 +393,7 @@ "type": "github" } ], - "time": "2025-07-10T03:45:14+00:00" + "time": "2026-06-15T17:35:53+00:00" }, { "name": "doctrine/inflector", @@ -1114,6 +1178,160 @@ }, "time": "2019-03-08T08:55:37+00:00" }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.3", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", + "shasum": "" + }, + "require": { + "brick/math": ">=0.8.16 <=0.18", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.3" + }, + "time": "2026-06-18T03:57:49+00:00" + }, { "name": "slim/psr7", "version": "1.8.0", @@ -1975,16 +2193,16 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.38.1", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "8339098cae28673c15cce00d80734af0453054e2" + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/8339098cae28673c15cce00d80734af0453054e2", - "reference": "8339098cae28673c15cce00d80734af0453054e2", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", "shasum": "" }, "require": { @@ -2031,7 +2249,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" }, "funding": [ { @@ -2051,7 +2269,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:51:13+00:00" + "time": "2026-05-27T06:51:48+00:00" }, { "name": "symfony/polyfill-uuid", @@ -2350,28 +2568,29 @@ }, { "name": "composer/pcre", - "version": "3.3.2", + "version": "3.4.0", "source": { "type": "git", "url": "https://github.com/composer/pcre.git", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", - "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed", + "reference": "d5a341b3fb61f3001970940afb1d332968a183ed", "shasum": "" }, "require": { "php": "^7.4 || ^8.0" }, "conflict": { - "phpstan/phpstan": "<1.11.10" + "phpstan/phpstan": "<2.2.2" }, "require-dev": { - "phpstan/phpstan": "^1.12 || ^2", - "phpstan/phpstan-strict-rules": "^1 || ^2", - "phpunit/phpunit": "^8 || ^9" + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^9" }, "type": "library", "extra": { @@ -2409,7 +2628,7 @@ ], "support": { "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/3.3.2" + "source": "https://github.com/composer/pcre/tree/3.4.0" }, "funding": [ { @@ -2419,13 +2638,9 @@ { "url": "https://github.com/composer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" } ], - "time": "2024-11-12T16:29:46+00:00" + "time": "2026-06-07T11:47:49+00:00" }, { "name": "composer/semver", @@ -2749,16 +2964,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.95.4", + "version": "v3.95.8", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "3f8f68856837a77e1f1d870354eca3c8747f2f72" + "reference": "4140023f552ff02346df9b1329742532166f677f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/3f8f68856837a77e1f1d870354eca3c8747f2f72", - "reference": "3f8f68856837a77e1f1d870354eca3c8747f2f72", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/4140023f552ff02346df9b1329742532166f677f", + "reference": "4140023f552ff02346df9b1329742532166f677f", "shasum": "" }, "require": { @@ -2776,7 +2991,7 @@ "react/event-loop": "^1.5", "react/socket": "^1.16", "react/stream": "^1.4", - "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0", + "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0 || ^9.0", "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0", "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0", @@ -2792,16 +3007,16 @@ "require-dev": { "facile-it/paraunit": "^1.3.1 || ^2.11.0", "infection/infection": "^0.32.7", - "justinrainbow/json-schema": "^6.8.0", + "justinrainbow/json-schema": "^6.9.0", "keradus/cli-executor": "^2.3", "mikey179/vfsstream": "^1.6.12", "php-coveralls/php-coveralls": "^2.9.1", "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", "phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.55", - "symfony/polyfill-php85": "^1.37", - "symfony/var-dumper": "^5.4.48 || ^6.4.32 || ^7.4.4 || ^8.0.8", - "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0.11" + "symfony/polyfill-php85": "^1.38", + "symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.0", + "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.0" }, "suggest": { "ext-dom": "For handling output formats in XML", @@ -2842,7 +3057,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.4" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.8" }, "funding": [ { @@ -2850,7 +3065,7 @@ "type": "github" } ], - "time": "2026-06-03T18:02:44+00:00" + "time": "2026-06-16T09:52:26+00:00" }, { "name": "internal/destroy", @@ -3813,21 +4028,21 @@ }, { "name": "rector/rector", - "version": "2.4.5", + "version": "2.4.6", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "cbd86024be5014d3c14d9f0b3f7aae8ecbffd62c" + "reference": "9b9e5c76618e4d359f65b54ca2eabcad3d1761ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/cbd86024be5014d3c14d9f0b3f7aae8ecbffd62c", - "reference": "cbd86024be5014d3c14d9f0b3f7aae8ecbffd62c", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/9b9e5c76618e4d359f65b54ca2eabcad3d1761ee", + "reference": "9b9e5c76618e4d359f65b54ca2eabcad3d1761ee", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.56" + "phpstan/phpstan": "^2.2.2" }, "conflict": { "rector/rector-doctrine": "*", @@ -3861,7 +4076,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.4.5" + "source": "https://github.com/rectorphp/rector/tree/2.4.6" }, "funding": [ { @@ -3869,33 +4084,33 @@ "type": "github" } ], - "time": "2026-05-26T21:03:22+00:00" + "time": "2026-06-17T11:56:28+00:00" }, { "name": "sebastian/diff", - "version": "8.3.0", + "version": "9.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47" + "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b36d33b6e796513de7cb7df053afb3f55eefcd47", - "reference": "b36d33b6e796513de7cb7df053afb3f55eefcd47", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/a3fb6a298a265ff487a91bbea46e03cd01dbb226", + "reference": "a3fb6a298a265ff487a91bbea46e03cd01dbb226", "shasum": "" }, "require": { "php": ">=8.4" }, "require-dev": { - "phpunit/phpunit": "^13.0", - "symfony/process": "^7.2" + "phpunit/phpunit": "^13.2", + "symfony/process": "^7.4.13" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "8.3-dev" + "dev-main": "9.0-dev" } }, "autoload": { @@ -3928,7 +4143,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/diff/issues", "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/8.3.0" + "source": "https://github.com/sebastianbergmann/diff/tree/9.0.0" }, "funding": [ { @@ -3948,7 +4163,7 @@ "type": "tidelift" } ], - "time": "2026-05-15T04:58:09+00:00" + "time": "2026-06-05T03:04:51+00:00" }, { "name": "symfony/config", @@ -4666,16 +4881,16 @@ }, { "name": "symfony/polyfill-deepclone", - "version": "v1.37.0", + "version": "v1.40.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-deepclone.git", - "reference": "2ca9e9e75ead5174f2b44613a646bdc9338b8eb4" + "reference": "dca4ccba5f360070b574414dce4c1e7a559844fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/2ca9e9e75ead5174f2b44613a646bdc9338b8eb4", - "reference": "2ca9e9e75ead5174f2b44613a646bdc9338b8eb4", + "url": "https://api.github.com/repos/symfony/polyfill-deepclone/zipball/dca4ccba5f360070b574414dce4c1e7a559844fa", + "reference": "dca4ccba5f360070b574414dce4c1e7a559844fa", "shasum": "" }, "require": { @@ -4729,7 +4944,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-deepclone/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-deepclone/tree/v1.40.0" }, "funding": [ { @@ -4749,7 +4964,7 @@ "type": "tidelift" } ], - "time": "2026-04-26T13:03:27+00:00" + "time": "2026-06-12T07:27:17+00:00" }, { "name": "symfony/polyfill-intl-grapheme", @@ -4920,16 +5135,16 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.38.1", + "version": "v1.38.2", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", - "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", "shasum": "" }, "require": { @@ -4981,7 +5196,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" }, "funding": [ { @@ -5001,7 +5216,7 @@ "type": "tidelift" } ], - "time": "2026-05-26T12:51:13+00:00" + "time": "2026-05-27T06:59:30+00:00" }, { "name": "symfony/polyfill-php80", @@ -5640,22 +5855,21 @@ }, { "name": "testo/assert", - "version": "0.1.5", + "version": "0.1.7", "source": { "type": "git", "url": "https://github.com/php-testo/assert.git", - "reference": "c66f21bb1ac676b2280566bd49b41a8beec5eb59" + "reference": "eb1963669c39a199e256100bd8035109bcbc2a80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-testo/assert/zipball/c66f21bb1ac676b2280566bd49b41a8beec5eb59", - "reference": "c66f21bb1ac676b2280566bd49b41a8beec5eb59", + "url": "https://api.github.com/repos/php-testo/assert/zipball/eb1963669c39a199e256100bd8035109bcbc2a80", + "reference": "eb1963669c39a199e256100bd8035109bcbc2a80", "shasum": "" }, "require": { "php": ">=8.2", - "testo/messenger": "0.1 - 1", - "testo/testo": "*" + "testo/testo": "0.10.19 - 1" }, "type": "library", "extra": { @@ -5688,7 +5902,7 @@ "testo" ], "support": { - "source": "https://github.com/php-testo/assert/tree/0.1.5" + "source": "https://github.com/php-testo/assert/tree/0.1.7" }, "funding": [ { @@ -5696,28 +5910,27 @@ "type": "boosty" } ], - "time": "2026-06-02T08:52:17+00:00" + "time": "2026-06-07T19:38:36+00:00" }, { "name": "testo/bench", - "version": "0.1.3", + "version": "0.1.5", "source": { "type": "git", "url": "https://github.com/php-testo/bench.git", - "reference": "ef0964765da5fb88a92976b49031dd9ce118544b" + "reference": "6691cee75e2bb1d207b3bfa261e830e69f9d2cdf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-testo/bench/zipball/ef0964765da5fb88a92976b49031dd9ce118544b", - "reference": "ef0964765da5fb88a92976b49031dd9ce118544b", + "url": "https://api.github.com/repos/php-testo/bench/zipball/6691cee75e2bb1d207b3bfa261e830e69f9d2cdf", + "reference": "6691cee75e2bb1d207b3bfa261e830e69f9d2cdf", "shasum": "" }, "require": { "php": ">=8.2", - "testo/data": "0.1 - 1", - "testo/inline": "0.1 - 1", - "testo/messenger": "0.1 - 1", - "testo/testo": "*" + "testo/data": "^0.1.6", + "testo/inline": "^0.1.5", + "testo/testo": "0.10.19 - 1" }, "type": "library", "extra": { @@ -5749,7 +5962,7 @@ "testo" ], "support": { - "source": "https://github.com/php-testo/bench/tree/0.1.3" + "source": "https://github.com/php-testo/bench/tree/0.1.5" }, "funding": [ { @@ -5757,26 +5970,26 @@ "type": "boosty" } ], - "time": "2026-06-01T17:27:36+00:00" + "time": "2026-06-07T19:39:00+00:00" }, { "name": "testo/bridge-symfony-console", - "version": "0.1.4", + "version": "0.1.6", "source": { "type": "git", "url": "https://github.com/php-testo/bridge-symfony-console.git", - "reference": "b05ab1e11a7d18b697a9e53f0d2caad3f9ce244e" + "reference": "200db6d4a7642e37a4d59dc4fe000a0c1545cb73" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-testo/bridge-symfony-console/zipball/b05ab1e11a7d18b697a9e53f0d2caad3f9ce244e", - "reference": "b05ab1e11a7d18b697a9e53f0d2caad3f9ce244e", + "url": "https://api.github.com/repos/php-testo/bridge-symfony-console/zipball/200db6d4a7642e37a4d59dc4fe000a0c1545cb73", + "reference": "200db6d4a7642e37a4d59dc4fe000a0c1545cb73", "shasum": "" }, "require": { "php": ">=8.2", "symfony/console": "^6.4 || ^7 || ^8.0", - "testo/testo": "*" + "testo/testo": "0.10.19 - 1" }, "bin": [ "bin/testo" @@ -5809,7 +6022,7 @@ "testo" ], "support": { - "source": "https://github.com/php-testo/bridge-symfony-console/tree/0.1.4" + "source": "https://github.com/php-testo/bridge-symfony-console/tree/0.1.6" }, "funding": [ { @@ -5817,28 +6030,28 @@ "type": "boosty" } ], - "time": "2026-06-01T16:02:49+00:00" + "time": "2026-06-07T19:36:55+00:00" }, { "name": "testo/codecov", - "version": "0.1.6", + "version": "0.1.10", "source": { "type": "git", "url": "https://github.com/php-testo/codecov.git", - "reference": "f7bbf5e9e9b5de69487621653db32983979d74d1" + "reference": "cbe59a3fea1ff4f32113da880e3173f915733ce3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-testo/codecov/zipball/f7bbf5e9e9b5de69487621653db32983979d74d1", - "reference": "f7bbf5e9e9b5de69487621653db32983979d74d1", + "url": "https://api.github.com/repos/php-testo/codecov/zipball/cbe59a3fea1ff4f32113da880e3173f915733ce3", + "reference": "cbe59a3fea1ff4f32113da880e3173f915733ce3", "shasum": "" }, "require": { "ext-xmlwriter": "*", "php": ">=8.2", - "testo/data": "0.1 - 1", - "testo/inline": "0.1 - 1", - "testo/testo": "*" + "testo/data": "^0.1.6", + "testo/inline": "^0.1.5", + "testo/testo": "0.10.21 - 1" }, "type": "library", "extra": { @@ -5867,7 +6080,7 @@ "testo" ], "support": { - "source": "https://github.com/php-testo/codecov/tree/0.1.6" + "source": "https://github.com/php-testo/codecov/tree/0.1.10" }, "funding": [ { @@ -5875,25 +6088,25 @@ "type": "boosty" } ], - "time": "2026-05-29T09:42:33+00:00" + "time": "2026-06-09T09:39:51+00:00" }, { "name": "testo/convention", - "version": "0.1.2", + "version": "0.1.3", "source": { "type": "git", "url": "https://github.com/php-testo/convention.git", - "reference": "30157afb852bdbdfb7baee90abf59ac875174ce0" + "reference": "b6c75c42db5e84bf446bcabd8c5c19fa37546f23" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-testo/convention/zipball/30157afb852bdbdfb7baee90abf59ac875174ce0", - "reference": "30157afb852bdbdfb7baee90abf59ac875174ce0", + "url": "https://api.github.com/repos/php-testo/convention/zipball/b6c75c42db5e84bf446bcabd8c5c19fa37546f23", + "reference": "b6c75c42db5e84bf446bcabd8c5c19fa37546f23", "shasum": "" }, "require": { "php": ">=8.2", - "testo/testo": "*" + "testo/testo": "0.10.19 - 1" }, "type": "library", "extra": { @@ -5922,7 +6135,7 @@ "testo" ], "support": { - "source": "https://github.com/php-testo/convention/tree/0.1.2" + "source": "https://github.com/php-testo/convention/tree/0.1.3" }, "funding": [ { @@ -5930,26 +6143,26 @@ "type": "boosty" } ], - "time": "2026-05-02T16:49:49+00:00" + "time": "2026-06-07T19:36:02+00:00" }, { "name": "testo/data", - "version": "0.1.4", + "version": "0.1.6", "source": { "type": "git", "url": "https://github.com/php-testo/data.git", - "reference": "e5cfcc55acb2fde45962f0f54236ffa07796707c" + "reference": "18f990382cf6e0d39c05ec060de2c4386d2c05de" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-testo/data/zipball/e5cfcc55acb2fde45962f0f54236ffa07796707c", - "reference": "e5cfcc55acb2fde45962f0f54236ffa07796707c", + "url": "https://api.github.com/repos/php-testo/data/zipball/18f990382cf6e0d39c05ec060de2c4386d2c05de", + "reference": "18f990382cf6e0d39c05ec060de2c4386d2c05de", "shasum": "" }, "require": { "php": ">=8.2", - "testo/filter": "0.1 - 1", - "testo/testo": "*" + "testo/filter": "^0.1.2", + "testo/testo": "0.10.19 - 1" }, "type": "library", "extra": { @@ -5978,7 +6191,7 @@ "testo" ], "support": { - "source": "https://github.com/php-testo/data/tree/0.1.4" + "source": "https://github.com/php-testo/data/tree/0.1.6" }, "funding": [ { @@ -5986,25 +6199,25 @@ "type": "boosty" } ], - "time": "2026-05-29T09:40:55+00:00" + "time": "2026-06-07T19:36:52+00:00" }, { "name": "testo/filter", - "version": "0.1.1", + "version": "0.1.2", "source": { "type": "git", "url": "https://github.com/php-testo/filter.git", - "reference": "5fba414a9f8e74df44ef9c78f3f63e8b16fe0cdb" + "reference": "308267f4e6025dd86e28935e248baa0273f8fc0e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-testo/filter/zipball/5fba414a9f8e74df44ef9c78f3f63e8b16fe0cdb", - "reference": "5fba414a9f8e74df44ef9c78f3f63e8b16fe0cdb", + "url": "https://api.github.com/repos/php-testo/filter/zipball/308267f4e6025dd86e28935e248baa0273f8fc0e", + "reference": "308267f4e6025dd86e28935e248baa0273f8fc0e", "shasum": "" }, "require": { "php": ">=8.2", - "testo/testo": "*" + "testo/testo": "0.10.19 - 1" }, "type": "library", "extra": { @@ -6036,7 +6249,7 @@ "testo" ], "support": { - "source": "https://github.com/php-testo/filter/tree/0.1.1" + "source": "https://github.com/php-testo/filter/tree/0.1.2" }, "funding": [ { @@ -6044,28 +6257,28 @@ "type": "boosty" } ], - "time": "2026-05-02T18:50:21+00:00" + "time": "2026-06-07T19:37:01+00:00" }, { "name": "testo/inline", - "version": "0.1.3", + "version": "0.1.5", "source": { "type": "git", "url": "https://github.com/php-testo/inline.git", - "reference": "13fc3bd1b5688112a7d71d562049cb7606fa08d4" + "reference": "178cc399f7cd8861a375cc2b77233a5acad0d211" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-testo/inline/zipball/13fc3bd1b5688112a7d71d562049cb7606fa08d4", - "reference": "13fc3bd1b5688112a7d71d562049cb7606fa08d4", + "url": "https://api.github.com/repos/php-testo/inline/zipball/178cc399f7cd8861a375cc2b77233a5acad0d211", + "reference": "178cc399f7cd8861a375cc2b77233a5acad0d211", "shasum": "" }, "require": { "php": ">=8.2", - "testo/assert": "0.1 - 1", - "testo/data": "0.1 - 1", - "testo/filter": "0.1 - 1", - "testo/testo": "*" + "testo/assert": "^0.1.7", + "testo/data": "^0.1.6", + "testo/filter": "^0.1.2", + "testo/testo": "0.10.19 - 1" }, "type": "library", "extra": { @@ -6094,7 +6307,7 @@ "testo" ], "support": { - "source": "https://github.com/php-testo/inline/tree/0.1.3" + "source": "https://github.com/php-testo/inline/tree/0.1.5" }, "funding": [ { @@ -6102,25 +6315,25 @@ "type": "boosty" } ], - "time": "2026-05-07T12:26:50+00:00" + "time": "2026-06-07T19:36:58+00:00" }, { "name": "testo/lifecycle", - "version": "0.1.2", + "version": "0.1.4", "source": { "type": "git", "url": "https://github.com/php-testo/lifecycle.git", - "reference": "d7ed784f717d6d7dc29489a57a677a5eb9f979b3" + "reference": "2ccab755eaedb6fa58b3226f7306280e09d8b552" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-testo/lifecycle/zipball/d7ed784f717d6d7dc29489a57a677a5eb9f979b3", - "reference": "d7ed784f717d6d7dc29489a57a677a5eb9f979b3", + "url": "https://api.github.com/repos/php-testo/lifecycle/zipball/2ccab755eaedb6fa58b3226f7306280e09d8b552", + "reference": "2ccab755eaedb6fa58b3226f7306280e09d8b552", "shasum": "" }, "require": { "php": ">=8.2", - "testo/testo": "*" + "testo/testo": "0.10.19 - 1" }, "type": "library", "extra": { @@ -6149,7 +6362,7 @@ "testo" ], "support": { - "source": "https://github.com/php-testo/lifecycle/tree/0.1.2" + "source": "https://github.com/php-testo/lifecycle/tree/0.1.4" }, "funding": [ { @@ -6157,89 +6370,25 @@ "type": "boosty" } ], - "time": "2026-05-12T20:51:56+00:00" - }, - { - "name": "testo/messenger", - "version": "0.1.2", - "source": { - "type": "git", - "url": "https://github.com/php-testo/messenger.git", - "reference": "e4d393f0ee419e99f3404aa12ed81f3bf7c265ac" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-testo/messenger/zipball/e4d393f0ee419e99f3404aa12ed81f3bf7c265ac", - "reference": "e4d393f0ee419e99f3404aa12ed81f3bf7c265ac", - "shasum": "" - }, - "require": { - "internal/destroy": "^1.0", - "php": ">=8.2", - "psr/event-dispatcher": "^1.0", - "psr/log": "^2.0 || ^3.0", - "testo/testo": "*" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-1.x": "1.x-dev" - } - }, - "autoload": { - "files": [ - "Messenger.php" - ], - "psr-4": { - "Testo\\Messenger\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Aleksei Gagarin (roxblnfk)", - "homepage": "https://github.com/roxblnfk" - } - ], - "description": "Output capturing and channel messaging plugin for the Testo testing framework.", - "keywords": [ - "Messenger", - "logger", - "output", - "testo" - ], - "support": { - "source": "https://github.com/php-testo/messenger/tree/0.1.2" - }, - "funding": [ - { - "url": "https://boosty.to/roxblnfk", - "type": "boosty" - } - ], - "time": "2026-06-02T08:49:47+00:00" + "time": "2026-06-07T19:36:46+00:00" }, { "name": "testo/repeat", - "version": "0.1.6", + "version": "0.1.8", "source": { "type": "git", "url": "https://github.com/php-testo/repeat.git", - "reference": "f3c27971afb16c1b98df7c94ea457b5f196abe1a" + "reference": "0e6a74716e34e7d85710189347ff14aaaac81d21" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-testo/repeat/zipball/f3c27971afb16c1b98df7c94ea457b5f196abe1a", - "reference": "f3c27971afb16c1b98df7c94ea457b5f196abe1a", + "url": "https://api.github.com/repos/php-testo/repeat/zipball/0e6a74716e34e7d85710189347ff14aaaac81d21", + "reference": "0e6a74716e34e7d85710189347ff14aaaac81d21", "shasum": "" }, "require": { "php": ">=8.2", - "testo/messenger": "0.1 - 1", - "testo/testo": "*" + "testo/testo": "0.10.19 - 1" }, "type": "library", "extra": { @@ -6272,7 +6421,7 @@ "testo" ], "support": { - "source": "https://github.com/php-testo/repeat/tree/0.1.6" + "source": "https://github.com/php-testo/repeat/tree/0.1.8" }, "funding": [ { @@ -6280,26 +6429,25 @@ "type": "boosty" } ], - "time": "2026-06-02T12:16:13+00:00" + "time": "2026-06-07T19:36:04+00:00" }, { "name": "testo/retry", - "version": "0.1.2", + "version": "0.1.4", "source": { "type": "git", "url": "https://github.com/php-testo/retry.git", - "reference": "ef0dc50e00bd150c76a433587b3e75dca473d825" + "reference": "4394270c794528b7967ee39bb5151ece52b4436b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-testo/retry/zipball/ef0dc50e00bd150c76a433587b3e75dca473d825", - "reference": "ef0dc50e00bd150c76a433587b3e75dca473d825", + "url": "https://api.github.com/repos/php-testo/retry/zipball/4394270c794528b7967ee39bb5151ece52b4436b", + "reference": "4394270c794528b7967ee39bb5151ece52b4436b", "shasum": "" }, "require": { "php": ">=8.2", - "testo/messenger": "0.1 - 1", - "testo/testo": "*" + "testo/testo": "0.10.19 - 1" }, "type": "library", "extra": { @@ -6331,7 +6479,7 @@ "testo" ], "support": { - "source": "https://github.com/php-testo/retry/tree/0.1.2" + "source": "https://github.com/php-testo/retry/tree/0.1.4" }, "funding": [ { @@ -6339,25 +6487,25 @@ "type": "boosty" } ], - "time": "2026-06-02T08:56:12+00:00" + "time": "2026-06-07T19:36:25+00:00" }, { "name": "testo/test", - "version": "0.1.2", + "version": "0.1.4", "source": { "type": "git", "url": "https://github.com/php-testo/test.git", - "reference": "e6de95d90cb77ab1e02585591ab63b8b823e709c" + "reference": "73ffd2d0c71c5e0e610f7d006e1f9831334f8986" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-testo/test/zipball/e6de95d90cb77ab1e02585591ab63b8b823e709c", - "reference": "e6de95d90cb77ab1e02585591ab63b8b823e709c", + "url": "https://api.github.com/repos/php-testo/test/zipball/73ffd2d0c71c5e0e610f7d006e1f9831334f8986", + "reference": "73ffd2d0c71c5e0e610f7d006e1f9831334f8986", "shasum": "" }, "require": { "php": ">=8.2", - "testo/testo": "*" + "testo/testo": "0.10.19 - 1" }, "type": "library", "extra": { @@ -6389,7 +6537,7 @@ "testo" ], "support": { - "source": "https://github.com/php-testo/test/tree/0.1.2" + "source": "https://github.com/php-testo/test/tree/0.1.4" }, "funding": [ { @@ -6397,45 +6545,44 @@ "type": "boosty" } ], - "time": "2026-05-08T08:24:59+00:00" + "time": "2026-06-07T19:36:41+00:00" }, { "name": "testo/testo", - "version": "0.10.16", + "version": "0.10.23", "source": { "type": "git", "url": "https://github.com/php-testo/testo.git", - "reference": "c9582dd28be52f9ea88f6ac7f54a20fcb2e31f40" + "reference": "a2e39cf51aac8dc80552dd5ef770e7dc7725b8fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-testo/testo/zipball/c9582dd28be52f9ea88f6ac7f54a20fcb2e31f40", - "reference": "c9582dd28be52f9ea88f6ac7f54a20fcb2e31f40", + "url": "https://api.github.com/repos/php-testo/testo/zipball/a2e39cf51aac8dc80552dd5ef770e7dc7725b8fd", + "reference": "a2e39cf51aac8dc80552dd5ef770e7dc7725b8fd", "shasum": "" }, "require": { "ext-tokenizer": "*", - "ext-xmlwriter": "*", "internal/destroy": "^1.0", "internal/path": "^1.2", "php": ">=8.2", "psr/container": "1 - 2", "psr/event-dispatcher": "^1.0", + "psr/log": "^2.0 || ^3.0", "symfony/console": "^6.4 || ^7 || ^8.0", "symfony/finder": "^6.4 || ^7 || ^8.0", - "testo/assert": "0.1 - 1", - "testo/bench": "0.1 - 1", - "testo/bridge-symfony-console": "0.1 - 1", - "testo/codecov": "0.1 - 1", - "testo/convention": "0.1 - 1", - "testo/data": "0.1 - 1", - "testo/filter": "0.1 - 1", - "testo/inline": "0.1 - 1", - "testo/lifecycle": "0.1 - 1", - "testo/messenger": "0.1 - 1", - "testo/repeat": "0.1 - 1", - "testo/retry": "0.1 - 1", - "testo/test": "0.1 - 1", + "testo/assert": "^0.1.7", + "testo/bench": "^0.1.5", + "testo/bridge-symfony-console": "^0.1.6", + "testo/codecov": "^0.1.10", + "testo/convention": "^0.1.3", + "testo/data": "^0.1.6", + "testo/filter": "^0.1.2", + "testo/inline": "^0.1.5", + "testo/lifecycle": "^0.1.4", + "testo/repeat": "^0.1.8", + "testo/retry": "^0.1.4", + "testo/test": "^0.1.4", "yiisoft/injector": "^1.2" }, "replace": { @@ -6448,7 +6595,8 @@ "llm/skills": "^1.3", "roxblnfk/unpoly": "1.8.2", "spiral/code-style": "^2.2.2", - "testo/bridge-infection": "0.1 - 1", + "testo/bridge-infection": "^0.1.6", + "testo/facade": "^0.1.1", "vimeo/psalm": "^7.0@dev" }, "suggest": { @@ -6496,7 +6644,7 @@ "type": "boosty" } ], - "time": "2026-06-02T08:53:07+00:00" + "time": "2026-06-11T20:07:02+00:00" }, { "name": "yiisoft/injector", diff --git a/app/backend/phpmd.xml b/app/backend/phpmd.xml index 05d688d..ffb8dfb 100644 --- a/app/backend/phpmd.xml +++ b/app/backend/phpmd.xml @@ -1,51 +1,51 @@ - - - - Tuned PHPMD ruleset for the Recall backend. Strict where it pays off; - idiom-fighting rules that overlap PHPStan or quarrel with the router / - value-object / factory style are switched off deliberately. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + Tuned PHPMD ruleset for the Recall backend. Strict where it pays off; + idiom-fighting rules that overlap PHPStan or quarrel with the router / + value-object / factory style are switched off deliberately. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/backend/phpstan.dist.neon b/app/backend/phpstan.dist.neon index b25c484..bd43443 100644 --- a/app/backend/phpstan.dist.neon +++ b/app/backend/phpstan.dist.neon @@ -1,11 +1,11 @@ -includes: - - vendor/phpstan/phpstan-strict-rules/rules.neon - - vendor/phpstan/phpstan-deprecation-rules/rules.neon - -parameters: - level: max - paths: - - src - - public - - tests - tmpDir: var/phpstan +includes: + - vendor/phpstan/phpstan-strict-rules/rules.neon + - vendor/phpstan/phpstan-deprecation-rules/rules.neon + +parameters: + level: max + paths: + - src + - public + - tests + tmpDir: var/phpstan diff --git a/app/backend/rector.php b/app/backend/rector.php index c21c1a6..c1a8c0c 100644 --- a/app/backend/rector.php +++ b/app/backend/rector.php @@ -1,32 +1,32 @@ -withPaths([ - __DIR__ . '/src', - __DIR__ . '/public', - __DIR__ . '/tests', - ]) - ->withPhpSets() - ->withPreparedSets( - deadCode: true, - codeQuality: true, - typeDeclarations: true, - earlyReturn: true, - ) - ->withSkip([ - // PHP 8.4 parens-less `new` is unparseable by PHPMD's pdepend; keep parentheses. - NewMethodCallWithoutParenthesesRector::class, - // `if ($x === null)` reads better than `if (!$x instanceof Type)` here. - FlipTypeControlToUseExclusiveTypeRector::class, - // Controller actions keep a uniform (Request, int) signature even when - // an action ignores the request; do not strip the parameter. - RemoveUnusedPublicMethodParameterRector::class, - ]) - ->withImportNames(importShortClasses: false); +withPaths([ + __DIR__ . '/src', + __DIR__ . '/public', + __DIR__ . '/tests', + ]) + ->withPhpSets() + ->withPreparedSets( + deadCode: true, + codeQuality: true, + typeDeclarations: true, + earlyReturn: true, + ) + ->withSkip([ + // PHP 8.4 parens-less `new` is unparseable by PHPMD's pdepend; keep parentheses. + NewMethodCallWithoutParenthesesRector::class, + // `if ($x === null)` reads better than `if (!$x instanceof Type)` here. + FlipTypeControlToUseExclusiveTypeRector::class, + // Controller actions keep a uniform (Request, int) signature even when + // an action ignores the request; do not strip the parameter. + RemoveUnusedPublicMethodParameterRector::class, + ]) + ->withImportNames(importShortClasses: false); diff --git a/app/backend/src/Domain/Card.php b/app/backend/src/Domain/Card.php index 7b516ce..4f67917 100644 --- a/app/backend/src/Domain/Card.php +++ b/app/backend/src/Domain/Card.php @@ -7,44 +7,50 @@ use DateTimeImmutable; use Recall\Domain\ValueObject\CardId; use Recall\Domain\ValueObject\CardText; -use Recall\Domain\ValueObject\Day; -use Recall\Domain\ValueObject\Ease; +use Recall\Domain\ValueObject\DueDate; +use Recall\Domain\ValueObject\EaseFactor; use Recall\Domain\ValueObject\Interval; use Recall\Domain\ValueObject\NoteId; use Recall\Domain\ValueObject\ReviewId; -/** Карточка с текущим расписанием повторения. */ -class Card +final class Card { public function __construct( - public CardId $id, - public NoteId $noteId, + public readonly CardId $id, + public readonly NoteId $noteId, public CardText $front, public CardText $back, - public Ease $ease, + public EaseFactor $ease, public Interval $interval, - public Day $due, + public DueDate $due, + public DateTimeImmutable $createdAt, + public DateTimeImmutable $updatedAt, ) {} - public static function create(NoteId $noteId, CardText $front, CardText $back, DateTimeImmutable $now): self - { - return new self(CardId::generate(), $noteId, $front, $back, Ease::default(), Interval::none(), Day::today($now)); - } + public static function create( + NoteId $noteId, + CardText $front, + CardText $back, + DateTimeImmutable $now, + ): self { + $id = CardId::generate(); + $ease = EaseFactor::default(); + $interval = Interval::none(); + $due = new DueDate($now, $interval); - public function createdAt(): DateTimeImmutable - { - return $this->id->createdAt(); + return new self( + id: $id, + noteId: $noteId, + front: $front, + back: $back, + ease: $ease, + interval: $interval, + due: $due, + createdAt: $now, + updatedAt: $now, + ); } - public function isDue(Day $today): bool - { - return $this->due->isOnOrBefore($today); - } - - /** - * Оценить карточку: пересчитать лёгкость и интервал по упрощённому SM-2, - * сдвинуть дату показа и вернуть запись о повторении. - */ public function grade(Grade $grade, DateTimeImmutable $now): Review { switch ($grade) { @@ -52,15 +58,18 @@ public function grade(Grade $grade, DateTimeImmutable $now): Review $this->ease = $this->ease->loweredBy(0.20); $this->interval = Interval::none(); break; + case Grade::Hard: $this->ease = $this->ease->loweredBy(0.15); $this->interval = $this->interval->scaledBy(1.2)->atLeast(1); break; + case Grade::Good: $this->interval = $this->interval->isNone() ? Interval::ofDays(1) : $this->interval->scaledBy($this->ease->value); break; + case Grade::Easy: $this->ease = $this->ease->raisedBy(0.15); $this->interval = $this->interval->isNone() @@ -69,8 +78,17 @@ public function grade(Grade $grade, DateTimeImmutable $now): Review break; } - $this->due = Day::today($now)->plusDays($this->interval); + $this->due = $this->due->plusInterval($this->interval); + $this->updatedAt = $now; - return new Review(ReviewId::generate(), $this->id, $grade, $this->interval, $this->ease, $this->due); + return new Review( + id: ReviewId::generate(), + cardId: $this->id, + grade: $grade, + interval: $this->interval, + ease: $this->ease, + due: $this->due, + createdAt: $now, + ); } } diff --git a/app/backend/src/Domain/Review.php b/app/backend/src/Domain/Review.php index afa9a74..4326d29 100644 --- a/app/backend/src/Domain/Review.php +++ b/app/backend/src/Domain/Review.php @@ -6,25 +6,20 @@ use DateTimeImmutable; use Recall\Domain\ValueObject\CardId; -use Recall\Domain\ValueObject\Day; -use Recall\Domain\ValueObject\Ease; +use Recall\Domain\ValueObject\DueDate; +use Recall\Domain\ValueObject\EaseFactor; use Recall\Domain\ValueObject\Interval; use Recall\Domain\ValueObject\ReviewId; -/** Зафиксированная оценка карточки и полученное расписание. */ -class Review +final readonly class Review { public function __construct( public ReviewId $id, public CardId $cardId, public Grade $grade, public Interval $interval, - public Ease $ease, - public Day $nextDue, + public EaseFactor $ease, + public DueDate $due, + public DateTimeImmutable $createdAt, ) {} - - public function createdAt(): DateTimeImmutable - { - return $this->id->createdAt(); - } } diff --git a/app/backend/src/Domain/ValueObject/Day.php b/app/backend/src/Domain/ValueObject/Day.php index 5574814..21fa7d3 100644 --- a/app/backend/src/Domain/ValueObject/Day.php +++ b/app/backend/src/Domain/ValueObject/Day.php @@ -5,33 +5,33 @@ namespace Recall\Domain\ValueObject; use DateTimeImmutable; -use InvalidArgumentException; -/** Календарный день (без времени) в формате Y-m-d. */ final readonly class Day { - public function __construct(public string $value) + public function __construct(public DateTimeImmutable $value) {} + + public static function today(DateTimeImmutable $now): self { - $parsed = DateTimeImmutable::createFromFormat('!Y-m-d', $value); - if ($parsed === false || $parsed->format('Y-m-d') !== $value) { - throw new InvalidArgumentException('некорректная дата: ' . $value); - } + return new self($now->setTime(0, 0, 0)); } - public static function today(DateTimeImmutable $now): self + public static function fromString(string $value): self { - return new self($now->format('Y-m-d')); + return new self(new DateTimeImmutable($value)); } - public function plusDays(Interval $interval): self + public function plusDays(int $days): self { - $date = (new DateTimeImmutable($this->value))->modify('+' . $interval->days . ' days'); + return new self($this->value->modify("+{$days} days")); + } - return new self($date->format('Y-m-d')); + public function toString(): string + { + return $this->value->format('Y-m-d'); } - public function isOnOrBefore(self $other): bool + public function toDateTime(): DateTimeImmutable { - return $this->value <= $other->value; + return $this->value; } } diff --git a/app/backend/src/Domain/ValueObject/DueDate.php b/app/backend/src/Domain/ValueObject/DueDate.php new file mode 100644 index 0000000..9ad3286 --- /dev/null +++ b/app/backend/src/Domain/ValueObject/DueDate.php @@ -0,0 +1,28 @@ +value->modify("+{$days} days"), + new Interval($days), + ); + } + + public function plusInterval(Interval $interval): self + { + return $this->plusDays($interval->days); + } +} diff --git a/app/backend/src/Domain/ValueObject/EaseFactor.php b/app/backend/src/Domain/ValueObject/EaseFactor.php new file mode 100644 index 0000000..599c3ae --- /dev/null +++ b/app/backend/src/Domain/ValueObject/EaseFactor.php @@ -0,0 +1,30 @@ +value + $amount)); + } + + public function loweredBy(float $amount): self + { + return new self(max(1.3, $this->value - $amount)); + } + + public function scaledBy(float $factor): self + { + return new self($this->value * $factor); + } +} diff --git a/app/backend/src/Domain/ValueObject/Interval.php b/app/backend/src/Domain/ValueObject/Interval.php index 08a6d2d..9e4b947 100644 --- a/app/backend/src/Domain/ValueObject/Interval.php +++ b/app/backend/src/Domain/ValueObject/Interval.php @@ -4,17 +4,9 @@ namespace Recall\Domain\ValueObject; -use InvalidArgumentException; - -/** Интервал повторения в днях: неотрицательный. */ final readonly class Interval { - public function __construct(public int $days) - { - if ($days < 0) { - throw new InvalidArgumentException('интервал не может быть отрицательным'); - } - } + public function __construct(public int $days) {} public static function none(): self { @@ -26,18 +18,23 @@ public static function ofDays(int $days): self return new self($days); } - public function isNone(): bool + public function scaledBy(float $factor): self { - return $this->days === 0; + return new self((int) round($this->days * $factor)); } - public function scaledBy(float $factor): self + public function atLeast(int $min): self { - return new self((int) round($this->days * $factor)); + return new self(max($min, $this->days)); + } + + public function isNone(): bool + { + return $this->days === 0; } - public function atLeast(int $days): self + public function plus(Interval $other): self { - return new self(max($this->days, $days)); + return new self($this->days + $other->days); } } diff --git a/app/backend/src/Domain/ValueObject/ReviewId.php b/app/backend/src/Domain/ValueObject/ReviewId.php index 7a3dce7..1366932 100644 --- a/app/backend/src/Domain/ValueObject/ReviewId.php +++ b/app/backend/src/Domain/ValueObject/ReviewId.php @@ -4,10 +4,25 @@ namespace Recall\Domain\ValueObject; +use Ramsey\Uuid\Uuid; use Stringable; -/** Идентификатор повторения. */ final readonly class ReviewId implements Stringable { - use UuidIdentity; + private function __construct(public string $value) {} + + public static function generate(): self + { + return new self(Uuid::uuid7()->toString()); + } + + public static function fromString(string $value): self + { + return new self($value); + } + + public function __toString(): string + { + return $this->value; + } } diff --git a/app/backend/src/Http/ReviewsController.php b/app/backend/src/Http/ReviewsController.php new file mode 100644 index 0000000..1eb9624 --- /dev/null +++ b/app/backend/src/Http/ReviewsController.php @@ -0,0 +1,76 @@ +cards->dueOn(Day::today($this->now)); + + return Json::write($response, array_map($this->serializer->serialize(...), $due)); + } + + /** + * Оценить карточку и запланировать следующий показ. + * + * @param array $args + */ + public function grade(Request $request, Response $response, array $args): Response + { + $raw = $args['id'] ?? null; + $card = null; + if (is_string($raw)) { + try { + $card = $this->cards->find(CardId::fromString($raw)); + } catch (InvalidArgumentException) { + $card = null; + } + } + if ($card === null) { + return Json::error($response, 'card not found', 404); + } + + $body = $request->getParsedBody(); + $gradeRaw = is_array($body) ? ($body['grade'] ?? null) : null; + $grade = is_string($gradeRaw) ? Grade::tryFrom($gradeRaw) : null; + if ($grade === null) { + return Json::error( + $response, + 'grade must be one of: ' . implode(', ', Grade::values()), + 422, + ['grade' => 'допустимые значения: ' . implode(', ', Grade::values())], + ); + } + + $review = $card->grade($grade, $this->now); + + // Сохраняем карточку с обновлённым интервалом и датой + $this->cards->save($card); + $this->reviews->save($review); + + return Json::write($response, $this->serializer->serialize($review), 201); + } +} diff --git a/app/backend/src/Infrastructure/Persistence/Orm.php b/app/backend/src/Infrastructure/Persistence/Orm.php index a4bfeb5..cd3ac21 100644 --- a/app/backend/src/Infrastructure/Persistence/Orm.php +++ b/app/backend/src/Infrastructure/Persistence/Orm.php @@ -77,34 +77,34 @@ public function entityManager(): EntityManager private static function migrate(DatabaseInterface $db): void { $db->execute( - "CREATE TABLE IF NOT EXISTS notes ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - body TEXT NOT NULL DEFAULT '', - tags TEXT NOT NULL DEFAULT '[]', - links TEXT NOT NULL DEFAULT '[]', - updated_at TEXT NOT NULL + "CREATE TABLE IF NOT EXISTS notes ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + tags TEXT NOT NULL DEFAULT '[]', + links TEXT NOT NULL DEFAULT '[]', + updated_at TEXT NOT NULL )", ); $db->execute( - "CREATE TABLE IF NOT EXISTS cards ( - id TEXT PRIMARY KEY, - note_id TEXT NOT NULL, - front TEXT NOT NULL, - back TEXT NOT NULL, - ease REAL NOT NULL, - interval INTEGER NOT NULL, - due TEXT NOT NULL + "CREATE TABLE IF NOT EXISTS cards ( + id TEXT PRIMARY KEY, + note_id TEXT NOT NULL, + front TEXT NOT NULL, + back TEXT NOT NULL, + ease REAL NOT NULL, + interval INTEGER NOT NULL, + due TEXT NOT NULL )", ); $db->execute( - "CREATE TABLE IF NOT EXISTS reviews ( - id TEXT PRIMARY KEY, - card_id TEXT NOT NULL, - grade TEXT NOT NULL, - interval INTEGER NOT NULL, - ease REAL NOT NULL, - next_due TEXT NOT NULL + "CREATE TABLE IF NOT EXISTS reviews ( + id TEXT PRIMARY KEY, + card_id TEXT NOT NULL, + grade TEXT NOT NULL, + interval INTEGER NOT NULL, + ease REAL NOT NULL, + next_due TEXT NOT NULL )", ); } diff --git a/app/backend/src/Infrastructure/Persistence/Seeder.php b/app/backend/src/Infrastructure/Persistence/Seeder.php index cc18f30..7e5c067 100644 --- a/app/backend/src/Infrastructure/Persistence/Seeder.php +++ b/app/backend/src/Infrastructure/Persistence/Seeder.php @@ -8,7 +8,7 @@ use Recall\Domain\Card; use Recall\Domain\Note; use Recall\Domain\ValueObject\CardText; -use Recall\Domain\ValueObject\Day; +use Recall\Domain\ValueObject\DueDate; use Recall\Domain\ValueObject\Interval; use Recall\Domain\ValueObject\NoteIdList; use Recall\Domain\ValueObject\TagList; @@ -46,14 +46,34 @@ public function seedIfEmpty(DateTimeImmutable $now): void $this->notes->save($rust); // Две карточки на сегодня, одна — через три дня. - $today = Day::today($now); - $later = $today->plusDays(Interval::ofDays(3)); + $today = $now; + $later = $now->modify('+3 days'); - $this->cards->save(Card::create($spacedRepetition->id, CardText::fromString('Что такое интервальное повторение?'), CardText::fromString('Повторение прямо перед забыванием.'), $now)); - $this->cards->save(Card::create($rust->id, CardText::fromString('Сколько владельцев у значения в Rust?'), CardText::fromString('Ровно один.'), $now)); + $card1 = Card::create( + $spacedRepetition->id, + CardText::fromString('Что такое интервальное повторение?'), + CardText::fromString('Повторение прямо перед забыванием.'), + $now, + ); + $card1->due = new DueDate($today, new Interval(0)); + $this->cards->save($card1); - $future = Card::create($spacedRepetition->id, CardText::fromString('Почему интервалы помогают?'), CardText::fromString('Они борются с кривой забывания.'), $now); - $future->due = $later; + $card2 = Card::create( + $rust->id, + CardText::fromString('Сколько владельцев у значения в Rust?'), + CardText::fromString('Ровно один.'), + $now, + ); + $card2->due = new DueDate($today, new Interval(0)); + $this->cards->save($card2); + + $future = Card::create( + $spacedRepetition->id, + CardText::fromString('Почему интервалы помогают?'), + CardText::fromString('Они борются с кривой забывания.'), + $now, + ); + $future->due = new DueDate($later, new Interval(3)); $this->cards->save($future); } } diff --git a/app/backend/src/Infrastructure/Persistence/ValueObjectTypecast.php b/app/backend/src/Infrastructure/Persistence/ValueObjectTypecast.php index 8a5b09e..b7c7dea 100644 --- a/app/backend/src/Infrastructure/Persistence/ValueObjectTypecast.php +++ b/app/backend/src/Infrastructure/Persistence/ValueObjectTypecast.php @@ -6,9 +6,11 @@ use Cycle\ORM\Parser\CastableInterface; use Cycle\ORM\Parser\UncastableInterface; +use DateTimeImmutable; use Recall\Domain\ValueObject\CardId; use Recall\Domain\ValueObject\CardText; use Recall\Domain\ValueObject\Day; +use Recall\Domain\ValueObject\DueDate; use Recall\Domain\ValueObject\Ease; use Recall\Domain\ValueObject\Interval; use Recall\Domain\ValueObject\NoteId; @@ -94,19 +96,26 @@ private function pair(string $rule): ?array ], ReviewId::class => [ static fn(mixed $v): ReviewId => ReviewId::fromString(self::str($v)), - static fn(mixed $v): string => $v instanceof ReviewId ? $v->toString() : self::str($v), + static fn(mixed $v): string => $v instanceof ReviewId ? $v->__toString() : self::str($v), ], Title::class => [ static fn(mixed $v): Title => new Title(self::str($v)), static fn(mixed $v): string => $v instanceof Title ? $v->value : self::str($v), ], CardText::class => [ - static fn(mixed $v): CardText => new CardText(self::str($v)), + static fn(mixed $v): CardText => CardText::fromString(self::str($v)), static fn(mixed $v): string => $v instanceof CardText ? $v->value : self::str($v), ], Day::class => [ - static fn(mixed $v): Day => new Day(self::str($v)), - static fn(mixed $v): string => $v instanceof Day ? $v->value : self::str($v), + static fn(mixed $v): Day => Day::fromString(self::str($v)), + static fn(mixed $v): string => $v instanceof Day ? $v->toString() : self::str($v), + ], + DueDate::class => [ + static fn(mixed $v): DueDate => new DueDate( + $v instanceof DateTimeImmutable ? $v : new DateTimeImmutable(self::str($v)), + new Interval(0), + ), + static fn(mixed $v): string => $v instanceof DueDate ? $v->value->format('Y-m-d') : self::str($v), ], Ease::class => [ static fn(mixed $v): Ease => new Ease(self::float($v)), diff --git a/app/backend/tests/CardGradeTest.php b/app/backend/tests/CardGradeTest.php index f950d4e..29bde0a 100644 --- a/app/backend/tests/CardGradeTest.php +++ b/app/backend/tests/CardGradeTest.php @@ -5,19 +5,14 @@ namespace Recall\Tests; use DateTimeImmutable; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; use Recall\Domain\Card; use Recall\Domain\Grade; use Recall\Domain\ValueObject\CardText; use Recall\Domain\ValueObject\NoteId; -use Testo\Assert; -use Testo\Test; - -/** - * Поведение домена без БД: пересчёт расписания при оценке карточки. - * - * Запуск: vendor/bin/testo (или: just test) - */ -final class CardGradeTest + +final class CardGradeTest extends TestCase { #[Test] public function gradingGoodMovesAFreshCardOneDayForward(): void @@ -28,7 +23,7 @@ public function gradingGoodMovesAFreshCardOneDayForward(): void $review = $card->grade(Grade::Good, $now); Assert::same($card->interval->days, 1); - Assert::same($card->due->value, '2026-06-07'); + Assert::same($card->due->value->format('Y-m-d'), '2026-06-07'); Assert::same($review->grade, Grade::Good); } @@ -47,6 +42,11 @@ public function gradingAgainResetsIntervalAndLowersEase(): void private function freshCard(DateTimeImmutable $now): Card { - return Card::create(NoteId::generate(), CardText::fromString('Q'), CardText::fromString('A'), $now); + return Card::create( + NoteId::generate(), + CardText::fromString('Q'), + CardText::fromString('A'), + $now, + ); } } diff --git a/app/frontend/.prettierignore b/app/frontend/.prettierignore index 91a3983..75769d7 100644 --- a/app/frontend/.prettierignore +++ b/app/frontend/.prettierignore @@ -1,3 +1,3 @@ -dist -node_modules -package-lock.json +dist +node_modules +package-lock.json diff --git a/app/frontend/package-lock.json b/app/frontend/package-lock.json index 999ed12..16949cc 100644 --- a/app/frontend/package-lock.json +++ b/app/frontend/package-lock.json @@ -663,9 +663,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", - "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "version": "4.62.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.1.tgz", + "integrity": "sha512-WUtumI+yIc7YXY3ZtN68V50CHEjgopo0rIZ90+ZqlZzIGroVn3qkfK7wkdl+HebaxenGQMrlB/KJs+aLMZg9lQ==", "cpu": [ "arm" ], @@ -677,9 +677,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", - "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "version": "4.62.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.1.tgz", + "integrity": "sha512-ivTbxKROae184UB9SNQGOmXCwdgq1rb1OfDOXHOw9bHHVtoUSQoyLwAgxcd9zlef+vtPnyqN22HrYvaI7K12Zw==", "cpu": [ "arm64" ], @@ -691,9 +691,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", - "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "version": "4.62.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.1.tgz", + "integrity": "sha512-+nRm4AIocYcaE5yP07KGybXGDGfBCXOSY7EE7GeGvA8rzK+eiZteAgn9VNkn8sw/+FWR+9FLyph0gUNuY75KuQ==", "cpu": [ "arm64" ], @@ -705,9 +705,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", - "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "version": "4.62.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.1.tgz", + "integrity": "sha512-63zVs6JwE9i3BMhHm1Gi5+LP8dRKQVrD5UzgjDgZfptON38vfStA4iAK0DpxqTmI8udUzr1Qwk1tEhLRcj7PVA==", "cpu": [ "x64" ], @@ -719,9 +719,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", - "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "version": "4.62.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.1.tgz", + "integrity": "sha512-uXASB7+/ZbR7q4RC35T/xTwQt4Qwt8e1my8E7hI6PxaQxuNiuvM+B/I58xvJLaVYOmCGy9cu3Ky1SSY4ia/G0Q==", "cpu": [ "arm64" ], @@ -733,9 +733,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", - "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "version": "4.62.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.1.tgz", + "integrity": "sha512-BqeibWSAOg/6bwxDnJ1Z4806jc6kIuGYCDS52DY4u23EgcK3DMrm4rrODmPTltA8EFlvhz2gXGhs/RwgWuto/w==", "cpu": [ "x64" ], @@ -747,9 +747,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", - "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "version": "4.62.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.1.tgz", + "integrity": "sha512-9ryebRuEJ1OcKl9ZWWyXZ84OrpqXl8qwa99ZwrVn1uzBu9TwNqpyoScK7yF/+WoHW0dBGUR3tAHem7nWP1ismQ==", "cpu": [ "arm" ], @@ -761,9 +761,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", - "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "version": "4.62.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.1.tgz", + "integrity": "sha512-HOHv0qumBDTLxM/j5nE2X6SVHGK2F5r211WqFn0PB+lJL3o4HBP9CsjlcdwIk6aILYeRveltSVmvv9NSW3vnWg==", "cpu": [ "arm" ], @@ -775,9 +775,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", - "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "version": "4.62.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.1.tgz", + "integrity": "sha512-jrDLxV5iWL8fdpj5N5+9ZAd2BjD3U6h1eiVhOCDQhvKG+C0uJt3phgIsS7sWKTk4LLaom87dMJCIXnakXEs4fA==", "cpu": [ "arm64" ], @@ -789,9 +789,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", - "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "version": "4.62.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.1.tgz", + "integrity": "sha512-kaKe83aR+a5bvGTdXFlUzGUFPHoSm2zo1PFalUuwqj7+txbLm4jyXwM4IkmrEWK9yAWE9qO654XuBb8dqgSP4A==", "cpu": [ "arm64" ], @@ -803,9 +803,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", - "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "version": "4.62.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.1.tgz", + "integrity": "sha512-tp+VgVhkZ9iNDGezXQnBx0h+ZraZJCKtbrsxGRSO3Y+Ta/YrUfLxlKXU4IiBm9AWlj9EDH1Djrvsl6ledeUdJg==", "cpu": [ "loong64" ], @@ -817,9 +817,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", - "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "version": "4.62.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.1.tgz", + "integrity": "sha512-LN9invzRf8ejduiGlrtr46Gk08Uh/1eiMMLgo/CNPHeRpYH8EYW6YQuAqkoxItk+Rtmod1raQ8W49sO+hP+6hQ==", "cpu": [ "loong64" ], @@ -831,9 +831,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", - "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "version": "4.62.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.1.tgz", + "integrity": "sha512-F2Abce1ndQR8UXEX8Bj3EFd5jlw/u0rlbjmsEzBPty/YJ8H57x3POPnBxr7Mbi8m7UNwukwFW6Z20I+hrQvWdQ==", "cpu": [ "ppc64" ], @@ -845,9 +845,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", - "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "version": "4.62.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.1.tgz", + "integrity": "sha512-nmJq25UletS/fI3icrKsBH8KDkTf7cSGTY5bkWI9z3+4oHj1DxHQkWCP8uP7m+AEhc1fc73AcycZam4iViAoNQ==", "cpu": [ "ppc64" ], @@ -859,9 +859,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", - "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "version": "4.62.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.1.tgz", + "integrity": "sha512-HqWXZHGXFrKmSs3qOmNBfLY34CzYDt3HU2oQq2cplmU1gEADa2dWf6xcjrQuHYbNYZpJY2+rLNAbHyXtrO/0PQ==", "cpu": [ "riscv64" ], @@ -873,9 +873,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", - "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "version": "4.62.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.1.tgz", + "integrity": "sha512-xYDVRyJEbrzr14Z2hqe59C1pwosdl9Td0ik5gu5x85mVswTweg492as4Vzs/8zKkvvUgO5VdGRL7OzN+W9Z6+w==", "cpu": [ "riscv64" ], @@ -887,9 +887,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", - "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "version": "4.62.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.1.tgz", + "integrity": "sha512-X6n4yZUYAGSZTsIRjHUFkRZy/ml+EyS5vsgnyUOfhflKros0TEjX9yAoFqiRdJSfmykStVUyfcFDy/tHJ64JuQ==", "cpu": [ "s390x" ], @@ -901,9 +901,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", - "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "version": "4.62.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.1.tgz", + "integrity": "sha512-nVQGk/jStQc2V4rrkI+vPD2J+85boKqS4R4nOdPhc3eWw0kyW/b+AYRGoH8qo057XSVqaTx13AliH5qPeLTtgQ==", "cpu": [ "x64" ], @@ -915,9 +915,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", - "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "version": "4.62.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.1.tgz", + "integrity": "sha512-Ae54IyMwpY3JsYjBH4k29vQ9FSoILwJdh7j7c9lmLOczKnU/WL5jMRL9epsgPrs+ph48YVTsy6PkQDq0nK8Kvg==", "cpu": [ "x64" ], @@ -929,9 +929,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", - "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "version": "4.62.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.1.tgz", + "integrity": "sha512-7oOS0UqUXLRi2dVeEXdQxbml854xxQSx+6Pdnuo4G0iAIRiPBCIyzhLIv8oSmvqLkAftGaRk+ft70fVHXjsXsQ==", "cpu": [ "x64" ], @@ -943,9 +943,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", - "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "version": "4.62.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.1.tgz", + "integrity": "sha512-e6kAhhmUK3pwICnBtsQFkg/czVxFlY5e4Ppi4fuXWvOwiHOXlgQMEvpg0H5ceuEh2T1nyI0U6SfhV3qojKWpAg==", "cpu": [ "arm64" ], @@ -957,9 +957,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", - "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "version": "4.62.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.1.tgz", + "integrity": "sha512-xXRJSv00uVmj5DwS9DwIvS+Re5VdDnaspDfk7GzsnhP1IbTzFjJwhY+c3j3jr/2pP/prBrXvZ1OmjjhkkAOUlQ==", "cpu": [ "arm64" ], @@ -971,9 +971,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", - "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "version": "4.62.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.1.tgz", + "integrity": "sha512-D3S8+6cSEW0QZZHcKKDQ/Fsz/eqvYmJbtkZZziFxEb4Fi4fyWTCaMs1p5siQ85/T6gNdYKJ3OIJ4M/phYQgICA==", "cpu": [ "ia32" ], @@ -985,9 +985,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", - "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "version": "4.62.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.1.tgz", + "integrity": "sha512-CRVGPQKdEB/ujGfrq3SgITWc2N9iWM+sqaBKHh62Dc6xRLQGTVrqHpOVEitfly941kr244j14sswRw47bmMjjg==", "cpu": [ "x64" ], @@ -999,9 +999,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", - "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "version": "4.62.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.1.tgz", + "integrity": "sha512-/N8QHE1y6A9nmN3HCIFZWr5FUu/rKcT/A7JgaMJH3dcvL5RS++o0brK5SitYVTis/dJFiasK7Xva0cqeWYmCzQ==", "cpu": [ "x64" ], @@ -1059,17 +1059,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", - "integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", + "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.60.1", - "@typescript-eslint/type-utils": "8.60.1", - "@typescript-eslint/utils": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/type-utils": "8.61.1", + "@typescript-eslint/utils": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1082,7 +1082,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.60.1", + "@typescript-eslint/parser": "^8.61.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -1098,16 +1098,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz", - "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz", + "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.60.1", - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/typescript-estree": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", "debug": "^4.4.3" }, "engines": { @@ -1123,14 +1123,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz", - "integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz", + "integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.60.1", - "@typescript-eslint/types": "^8.60.1", + "@typescript-eslint/tsconfig-utils": "^8.61.1", + "@typescript-eslint/types": "^8.61.1", "debug": "^4.4.3" }, "engines": { @@ -1145,14 +1145,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz", - "integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz", + "integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1" + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1163,9 +1163,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz", - "integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz", + "integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==", "dev": true, "license": "MIT", "engines": { @@ -1180,15 +1180,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz", - "integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz", + "integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/typescript-estree": "8.60.1", - "@typescript-eslint/utils": "8.60.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1205,9 +1205,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz", - "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz", + "integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==", "dev": true, "license": "MIT", "engines": { @@ -1219,16 +1219,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz", - "integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz", + "integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.60.1", - "@typescript-eslint/tsconfig-utils": "8.60.1", - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/visitor-keys": "8.60.1", + "@typescript-eslint/project-service": "8.61.1", + "@typescript-eslint/tsconfig-utils": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1247,16 +1247,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz", - "integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz", + "integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.60.1", - "@typescript-eslint/types": "8.60.1", - "@typescript-eslint/typescript-estree": "8.60.1" + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1271,13 +1271,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz", - "integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz", + "integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/types": "8.61.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1289,16 +1289,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", - "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1307,13 +1307,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", - "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.8", + "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1334,9 +1334,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", - "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", "dev": true, "license": "MIT", "dependencies": { @@ -1347,13 +1347,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", - "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.9", "pathe": "^2.0.3" }, "funding": { @@ -1361,14 +1361,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", - "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1377,9 +1377,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", - "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", "dev": true, "license": "MIT", "funding": { @@ -1387,13 +1387,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", - "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", + "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1402,9 +1402,9 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", "bin": { @@ -1594,11 +1594,14 @@ } }, "node_modules/eslint": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz", - "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz", + "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -2057,9 +2060,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.13", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz", + "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==", "dev": true, "funding": [ { @@ -2083,9 +2086,9 @@ "license": "MIT" }, "node_modules/obug": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", - "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -2233,9 +2236,9 @@ } }, "node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", "dev": true, "license": "MIT", "bin": { @@ -2259,9 +2262,9 @@ } }, "node_modules/rollup": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", - "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "version": "4.62.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.1.tgz", + "integrity": "sha512-XTvxjHHM/0J/WZBg+ehDbAZgIpZoIZtWO+aImyuhjoyQa56NBX/bqnXw32rT27fkjSRrqthOgkLjRVtwXFI7jQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2275,38 +2278,38 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.61.1", - "@rollup/rollup-android-arm64": "4.61.1", - "@rollup/rollup-darwin-arm64": "4.61.1", - "@rollup/rollup-darwin-x64": "4.61.1", - "@rollup/rollup-freebsd-arm64": "4.61.1", - "@rollup/rollup-freebsd-x64": "4.61.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", - "@rollup/rollup-linux-arm-musleabihf": "4.61.1", - "@rollup/rollup-linux-arm64-gnu": "4.61.1", - "@rollup/rollup-linux-arm64-musl": "4.61.1", - "@rollup/rollup-linux-loong64-gnu": "4.61.1", - "@rollup/rollup-linux-loong64-musl": "4.61.1", - "@rollup/rollup-linux-ppc64-gnu": "4.61.1", - "@rollup/rollup-linux-ppc64-musl": "4.61.1", - "@rollup/rollup-linux-riscv64-gnu": "4.61.1", - "@rollup/rollup-linux-riscv64-musl": "4.61.1", - "@rollup/rollup-linux-s390x-gnu": "4.61.1", - "@rollup/rollup-linux-x64-gnu": "4.61.1", - "@rollup/rollup-linux-x64-musl": "4.61.1", - "@rollup/rollup-openbsd-x64": "4.61.1", - "@rollup/rollup-openharmony-arm64": "4.61.1", - "@rollup/rollup-win32-arm64-msvc": "4.61.1", - "@rollup/rollup-win32-ia32-msvc": "4.61.1", - "@rollup/rollup-win32-x64-gnu": "4.61.1", - "@rollup/rollup-win32-x64-msvc": "4.61.1", + "@rollup/rollup-android-arm-eabi": "4.62.1", + "@rollup/rollup-android-arm64": "4.62.1", + "@rollup/rollup-darwin-arm64": "4.62.1", + "@rollup/rollup-darwin-x64": "4.62.1", + "@rollup/rollup-freebsd-arm64": "4.62.1", + "@rollup/rollup-freebsd-x64": "4.62.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.1", + "@rollup/rollup-linux-arm-musleabihf": "4.62.1", + "@rollup/rollup-linux-arm64-gnu": "4.62.1", + "@rollup/rollup-linux-arm64-musl": "4.62.1", + "@rollup/rollup-linux-loong64-gnu": "4.62.1", + "@rollup/rollup-linux-loong64-musl": "4.62.1", + "@rollup/rollup-linux-ppc64-gnu": "4.62.1", + "@rollup/rollup-linux-ppc64-musl": "4.62.1", + "@rollup/rollup-linux-riscv64-gnu": "4.62.1", + "@rollup/rollup-linux-riscv64-musl": "4.62.1", + "@rollup/rollup-linux-s390x-gnu": "4.62.1", + "@rollup/rollup-linux-x64-gnu": "4.62.1", + "@rollup/rollup-linux-x64-musl": "4.62.1", + "@rollup/rollup-openbsd-x64": "4.62.1", + "@rollup/rollup-openharmony-arm64": "4.62.1", + "@rollup/rollup-win32-arm64-msvc": "4.62.1", + "@rollup/rollup-win32-ia32-msvc": "4.62.1", + "@rollup/rollup-win32-x64-gnu": "4.62.1", + "@rollup/rollup-win32-x64-msvc": "4.62.1", "fsevents": "~2.3.2" } }, "node_modules/semver": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz", - "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "dev": true, "license": "ISC", "bin": { @@ -2455,16 +2458,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.60.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.1.tgz", - "integrity": "sha512-6m5hkkRAp8lKvhVpcprAIn5KkehQEh+47oHH2VGnExEh7dhNxXlg6GPAOIu6TxbVQxhebrJDvjl3020ooiWCMA==", + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz", + "integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.60.1", - "@typescript-eslint/parser": "8.60.1", - "@typescript-eslint/typescript-estree": "8.60.1", - "@typescript-eslint/utils": "8.60.1" + "@typescript-eslint/eslint-plugin": "8.61.1", + "@typescript-eslint/parser": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2564,19 +2567,19 @@ } }, "node_modules/vitest": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", - "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.8", - "@vitest/mocker": "4.1.8", - "@vitest/pretty-format": "4.1.8", - "@vitest/runner": "4.1.8", - "@vitest/snapshot": "4.1.8", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -2604,12 +2607,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.8", - "@vitest/browser-preview": "4.1.8", - "@vitest/browser-webdriverio": "4.1.8", - "@vitest/coverage-istanbul": "4.1.8", - "@vitest/coverage-v8": "4.1.8", - "@vitest/ui": "4.1.8", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/compose.yaml b/compose.yaml index 4b6b7a6..6fa4cf8 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,53 +1,53 @@ -# Recall — local app stack: backend (PHP) + frontend (Vite). -# Storage is SQLite, embedded in the backend (a single-user tool needs no DB server). -# -# Зависимости живут в именованных томах (backend_vendor / frontend_node_modules), -# а не в bind-mount: контейнерный `composer install --no-dev` / `npm install` -# не затирает локальные dev-инструменты и не оставляет на хосте файлы от root. - -services: - # One-shot: install backend dependencies into the named volume before the server starts. - backend-deps: - image: composer:2 - working_dir: /app - volumes: - - ./app/backend:/app - - backend_vendor:/app/vendor - command: composer install --no-dev --no-interaction --ignore-platform-reqs - - backend: - image: php:8.5-cli - working_dir: /app - # Constrained to mimic the client's small server (see README "Производительность"). - # Limits come from .env.example (single source; the guide renders them too). - command: php -d memory_limit=${RECALL_PHP_MEMORY_LIMIT:-96M} -S 0.0.0.0:8080 -t public public/index.php - environment: - # Ephemeral DB inside the container: a fresh, seeded database on every `up`. - RECALL_DB: /tmp/recall.sqlite - volumes: - - ./app/backend:/app - - backend_vendor:/app/vendor - ports: - - "${RECALL_BACKEND_PORT}:8080" - mem_limit: ${RECALL_CONTAINER_MEMORY_LIMIT:-256M} - depends_on: - backend-deps: - condition: service_completed_successfully - - frontend: - image: node:22 - working_dir: /app - command: sh -c "npm install && npm run dev" - environment: - VITE_API_BASE: http://localhost:${RECALL_BACKEND_PORT} - volumes: - - ./app/frontend:/app - - frontend_node_modules:/app/node_modules - ports: - - "${RECALL_FRONTEND_PORT}:5173" - depends_on: - - backend - -volumes: - backend_vendor: - frontend_node_modules: +# Recall — local app stack: backend (PHP) + frontend (Vite). +# Storage is SQLite, embedded in the backend (a single-user tool needs no DB server). +# +# Зависимости живут в именованных томах (backend_vendor / frontend_node_modules), +# а не в bind-mount: контейнерный `composer install --no-dev` / `npm install` +# не затирает локальные dev-инструменты и не оставляет на хосте файлы от root. + +services: + # One-shot: install backend dependencies into the named volume before the server starts. + backend-deps: + image: composer:2 + working_dir: /app + volumes: + - ./app/backend:/app + - backend_vendor:/app/vendor + command: composer install --no-dev --no-interaction --ignore-platform-reqs + + backend: + image: php:8.5-cli + working_dir: /app + # Constrained to mimic the client's small server (see README "Производительность"). + # Limits come from .env.example (single source; the guide renders them too). + command: php -d memory_limit=${RECALL_PHP_MEMORY_LIMIT:-96M} -S 0.0.0.0:8080 -t public public/index.php + environment: + # Ephemeral DB inside the container: a fresh, seeded database on every `up`. + RECALL_DB: /tmp/recall.sqlite + volumes: + - ./app/backend:/app + - backend_vendor:/app/vendor + ports: + - "${RECALL_BACKEND_PORT}:8080" + mem_limit: ${RECALL_CONTAINER_MEMORY_LIMIT:-256M} + depends_on: + backend-deps: + condition: service_completed_successfully + + frontend: + image: node:22 + working_dir: /app + command: sh -c "npm install && npm run dev" + environment: + VITE_API_BASE: http://localhost:${RECALL_BACKEND_PORT} + volumes: + - ./app/frontend:/app + - frontend_node_modules:/app/node_modules + ports: + - "${RECALL_FRONTEND_PORT}:5173" + depends_on: + - backend + +volumes: + backend_vendor: + frontend_node_modules: diff --git a/devbox.d/php/php-fpm.conf b/devbox.d/php/php-fpm.conf index b935957..3a5d45a 100644 --- a/devbox.d/php/php-fpm.conf +++ b/devbox.d/php/php-fpm.conf @@ -1,17 +1,17 @@ -[global] -pid = ${PHPFPM_PID_FILE} -error_log = ${PHPFPM_ERROR_LOG_FILE} -daemonize = yes - -[www] -; user = www-data -; group = www-data -listen = 127.0.0.1:${PHPFPM_PORT} -; listen.owner = www-data -; listen.group = www-data -pm = dynamic -pm.max_children = 5 -pm.start_servers = 2 -pm.min_spare_servers = 1 -pm.max_spare_servers = 3 -chdir = / +[global] +pid = ${PHPFPM_PID_FILE} +error_log = ${PHPFPM_ERROR_LOG_FILE} +daemonize = yes + +[www] +; user = www-data +; group = www-data +listen = 127.0.0.1:${PHPFPM_PORT} +; listen.owner = www-data +; listen.group = www-data +pm = dynamic +pm.max_children = 5 +pm.start_servers = 2 +pm.min_spare_servers = 1 +pm.max_spare_servers = 3 +chdir = / diff --git a/devbox.d/php/php.ini b/devbox.d/php/php.ini index b4626d5..8ef6bee 100644 --- a/devbox.d/php/php.ini +++ b/devbox.d/php/php.ini @@ -1,6 +1,6 @@ -[php] - -; Put your php.ini directives here. For the latest default php.ini file, see https://github.com/php/php-src/blob/master/php.ini-production - -; memory_limit = 128M -; expose_php = Off +[php] + +; Put your php.ini directives here. For the latest default php.ini file, see https://github.com/php/php-src/blob/master/php.ini-production + +; memory_limit = 128M +; expose_php = Off diff --git a/devbox.json b/devbox.json index eaebccf..deb2711 100644 --- a/devbox.json +++ b/devbox.json @@ -1,14 +1,14 @@ -{ - "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.16.0/.schema/devbox.schema.json", - "packages": [ - "php@8.5", - "nodejs@22", - "hurl@latest", - "just@latest", - "typst@latest", - "parallel@latest", - "github:tola-rs/tola-ssg/v0.7.1", - "github:mlavrinenko/linecop/v0.3.0", - "github:mlavrinenko/outdatty/v0.1.0" - ] -} +{ + "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.16.0/.schema/devbox.schema.json", + "packages": [ + "php@8.5", + "nodejs@22", + "hurl@latest", + "just@latest", + "typst@latest", + "parallel@latest", + "github:tola-rs/tola-ssg/v0.7.1", + "github:mlavrinenko/linecop/v0.3.0", + "github:mlavrinenko/outdatty/v0.1.0" + ] +} diff --git a/devbox.lock b/devbox.lock index bb31cb2..852a7ce 100644 --- a/devbox.lock +++ b/devbox.lock @@ -151,7 +151,7 @@ }, "nodejs@22": { "last_modified": "2026-05-21T08:15:18Z", - "plugin_version": "0.0.2", + "plugin_version": "0.0.3", "resolved": "github:NixOS/nixpkgs/4a29d733e8a7d5b824c3d8c958a946a9867b3eb2#nodejs_22", "source": "devbox-search", "version": "22.22.3", diff --git a/e2e/package-lock.json b/e2e/package-lock.json index a7b0102..c7d119f 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,78 +1,78 @@ -{ - "name": "recall-e2e", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "recall-e2e", - "version": "0.1.0", - "devDependencies": { - "@playwright/test": "1.56.1" - } - }, - "node_modules/@playwright/test": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", - "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.56.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/playwright": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", - "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.56.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", - "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - } - } -} +{ + "name": "recall-e2e", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "recall-e2e", + "version": "0.1.0", + "devDependencies": { + "@playwright/test": "1.56.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", + "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", + "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.56.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json index 81d2a6e..5157df7 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,13 +1,13 @@ -{ - "name": "recall-e2e", - "description": "Recall — браузерные сценарии (Playwright)", - "private": true, - "version": "0.1.0", - "type": "module", - "scripts": { - "test": "playwright test" - }, - "devDependencies": { - "@playwright/test": "1.56.1" - } -} +{ + "name": "recall-e2e", + "description": "Recall — браузерные сценарии (Playwright)", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.56.1" + } +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 21edfb1..45084c8 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,20 +1,20 @@ -import { defineConfig, devices } from "@playwright/test"; - -// The frontend URL. `just verify` brings the app up (compose) before running -// these flows, so the config does not start servers itself. -const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:5173"; - -export default defineConfig({ - testDir: "./tests", - fullyParallel: false, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 1 : 0, - reporter: process.env.CI ? "list" : "line", - use: { - baseURL, - trace: "on-first-retry", - }, - projects: [ - { name: "chromium", use: { ...devices["Desktop Chrome"] } }, - ], -}); +import { defineConfig, devices } from "@playwright/test"; + +// The frontend URL. `just verify` brings the app up (compose) before running +// these flows, so the config does not start servers itself. +const baseURL = process.env.E2E_BASE_URL ?? "http://localhost:5173"; + +export default defineConfig({ + testDir: "./tests", + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? "list" : "line", + use: { + baseURL, + trace: "on-first-retry", + }, + projects: [ + { name: "chromium", use: { ...devices["Desktop Chrome"] } }, + ], +}); diff --git a/e2e/tests/notes.spec.ts b/e2e/tests/notes.spec.ts index 07c2dee..b4e711d 100644 --- a/e2e/tests/notes.spec.ts +++ b/e2e/tests/notes.spec.ts @@ -1,14 +1,14 @@ -import { test, expect } from "@playwright/test"; - -test("a created note appears in the list", async ({ page }) => { - await page.goto("/"); - - const title = `E2E note ${Date.now()}`; - await page.fill("#note-form input[name='title']", title); - await page.fill("#note-form input[name='tags']", "e2e, demo"); - await page.click("#note-form button[type='submit']"); - - await expect( - page.locator("[data-testid='note']", { hasText: title }), - ).toBeVisible(); -}); +import { test, expect } from "@playwright/test"; + +test("a created note appears in the list", async ({ page }) => { + await page.goto("/"); + + const title = `E2E note ${Date.now()}`; + await page.fill("#note-form input[name='title']", title); + await page.fill("#note-form input[name='tags']", "e2e, demo"); + await page.click("#note-form button[type='submit']"); + + await expect( + page.locator("[data-testid='note']", { hasText: title }), + ).toBeVisible(); +}); diff --git a/e2e/tests/review.spec.ts b/e2e/tests/review.spec.ts index 62dbd1c..99272db 100644 --- a/e2e/tests/review.spec.ts +++ b/e2e/tests/review.spec.ts @@ -1,23 +1,23 @@ -import { test, expect } from "@playwright/test"; - -test("grading a due card removes it from the queue", async ({ page }) => { - await page.goto("/"); - - const cards = page.locator("[data-testid='queue-card']"); - await expect(cards.first()).toBeVisible(); - - const firstId = await cards.first().getAttribute("data-id"); - const before = await cards.count(); - - await page.locator("[data-testid='queue-card']").first() - .locator("[data-testid='grade-good']") - .click(); - - // The graded card is rescheduled into the future, so it leaves today's queue. - await expect( - page.locator(`[data-testid='queue-card'][data-id='${firstId}']`), - ).toHaveCount(0); - - const after = await page.locator("[data-testid='queue-card']").count(); - expect(after).toBeLessThan(before); -}); +import { test, expect } from "@playwright/test"; + +test("grading a due card removes it from the queue", async ({ page }) => { + await page.goto("/"); + + const cards = page.locator("[data-testid='queue-card']"); + await expect(cards.first()).toBeVisible(); + + const firstId = await cards.first().getAttribute("data-id"); + const before = await cards.count(); + + await page.locator("[data-testid='queue-card']").first() + .locator("[data-testid='grade-good']") + .click(); + + // The graded card is rescheduled into the future, so it leaves today's queue. + await expect( + page.locator(`[data-testid='queue-card'][data-id='${firstId}']`), + ).toHaveCount(0); + + const after = await page.locator("[data-testid='queue-card']").count(); + expect(after).toBeLessThan(before); +}); diff --git a/outdatty.lock b/outdatty.lock index a6d8419..0ef9722 100644 --- a/outdatty.lock +++ b/outdatty.lock @@ -1,57 +1,57 @@ -version: 1 -algorithm: blake3 -groups: - api-surface: - source: - spec/api/openapi.yaml: ab9c8ba2dbd1cd1a692a5e4bd62e5e4c71a076c08be9ef5a8316f7e068fe637e - dependents: - www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d - www/utils/glossary.typ: 26c7e80019b4f737ee8f359fab52f268018a370051c8c86818b156e558c05892 - backend-stack: - source: - app/backend/composer.json: affae8dc08e919c50426844bbdd6b4ab76797d4e7957e210c445ff4a670ccdc0 - compose.yaml: 620a520be1cafed7b84e3a3de3cc79189cdba0e972d8e5f53db51a097a34b285 - dependents: - www/content/stack.typ: 8ed4066fd5b324331297dd50a3b88fafe76668342af0c974808aefd01a009031 - www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d - commands: - source: - Justfile: ef1c6f7a372a855277bee5f724a31bdefd1368f1baf24f2f08f3e6a5984e599a - dependents: - www/content/index.typ: 8cd9d3668f05e8e02ec0008b8b280df43d07e058c1c5a91abdf9b78a19033210 - www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d - domain-model: - source: - app/backend/src/Domain/Card.php: e0677882247a9f8df2aa5d221e0993eb5fbf549e437a66cecacc13ed19fdcf82 - app/backend/src/Domain/Grade.php: ef3238e498d5c52d921ac4c953034db833f085d64cbc170932d5b96eeb95f68a - app/backend/src/Domain/Note.php: 7fe1af898b3fb83f065e18b588834a5cb4a5821d4066a1d33d41d5a93fa34d48 - dependents: - www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d - www/utils/glossary.typ: 26c7e80019b4f737ee8f359fab52f268018a370051c8c86818b156e558c05892 - frontend-stack: - source: - app/frontend/package.json: bc17e2cc52e9a2eb4c4ffcf6c160cd2b5551f3fa8a1d275baba26a4f694eb387 - dependents: - www/content/stack.typ: 8ed4066fd5b324331297dd50a3b88fafe76668342af0c974808aefd01a009031 - www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d - logbook-cap: - source: - .linecop.yaml: 2827b8330d9cfcf6b999e9760b3a49002ee8326e9457325cabac417512c8a1ec - dependents: - www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d - repo-layout: - source: - Justfile: ef1c6f7a372a855277bee5f724a31bdefd1368f1baf24f2f08f3e6a5984e599a - compose.yaml: 620a520be1cafed7b84e3a3de3cc79189cdba0e972d8e5f53db51a097a34b285 - devbox.json: 66bbcef55a4654318df272586c8b12ed0d79e3b70242dd25af77e22cff3e1c13 - dependents: - www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d - static-analysis: - source: - app/backend/phpmd.xml: 2817595c64656aeef87c4299d713bc9ab04201876fadce534c63687285b54b96 - app/backend/phpstan.dist.neon: 81f53777902b546a4f6415321d275b19340e524dcd1ac7b521842628e0ee0386 - app/backend/rector.php: 410098cdf2d0b5a96d1be441b17e9e699f716a842be2cd95e6ea6dc239ae7f67 - app/frontend/eslint.config.js: f4be9f9cccbe6c4010c349bb58c9023121bfa90df72b6607ad325b615eff6715 - app/frontend/tsconfig.json: b54cdbd4eb3b9b84ca004ba260a80fb25f22128c20db85f4640a97479e4b927c - dependents: - www/content/stack.typ: 8ed4066fd5b324331297dd50a3b88fafe76668342af0c974808aefd01a009031 +version: 1 +algorithm: blake3 +groups: + api-surface: + source: + spec/api/openapi.yaml: ab9c8ba2dbd1cd1a692a5e4bd62e5e4c71a076c08be9ef5a8316f7e068fe637e + dependents: + www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d + www/utils/glossary.typ: 26c7e80019b4f737ee8f359fab52f268018a370051c8c86818b156e558c05892 + backend-stack: + source: + app/backend/composer.json: affae8dc08e919c50426844bbdd6b4ab76797d4e7957e210c445ff4a670ccdc0 + compose.yaml: 620a520be1cafed7b84e3a3de3cc79189cdba0e972d8e5f53db51a097a34b285 + dependents: + www/content/stack.typ: 8ed4066fd5b324331297dd50a3b88fafe76668342af0c974808aefd01a009031 + www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d + commands: + source: + Justfile: ef1c6f7a372a855277bee5f724a31bdefd1368f1baf24f2f08f3e6a5984e599a + dependents: + www/content/index.typ: 8cd9d3668f05e8e02ec0008b8b280df43d07e058c1c5a91abdf9b78a19033210 + www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d + domain-model: + source: + app/backend/src/Domain/Card.php: e0677882247a9f8df2aa5d221e0993eb5fbf549e437a66cecacc13ed19fdcf82 + app/backend/src/Domain/Grade.php: ef3238e498d5c52d921ac4c953034db833f085d64cbc170932d5b96eeb95f68a + app/backend/src/Domain/Note.php: 7fe1af898b3fb83f065e18b588834a5cb4a5821d4066a1d33d41d5a93fa34d48 + dependents: + www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d + www/utils/glossary.typ: 26c7e80019b4f737ee8f359fab52f268018a370051c8c86818b156e558c05892 + frontend-stack: + source: + app/frontend/package.json: bc17e2cc52e9a2eb4c4ffcf6c160cd2b5551f3fa8a1d275baba26a4f694eb387 + dependents: + www/content/stack.typ: 8ed4066fd5b324331297dd50a3b88fafe76668342af0c974808aefd01a009031 + www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d + logbook-cap: + source: + .linecop.yaml: 2827b8330d9cfcf6b999e9760b3a49002ee8326e9457325cabac417512c8a1ec + dependents: + www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d + repo-layout: + source: + Justfile: ef1c6f7a372a855277bee5f724a31bdefd1368f1baf24f2f08f3e6a5984e599a + compose.yaml: 620a520be1cafed7b84e3a3de3cc79189cdba0e972d8e5f53db51a097a34b285 + devbox.json: 66bbcef55a4654318df272586c8b12ed0d79e3b70242dd25af77e22cff3e1c13 + dependents: + www/content/task.typ: ebf6e0b273d849e2cf1a276ed5502ed8b9c2f8bb05f814d8e83e653087f2521d + static-analysis: + source: + app/backend/phpmd.xml: 2817595c64656aeef87c4299d713bc9ab04201876fadce534c63687285b54b96 + app/backend/phpstan.dist.neon: 81f53777902b546a4f6415321d275b19340e524dcd1ac7b521842628e0ee0386 + app/backend/rector.php: 410098cdf2d0b5a96d1be441b17e9e699f716a842be2cd95e6ea6dc239ae7f67 + app/frontend/eslint.config.js: f4be9f9cccbe6c4010c349bb58c9023121bfa90df72b6607ad325b615eff6715 + app/frontend/tsconfig.json: b54cdbd4eb3b9b84ca004ba260a80fb25f22128c20db85f4640a97479e4b927c + dependents: + www/content/stack.typ: 8ed4066fd5b324331297dd50a3b88fafe76668342af0c974808aefd01a009031 diff --git a/outdatty.yaml b/outdatty.yaml index 880c214..63a1c3e 100644 --- a/outdatty.yaml +++ b/outdatty.yaml @@ -1,74 +1,74 @@ -# Doc-sync tripwires: when a source artifact changes, outdatty fails until the -# guide pages that describe it are re-confirmed (`outdatty update` re-signs the -# blake3 lockfile). Hash-based; runs no commands. -# -# Maintainer-side only — wired into .github/workflows/docs-sync.yml with a -# repository guard, NOT into `just verify`. Candidates fork and change sources -# as part of the task; they must never be gated by our guide's lockfile. -# -# Values quoted verbatim in the guide (memory limits, PHP/Slim versions, the -# LOGBOOK line cap) are NOT tripwired here — they are rendered live via Typst -# data-loading (www/utils/env.typ, www/utils/repo.typ) and cannot drift. These -# groups guard the prose, structure and behaviour that data-loading can't. - -groups: - - name: commands - # Recipe names quoted in the guide's run flow and command list. - source: [Justfile] - dependents: - - www/content/index.typ - - www/content/task.typ - - - name: repo-layout - # The "Структура репозитория" tree and the run flow. - source: [compose.yaml, Justfile, devbox.json] - dependents: - - www/content/task.typ - - - name: backend-stack - # Slim/Cycle/SQLite/UUIDv7 prose + the static stack line in the repo tree. - source: [app/backend/composer.json, compose.yaml] - dependents: - - www/content/stack.typ - - www/content/task.typ - - - name: frontend-stack - # "TypeScript + Vite" claims. - source: [app/frontend/package.json] - dependents: - - www/content/stack.typ - - www/content/task.typ - - - name: static-analysis - # PHPStan max+strict / PHPMD / Rector / ESLint type-aware / Prettier claims. - source: - - app/backend/phpstan.dist.neon - - app/backend/phpmd.xml - - app/backend/rector.php - - app/frontend/eslint.config.js - - app/frontend/tsconfig.json - dependents: - - www/content/stack.typ - - - name: domain-model - # Entities + the again/hard/good/easy grade scale shown in guide/glossary. - source: - - app/backend/src/Domain/Note.php - - app/backend/src/Domain/Card.php - - app/backend/src/Domain/Grade.php - dependents: - - www/content/task.typ - - www/utils/glossary.typ - - - name: api-surface - # "Что уже есть" feature list + OpenAPI-as-source-of-truth claim. - source: [spec/api/openapi.yaml] - dependents: - - www/content/task.typ - - www/utils/glossary.typ - - - name: logbook-cap - # index.typ renders the cap live; the repo tree in task.typ still states it. - source: [.linecop.yaml] - dependents: - - www/content/task.typ +# Doc-sync tripwires: when a source artifact changes, outdatty fails until the +# guide pages that describe it are re-confirmed (`outdatty update` re-signs the +# blake3 lockfile). Hash-based; runs no commands. +# +# Maintainer-side only — wired into .github/workflows/docs-sync.yml with a +# repository guard, NOT into `just verify`. Candidates fork and change sources +# as part of the task; they must never be gated by our guide's lockfile. +# +# Values quoted verbatim in the guide (memory limits, PHP/Slim versions, the +# LOGBOOK line cap) are NOT tripwired here — they are rendered live via Typst +# data-loading (www/utils/env.typ, www/utils/repo.typ) and cannot drift. These +# groups guard the prose, structure and behaviour that data-loading can't. + +groups: + - name: commands + # Recipe names quoted in the guide's run flow and command list. + source: [Justfile] + dependents: + - www/content/index.typ + - www/content/task.typ + + - name: repo-layout + # The "Структура репозитория" tree and the run flow. + source: [compose.yaml, Justfile, devbox.json] + dependents: + - www/content/task.typ + + - name: backend-stack + # Slim/Cycle/SQLite/UUIDv7 prose + the static stack line in the repo tree. + source: [app/backend/composer.json, compose.yaml] + dependents: + - www/content/stack.typ + - www/content/task.typ + + - name: frontend-stack + # "TypeScript + Vite" claims. + source: [app/frontend/package.json] + dependents: + - www/content/stack.typ + - www/content/task.typ + + - name: static-analysis + # PHPStan max+strict / PHPMD / Rector / ESLint type-aware / Prettier claims. + source: + - app/backend/phpstan.dist.neon + - app/backend/phpmd.xml + - app/backend/rector.php + - app/frontend/eslint.config.js + - app/frontend/tsconfig.json + dependents: + - www/content/stack.typ + + - name: domain-model + # Entities + the again/hard/good/easy grade scale shown in guide/glossary. + source: + - app/backend/src/Domain/Note.php + - app/backend/src/Domain/Card.php + - app/backend/src/Domain/Grade.php + dependents: + - www/content/task.typ + - www/utils/glossary.typ + + - name: api-surface + # "Что уже есть" feature list + OpenAPI-as-source-of-truth claim. + source: [spec/api/openapi.yaml] + dependents: + - www/content/task.typ + - www/utils/glossary.typ + + - name: logbook-cap + # index.typ renders the cap live; the repo tree in task.typ still states it. + source: [.linecop.yaml] + dependents: + - www/content/task.typ diff --git a/preflight-baseline.txt b/preflight-baseline.txt index 3ca5547..adf762b 100644 --- a/preflight-baseline.txt +++ b/preflight-baseline.txt @@ -1,2 +1,2 @@ -acceptance reviews.hurl -e2e grading a due card removes it from the queue +acceptance reviews.hurl +e2e grading a due card removes it from the queue diff --git a/spec/acceptance/cards.hurl b/spec/acceptance/cards.hurl index 0cc55e8..3061275 100644 --- a/spec/acceptance/cards.hurl +++ b/spec/acceptance/cards.hurl @@ -1,52 +1,52 @@ -# Cards: created from a note, then read and listed. - -# A card needs a note. -POST {{base}}/notes -Content-Type: application/json -{ - "title": "Capital of France", - "body": "Paris." -} -HTTP 201 -[Captures] -note_id: jsonpath "$.id" - -# Create a card from that note. -POST {{base}}/cards -Content-Type: application/json -{ - "note_id": "{{note_id}}", - "front": "Capital of France?", - "back": "Paris" -} -HTTP 201 -[Captures] -card_id: jsonpath "$.id" -[Asserts] -jsonpath "$.note_id" == {{note_id}} -jsonpath "$.front" == "Capital of France?" -jsonpath "$.interval" isInteger -jsonpath "$.due" matches /^\d{4}-\d{2}-\d{2}$/ - -# Read it back. -GET {{base}}/cards/{{card_id}} -HTTP 200 -[Asserts] -jsonpath "$.back" == "Paris" - -# It shows up in the list. -GET {{base}}/cards -HTTP 200 -[Asserts] -jsonpath "$" isCollection -jsonpath "$[*].id" contains {{card_id}} - -# A card needs an existing note. -POST {{base}}/cards -Content-Type: application/json -{ - "note_id": 999999, - "front": "x", - "back": "y" -} -HTTP 404 +# Cards: created from a note, then read and listed. + +# A card needs a note. +POST {{base}}/notes +Content-Type: application/json +{ + "title": "Capital of France", + "body": "Paris." +} +HTTP 201 +[Captures] +note_id: jsonpath "$.id" + +# Create a card from that note. +POST {{base}}/cards +Content-Type: application/json +{ + "note_id": "{{note_id}}", + "front": "Capital of France?", + "back": "Paris" +} +HTTP 201 +[Captures] +card_id: jsonpath "$.id" +[Asserts] +jsonpath "$.note_id" == {{note_id}} +jsonpath "$.front" == "Capital of France?" +jsonpath "$.interval" isInteger +jsonpath "$.due" matches /^\d{4}-\d{2}-\d{2}$/ + +# Read it back. +GET {{base}}/cards/{{card_id}} +HTTP 200 +[Asserts] +jsonpath "$.back" == "Paris" + +# It shows up in the list. +GET {{base}}/cards +HTTP 200 +[Asserts] +jsonpath "$" isCollection +jsonpath "$[*].id" contains {{card_id}} + +# A card needs an existing note. +POST {{base}}/cards +Content-Type: application/json +{ + "note_id": 999999, + "front": "x", + "back": "y" +} +HTTP 404 diff --git a/spec/acceptance/notes.hurl b/spec/acceptance/notes.hurl index 3c85689..06c894c 100644 --- a/spec/acceptance/notes.hurl +++ b/spec/acceptance/notes.hurl @@ -1,60 +1,60 @@ -# Notes: create, read, update, filter by tag, delete. -# Loose by design — asserts observable behaviour, not internal representation. - -# Create a note. -POST {{base}}/notes -Content-Type: application/json -{ - "title": "Forgetting curve", - "body": "Memory decays unless refreshed.", - "tags": ["memory", "learning"] -} -HTTP 201 -[Captures] -note_id: jsonpath "$.id" -[Asserts] -jsonpath "$.title" == "Forgetting curve" -jsonpath "$.tags" contains "memory" -jsonpath "$.created_at" exists - -# Read it back. -GET {{base}}/notes/{{note_id}} -HTTP 200 -[Asserts] -jsonpath "$.id" == {{note_id}} -jsonpath "$.body" == "Memory decays unless refreshed." - -# Update it. -PUT {{base}}/notes/{{note_id}} -Content-Type: application/json -{ - "title": "Forgetting curve (Ebbinghaus)", - "body": "Memory decays unless refreshed.", - "tags": ["memory"] -} -HTTP 200 -[Asserts] -jsonpath "$.title" == "Forgetting curve (Ebbinghaus)" - -# Filter notes by an existing tag. -GET {{base}}/notes?tag=memory -HTTP 200 -[Asserts] -jsonpath "$" isCollection -jsonpath "$[*].id" contains {{note_id}} - -# Validation: a note needs a title. -POST {{base}}/notes -Content-Type: application/json -{ - "body": "no title here" -} -HTTP 422 - -# Delete it. -DELETE {{base}}/notes/{{note_id}} -HTTP 204 - -# Now it is gone. -GET {{base}}/notes/{{note_id}} -HTTP 404 +# Notes: create, read, update, filter by tag, delete. +# Loose by design — asserts observable behaviour, not internal representation. + +# Create a note. +POST {{base}}/notes +Content-Type: application/json +{ + "title": "Forgetting curve", + "body": "Memory decays unless refreshed.", + "tags": ["memory", "learning"] +} +HTTP 201 +[Captures] +note_id: jsonpath "$.id" +[Asserts] +jsonpath "$.title" == "Forgetting curve" +jsonpath "$.tags" contains "memory" +jsonpath "$.created_at" exists + +# Read it back. +GET {{base}}/notes/{{note_id}} +HTTP 200 +[Asserts] +jsonpath "$.id" == {{note_id}} +jsonpath "$.body" == "Memory decays unless refreshed." + +# Update it. +PUT {{base}}/notes/{{note_id}} +Content-Type: application/json +{ + "title": "Forgetting curve (Ebbinghaus)", + "body": "Memory decays unless refreshed.", + "tags": ["memory"] +} +HTTP 200 +[Asserts] +jsonpath "$.title" == "Forgetting curve (Ebbinghaus)" + +# Filter notes by an existing tag. +GET {{base}}/notes?tag=memory +HTTP 200 +[Asserts] +jsonpath "$" isCollection +jsonpath "$[*].id" contains {{note_id}} + +# Validation: a note needs a title. +POST {{base}}/notes +Content-Type: application/json +{ + "body": "no title here" +} +HTTP 422 + +# Delete it. +DELETE {{base}}/notes/{{note_id}} +HTTP 204 + +# Now it is gone. +GET {{base}}/notes/{{note_id}} +HTTP 404 diff --git a/spec/acceptance/reviews.hurl b/spec/acceptance/reviews.hurl index a04edf2..06baf8f 100644 --- a/spec/acceptance/reviews.hurl +++ b/spec/acceptance/reviews.hurl @@ -1,67 +1,67 @@ -# Reviews: a freshly created card is due now, can be graded, and gets rescheduled. -# Intervals are intentionally not pinned here — only that grading moves a card forward. - -POST {{base}}/notes -Content-Type: application/json -{ - "title": "HTTP status 201", - "body": "Created." -} -HTTP 201 -[Captures] -note_id: jsonpath "$.id" - -POST {{base}}/cards -Content-Type: application/json -{ - "note_id": "{{note_id}}", - "front": "What does 201 mean?", - "back": "Created" -} -HTTP 201 -[Captures] -card_id: jsonpath "$.id" - -# A new card is due today, so it appears in the queue. -GET {{base}}/reviews/queue -HTTP 200 -[Asserts] -jsonpath "$" isCollection -jsonpath "$[*].id" contains {{card_id}} - -# Grade it "good": it should move forward (interval grows, next_due is a date). -POST {{base}}/reviews/{{card_id}} -Content-Type: application/json -{ - "grade": "good" -} -HTTP 201 -[Asserts] -jsonpath "$.card_id" == {{card_id}} -jsonpath "$.grade" == "good" -jsonpath "$.interval" >= 1 -jsonpath "$.next_due" matches /^\d{4}-\d{2}-\d{2}$/ - -# Grading moved the card forward, so it must leave today's queue. -# (Only that it left — the interval value stays unpinned.) -GET {{base}}/reviews/queue -HTTP 200 -[Asserts] -jsonpath "$" isCollection -jsonpath "$[*].id" not contains {{card_id}} - -# An unknown grade is rejected. -POST {{base}}/reviews/{{card_id}} -Content-Type: application/json -{ - "grade": "perfect" -} -HTTP 422 - -# Grading a card that does not exist is a 404. -POST {{base}}/reviews/999999 -Content-Type: application/json -{ - "grade": "good" -} -HTTP 404 +# Reviews: a freshly created card is due now, can be graded, and gets rescheduled. +# Intervals are intentionally not pinned here — only that grading moves a card forward. + +POST {{base}}/notes +Content-Type: application/json +{ + "title": "HTTP status 201", + "body": "Created." +} +HTTP 201 +[Captures] +note_id: jsonpath "$.id" + +POST {{base}}/cards +Content-Type: application/json +{ + "note_id": "{{note_id}}", + "front": "What does 201 mean?", + "back": "Created" +} +HTTP 201 +[Captures] +card_id: jsonpath "$.id" + +# A new card is due today, so it appears in the queue. +GET {{base}}/reviews/queue +HTTP 200 +[Asserts] +jsonpath "$" isCollection +jsonpath "$[*].id" contains {{card_id}} + +# Grade it "good": it should move forward (interval grows, next_due is a date). +POST {{base}}/reviews/{{card_id}} +Content-Type: application/json +{ + "grade": "good" +} +HTTP 201 +[Asserts] +jsonpath "$.card_id" == {{card_id}} +jsonpath "$.grade" == "good" +jsonpath "$.interval" >= 1 +jsonpath "$.next_due" matches /^\d{4}-\d{2}-\d{2}$/ + +# Grading moved the card forward, so it must leave today's queue. +# (Only that it left — the interval value stays unpinned.) +GET {{base}}/reviews/queue +HTTP 200 +[Asserts] +jsonpath "$" isCollection +jsonpath "$[*].id" not contains {{card_id}} + +# An unknown grade is rejected. +POST {{base}}/reviews/{{card_id}} +Content-Type: application/json +{ + "grade": "perfect" +} +HTTP 422 + +# Grading a card that does not exist is a 404. +POST {{base}}/reviews/999999 +Content-Type: application/json +{ + "grade": "good" +} +HTTP 404 diff --git a/spec/acceptance/stats.hurl b/spec/acceptance/stats.hurl index f36d332..d112edf 100644 --- a/spec/acceptance/stats.hurl +++ b/spec/acceptance/stats.hurl @@ -1,12 +1,12 @@ -# Stats: shape and basic monotonicity only. -# The exact counting rules are part of the task, so they are not pinned here. - -GET {{base}}/stats -HTTP 200 -[Asserts] -jsonpath "$.due_today" isInteger -jsonpath "$.due_today" >= 0 -jsonpath "$.due_week" isInteger -jsonpath "$.due_week" >= 0 -jsonpath "$.streak" isInteger -jsonpath "$.streak" >= 0 +# Stats: shape and basic monotonicity only. +# The exact counting rules are part of the task, so they are not pinned here. + +GET {{base}}/stats +HTTP 200 +[Asserts] +jsonpath "$.due_today" isInteger +jsonpath "$.due_today" >= 0 +jsonpath "$.due_week" isInteger +jsonpath "$.due_week" >= 0 +jsonpath "$.streak" isInteger +jsonpath "$.streak" >= 0 diff --git a/spec/api/openapi.yaml b/spec/api/openapi.yaml index 87e48a4..2b5c806 100644 --- a/spec/api/openapi.yaml +++ b/spec/api/openapi.yaml @@ -1,293 +1,293 @@ -openapi: 3.1.0 -info: - title: Recall API - version: 0.1.0 - description: | - Personal knowledge base with spaced repetition. - Capture notes, tag and link them, turn notes into cards, review on a - daily queue, track simple stats. - - This contract is the floor: the acceptance suite (spec/acceptance/*.hurl) - asserts the observable behaviour described here. It is deliberately loose. - The exact scheduling interval table and the precise stats definitions are - NOT pinned in this contract — that is part of the task. -servers: - - url: http://localhost:8080 - description: Local backend (php -S / compose) - -tags: - - name: notes - - name: cards - - name: reviews - - name: stats - -paths: - /notes: - get: - tags: [notes] - summary: List notes, optionally filtered by tag - parameters: - - name: tag - in: query - required: false - schema: { type: string } - description: Return only notes carrying this tag. - responses: - "200": - description: List of notes - content: - application/json: - schema: - type: array - items: { $ref: "#/components/schemas/Note" } - post: - tags: [notes] - summary: Create a note - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/NoteInput" } - responses: - "201": - description: Created note - content: - application/json: - schema: { $ref: "#/components/schemas/Note" } - "422": { $ref: "#/components/responses/ValidationError" } - - /notes/{id}: - parameters: - - $ref: "#/components/parameters/Id" - get: - tags: [notes] - summary: Get a note - responses: - "200": - description: Note - content: - application/json: - schema: { $ref: "#/components/schemas/Note" } - "404": { $ref: "#/components/responses/NotFound" } - put: - tags: [notes] - summary: Update a note - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/NoteInput" } - responses: - "200": - description: Updated note - content: - application/json: - schema: { $ref: "#/components/schemas/Note" } - "404": { $ref: "#/components/responses/NotFound" } - "422": { $ref: "#/components/responses/ValidationError" } - delete: - tags: [notes] - summary: Delete a note - responses: - "204": { description: Deleted } - "404": { $ref: "#/components/responses/NotFound" } - - /cards: - get: - tags: [cards] - summary: List cards - responses: - "200": - description: List of cards - content: - application/json: - schema: - type: array - items: { $ref: "#/components/schemas/Card" } - post: - tags: [cards] - summary: Create a card from a note - requestBody: - required: true - content: - application/json: - schema: { $ref: "#/components/schemas/CardInput" } - responses: - "201": - description: Created card - content: - application/json: - schema: { $ref: "#/components/schemas/Card" } - "404": { $ref: "#/components/responses/NotFound" } - "422": { $ref: "#/components/responses/ValidationError" } - - /cards/{id}: - parameters: - - $ref: "#/components/parameters/Id" - get: - tags: [cards] - summary: Get a card - responses: - "200": - description: Card - content: - application/json: - schema: { $ref: "#/components/schemas/Card" } - "404": { $ref: "#/components/responses/NotFound" } - delete: - tags: [cards] - summary: Delete a card - responses: - "204": { description: Deleted } - "404": { $ref: "#/components/responses/NotFound" } - - /reviews/queue: - get: - tags: [reviews] - summary: Cards due for review now - responses: - "200": - description: Due cards - content: - application/json: - schema: - type: array - items: { $ref: "#/components/schemas/Card" } - - /reviews/{id}: - parameters: - - $ref: "#/components/parameters/Id" - post: - tags: [reviews] - summary: Grade a card and schedule the next review - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [grade] - properties: - grade: - type: string - enum: [again, hard, good, easy] - responses: - "201": - description: Recorded review - content: - application/json: - schema: { $ref: "#/components/schemas/Review" } - "404": { $ref: "#/components/responses/NotFound" } - "422": { $ref: "#/components/responses/ValidationError" } - - /stats: - get: - tags: [stats] - summary: Review stats - responses: - "200": - description: Stats - content: - application/json: - schema: { $ref: "#/components/schemas/Stats" } - -components: - parameters: - Id: - name: id - in: path - required: true - schema: { type: string, format: uuid } - - responses: - NotFound: - description: Resource not found - content: - application/json: - schema: { $ref: "#/components/schemas/Error" } - ValidationError: - description: Invalid input - content: - application/json: - schema: { $ref: "#/components/schemas/Error" } - - schemas: - Note: - type: object - required: [id, title, body, tags, links, created_at, updated_at] - properties: - id: { type: string, format: uuid } - title: { type: string } - body: { type: string } - tags: - type: array - items: { type: string } - links: - type: array - description: Ids of linked notes. - items: { type: string, format: uuid } - created_at: { type: string, format: date-time } - updated_at: { type: string, format: date-time } - - NoteInput: - type: object - required: [title, body] - properties: - title: { type: string, minLength: 1, maxLength: 200 } - body: { type: string, maxLength: 20000 } - tags: - type: array - items: { type: string } - links: - type: array - items: { type: string, format: uuid } - - Card: - type: object - required: [id, note_id, front, back, ease, interval, due, created_at] - properties: - id: { type: string, format: uuid } - note_id: { type: string, format: uuid } - front: { type: string } - back: { type: string } - ease: { type: number } - interval: { type: integer, description: Days until next review. } - due: { type: string, format: date } - created_at: { type: string, format: date-time } - - CardInput: - type: object - required: [note_id, front, back] - properties: - note_id: { type: string, format: uuid } - front: { type: string, minLength: 1, maxLength: 4000 } - back: { type: string, minLength: 1, maxLength: 4000 } - - Review: - type: object - required: [id, card_id, grade, interval, ease, next_due, created_at] - properties: - id: { type: string, format: uuid } - card_id: { type: string, format: uuid } - grade: { type: string, enum: [again, hard, good, easy] } - interval: { type: integer } - ease: { type: number } - next_due: { type: string, format: date } - created_at: { type: string, format: date-time } - - Stats: - type: object - required: [due_today, due_week, streak] - properties: - due_today: { type: integer } - due_week: { type: integer } - streak: { type: integer } - - Error: - type: object - required: [error] - properties: - error: { type: string } - details: - type: object - additionalProperties: true +openapi: 3.1.0 +info: + title: Recall API + version: 0.1.0 + description: | + Personal knowledge base with spaced repetition. + Capture notes, tag and link them, turn notes into cards, review on a + daily queue, track simple stats. + + This contract is the floor: the acceptance suite (spec/acceptance/*.hurl) + asserts the observable behaviour described here. It is deliberately loose. + The exact scheduling interval table and the precise stats definitions are + NOT pinned in this contract — that is part of the task. +servers: + - url: http://localhost:8080 + description: Local backend (php -S / compose) + +tags: + - name: notes + - name: cards + - name: reviews + - name: stats + +paths: + /notes: + get: + tags: [notes] + summary: List notes, optionally filtered by tag + parameters: + - name: tag + in: query + required: false + schema: { type: string } + description: Return only notes carrying this tag. + responses: + "200": + description: List of notes + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/Note" } + post: + tags: [notes] + summary: Create a note + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/NoteInput" } + responses: + "201": + description: Created note + content: + application/json: + schema: { $ref: "#/components/schemas/Note" } + "422": { $ref: "#/components/responses/ValidationError" } + + /notes/{id}: + parameters: + - $ref: "#/components/parameters/Id" + get: + tags: [notes] + summary: Get a note + responses: + "200": + description: Note + content: + application/json: + schema: { $ref: "#/components/schemas/Note" } + "404": { $ref: "#/components/responses/NotFound" } + put: + tags: [notes] + summary: Update a note + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/NoteInput" } + responses: + "200": + description: Updated note + content: + application/json: + schema: { $ref: "#/components/schemas/Note" } + "404": { $ref: "#/components/responses/NotFound" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [notes] + summary: Delete a note + responses: + "204": { description: Deleted } + "404": { $ref: "#/components/responses/NotFound" } + + /cards: + get: + tags: [cards] + summary: List cards + responses: + "200": + description: List of cards + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/Card" } + post: + tags: [cards] + summary: Create a card from a note + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CardInput" } + responses: + "201": + description: Created card + content: + application/json: + schema: { $ref: "#/components/schemas/Card" } + "404": { $ref: "#/components/responses/NotFound" } + "422": { $ref: "#/components/responses/ValidationError" } + + /cards/{id}: + parameters: + - $ref: "#/components/parameters/Id" + get: + tags: [cards] + summary: Get a card + responses: + "200": + description: Card + content: + application/json: + schema: { $ref: "#/components/schemas/Card" } + "404": { $ref: "#/components/responses/NotFound" } + delete: + tags: [cards] + summary: Delete a card + responses: + "204": { description: Deleted } + "404": { $ref: "#/components/responses/NotFound" } + + /reviews/queue: + get: + tags: [reviews] + summary: Cards due for review now + responses: + "200": + description: Due cards + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/Card" } + + /reviews/{id}: + parameters: + - $ref: "#/components/parameters/Id" + post: + tags: [reviews] + summary: Grade a card and schedule the next review + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [grade] + properties: + grade: + type: string + enum: [again, hard, good, easy] + responses: + "201": + description: Recorded review + content: + application/json: + schema: { $ref: "#/components/schemas/Review" } + "404": { $ref: "#/components/responses/NotFound" } + "422": { $ref: "#/components/responses/ValidationError" } + + /stats: + get: + tags: [stats] + summary: Review stats + responses: + "200": + description: Stats + content: + application/json: + schema: { $ref: "#/components/schemas/Stats" } + +components: + parameters: + Id: + name: id + in: path + required: true + schema: { type: string, format: uuid } + + responses: + NotFound: + description: Resource not found + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + ValidationError: + description: Invalid input + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + schemas: + Note: + type: object + required: [id, title, body, tags, links, created_at, updated_at] + properties: + id: { type: string, format: uuid } + title: { type: string } + body: { type: string } + tags: + type: array + items: { type: string } + links: + type: array + description: Ids of linked notes. + items: { type: string, format: uuid } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + NoteInput: + type: object + required: [title, body] + properties: + title: { type: string, minLength: 1, maxLength: 200 } + body: { type: string, maxLength: 20000 } + tags: + type: array + items: { type: string } + links: + type: array + items: { type: string, format: uuid } + + Card: + type: object + required: [id, note_id, front, back, ease, interval, due, created_at] + properties: + id: { type: string, format: uuid } + note_id: { type: string, format: uuid } + front: { type: string } + back: { type: string } + ease: { type: number } + interval: { type: integer, description: Days until next review. } + due: { type: string, format: date } + created_at: { type: string, format: date-time } + + CardInput: + type: object + required: [note_id, front, back] + properties: + note_id: { type: string, format: uuid } + front: { type: string, minLength: 1, maxLength: 4000 } + back: { type: string, minLength: 1, maxLength: 4000 } + + Review: + type: object + required: [id, card_id, grade, interval, ease, next_due, created_at] + properties: + id: { type: string, format: uuid } + card_id: { type: string, format: uuid } + grade: { type: string, enum: [again, hard, good, easy] } + interval: { type: integer } + ease: { type: number } + next_due: { type: string, format: date } + created_at: { type: string, format: date-time } + + Stats: + type: object + required: [due_today, due_week, streak] + properties: + due_today: { type: integer } + due_week: { type: integer } + streak: { type: integer } + + Error: + type: object + required: [error] + properties: + error: { type: string } + details: + type: object + additionalProperties: true diff --git a/www/assets/styles/components.css b/www/assets/styles/components.css index af49f06..7426d17 100644 --- a/www/assets/styles/components.css +++ b/www/assets/styles/components.css @@ -1,208 +1,208 @@ -/* Reusable content components for the guide: the client letter, the email - card, and tables. Kept apart from main.css so each stylesheet stays under - the linecop size cap. */ - -/* The client letter (blockquote) ----------------------------------------- */ - -blockquote { - margin: 1.5rem 0; - padding: 1.5rem 1.75rem; - background: var(--letter-bg); - border-left: 4px solid var(--accent); - border-radius: 0 12px 12px 0; -} - -blockquote p:first-child { - margin-top: 0; -} - -blockquote p:last-child { - margin-bottom: 0; -} - -/* The client letter, styled as an email message -------------------------- */ - -.email { - margin: 1.75rem 0; - background: var(--card-bg); - border: 1px solid var(--border); - border-radius: 14px; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 10px 28px rgba(0, 0, 0, 0.07); - overflow: hidden; -} - -.email-head { - padding: 1.25rem 1.5rem 1.1rem; - border-bottom: 1px solid var(--border); -} - -.email-subject { - font-size: 1.2rem; - font-weight: 600; - line-height: 1.3; - margin-bottom: 0.9rem; -} - -.email-meta { - display: flex; - align-items: center; - gap: 0.85rem; -} - -/* Typst html export wraps the inline avatar/date in

; dissolve those - wrappers so they lay out as direct flex children of the meta row. */ -.email-meta > p { - display: contents; -} - -.email-avatar { - flex-shrink: 0; - width: 42px; - height: 42px; - border-radius: 50%; - display: inline-flex; - align-items: center; - justify-content: center; - background: var(--accent); - color: #fff; - font-weight: 600; - font-size: 1.1rem; - line-height: 1; -} - -.email-ident { - flex: 1; - min-width: 0; -} - -.email-from { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: 0.4rem; - line-height: 1.3; -} - -.email-name { - font-weight: 600; -} - -.email-addr { - color: var(--muted); - font-size: 0.9rem; -} - -.email-to { - color: var(--muted); - font-size: 0.85rem; - margin-top: 0.15rem; -} - -.email-date { - flex-shrink: 0; - align-self: flex-start; - color: var(--muted); - font-size: 0.85rem; - white-space: nowrap; -} - -.email-body { - padding: 1.25rem 1.5rem 1.5rem; -} - -.email-body p:first-child { - margin-top: 0; -} - -.email-body p:last-child { - margin-bottom: 0; -} - -/* Tables ------------------------------------------------------------------ */ - -table { - border-collapse: collapse; - width: 100%; - margin: 1.5rem 0; - font-size: 0.95rem; -} - -th, -td { - border: 1px solid var(--border); - padding: 0.6rem 0.85rem; - text-align: left; -} - -th { - background: var(--code-bg); - font-weight: 600; -} - -/* Glossary terms with hover/focus tooltips -------------------------------- */ - -.term { - border-bottom: 1px dotted var(--muted); - cursor: help; - position: relative; -} - -.term:focus { - outline: 2px solid var(--accent); - outline-offset: 2px; -} - -.term > .tip { - position: absolute; - left: 0; - top: 100%; - margin-top: 0.5rem; - z-index: 20; - width: max-content; - max-width: min(22rem, 80vw); - padding: 0.6rem 0.8rem; - background: var(--fg); - color: var(--bg); - font-size: 0.85rem; - font-weight: 400; - line-height: 1.45; - border-radius: 8px; - box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18); - opacity: 0; - visibility: hidden; - transition: opacity 0.12s; -} - -/* Transparent bridge over the gap so the cursor can reach a link in the tip. */ -.term > .tip::before { - content: ""; - position: absolute; - bottom: 100%; - left: 0; - right: 0; - height: 0.5rem; -} - -.term:hover > .tip, -.term:focus > .tip, -.term:focus-within > .tip { - opacity: 1; - visibility: visible; -} - -/* Keep links legible on the dark tooltip. */ -.term > .tip a { - color: var(--bg); - text-decoration: underline; -} - -/* Definition list on the glossary page. */ -.glossary dt { - font-weight: 600; - margin-top: 1.2rem; -} - -.glossary dd { - margin: 0.3rem 0 0; - color: var(--fg); -} +/* Reusable content components for the guide: the client letter, the email + card, and tables. Kept apart from main.css so each stylesheet stays under + the linecop size cap. */ + +/* The client letter (blockquote) ----------------------------------------- */ + +blockquote { + margin: 1.5rem 0; + padding: 1.5rem 1.75rem; + background: var(--letter-bg); + border-left: 4px solid var(--accent); + border-radius: 0 12px 12px 0; +} + +blockquote p:first-child { + margin-top: 0; +} + +blockquote p:last-child { + margin-bottom: 0; +} + +/* The client letter, styled as an email message -------------------------- */ + +.email { + margin: 1.75rem 0; + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 14px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 10px 28px rgba(0, 0, 0, 0.07); + overflow: hidden; +} + +.email-head { + padding: 1.25rem 1.5rem 1.1rem; + border-bottom: 1px solid var(--border); +} + +.email-subject { + font-size: 1.2rem; + font-weight: 600; + line-height: 1.3; + margin-bottom: 0.9rem; +} + +.email-meta { + display: flex; + align-items: center; + gap: 0.85rem; +} + +/* Typst html export wraps the inline avatar/date in

; dissolve those + wrappers so they lay out as direct flex children of the meta row. */ +.email-meta > p { + display: contents; +} + +.email-avatar { + flex-shrink: 0; + width: 42px; + height: 42px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--accent); + color: #fff; + font-weight: 600; + font-size: 1.1rem; + line-height: 1; +} + +.email-ident { + flex: 1; + min-width: 0; +} + +.email-from { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.4rem; + line-height: 1.3; +} + +.email-name { + font-weight: 600; +} + +.email-addr { + color: var(--muted); + font-size: 0.9rem; +} + +.email-to { + color: var(--muted); + font-size: 0.85rem; + margin-top: 0.15rem; +} + +.email-date { + flex-shrink: 0; + align-self: flex-start; + color: var(--muted); + font-size: 0.85rem; + white-space: nowrap; +} + +.email-body { + padding: 1.25rem 1.5rem 1.5rem; +} + +.email-body p:first-child { + margin-top: 0; +} + +.email-body p:last-child { + margin-bottom: 0; +} + +/* Tables ------------------------------------------------------------------ */ + +table { + border-collapse: collapse; + width: 100%; + margin: 1.5rem 0; + font-size: 0.95rem; +} + +th, +td { + border: 1px solid var(--border); + padding: 0.6rem 0.85rem; + text-align: left; +} + +th { + background: var(--code-bg); + font-weight: 600; +} + +/* Glossary terms with hover/focus tooltips -------------------------------- */ + +.term { + border-bottom: 1px dotted var(--muted); + cursor: help; + position: relative; +} + +.term:focus { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.term > .tip { + position: absolute; + left: 0; + top: 100%; + margin-top: 0.5rem; + z-index: 20; + width: max-content; + max-width: min(22rem, 80vw); + padding: 0.6rem 0.8rem; + background: var(--fg); + color: var(--bg); + font-size: 0.85rem; + font-weight: 400; + line-height: 1.45; + border-radius: 8px; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18); + opacity: 0; + visibility: hidden; + transition: opacity 0.12s; +} + +/* Transparent bridge over the gap so the cursor can reach a link in the tip. */ +.term > .tip::before { + content: ""; + position: absolute; + bottom: 100%; + left: 0; + right: 0; + height: 0.5rem; +} + +.term:hover > .tip, +.term:focus > .tip, +.term:focus-within > .tip { + opacity: 1; + visibility: visible; +} + +/* Keep links legible on the dark tooltip. */ +.term > .tip a { + color: var(--bg); + text-decoration: underline; +} + +/* Definition list on the glossary page. */ +.glossary dt { + font-weight: 600; + margin-top: 1.2rem; +} + +.glossary dd { + margin: 0.3rem 0 0; + color: var(--fg); +} diff --git a/www/assets/styles/main.css b/www/assets/styles/main.css index ccfd49e..08fa233 100644 --- a/www/assets/styles/main.css +++ b/www/assets/styles/main.css @@ -1,275 +1,275 @@ -:root { - --fg: #242424; - --muted: #6b6b6b; - --bg: #ffffff; - --accent: #b83f53; - --accent-dark: #9c3447; - --border: #ececec; - --code-bg: #f6f6f4; - --letter-bg: #fbf6f7; - --card-bg: #ffffff; - --header-bg: rgba(255, 255, 255, 0.85); - --max: 54rem; - font-family: "Montserrat", system-ui, sans-serif; - line-height: 1.7; -} - -[data-theme="dark"] { - --fg: #e4e4e4; - --muted: #999; - --bg: #1a1a1a; - --accent: #e06c7c; - --accent-dark: #c95464; - --border: #333; - --code-bg: #2a2a2a; - --letter-bg: #2a2022; - --card-bg: #222222; - --header-bg: rgba(0, 0, 0, 0.85); -} - -* { - box-sizing: border-box; -} - -html { - scroll-behavior: smooth; -} - -body { - margin: 0; - color: var(--fg); - background: var(--bg); - font-size: 17px; - -webkit-font-smoothing: antialiased; -} - -/* Header ------------------------------------------------------------------ */ - -.site-header { - position: sticky; - top: 0; - z-index: 10; - max-width: var(--max); - margin: 0 auto; - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - gap: 0.5rem 1.5rem; - padding: 0.7rem 1.5rem; - background: var(--header-bg); - backdrop-filter: saturate(180%) blur(10px); - border-bottom: 1px solid var(--border); -} - -/* Typst html export wraps inline content in

; dissolve those wrappers - inside the header so the brand (logo + name) and nav lay out cleanly. */ -.site-header p { - display: contents; -} - -.brand { - display: inline-flex; - align-items: center; - gap: 0.65rem; -} - -.brand-home { - display: inline-flex; - flex-shrink: 0; - text-decoration: none; -} - -.brand-mark { - display: inline-block; - width: 34px; - height: 34px; - background: url("../images/logo.svg") center / contain no-repeat; -} - -/* Two-line text column: project name над строкой атрибуции. */ -.brand-text { - display: flex; - flex-direction: column; - line-height: 1.2; -} - -.brand-name { - font-weight: 700; - font-size: 1.1rem; - color: var(--fg); - text-decoration: none; -} - -.brand-name:hover { - color: var(--accent); -} - -.brand-by { - font-size: 0.78rem; - color: var(--muted); -} - -.brand-by a { - color: var(--muted); - text-decoration: underline; - text-underline-offset: 2px; -} - -.brand-by a:hover { - color: var(--accent); -} - -.site-header nav { - display: flex; - flex-wrap: wrap; - gap: 1.5rem; -} - -.site-header nav a { - color: var(--fg); - text-decoration: none; - font-weight: 500; - font-size: 0.95rem; - transition: color 0.15s; -} - -.site-header nav a:hover { - color: var(--accent); -} - -/* The nav link for the page you're on. */ -.site-header nav a[aria-current="page"] { - color: var(--accent); - font-weight: 600; - border-bottom: 2px solid var(--accent); -} - -/* Main -------------------------------------------------------------------- */ - -main { - max-width: var(--max); - margin: 0 auto; - padding: 2.5rem 1.5rem 4rem; -} - -h1 { - font-size: clamp(2rem, 5vw, 2.75rem); - font-weight: 600; - letter-spacing: -0.01em; - line-height: 1.15; - margin: 0 0 1.5rem; -} - -h2 { - font-size: 1.5rem; - font-weight: 600; - margin: 2.75rem 0 0.75rem; - padding-top: 0.5rem; - scroll-margin-top: 4.5rem; -} - -h3 { - font-size: 1.2rem; - font-weight: 600; - margin: 2rem 0 0.5rem; - scroll-margin-top: 4.5rem; -} - -p { - margin: 0.9rem 0; -} - -a { - color: var(--accent); - text-decoration-thickness: 1px; - text-underline-offset: 2px; -} - -a:hover { - color: var(--accent-dark); -} - -ul, -ol { - padding-left: 1.4rem; -} - -li { - margin: 0.4rem 0; -} - -/* Code -------------------------------------------------------------------- */ - -code { - background: var(--code-bg); - padding: 0.12em 0.4em; - border-radius: 5px; - font-size: 0.88em; - font-family: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace; -} - -pre { - background: var(--code-bg); - border: 1px solid var(--border); - padding: 1rem 1.2rem; - border-radius: 10px; - overflow-x: auto; - line-height: 1.5; -} - -pre code { - background: none; - padding: 0; - font-size: 0.85rem; -} - -/* Theme toggle ------------------------------------------------------------ */ - -.theme-toggle { - background: none; - border: 1px solid var(--border); - border-radius: 50%; - width: 36px; - height: 36px; - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - color: var(--fg); - font-size: 1.1rem; - transition: background 0.2s, border-color 0.2s; - flex-shrink: 0; -} - -.theme-toggle:hover { - background: var(--code-bg); - border-color: var(--muted); -} - -.theme-icon-sun { - display: inline; -} - -.theme-icon-moon { - display: none; -} - -[data-theme="dark"] .theme-icon-sun { - display: none; -} - -[data-theme="dark"] .theme-icon-moon { - display: inline; -} - -/* Footer ------------------------------------------------------------------ */ - -.site-footer { - max-width: var(--max); - margin: 3rem auto 0; - padding: 1.5rem; - color: var(--muted); - font-size: 0.9rem; - border-top: 1px solid var(--border); -} +:root { + --fg: #242424; + --muted: #6b6b6b; + --bg: #ffffff; + --accent: #b83f53; + --accent-dark: #9c3447; + --border: #ececec; + --code-bg: #f6f6f4; + --letter-bg: #fbf6f7; + --card-bg: #ffffff; + --header-bg: rgba(255, 255, 255, 0.85); + --max: 54rem; + font-family: "Montserrat", system-ui, sans-serif; + line-height: 1.7; +} + +[data-theme="dark"] { + --fg: #e4e4e4; + --muted: #999; + --bg: #1a1a1a; + --accent: #e06c7c; + --accent-dark: #c95464; + --border: #333; + --code-bg: #2a2a2a; + --letter-bg: #2a2022; + --card-bg: #222222; + --header-bg: rgba(0, 0, 0, 0.85); +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + color: var(--fg); + background: var(--bg); + font-size: 17px; + -webkit-font-smoothing: antialiased; +} + +/* Header ------------------------------------------------------------------ */ + +.site-header { + position: sticky; + top: 0; + z-index: 10; + max-width: var(--max); + margin: 0 auto; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem 1.5rem; + padding: 0.7rem 1.5rem; + background: var(--header-bg); + backdrop-filter: saturate(180%) blur(10px); + border-bottom: 1px solid var(--border); +} + +/* Typst html export wraps inline content in

; dissolve those wrappers + inside the header so the brand (logo + name) and nav lay out cleanly. */ +.site-header p { + display: contents; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 0.65rem; +} + +.brand-home { + display: inline-flex; + flex-shrink: 0; + text-decoration: none; +} + +.brand-mark { + display: inline-block; + width: 34px; + height: 34px; + background: url("../images/logo.svg") center / contain no-repeat; +} + +/* Two-line text column: project name над строкой атрибуции. */ +.brand-text { + display: flex; + flex-direction: column; + line-height: 1.2; +} + +.brand-name { + font-weight: 700; + font-size: 1.1rem; + color: var(--fg); + text-decoration: none; +} + +.brand-name:hover { + color: var(--accent); +} + +.brand-by { + font-size: 0.78rem; + color: var(--muted); +} + +.brand-by a { + color: var(--muted); + text-decoration: underline; + text-underline-offset: 2px; +} + +.brand-by a:hover { + color: var(--accent); +} + +.site-header nav { + display: flex; + flex-wrap: wrap; + gap: 1.5rem; +} + +.site-header nav a { + color: var(--fg); + text-decoration: none; + font-weight: 500; + font-size: 0.95rem; + transition: color 0.15s; +} + +.site-header nav a:hover { + color: var(--accent); +} + +/* The nav link for the page you're on. */ +.site-header nav a[aria-current="page"] { + color: var(--accent); + font-weight: 600; + border-bottom: 2px solid var(--accent); +} + +/* Main -------------------------------------------------------------------- */ + +main { + max-width: var(--max); + margin: 0 auto; + padding: 2.5rem 1.5rem 4rem; +} + +h1 { + font-size: clamp(2rem, 5vw, 2.75rem); + font-weight: 600; + letter-spacing: -0.01em; + line-height: 1.15; + margin: 0 0 1.5rem; +} + +h2 { + font-size: 1.5rem; + font-weight: 600; + margin: 2.75rem 0 0.75rem; + padding-top: 0.5rem; + scroll-margin-top: 4.5rem; +} + +h3 { + font-size: 1.2rem; + font-weight: 600; + margin: 2rem 0 0.5rem; + scroll-margin-top: 4.5rem; +} + +p { + margin: 0.9rem 0; +} + +a { + color: var(--accent); + text-decoration-thickness: 1px; + text-underline-offset: 2px; +} + +a:hover { + color: var(--accent-dark); +} + +ul, +ol { + padding-left: 1.4rem; +} + +li { + margin: 0.4rem 0; +} + +/* Code -------------------------------------------------------------------- */ + +code { + background: var(--code-bg); + padding: 0.12em 0.4em; + border-radius: 5px; + font-size: 0.88em; + font-family: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace; +} + +pre { + background: var(--code-bg); + border: 1px solid var(--border); + padding: 1rem 1.2rem; + border-radius: 10px; + overflow-x: auto; + line-height: 1.5; +} + +pre code { + background: none; + padding: 0; + font-size: 0.85rem; +} + +/* Theme toggle ------------------------------------------------------------ */ + +.theme-toggle { + background: none; + border: 1px solid var(--border); + border-radius: 50%; + width: 36px; + height: 36px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--fg); + font-size: 1.1rem; + transition: background 0.2s, border-color 0.2s; + flex-shrink: 0; +} + +.theme-toggle:hover { + background: var(--code-bg); + border-color: var(--muted); +} + +.theme-icon-sun { + display: inline; +} + +.theme-icon-moon { + display: none; +} + +[data-theme="dark"] .theme-icon-sun { + display: none; +} + +[data-theme="dark"] .theme-icon-moon { + display: inline; +} + +/* Footer ------------------------------------------------------------------ */ + +.site-footer { + max-width: var(--max); + margin: 3rem auto 0; + padding: 1.5rem; + color: var(--muted); + font-size: 0.9rem; + border-top: 1px solid var(--border); +} diff --git a/www/content/brief.typ b/www/content/brief.typ index b0be07b..3e302a7 100644 --- a/www/content/brief.typ +++ b/www/content/brief.typ @@ -1,66 +1,66 @@ -#import "../utils/page.typ": page -#import "../utils/site.typ": u -#import "../utils/email.typ": email - -#show: page.with(title: "Бриф заказчика", active: "/brief/") - -Ниже письмо заказчика, подобное тому, которое вы могли бы получить, участвуя в -разработке проектов. Заказчики бывают скупы на детали, а местами -могут просить то, что не решит их задачу вовсе. - -Конечно, в обычных обстоятельствах стоило бы обсудить детали и лишь потом -приступать, но сколько ни обсуждай задачу, всё равно рано или поздно придётся -выбирать между импровизацией и затягиванием процесса. Разные заказчики -предпочитают разные стили. Давайте просто представим, что работа с этим конкретным -заказчиком будет эффективнее при импровизации. - -#email( - subject: "Хочу запоминать прочитанное", - from-name: "Иван Иванов", - from-addr: "", - to: "Максимастер", - date: "6 июня, 10:24", -)[ - Здравствуйте! - - Короче, проблема такая. Я очень много читаю — статьи, книги, всякие - конспекты по работе — и через неделю не помню вообще ничего. Как будто и не - читал. Бесит страшно. Хочу какую-нибудь штуку, чтобы наконец запоминать то, - что прочитал. Чтобы она сама мне напоминала, что повторить, пока я не забыл. - - Я слышал, есть какой-то метод с карточками, когда тебе показывают вопрос, ты - вспоминаешь ответ, и дальше оно само решает, когда показать снова. Вот что-то - в таком духе. Сами карточки, наверное, как-то из заметок получаются — тут уж - вам виднее, как удобнее. Но не только карточки — я ещё хочу просто складывать заметки, - помечать их тегами, связывать между собой. Чтобы был порядок. - - Очень важно: все мои заметки — личные. Никто, кроме меня, их видеть не должен, - это прямо принципиально, там бывает личное. И ещё хочу публичную страничку, - где видно всё, что я выучил за всё время — чтобы можно было ссылку другу - скинуть и похвастаться, мол, смотри, сколько я знаю. Что именно там показывать, - решайте сами, лишь бы выглядело прилично. Может, туда ещё какую-нибудь умную - цитату дня прилепить для красоты, чтобы солиднее смотрелось? - - Да, ещё мыслей накидаю, пока не забыл: - - - Заметки старше 30 дней пусть удаляются автоматически, не люблю замусоренность. - - И прикрутите туда ИИ, чтобы он сам писал за меня заметки по теме. Я ему - тему — он мне готовый конспект, а я его уже повторяю. Так же быстрее, не? - - Ещё хорошо бы помечать, из какой книги заметка — название там, обложечку, - чтобы помнить, откуда я это взял. Это же где-то можно подтянуть, наверное? - - И когда в тексте попадается слово, которого я не знаю, пусть само - показывает, что оно значит, и сразу делает из него карточку. А то лезть - каждый раз в словарь — лень. - - В общем, сделайте красиво, я в этом не разбираюсь, на вас вся надежда. - Бюджет и сроки обсудим. Спасибо! -] - -= И что с ним делать? - -Постарайтесь понять, чего хочет заказчик, и сделайте для него лучшую версию -приложения. - -= Следующий шаг - -Прочитайте #link(u("/task/"))[задание]. +#import "../utils/page.typ": page +#import "../utils/site.typ": u +#import "../utils/email.typ": email + +#show: page.with(title: "Бриф заказчика", active: "/brief/") + +Ниже письмо заказчика, подобное тому, которое вы могли бы получить, участвуя в +разработке проектов. Заказчики бывают скупы на детали, а местами +могут просить то, что не решит их задачу вовсе. + +Конечно, в обычных обстоятельствах стоило бы обсудить детали и лишь потом +приступать, но сколько ни обсуждай задачу, всё равно рано или поздно придётся +выбирать между импровизацией и затягиванием процесса. Разные заказчики +предпочитают разные стили. Давайте просто представим, что работа с этим конкретным +заказчиком будет эффективнее при импровизации. + +#email( + subject: "Хочу запоминать прочитанное", + from-name: "Иван Иванов", + from-addr: "", + to: "Максимастер", + date: "6 июня, 10:24", +)[ + Здравствуйте! + + Короче, проблема такая. Я очень много читаю — статьи, книги, всякие + конспекты по работе — и через неделю не помню вообще ничего. Как будто и не + читал. Бесит страшно. Хочу какую-нибудь штуку, чтобы наконец запоминать то, + что прочитал. Чтобы она сама мне напоминала, что повторить, пока я не забыл. + + Я слышал, есть какой-то метод с карточками, когда тебе показывают вопрос, ты + вспоминаешь ответ, и дальше оно само решает, когда показать снова. Вот что-то + в таком духе. Сами карточки, наверное, как-то из заметок получаются — тут уж + вам виднее, как удобнее. Но не только карточки — я ещё хочу просто складывать заметки, + помечать их тегами, связывать между собой. Чтобы был порядок. + + Очень важно: все мои заметки — личные. Никто, кроме меня, их видеть не должен, + это прямо принципиально, там бывает личное. И ещё хочу публичную страничку, + где видно всё, что я выучил за всё время — чтобы можно было ссылку другу + скинуть и похвастаться, мол, смотри, сколько я знаю. Что именно там показывать, + решайте сами, лишь бы выглядело прилично. Может, туда ещё какую-нибудь умную + цитату дня прилепить для красоты, чтобы солиднее смотрелось? + + Да, ещё мыслей накидаю, пока не забыл: + + - Заметки старше 30 дней пусть удаляются автоматически, не люблю замусоренность. + - И прикрутите туда ИИ, чтобы он сам писал за меня заметки по теме. Я ему + тему — он мне готовый конспект, а я его уже повторяю. Так же быстрее, не? + - Ещё хорошо бы помечать, из какой книги заметка — название там, обложечку, + чтобы помнить, откуда я это взял. Это же где-то можно подтянуть, наверное? + - И когда в тексте попадается слово, которого я не знаю, пусть само + показывает, что оно значит, и сразу делает из него карточку. А то лезть + каждый раз в словарь — лень. + + В общем, сделайте красиво, я в этом не разбираюсь, на вас вся надежда. + Бюджет и сроки обсудим. Спасибо! +] + += И что с ним делать? + +Постарайтесь понять, чего хочет заказчик, и сделайте для него лучшую версию +приложения. + += Следующий шаг + +Прочитайте #link(u("/task/"))[задание]. diff --git a/www/content/glossary.typ b/www/content/glossary.typ index 4eb0a15..3978612 100644 --- a/www/content/glossary.typ +++ b/www/content/glossary.typ @@ -1,18 +1,18 @@ -#import "../utils/page.typ": page -#import "../utils/glossary.typ": TERMS - -#show: page.with(title: "Глоссарий", active: "/glossary/") - -Короткий словарь терминов из задания и руководства. Подсказки по этим терминам -всплывают, если навести курсор на подчёркнутое слово в тексте. - -#html.elem( - "dl", - attrs: (class: "glossary"), - { - for (code, entry) in TERMS { - html.elem("dt", entry.term) - html.elem("dd", entry.tip) - } - }, -) +#import "../utils/page.typ": page +#import "../utils/glossary.typ": TERMS + +#show: page.with(title: "Глоссарий", active: "/glossary/") + +Короткий словарь терминов из задания и руководства. Подсказки по этим терминам +всплывают, если навести курсор на подчёркнутое слово в тексте. + +#html.elem( + "dl", + attrs: (class: "glossary"), + { + for (code, entry) in TERMS { + html.elem("dt", entry.term) + html.elem("dd", entry.tip) + } + }, +) diff --git a/www/content/index.typ b/www/content/index.typ index 8309917..e790377 100644 --- a/www/content/index.typ +++ b/www/content/index.typ @@ -1,71 +1,71 @@ -#import "../utils/page.typ": page -#import "../utils/site.typ": u -#import "../utils/glossary.typ" as g -#import "../utils/repo.typ": logbook-cap - -#show: page.with(title: "Recall — практика", active: "/") - -Привет. Это задание для практики. Оно устроено не как экзамен, а как маленькая -часть настоящей работы: есть заказчик со своей идеей, есть наполовину готовый -проект, и есть вы. - -= Что делать? - -Заказчик хочет инструмент, чтобы запоминать прочитанное. Мы начали делать его — -персональную базу знаний с #g.spaced-repetition()[интервальным повторением] — и не закончили. Ваша -задача: разобраться, что на самом деле нужно, и довести проект до рабочего -состояния. - -- Прочитайте #link(u("/brief/"))[бриф заказчика] — это его собственные слова, как есть. -- Изучите #link(u("/task/"))[задание] — описано содержимое репозитория и критерии приёмки. -- Форкните #link("https://github.com/maximaster/practice")[репозиторий], поднимите проект, - пройдите автопроверку и пришлите ссылку на форк. - -= А можно написать с помощью ИИ? - -Можно. Мы сами работаем с ИИ-агентами каждый день и не пытаемся это запрещать -или ловить. Если вы решите задачу полностью с помощью агента и тесты пройдут — -его можно отдавать на проверку. - -Но нам интересно другое: что вы добавляете к этой генерации от себя. -Поэтому не торопитесь сдавать проходящий проверку результат — посмотрите на него -глазами человека, которому с этим кодом потом жить. - -= Как запустить? - -Нужны #link("https://git-scm.com/")[git], #link("https://docs.docker.com/engine/install/")[docker] и #link("https://www.jetify.com/devbox")[devbox]. Дальше: - -```sh -devbox shell # разворачивает окружение, где есть весь нужный софт (помимо git и docker) -just dev # поднять backend + frontend (под капотом docker compose) -just verify # полный прогон тестов — то же, что гоняет наш CI -``` - -`just verify` — это наша автоматическая проверка: если она проходит, значит задание выполнено. - -= Что прислать? - -Рабочий журнал `LOGBOOK.md` — не больше #logbook-cap строк (проверяется `just lint`). -Лаконичность — часть задания: умение писать коротко отражает способность мыслить -чётко, структурированно и без лишнего «шума». Мы это очень ценим. - -Разделы файла: - -- *Решения* — ключевые решения и отвергнутые альтернативы; по каждому коротко почему. -- *Допущения* — на каких допущениях держится результат там, где задание недосказано. -- *С чем поспорил / что отклонил* — что в брифе вы переосмыслили или решили не делать, и почему. -- *Мнение* — ваше личное мнение о процессе и результате в свободной форме: о слабых местах, об удобстве разработки и т.д. - -Этот файл не для ИИ-агента — заполняйте его вручную, даже если считаете, что агент пишет лучше вас (грамотнее, яснее и т.д.). - -= Почему задание такое? - -Раньше тестовые задания проверяли, умеет ли человек писать код. Сейчас код -пишет агент. Поэтому мы проверяем то, что агент за вас не сделает: понимание -настоящей цели, вкус, способность остановиться и сказать «вот это делать не -надо». Это задание — первое, что вы видите про нас. Мы старались сделать его -честным и интересным. - -= Следующий шаг - -Прочитайте #link(u("/brief/"))[бриф заказчика]. +#import "../utils/page.typ": page +#import "../utils/site.typ": u +#import "../utils/glossary.typ" as g +#import "../utils/repo.typ": logbook-cap + +#show: page.with(title: "Recall — практика", active: "/") + +Привет. Это задание для практики. Оно устроено не как экзамен, а как маленькая +часть настоящей работы: есть заказчик со своей идеей, есть наполовину готовый +проект, и есть вы. + += Что делать? + +Заказчик хочет инструмент, чтобы запоминать прочитанное. Мы начали делать его — +персональную базу знаний с #g.spaced-repetition()[интервальным повторением] — и не закончили. Ваша +задача: разобраться, что на самом деле нужно, и довести проект до рабочего +состояния. + +- Прочитайте #link(u("/brief/"))[бриф заказчика] — это его собственные слова, как есть. +- Изучите #link(u("/task/"))[задание] — описано содержимое репозитория и критерии приёмки. +- Форкните #link("https://github.com/maximaster/practice")[репозиторий], поднимите проект, + пройдите автопроверку и пришлите ссылку на форк. + += А можно написать с помощью ИИ? + +Можно. Мы сами работаем с ИИ-агентами каждый день и не пытаемся это запрещать +или ловить. Если вы решите задачу полностью с помощью агента и тесты пройдут — +его можно отдавать на проверку. + +Но нам интересно другое: что вы добавляете к этой генерации от себя. +Поэтому не торопитесь сдавать проходящий проверку результат — посмотрите на него +глазами человека, которому с этим кодом потом жить. + += Как запустить? + +Нужны #link("https://git-scm.com/")[git], #link("https://docs.docker.com/engine/install/")[docker] и #link("https://www.jetify.com/devbox")[devbox]. Дальше: + +```sh +devbox shell # разворачивает окружение, где есть весь нужный софт (помимо git и docker) +just dev # поднять backend + frontend (под капотом docker compose) +just verify # полный прогон тестов — то же, что гоняет наш CI +``` + +`just verify` — это наша автоматическая проверка: если она проходит, значит задание выполнено. + += Что прислать? + +Рабочий журнал `LOGBOOK.md` — не больше #logbook-cap строк (проверяется `just lint`). +Лаконичность — часть задания: умение писать коротко отражает способность мыслить +чётко, структурированно и без лишнего «шума». Мы это очень ценим. + +Разделы файла: + +- *Решения* — ключевые решения и отвергнутые альтернативы; по каждому коротко почему. +- *Допущения* — на каких допущениях держится результат там, где задание недосказано. +- *С чем поспорил / что отклонил* — что в брифе вы переосмыслили или решили не делать, и почему. +- *Мнение* — ваше личное мнение о процессе и результате в свободной форме: о слабых местах, об удобстве разработки и т.д. + +Этот файл не для ИИ-агента — заполняйте его вручную, даже если считаете, что агент пишет лучше вас (грамотнее, яснее и т.д.). + += Почему задание такое? + +Раньше тестовые задания проверяли, умеет ли человек писать код. Сейчас код +пишет агент. Поэтому мы проверяем то, что агент за вас не сделает: понимание +настоящей цели, вкус, способность остановиться и сказать «вот это делать не +надо». Это задание — первое, что вы видите про нас. Мы старались сделать его +честным и интересным. + += Следующий шаг + +Прочитайте #link(u("/brief/"))[бриф заказчика]. diff --git a/www/content/stack.typ b/www/content/stack.typ index 4fe1b15..2991fb3 100644 --- a/www/content/stack.typ +++ b/www/content/stack.typ @@ -1,68 +1,68 @@ -#import "../utils/page.typ": page -#import "../utils/site.typ": u -#import "../utils/glossary.typ" as g -#import "../utils/env.typ": ENV -#import "../utils/repo.typ": php-version, slim-version - -#let php-mem = ENV.at("RECALL_PHP_MEMORY_LIMIT") -#let container-mem = ENV.at("RECALL_CONTAINER_MEMORY_LIMIT") - -#show: page.with(title: "Почему такой стек", active: "/stack/") - -Проект собран неспроста, каждый выбор был обоснован. Если вы сделали -иначе и можете объяснить, почему — это ровно тот разговор, который нам интересен: -пишите в `LOGBOOK.md`. - -= Backend - -- PHP #php-version + Slim #slim-version — это тонкий микрофреймворк, а не тяжеловесные Laravel или Symfony: - видно, как устроено приложение; фреймворк ничего не прячет. -- Cycle ORM — схема задаётся массивом, домен остаётся чистым: без аннотаций в - сущностях и без компиляции на каждый запрос. -- SQLite — ноль настройки, база — это один файл. Для небольших объёмов достаточно; - #link("https://www.postgresql.org")[PostgreSQL] был бы избыточен для нужд заказчика. -- id — это UUIDv7, а момент создания берём из самого id — сортируемый ключ и время - «бесплатно», без автоинкремента и колонки created_at. - -= Frontend - -- TypeScript + Vite, без UI-фреймворка. Видно платформу, а не абстракции React - или Vue. Захотите фреймворк — обоснуйте, зачем он здесь. - -= Соглашения и проверки - -- #g.openapi()[OpenAPI] (`spec/api/openapi.yaml`) — схема первична: и фронтенд, - и проверки опираются на неё. -- #g.hurl()[Hurl] обеспечивает #g.acceptance-tests()[приёмочные проверки] через - файлы с одноимённым расширением, которые запускаются в #g.ci()[CI]. Удобнее, - чем держать коллекцию в #link("https://www.postman.com")[Postman]. -- #g.playwright()[Playwright] — несколько UI-сценариев поверх контракта. - -= Дисциплина - -- Строгий #g.static-analysis()[статанализ] (PHPStan на уровне max+strict, PHPMD, - Rector; ESLint с type-aware правилами, Prettier) — мы держим планку - #link("https://maximaster.ru/blog/code-quality/")[качества кода], критичную для разработки - #link("https://maximaster.ru/blog/hygienic-minimum-of-non-functional-requirements/")[современных проектов]. -- Лимиты памяти (#raw(php-mem) / #raw(container-mem)) — приближаем условия - разработки к скромному серверу заказчика, чтобы исключить неожиданности при - деплое. -- devbox — окружение разворачивается одной командой и одинаково у всех, без - «поставьте десять инструментов руками». - -= Этот сайт - -Сам гайд собран на #link("https://typst.app/docs/")[Typst] + -#link("https://github.com/tola-rs/tola-ssg")[Tola]. Инструкция живёт рядом с -кодом и собирается в статический сайт. Typst хорош тем, что читается как -Markdown, но под капотом полноценный язык программирования, который легко -конвертируется в PDF, если вам так удобнее. - -Цифры выше — не переписаны вручную: лимиты памяти страница читает прямо из -`.env.example` через #link("https://typst.app/docs/reference/data-loading/")[data-loading] -Typst, поэтому инструкция не может разойтись с конфигом. - -А ещё Typst даёт хорошую ссылочность. Когда в тексте встречается термин, к нему -всплывает подсказка — все они собраны в #link(u("/glossary/"))[глоссарий]. -На термины мы ссылаемся через код, так что при их переименовании или удалении -#g.lsp()[LSP] и компилятор сразу покажут, где термин ещё используется. +#import "../utils/page.typ": page +#import "../utils/site.typ": u +#import "../utils/glossary.typ" as g +#import "../utils/env.typ": ENV +#import "../utils/repo.typ": php-version, slim-version + +#let php-mem = ENV.at("RECALL_PHP_MEMORY_LIMIT") +#let container-mem = ENV.at("RECALL_CONTAINER_MEMORY_LIMIT") + +#show: page.with(title: "Почему такой стек", active: "/stack/") + +Проект собран неспроста, каждый выбор был обоснован. Если вы сделали +иначе и можете объяснить, почему — это ровно тот разговор, который нам интересен: +пишите в `LOGBOOK.md`. + += Backend + +- PHP #php-version + Slim #slim-version — это тонкий микрофреймворк, а не тяжеловесные Laravel или Symfony: + видно, как устроено приложение; фреймворк ничего не прячет. +- Cycle ORM — схема задаётся массивом, домен остаётся чистым: без аннотаций в + сущностях и без компиляции на каждый запрос. +- SQLite — ноль настройки, база — это один файл. Для небольших объёмов достаточно; + #link("https://www.postgresql.org")[PostgreSQL] был бы избыточен для нужд заказчика. +- id — это UUIDv7, а момент создания берём из самого id — сортируемый ключ и время + «бесплатно», без автоинкремента и колонки created_at. + += Frontend + +- TypeScript + Vite, без UI-фреймворка. Видно платформу, а не абстракции React + или Vue. Захотите фреймворк — обоснуйте, зачем он здесь. + += Соглашения и проверки + +- #g.openapi()[OpenAPI] (`spec/api/openapi.yaml`) — схема первична: и фронтенд, + и проверки опираются на неё. +- #g.hurl()[Hurl] обеспечивает #g.acceptance-tests()[приёмочные проверки] через + файлы с одноимённым расширением, которые запускаются в #g.ci()[CI]. Удобнее, + чем держать коллекцию в #link("https://www.postman.com")[Postman]. +- #g.playwright()[Playwright] — несколько UI-сценариев поверх контракта. + += Дисциплина + +- Строгий #g.static-analysis()[статанализ] (PHPStan на уровне max+strict, PHPMD, + Rector; ESLint с type-aware правилами, Prettier) — мы держим планку + #link("https://maximaster.ru/blog/code-quality/")[качества кода], критичную для разработки + #link("https://maximaster.ru/blog/hygienic-minimum-of-non-functional-requirements/")[современных проектов]. +- Лимиты памяти (#raw(php-mem) / #raw(container-mem)) — приближаем условия + разработки к скромному серверу заказчика, чтобы исключить неожиданности при + деплое. +- devbox — окружение разворачивается одной командой и одинаково у всех, без + «поставьте десять инструментов руками». + += Этот сайт + +Сам гайд собран на #link("https://typst.app/docs/")[Typst] + +#link("https://github.com/tola-rs/tola-ssg")[Tola]. Инструкция живёт рядом с +кодом и собирается в статический сайт. Typst хорош тем, что читается как +Markdown, но под капотом полноценный язык программирования, который легко +конвертируется в PDF, если вам так удобнее. + +Цифры выше — не переписаны вручную: лимиты памяти страница читает прямо из +`.env.example` через #link("https://typst.app/docs/reference/data-loading/")[data-loading] +Typst, поэтому инструкция не может разойтись с конфигом. + +А ещё Typst даёт хорошую ссылочность. Когда в тексте встречается термин, к нему +всплывает подсказка — все они собраны в #link(u("/glossary/"))[глоссарий]. +На термины мы ссылаемся через код, так что при их переименовании или удалении +#g.lsp()[LSP] и компилятор сразу покажут, где термин ещё используется. diff --git a/www/content/task.typ b/www/content/task.typ index 8e65ef4..2b49f46 100644 --- a/www/content/task.typ +++ b/www/content/task.typ @@ -1,72 +1,72 @@ -#import "../utils/page.typ": page -#import "../utils/glossary.typ" as g -#import "../utils/repo.typ": logbook-cap - -#show: page.with(title: "Задание", active: "/task/") - -В репозитории хранится начатый проект «Recall» — персональная база знаний с -#g.spaced-repetition()[интервальным повторением]. Backend на PHP, frontend на TypeScript, схема API -описана в `spec/api/openapi.yaml`. Проект запускается, и даже что-то уже работает. - -= Что уже есть - -- #g.note()[Заметки]: создание, чтение, изменение, удаление, фильтрация по тегу. -- #g.card()[Карточки]: создаются из заметок. -- Повторение: #g.review-queue()[очередь на сегодня] и выставление оценки карточке. -- Статистика: сколько повторить сегодня, сколько за неделю, #g.streak()[серия дней]. - -= Сущности - -- Заметка — заголовок, текст, теги, ссылки на другие заметки. -- Карточка — лицевая и оборотная сторона, создаётся из заметки. -- Повторение — #g.grade() (`again` / `hard` / `good` / `easy`) и дата - следующего показа. - -= Про алгоритм повторения - -Используется упрощённый интервальный алгоритм в духе #g.sm2(): -чем лучше вы помните карточку, тем дальше отодвигается следующий показ; чем хуже -— тем ближе. Конкретные интервалы и правила подсчёта статистики намеренно не -зафиксированы в задании — это часть того, что вам предстоит определить и -реализовать. #g.acceptance-tests()[Приёмочные тесты] проверяют поведение, а не конкретные числа. - -= Как определить готовность - -`just verify` запускает приёмочные тесты. Они нарочно нестрогие. - -Команда поднимает приложение, гоняет HTTP-проверки (#g.hurl()[Hurl]) по схеме из -`spec/api/` и несколько #g.playwright()[Playwright]-сценариев. - -Поднять руками для разработки: - -```sh -just dev # запустить приложение в режиме разработки через docker compose -just test # запуск backend юнит-тестов -just lint # провести статанализ и проверку стиля кода (входит в verify) -just fmt # автоформат и безопасные автоправки -just guide # собрать сайт-инструкцию локально -``` - -= Структура репозитория - -- `www/` — данная инструкция (Typst + Tola) -- `app/backend/` — PHP 8.5, Slim 4, Cycle ORM, SQLite -- `app/frontend/` — фронтенд на TypeScript + Vite -- `spec/api/` — схема OpenAPI -- `spec/acceptance/` — приёмочные проверки (`*.hurl`) -- `e2e/` — Playwright-сценарии -- `compose.yaml` — конфигурация docker compose для запуска приложения -- `Justfile` — команды `just verify`, `dev`, `guide`, `test` и др. -- `LOGBOOK.md` — журнал решений (#raw(logbook-cap) строк) - -= Как сдавать работу - -1. Форкните публичный шаблон репозитория. -2. Работайте в отдельной ветке, а не в `master`: автопроверка `just verify` - в CI гоняется на ветках и в PR. -3. Доведите проект до успешного прохождения `just verify`. -4. В процессе заполняйте `LOGBOOK.md`. -5. Откройте Pull Request (PR) из своей ветки и убедитесь, что проверки прошли в CI. -6. Пришлите нам ссылку на PR. - -Удачи! +#import "../utils/page.typ": page +#import "../utils/glossary.typ" as g +#import "../utils/repo.typ": logbook-cap + +#show: page.with(title: "Задание", active: "/task/") + +В репозитории хранится начатый проект «Recall» — персональная база знаний с +#g.spaced-repetition()[интервальным повторением]. Backend на PHP, frontend на TypeScript, схема API +описана в `spec/api/openapi.yaml`. Проект запускается, и даже что-то уже работает. + += Что уже есть + +- #g.note()[Заметки]: создание, чтение, изменение, удаление, фильтрация по тегу. +- #g.card()[Карточки]: создаются из заметок. +- Повторение: #g.review-queue()[очередь на сегодня] и выставление оценки карточке. +- Статистика: сколько повторить сегодня, сколько за неделю, #g.streak()[серия дней]. + += Сущности + +- Заметка — заголовок, текст, теги, ссылки на другие заметки. +- Карточка — лицевая и оборотная сторона, создаётся из заметки. +- Повторение — #g.grade() (`again` / `hard` / `good` / `easy`) и дата + следующего показа. + += Про алгоритм повторения + +Используется упрощённый интервальный алгоритм в духе #g.sm2(): +чем лучше вы помните карточку, тем дальше отодвигается следующий показ; чем хуже +— тем ближе. Конкретные интервалы и правила подсчёта статистики намеренно не +зафиксированы в задании — это часть того, что вам предстоит определить и +реализовать. #g.acceptance-tests()[Приёмочные тесты] проверяют поведение, а не конкретные числа. + += Как определить готовность + +`just verify` запускает приёмочные тесты. Они нарочно нестрогие. + +Команда поднимает приложение, гоняет HTTP-проверки (#g.hurl()[Hurl]) по схеме из +`spec/api/` и несколько #g.playwright()[Playwright]-сценариев. + +Поднять руками для разработки: + +```sh +just dev # запустить приложение в режиме разработки через docker compose +just test # запуск backend юнит-тестов +just lint # провести статанализ и проверку стиля кода (входит в verify) +just fmt # автоформат и безопасные автоправки +just guide # собрать сайт-инструкцию локально +``` + += Структура репозитория + +- `www/` — данная инструкция (Typst + Tola) +- `app/backend/` — PHP 8.5, Slim 4, Cycle ORM, SQLite +- `app/frontend/` — фронтенд на TypeScript + Vite +- `spec/api/` — схема OpenAPI +- `spec/acceptance/` — приёмочные проверки (`*.hurl`) +- `e2e/` — Playwright-сценарии +- `compose.yaml` — конфигурация docker compose для запуска приложения +- `Justfile` — команды `just verify`, `dev`, `guide`, `test` и др. +- `LOGBOOK.md` — журнал решений (#raw(logbook-cap) строк) + += Как сдавать работу + +1. Форкните публичный шаблон репозитория. +2. Работайте в отдельной ветке, а не в `master`: автопроверка `just verify` + в CI гоняется на ветках и в PR. +3. Доведите проект до успешного прохождения `just verify`. +4. В процессе заполняйте `LOGBOOK.md`. +5. Откройте Pull Request (PR) из своей ветки и убедитесь, что проверки прошли в CI. +6. Пришлите нам ссылку на PR. + +Удачи! diff --git a/www/templates/base.typ b/www/templates/base.typ index c2a6db2..95c90b5 100644 --- a/www/templates/base.typ +++ b/www/templates/base.typ @@ -1,43 +1,43 @@ -// Show rules for content inside the layout. - -#import "../utils/tola.typ": to-string - -// Транслитерация кириллицы — иначе кириллические заголовки дают пустой id="" -// (невалидный дублирующийся якорь). Сводим к латинице, потом к slug. -#let _translit = ( - "а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e", - "ж": "zh", "з": "z", "и": "i", "й": "i", "к": "k", "л": "l", "м": "m", - "н": "n", "о": "o", "п": "p", "р": "r", "с": "s", "т": "t", "у": "u", - "ф": "f", "х": "h", "ц": "c", "ч": "ch", "ш": "sh", "щ": "sch", "ъ": "", - "ы": "y", "ь": "", "э": "e", "ю": "yu", "я": "ya", -) - -#let _slug(s) = { - let t = lower(if type(s) == str { s } else { to-string(s) }) - let r = "" - let prev-dash = true - for c in t.clusters() { - for ch in _translit.at(c, default: c).clusters() { - let ok = (ch >= "a" and ch <= "z") or (ch >= "0" and ch <= "9") - if ok { - r = r + ch - prev-dash = false - } else if not prev-dash { - r = r + "-" - prev-dash = true - } - } - } - if r.ends-with("-") { r = r.slice(0, r.len() - 1) } - if r.starts-with("-") { r = r.slice(1) } - r -} - -#let base(body) = { - // h1 is the page title (rendered by the layout); content headings start at h2. - show heading.where(level: 1): it => html.elem("h2", attrs: (id: _slug(it.body)))[#it.body] - show heading.where(level: 2): it => html.elem("h3", attrs: (id: _slug(it.body)))[#it.body] - show heading.where(level: 3): it => html.elem("h4", attrs: (id: _slug(it.body)))[#it.body] - - body -} +// Show rules for content inside the layout. + +#import "../utils/tola.typ": to-string + +// Транслитерация кириллицы — иначе кириллические заголовки дают пустой id="" +// (невалидный дублирующийся якорь). Сводим к латинице, потом к slug. +#let _translit = ( + "а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e", + "ж": "zh", "з": "z", "и": "i", "й": "i", "к": "k", "л": "l", "м": "m", + "н": "n", "о": "o", "п": "p", "р": "r", "с": "s", "т": "t", "у": "u", + "ф": "f", "х": "h", "ц": "c", "ч": "ch", "ш": "sh", "щ": "sch", "ъ": "", + "ы": "y", "ь": "", "э": "e", "ю": "yu", "я": "ya", +) + +#let _slug(s) = { + let t = lower(if type(s) == str { s } else { to-string(s) }) + let r = "" + let prev-dash = true + for c in t.clusters() { + for ch in _translit.at(c, default: c).clusters() { + let ok = (ch >= "a" and ch <= "z") or (ch >= "0" and ch <= "9") + if ok { + r = r + ch + prev-dash = false + } else if not prev-dash { + r = r + "-" + prev-dash = true + } + } + } + if r.ends-with("-") { r = r.slice(0, r.len() - 1) } + if r.starts-with("-") { r = r.slice(1) } + r +} + +#let base(body) = { + // h1 is the page title (rendered by the layout); content headings start at h2. + show heading.where(level: 1): it => html.elem("h2", attrs: (id: _slug(it.body)))[#it.body] + show heading.where(level: 2): it => html.elem("h3", attrs: (id: _slug(it.body)))[#it.body] + show heading.where(level: 3): it => html.elem("h4", attrs: (id: _slug(it.body)))[#it.body] + + body +} diff --git a/www/templates/layout.typ b/www/templates/layout.typ index 196b7f1..ed7fb34 100644 --- a/www/templates/layout.typ +++ b/www/templates/layout.typ @@ -1,116 +1,116 @@ -// Page chrome: head, header nav, main, footer. - -#import "../utils/site.typ": SITE, NAV, u -#import "../utils/tola.typ": og-tags - -#let make-head(m) = { - html.elem("meta", attrs: (charset: "utf-8")) - html.elem("meta", attrs: (name: "viewport", content: "width=device-width, initial-scale=1")) - - // : на главной — полное самоописательное название, на остальных - // страницах добавляем хвост «Recall · Максимастер», чтобы выдача и вкладка - // всегда называли проект и автора. - let page-title = m.at("title", default: SITE.title) - let title = if page-title == SITE.title { SITE.title-home } else { - page-title + " — " + SITE.title-suffix - } - let desc = m.at("summary", default: SITE.description) - html.elem("title")[#title] - html.elem("meta", attrs: (name: "description", content: desc)) - html.elem("meta", attrs: (name: "author", content: SITE.author)) - html.elem("link", attrs: (rel: "canonical", href: SITE.url)) - og-tags( - title: title, - description: desc, - url: SITE.url, - type: "website", - site-name: SITE.author, - locale: "ru_RU", - ) - - html.elem("link", attrs: (rel: "preconnect", href: "https://fonts.googleapis.com")) - html.elem("link", attrs: (rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "")) - html.elem("link", attrs: ( - rel: "stylesheet", - href: "https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap", - )) - // Asset links stay source-relative (/assets/...): Tola applies the site - // prefix itself on build. Wrapping them in u() emits the same path but makes - // `tola validate` treat them as page links → "not found". u() is for pages. - html.elem("link", attrs: (rel: "stylesheet", href: "/assets/styles/main.css")) - html.elem("link", attrs: (rel: "stylesheet", href: "/assets/styles/components.css")) - html.script( - "(function(){var d=document.documentElement;var t=localStorage.getItem('theme');if(t){d.setAttribute('data-theme',t)}else if(window.matchMedia('(prefers-color-scheme:dark)').matches){d.setAttribute('data-theme','dark')}}());function toggleTheme(){var d=document.documentElement;d.setAttribute('data-theme',d.getAttribute('data-theme')==='dark'?'light':'dark');localStorage.setItem('theme',d.getAttribute('data-theme'))}", - ) -} - -#let layout(body, meta: (:)) = { - let title = meta.at("title", default: none) - let active = meta.at("active", default: none) - - // Brand block: logo links home, next to a two-line text column — the project - // name (links home) над строкой атрибуции «сделано в Максимастер», где имя - // ведёт на сайт студии. Атрибуция — отдельная ссылка, поэтому она НЕ может - // лежать внутри ссылки-названия (вложенные <a> невалидны) — отсюда колонка - // из двух самостоятельных якорей. - // - // Logo stays a CSS background on an inline span: a real <img> is block-level - // in Typst's html model and would force a <p>, breaking the layout. - let mark = html.elem("span", attrs: ( - class: "brand-mark", - role: "img", - "aria-label": SITE.author, - )) - let brand-by = html.elem( - "span", - attrs: (class: "brand-by"), - [сделано в ] - + html.elem("a", attrs: (href: SITE.author-url, rel: "author"), [#SITE.author]), - ) - let brand = html.elem( - "div", - attrs: (class: "brand"), - html.elem("a", attrs: (href: u("/"), class: "brand-home", "aria-label": "Recall"), mark) - + html.elem( - "span", - attrs: (class: "brand-text"), - html.elem("a", attrs: (href: u("/"), class: "brand-name"), [Recall: задание для практики]) - + brand-by, - ), - ) - - let nav = html.elem("nav", { - for item in NAV { - let attrs = (href: u(item.href)) - // Mark the link for the current page so CSS can highlight it. - if active == item.href { attrs.insert("aria-current", "page") } - html.elem("a", attrs: attrs, [#item.label]) - } - }) - - let toggleBtn = html.elem( - "button", - attrs: ( - class: "theme-toggle", - type: "button", - "aria-label": "Переключить тему", - onclick: "toggleTheme()", - ), - html.elem("span", attrs: (class: "theme-icon theme-icon-sun"), [☀]) - + html.elem("span", attrs: (class: "theme-icon theme-icon-moon"), [🌙]), - ) - - html.elem("header", attrs: (class: "site-header"), brand + nav + toggleBtn) - - html.elem("main")[ - #if title != none { html.elem("h1")[#title] } - #body - ] - - html.elem( - "footer", - attrs: (class: "site-footer"), - [#SITE.tagline · сделано в ] - + html.elem("a", attrs: (href: SITE.author-url, rel: "author"), [#SITE.author]), - ) -} +// Page chrome: head, header nav, main, footer. + +#import "../utils/site.typ": SITE, NAV, u +#import "../utils/tola.typ": og-tags + +#let make-head(m) = { + html.elem("meta", attrs: (charset: "utf-8")) + html.elem("meta", attrs: (name: "viewport", content: "width=device-width, initial-scale=1")) + + // <title>: на главной — полное самоописательное название, на остальных + // страницах добавляем хвост «Recall · Максимастер», чтобы выдача и вкладка + // всегда называли проект и автора. + let page-title = m.at("title", default: SITE.title) + let title = if page-title == SITE.title { SITE.title-home } else { + page-title + " — " + SITE.title-suffix + } + let desc = m.at("summary", default: SITE.description) + html.elem("title")[#title] + html.elem("meta", attrs: (name: "description", content: desc)) + html.elem("meta", attrs: (name: "author", content: SITE.author)) + html.elem("link", attrs: (rel: "canonical", href: SITE.url)) + og-tags( + title: title, + description: desc, + url: SITE.url, + type: "website", + site-name: SITE.author, + locale: "ru_RU", + ) + + html.elem("link", attrs: (rel: "preconnect", href: "https://fonts.googleapis.com")) + html.elem("link", attrs: (rel: "preconnect", href: "https://fonts.gstatic.com", crossorigin: "")) + html.elem("link", attrs: ( + rel: "stylesheet", + href: "https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap", + )) + // Asset links stay source-relative (/assets/...): Tola applies the site + // prefix itself on build. Wrapping them in u() emits the same path but makes + // `tola validate` treat them as page links → "not found". u() is for pages. + html.elem("link", attrs: (rel: "stylesheet", href: "/assets/styles/main.css")) + html.elem("link", attrs: (rel: "stylesheet", href: "/assets/styles/components.css")) + html.script( + "(function(){var d=document.documentElement;var t=localStorage.getItem('theme');if(t){d.setAttribute('data-theme',t)}else if(window.matchMedia('(prefers-color-scheme:dark)').matches){d.setAttribute('data-theme','dark')}}());function toggleTheme(){var d=document.documentElement;d.setAttribute('data-theme',d.getAttribute('data-theme')==='dark'?'light':'dark');localStorage.setItem('theme',d.getAttribute('data-theme'))}", + ) +} + +#let layout(body, meta: (:)) = { + let title = meta.at("title", default: none) + let active = meta.at("active", default: none) + + // Brand block: logo links home, next to a two-line text column — the project + // name (links home) над строкой атрибуции «сделано в Максимастер», где имя + // ведёт на сайт студии. Атрибуция — отдельная ссылка, поэтому она НЕ может + // лежать внутри ссылки-названия (вложенные <a> невалидны) — отсюда колонка + // из двух самостоятельных якорей. + // + // Logo stays a CSS background on an inline span: a real <img> is block-level + // in Typst's html model and would force a <p>, breaking the layout. + let mark = html.elem("span", attrs: ( + class: "brand-mark", + role: "img", + "aria-label": SITE.author, + )) + let brand-by = html.elem( + "span", + attrs: (class: "brand-by"), + [сделано в ] + + html.elem("a", attrs: (href: SITE.author-url, rel: "author"), [#SITE.author]), + ) + let brand = html.elem( + "div", + attrs: (class: "brand"), + html.elem("a", attrs: (href: u("/"), class: "brand-home", "aria-label": "Recall"), mark) + + html.elem( + "span", + attrs: (class: "brand-text"), + html.elem("a", attrs: (href: u("/"), class: "brand-name"), [Recall: задание для практики]) + + brand-by, + ), + ) + + let nav = html.elem("nav", { + for item in NAV { + let attrs = (href: u(item.href)) + // Mark the link for the current page so CSS can highlight it. + if active == item.href { attrs.insert("aria-current", "page") } + html.elem("a", attrs: attrs, [#item.label]) + } + }) + + let toggleBtn = html.elem( + "button", + attrs: ( + class: "theme-toggle", + type: "button", + "aria-label": "Переключить тему", + onclick: "toggleTheme()", + ), + html.elem("span", attrs: (class: "theme-icon theme-icon-sun"), [☀]) + + html.elem("span", attrs: (class: "theme-icon theme-icon-moon"), [🌙]), + ) + + html.elem("header", attrs: (class: "site-header"), brand + nav + toggleBtn) + + html.elem("main")[ + #if title != none { html.elem("h1")[#title] } + #body + ] + + html.elem( + "footer", + attrs: (class: "site-footer"), + [#SITE.tagline · сделано в ] + + html.elem("a", attrs: (href: SITE.author-url, rel: "author"), [#SITE.author]), + ) +} diff --git a/www/templates/tola.typ b/www/templates/tola.typ index e1637b8..c18fb04 100644 --- a/www/templates/tola.typ +++ b/www/templates/tola.typ @@ -1,260 +1,260 @@ -// Tola SSG base template (v0.7.1) -// -// AUTO-GENERATED - Avoid modifying this file directly. -// Instead, extend it or create your own copy to reduce migration -// difficulty when upgrading to future versions with breaking changes. -// -// Handles math/table/figure rendering with proper HTML structure -// Provides page template with metadata for SSG - -// ============================================================================ -// Shared State -// ============================================================================ - -#let inside-figure = state("_tola-inside-figure", false) - -// Inline math baseline fix (pin + measure). -#let bounded(eq) = text(top-edge: "bounds", bottom-edge: "bounds", eq) -#let equations-height-dict = state("eq_height_dict", (:)) -#let is-inside-pin = state("inside_pin", false) - -#let pin(label) = context { - let height = here().position().y - equations-height-dict.update(dict => { - if label in dict.keys() or height < 0.000001pt { - dict - } else { - dict.insert(label, height) - dict - } - }) -} - -#let add-pin(eq) = { - let label = repr(eq) - is-inside-pin.update(true) - $ inline(pin(label)#bounded(eq)) $ - is-inside-pin.update(false) -} - -#let to-em(pt) = str(pt / text.size.pt()) + "em" - -#let math-span(class: "", style: none, body) = { - let attrs = (role: "math") - if class != "" { - attrs.insert("class", class) - } - if style != none { - attrs.insert("style", style) - } - html.elem("span", body, attrs: attrs) -} - -#let render-inline-math(eq, class: "") = context { - if is-inside-pin.get() { - return math-span(class: class)[#html.frame(bounded(eq))] - } - - let label = repr(eq) - let cache = equations-height-dict.final() - if label in cache.keys() { - let reference-height = cache.at(label, default: none) - equations-height-dict.update(dict => { - dict.insert(label, reference-height) - dict - }) - - let measured-height = measure(bounded(eq)).height - let shift = measured-height - reference-height - let style = "vertical-align: -" + to-em(shift.pt()) + ";" - math-span(class: class, style: style)[#html.frame(bounded(eq))] - } else { - math-span(class: class)[#box(html.frame(add-pin(eq)))] - } -} - -// ============================================================================ -// Base Template (Show Rules) -// ============================================================================ - -#let tola-base( - // CSS classes for customization - figure-class: "", - math-inline-class: "", - math-block-class: "", - // Math font (string or array for fallback) - math-font: "New Computer Modern Math", - body, -) = { - // Figure wrapper: use target() to avoid html.elem warnings inside html.frame() - // internal paged render passes. - show figure: it => context { - if target() == "html" { - inside-figure.update(true) - let wrapped = html.figure(class: figure-class)[#it] - inside-figure.update(false) - wrapped - } else { it } - } - - // Note: No table show rule - Typst renders tables as native HTML <table>. - // Using html.frame() on tables would convert them to SVG, causing internal - // HTML elements (like html.code, html.span for math) to be ignored. - - show math.equation: set text( - font: math-font, - top-edge: "bounds", - bottom-edge: "bounds", - ) - - // Math equations: use target() - // - html.frame() internally renders to SVG using "paged" mode - // - target() returns "paged" inside html.frame(), so the show rule skips - show math.equation.where(block: false): it => context { - if target() == "html" and not inside-figure.get() { - render-inline-math(it, class: math-inline-class) - } else { it } - } - - show math.equation.where(block: true): it => context { - if target() == "html" and not inside-figure.get() { - html.div(class: math-block-class, role: "math")[#html.frame(it)] - } else { it } - } - - body -} - -// ============================================================================ -// Date Utilities -// ============================================================================ - -/// Parse date string to datetime (strict version). -#let _parse-date(s) = { - if s == none { return none } - if type(s) == datetime { return s } - let s = str(s).split("T").at(0) - let parts = s.split("-") - assert(parts.len() == 3, message: "Invalid date format: '" + s + "', expected YYYY-MM-DD") - datetime(year: int(parts.at(0)), month: int(parts.at(1)), day: int(parts.at(2))) -} - -// ============================================================================ -// Page Template -// ============================================================================ - -/// Page template with metadata for Tola SSG. -/// Usage: `tola-page(title: "...", ...)[body]` or `tola-page(title: "...", ..., head: [...])[body]` -/// -/// Date fields (date, update) are automatically converted from string to datetime. -#let tola-page( - // Content metadata (standard fields recognized by Tola SSG) - title: none, - summary: none, - date: none, - update: none, - author: none, - draft: false, - tags: (), - permalink: none, - aliases: (), - global-header: true, - // Head content (optional) - head: [], - // Body content (required, positional) - body, - // Extra metadata fields (order, pinned, etc.) - ..extra, -) = { - // Auto-convert date strings to datetime - let date = _parse-date(date) - let update = _parse-date(update) - - [#metadata(( - title: title, - summary: summary, - date: date, - update: update, - author: author, - draft: draft, - tags: tags, - permalink: permalink, - aliases: aliases, - global-header: global-header, - ..extra.named(), - )) <tola-meta>] - - show: tola-base - - // Keep this top-level branch outside context so query/validate sees body directly. - let is-html = sys.inputs.at("format", default: "paged") == "html" - if is-html { - html.html[ - #html.head[#head] - #html.body[#body] - ] - } else { - body - } -} - -// ============================================================================ -// Template Builder -// ============================================================================ - -/// Create a custom template with automatic parameter forwarding. -/// -/// This helper reduces boilerplate when creating templates that extend tola-page. -/// It automatically handles: -/// - Parameter declaration and forwarding to tola-page -/// - Applying base show rules (won't forget `show: base`) -/// - Head content generation from metadata -/// -/// Parameters: -/// - `base`: Show rule function to apply (e.g., your custom base with heading styles) -/// - `head`: Function `(meta) => content` to generate <head> content (e.g., og-tags) -/// - `view`: Function `(body, meta) => content` to wrap the body with layout -/// - `transform-meta`: Function `(meta) => meta` to transform metadata before passing to tola-page. -/// Use this to derive fields from source path (e.g., extract date/permalink from filename). -/// -/// Example: -/// ```typst -/// #import "/templates/tola.typ": wrap-page -/// #import "/templates/base.typ": base -/// #import "/utils/tola.typ": og-tags -/// -/// #let post = wrap-page( -/// base: base, -/// head: (m) => og-tags(title: m.title, published: m.date), -/// view: (body, m) => { -/// show heading.where(level: 1): it => html.h2[#it.body] -/// html.article[ -/// #if m.title != none { html.h1[#m.title] } -/// #body -/// ] -/// }, -/// ) -/// ``` -#let wrap-page( - base: none, - head: none, - view: (body, meta) => body, - transform-meta: none, -) = (body, ..args) => { - let meta = args.named() - - // Transform meta first (e.g., derive date/permalink from source) - if transform-meta != none { meta = transform-meta(meta) } - - // Auto-convert date strings to datetime (after transform, so derived dates get converted) - if "date" in meta { meta.date = _parse-date(meta.date) } - if "update" in meta { meta.update = _parse-date(meta.update) } - - let head-content = if head != none { head(meta) } - let base-fn = if base == none { it => it } else { base } - - tola-page(..meta, head: head-content)[ - #show: base-fn - #view(body, meta) - ] -} +// Tola SSG base template (v0.7.1) +// +// AUTO-GENERATED - Avoid modifying this file directly. +// Instead, extend it or create your own copy to reduce migration +// difficulty when upgrading to future versions with breaking changes. +// +// Handles math/table/figure rendering with proper HTML structure +// Provides page template with metadata for SSG + +// ============================================================================ +// Shared State +// ============================================================================ + +#let inside-figure = state("_tola-inside-figure", false) + +// Inline math baseline fix (pin + measure). +#let bounded(eq) = text(top-edge: "bounds", bottom-edge: "bounds", eq) +#let equations-height-dict = state("eq_height_dict", (:)) +#let is-inside-pin = state("inside_pin", false) + +#let pin(label) = context { + let height = here().position().y + equations-height-dict.update(dict => { + if label in dict.keys() or height < 0.000001pt { + dict + } else { + dict.insert(label, height) + dict + } + }) +} + +#let add-pin(eq) = { + let label = repr(eq) + is-inside-pin.update(true) + $ inline(pin(label)#bounded(eq)) $ + is-inside-pin.update(false) +} + +#let to-em(pt) = str(pt / text.size.pt()) + "em" + +#let math-span(class: "", style: none, body) = { + let attrs = (role: "math") + if class != "" { + attrs.insert("class", class) + } + if style != none { + attrs.insert("style", style) + } + html.elem("span", body, attrs: attrs) +} + +#let render-inline-math(eq, class: "") = context { + if is-inside-pin.get() { + return math-span(class: class)[#html.frame(bounded(eq))] + } + + let label = repr(eq) + let cache = equations-height-dict.final() + if label in cache.keys() { + let reference-height = cache.at(label, default: none) + equations-height-dict.update(dict => { + dict.insert(label, reference-height) + dict + }) + + let measured-height = measure(bounded(eq)).height + let shift = measured-height - reference-height + let style = "vertical-align: -" + to-em(shift.pt()) + ";" + math-span(class: class, style: style)[#html.frame(bounded(eq))] + } else { + math-span(class: class)[#box(html.frame(add-pin(eq)))] + } +} + +// ============================================================================ +// Base Template (Show Rules) +// ============================================================================ + +#let tola-base( + // CSS classes for customization + figure-class: "", + math-inline-class: "", + math-block-class: "", + // Math font (string or array for fallback) + math-font: "New Computer Modern Math", + body, +) = { + // Figure wrapper: use target() to avoid html.elem warnings inside html.frame() + // internal paged render passes. + show figure: it => context { + if target() == "html" { + inside-figure.update(true) + let wrapped = html.figure(class: figure-class)[#it] + inside-figure.update(false) + wrapped + } else { it } + } + + // Note: No table show rule - Typst renders tables as native HTML <table>. + // Using html.frame() on tables would convert them to SVG, causing internal + // HTML elements (like html.code, html.span for math) to be ignored. + + show math.equation: set text( + font: math-font, + top-edge: "bounds", + bottom-edge: "bounds", + ) + + // Math equations: use target() + // - html.frame() internally renders to SVG using "paged" mode + // - target() returns "paged" inside html.frame(), so the show rule skips + show math.equation.where(block: false): it => context { + if target() == "html" and not inside-figure.get() { + render-inline-math(it, class: math-inline-class) + } else { it } + } + + show math.equation.where(block: true): it => context { + if target() == "html" and not inside-figure.get() { + html.div(class: math-block-class, role: "math")[#html.frame(it)] + } else { it } + } + + body +} + +// ============================================================================ +// Date Utilities +// ============================================================================ + +/// Parse date string to datetime (strict version). +#let _parse-date(s) = { + if s == none { return none } + if type(s) == datetime { return s } + let s = str(s).split("T").at(0) + let parts = s.split("-") + assert(parts.len() == 3, message: "Invalid date format: '" + s + "', expected YYYY-MM-DD") + datetime(year: int(parts.at(0)), month: int(parts.at(1)), day: int(parts.at(2))) +} + +// ============================================================================ +// Page Template +// ============================================================================ + +/// Page template with metadata for Tola SSG. +/// Usage: `tola-page(title: "...", ...)[body]` or `tola-page(title: "...", ..., head: [...])[body]` +/// +/// Date fields (date, update) are automatically converted from string to datetime. +#let tola-page( + // Content metadata (standard fields recognized by Tola SSG) + title: none, + summary: none, + date: none, + update: none, + author: none, + draft: false, + tags: (), + permalink: none, + aliases: (), + global-header: true, + // Head content (optional) + head: [], + // Body content (required, positional) + body, + // Extra metadata fields (order, pinned, etc.) + ..extra, +) = { + // Auto-convert date strings to datetime + let date = _parse-date(date) + let update = _parse-date(update) + + [#metadata(( + title: title, + summary: summary, + date: date, + update: update, + author: author, + draft: draft, + tags: tags, + permalink: permalink, + aliases: aliases, + global-header: global-header, + ..extra.named(), + )) <tola-meta>] + + show: tola-base + + // Keep this top-level branch outside context so query/validate sees body directly. + let is-html = sys.inputs.at("format", default: "paged") == "html" + if is-html { + html.html[ + #html.head[#head] + #html.body[#body] + ] + } else { + body + } +} + +// ============================================================================ +// Template Builder +// ============================================================================ + +/// Create a custom template with automatic parameter forwarding. +/// +/// This helper reduces boilerplate when creating templates that extend tola-page. +/// It automatically handles: +/// - Parameter declaration and forwarding to tola-page +/// - Applying base show rules (won't forget `show: base`) +/// - Head content generation from metadata +/// +/// Parameters: +/// - `base`: Show rule function to apply (e.g., your custom base with heading styles) +/// - `head`: Function `(meta) => content` to generate <head> content (e.g., og-tags) +/// - `view`: Function `(body, meta) => content` to wrap the body with layout +/// - `transform-meta`: Function `(meta) => meta` to transform metadata before passing to tola-page. +/// Use this to derive fields from source path (e.g., extract date/permalink from filename). +/// +/// Example: +/// ```typst +/// #import "/templates/tola.typ": wrap-page +/// #import "/templates/base.typ": base +/// #import "/utils/tola.typ": og-tags +/// +/// #let post = wrap-page( +/// base: base, +/// head: (m) => og-tags(title: m.title, published: m.date), +/// view: (body, m) => { +/// show heading.where(level: 1): it => html.h2[#it.body] +/// html.article[ +/// #if m.title != none { html.h1[#m.title] } +/// #body +/// ] +/// }, +/// ) +/// ``` +#let wrap-page( + base: none, + head: none, + view: (body, meta) => body, + transform-meta: none, +) = (body, ..args) => { + let meta = args.named() + + // Transform meta first (e.g., derive date/permalink from source) + if transform-meta != none { meta = transform-meta(meta) } + + // Auto-convert date strings to datetime (after transform, so derived dates get converted) + if "date" in meta { meta.date = _parse-date(meta.date) } + if "update" in meta { meta.update = _parse-date(meta.update) } + + let head-content = if head != none { head(meta) } + let base-fn = if base == none { it => it } else { base } + + tola-page(..meta, head: head-content)[ + #show: base-fn + #view(body, meta) + ] +} diff --git a/www/tola.toml b/www/tola.toml index 8f2bc70..62596d5 100644 --- a/www/tola.toml +++ b/www/tola.toml @@ -1,51 +1,51 @@ -# Tola configuration (v0.7.1) — Recall guide site. - -[site.info] -title = "Recall — практика" -author = "maximaster" -description = "Задание для практики: персональная база знаний с интервальным повторением." -url = "https://maximaster.github.io/practice" -language = "ru" - -[site.seo] -auto_og = false - -[site.seo.feed] -enable = false - -[site.seo.sitemap] -enable = false - -[site.header] -no_fouc = true -styles = [] -scripts = [] -elements = [] - -[build] -content = "content" -output = "public" -minify = true -deps = ["templates", "utils"] - -[build.assets] -nested = ["assets"] -flatten = [] - -[build.hooks.css] -enable = false - -[serve] -interface = "127.0.0.1" -port = 5277 -watch = true -# Serve under the same /practice prefix the build uses, so local links resolve. -respect_prefix = true - -[validate.pages] -enable = true -level = "error" - -[validate.assets] -enable = true -level = "error" +# Tola configuration (v0.7.1) — Recall guide site. + +[site.info] +title = "Recall — практика" +author = "maximaster" +description = "Задание для практики: персональная база знаний с интервальным повторением." +url = "https://maximaster.github.io/practice" +language = "ru" + +[site.seo] +auto_og = false + +[site.seo.feed] +enable = false + +[site.seo.sitemap] +enable = false + +[site.header] +no_fouc = true +styles = [] +scripts = [] +elements = [] + +[build] +content = "content" +output = "public" +minify = true +deps = ["templates", "utils"] + +[build.assets] +nested = ["assets"] +flatten = [] + +[build.hooks.css] +enable = false + +[serve] +interface = "127.0.0.1" +port = 5277 +watch = true +# Serve under the same /practice prefix the build uses, so local links resolve. +respect_prefix = true + +[validate.pages] +enable = true +level = "error" + +[validate.assets] +enable = true +level = "error" diff --git a/www/utils/email.typ b/www/utils/email.typ index d1871ae..daa3e49 100644 --- a/www/utils/email.typ +++ b/www/utils/email.typ @@ -1,61 +1,61 @@ -// Email-card component: renders a message the way an email app shows it — -// subject, sender avatar/name/address, recipient, date, then the body. -// -// Usage: -// #import "../utils/email.typ": email -// #email( -// subject: "...", -// from-name: "...", -// from-addr: "...", -// to: "...", -// date: "...", -// )[ letter body ] - -#import "tola.typ": to-string - -#let email( - subject: none, - from-name: none, - from-addr: none, - to: none, - date: none, - // Avatar glyph; defaults to the first letter of the sender name. - avatar: none, - body, -) = { - let initial = if avatar != none { - avatar - } else if from-name != none { - upper(to-string(from-name).clusters().at(0, default: "?")) - } else { - "?" - } - - html.elem("article", attrs: (class: "email"), { - html.elem("header", attrs: (class: "email-head"), { - if subject != none { - html.elem("div", attrs: (class: "email-subject"), subject) - } - html.elem("div", attrs: (class: "email-meta"), { - html.elem("span", attrs: (class: "email-avatar", "aria-hidden": "true"), initial) - html.elem("div", attrs: (class: "email-ident"), { - html.elem("div", attrs: (class: "email-from"), { - if from-name != none { - html.elem("span", attrs: (class: "email-name"), from-name) - } - if from-addr != none { - html.elem("span", attrs: (class: "email-addr"), from-addr) - } - }) - if to != none { - html.elem("div", attrs: (class: "email-to"), [кому: #to]) - } - }) - if date != none { - html.elem("time", attrs: (class: "email-date"), date) - } - }) - }) - html.elem("div", attrs: (class: "email-body"), body) - }) -} +// Email-card component: renders a message the way an email app shows it — +// subject, sender avatar/name/address, recipient, date, then the body. +// +// Usage: +// #import "../utils/email.typ": email +// #email( +// subject: "...", +// from-name: "...", +// from-addr: "...", +// to: "...", +// date: "...", +// )[ letter body ] + +#import "tola.typ": to-string + +#let email( + subject: none, + from-name: none, + from-addr: none, + to: none, + date: none, + // Avatar glyph; defaults to the first letter of the sender name. + avatar: none, + body, +) = { + let initial = if avatar != none { + avatar + } else if from-name != none { + upper(to-string(from-name).clusters().at(0, default: "?")) + } else { + "?" + } + + html.elem("article", attrs: (class: "email"), { + html.elem("header", attrs: (class: "email-head"), { + if subject != none { + html.elem("div", attrs: (class: "email-subject"), subject) + } + html.elem("div", attrs: (class: "email-meta"), { + html.elem("span", attrs: (class: "email-avatar", "aria-hidden": "true"), initial) + html.elem("div", attrs: (class: "email-ident"), { + html.elem("div", attrs: (class: "email-from"), { + if from-name != none { + html.elem("span", attrs: (class: "email-name"), from-name) + } + if from-addr != none { + html.elem("span", attrs: (class: "email-addr"), from-addr) + } + }) + if to != none { + html.elem("div", attrs: (class: "email-to"), [кому: #to]) + } + }) + if date != none { + html.elem("time", attrs: (class: "email-date"), date) + } + }) + }) + html.elem("div", attrs: (class: "email-body"), body) + }) +} diff --git a/www/utils/env.typ b/www/utils/env.typ index e219393..342bab1 100644 --- a/www/utils/env.typ +++ b/www/utils/env.typ @@ -1,30 +1,30 @@ -// Reads canonical `key=value` pairs from the repo's .env.example. -// -// Tola pins Typst's project root to www/, and Typst refuses to read files -// outside the root. The repo root lives one level up, so we expose the needed -// files through www/root/ — a real directory of individual file symlinks that -// mirror the repo layout (folders are NOT symlinked, only files). Typst follows -// a symlink located inside the root without re-checking the target, so -// `read("../root/.env.example")` reaches the real .env.example. -// -// Why: values such as memory limits live once (in .env.example, where docker -// compose also reads them) and the guide renders them via data-loading — the -// docs can't drift from the config. -// -// Usage: -// #import "../utils/env.typ": ENV -// #ENV.at("RECALL_PHP_MEMORY_LIMIT") // -> "96M" - -#let parse-dotenv(raw) = { - let out = (:) - for line in raw.split("\n") { - let t = line.trim() - if t == "" or t.starts-with("#") { continue } - let i = t.position("=") - if i == none { continue } - out.insert(t.slice(0, i).trim(), t.slice(i + 1).trim()) - } - out -} - -#let ENV = parse-dotenv(read("../root/.env.example")) +// Reads canonical `key=value` pairs from the repo's .env.example. +// +// Tola pins Typst's project root to www/, and Typst refuses to read files +// outside the root. The repo root lives one level up, so we expose the needed +// files through www/root/ — a real directory of individual file symlinks that +// mirror the repo layout (folders are NOT symlinked, only files). Typst follows +// a symlink located inside the root without re-checking the target, so +// `read("../root/.env.example")` reaches the real .env.example. +// +// Why: values such as memory limits live once (in .env.example, where docker +// compose also reads them) and the guide renders them via data-loading — the +// docs can't drift from the config. +// +// Usage: +// #import "../utils/env.typ": ENV +// #ENV.at("RECALL_PHP_MEMORY_LIMIT") // -> "96M" + +#let parse-dotenv(raw) = { + let out = (:) + for line in raw.split("\n") { + let t = line.trim() + if t == "" or t.starts-with("#") { continue } + let i = t.position("=") + if i == none { continue } + out.insert(t.slice(0, i).trim(), t.slice(i + 1).trim()) + } + out +} + +#let ENV = parse-dotenv(read("../root/.env.example")) diff --git a/www/utils/glossary.typ b/www/utils/glossary.typ index 1de54bb..90d2087 100644 --- a/www/utils/glossary.typ +++ b/www/utils/glossary.typ @@ -1,108 +1,108 @@ -// Glossary — single source for term definitions. Used two ways: -// - inline: #g.card[карточку] → a term with a hover/focus tooltip -// - the /glossary/ page renders the whole dict as a definition list -// Tooltip styling lives in assets/styles/components.css (.term / .tip). -// -// Each term is addressed by its code name (a dict field), not by its text: -// - the inline word and its dictionary key are decoupled, so you write -// the phrase in any grammatical form: #g.review-queue[очередь на сегодня]; -// - a typo in the code name is a hard compile error (unknown field), not a -// silently mis-rendered word; -// - `tip` is content, so a definition may contain #link(...) and markup. -// -// Usage (import the whole module as the `g` namespace): -// #import "../utils/glossary.typ" as g -// ...создаётся из #g.note()[заметки]... -// #g.card() // no body → the canonical term, "карточка" -// -// Each entry: `term` — canonical name shown on the /glossary/ page (<dt>); -// `tip` — definition shown in the tooltip and as <dd>. - -#let TERMS = ( - spaced-repetition: ( - term: "интервальное повторение", - tip: [Техника запоминания: материал показывают через растущие промежутки времени — перед тем, как вы успеете его забыть.], - ), - sm2: ( - term: "SM-2", - tip: [Классический алгоритм интервального повторения, родом из #link("https://super-memory.com/english/ol/sm2.htm")[SuperMemo]. По вашей оценке и «лёгкости» карточки считает, когда показать её снова.], - ), - note: ( - term: "заметка", - tip: [Единица знаний: заголовок, текст, теги и ссылки на другие заметки.], - ), - card: ( - term: "карточка", - tip: [Вопрос и ответ (лицо и оборот). Создаётся из заметки и участвует в повторении.], - ), - grade: ( - term: "оценка карточки", - tip: [Шкала припоминания: again, hard, good, easy — от худшего к лучшему, как кнопки в Anki. Это одна ось, а не разные оценки.], - ), - review-queue: ( - term: "очередь повторения", - tip: [Карточки, срок показа которых наступил.], - ), - streak: ( - term: "серия", - tip: [Streak — сколько дней подряд было хотя бы одно повторение.], - ), - acceptance-tests: ( - term: "приёмочные тесты", - tip: [Проверки готовности: по контракту (Hurl) и несколько UI-сценариев (Playwright). Намеренно нестрогие.], - ), - openapi: ( - term: "OpenAPI", - tip: [Формат описания HTTP-API. Схема в spec/api/openapi.yaml — источник истины для запросов и ответов.], - ), - hurl: ( - term: "Hurl", - tip: [Текстовый формат HTTP-проверок: запрос плюс ассерты на статус, заголовки и тело. Дружит с git и CI.], - ), - playwright: ( - term: "Playwright", - tip: [Браузерные #link("https://playwright.dev/")[end-to-end тесты]: открывают страницу и проходят сценарий как живой пользователь.], - ), - static-analysis: ( - term: "статический анализ", - tip: [Проверка кода без запуска: типы, стиль, «запахи» (PHPStan, PHPMD, Rector, ESLint).], - ), - ci: ( - term: "CI", - tip: [Continuous Integration — автопрогон проверок на каждый push. Здесь гоняет тот же just verify, что и у вас локально.], - ), - lsp: ( - term: "LSP", - tip: [Language Server Protocol — по нему редактор даёт автодополнение, переходы и поиск использований. Термины глоссария адресуются по коду, поэтому LSP находит все места, где термин употреблён.], - ), -) - -// Build one wrapper per entry. `..args`: an optional content body overrides -// the displayed word; with none, the canonical term is shown. -#let _term(entry, ..args) = { - let shown = if args.pos().len() > 0 { args.pos().first() } else { [#entry.term] } - html.elem( - "span", - attrs: (class: "term", tabindex: "0"), - shown + html.elem("span", attrs: (class: "tip"), entry.tip), - ) -} - -// One module-level binding per term, so importing this file `as g` exposes -// `g.<code>()`. Typst can't call a function stored in a dict field via -// `g.code()` (that's method-call syntax), but a module member call works. -// Kept explicit (not a loop) because Typst can't create bindings dynamically. -#let spaced-repetition = _term.with(TERMS.spaced-repetition) -#let sm2 = _term.with(TERMS.sm2) -#let note = _term.with(TERMS.note) -#let card = _term.with(TERMS.card) -#let grade = _term.with(TERMS.grade) -#let review-queue = _term.with(TERMS.review-queue) -#let streak = _term.with(TERMS.streak) -#let acceptance-tests = _term.with(TERMS.acceptance-tests) -#let openapi = _term.with(TERMS.openapi) -#let hurl = _term.with(TERMS.hurl) -#let playwright = _term.with(TERMS.playwright) -#let static-analysis = _term.with(TERMS.static-analysis) -#let ci = _term.with(TERMS.ci) -#let lsp = _term.with(TERMS.lsp) +// Glossary — single source for term definitions. Used two ways: +// - inline: #g.card[карточку] → a term with a hover/focus tooltip +// - the /glossary/ page renders the whole dict as a definition list +// Tooltip styling lives in assets/styles/components.css (.term / .tip). +// +// Each term is addressed by its code name (a dict field), not by its text: +// - the inline word and its dictionary key are decoupled, so you write +// the phrase in any grammatical form: #g.review-queue[очередь на сегодня]; +// - a typo in the code name is a hard compile error (unknown field), not a +// silently mis-rendered word; +// - `tip` is content, so a definition may contain #link(...) and markup. +// +// Usage (import the whole module as the `g` namespace): +// #import "../utils/glossary.typ" as g +// ...создаётся из #g.note()[заметки]... +// #g.card() // no body → the canonical term, "карточка" +// +// Each entry: `term` — canonical name shown on the /glossary/ page (<dt>); +// `tip` — definition shown in the tooltip and as <dd>. + +#let TERMS = ( + spaced-repetition: ( + term: "интервальное повторение", + tip: [Техника запоминания: материал показывают через растущие промежутки времени — перед тем, как вы успеете его забыть.], + ), + sm2: ( + term: "SM-2", + tip: [Классический алгоритм интервального повторения, родом из #link("https://super-memory.com/english/ol/sm2.htm")[SuperMemo]. По вашей оценке и «лёгкости» карточки считает, когда показать её снова.], + ), + note: ( + term: "заметка", + tip: [Единица знаний: заголовок, текст, теги и ссылки на другие заметки.], + ), + card: ( + term: "карточка", + tip: [Вопрос и ответ (лицо и оборот). Создаётся из заметки и участвует в повторении.], + ), + grade: ( + term: "оценка карточки", + tip: [Шкала припоминания: again, hard, good, easy — от худшего к лучшему, как кнопки в Anki. Это одна ось, а не разные оценки.], + ), + review-queue: ( + term: "очередь повторения", + tip: [Карточки, срок показа которых наступил.], + ), + streak: ( + term: "серия", + tip: [Streak — сколько дней подряд было хотя бы одно повторение.], + ), + acceptance-tests: ( + term: "приёмочные тесты", + tip: [Проверки готовности: по контракту (Hurl) и несколько UI-сценариев (Playwright). Намеренно нестрогие.], + ), + openapi: ( + term: "OpenAPI", + tip: [Формат описания HTTP-API. Схема в spec/api/openapi.yaml — источник истины для запросов и ответов.], + ), + hurl: ( + term: "Hurl", + tip: [Текстовый формат HTTP-проверок: запрос плюс ассерты на статус, заголовки и тело. Дружит с git и CI.], + ), + playwright: ( + term: "Playwright", + tip: [Браузерные #link("https://playwright.dev/")[end-to-end тесты]: открывают страницу и проходят сценарий как живой пользователь.], + ), + static-analysis: ( + term: "статический анализ", + tip: [Проверка кода без запуска: типы, стиль, «запахи» (PHPStan, PHPMD, Rector, ESLint).], + ), + ci: ( + term: "CI", + tip: [Continuous Integration — автопрогон проверок на каждый push. Здесь гоняет тот же just verify, что и у вас локально.], + ), + lsp: ( + term: "LSP", + tip: [Language Server Protocol — по нему редактор даёт автодополнение, переходы и поиск использований. Термины глоссария адресуются по коду, поэтому LSP находит все места, где термин употреблён.], + ), +) + +// Build one wrapper per entry. `..args`: an optional content body overrides +// the displayed word; with none, the canonical term is shown. +#let _term(entry, ..args) = { + let shown = if args.pos().len() > 0 { args.pos().first() } else { [#entry.term] } + html.elem( + "span", + attrs: (class: "term", tabindex: "0"), + shown + html.elem("span", attrs: (class: "tip"), entry.tip), + ) +} + +// One module-level binding per term, so importing this file `as g` exposes +// `g.<code>()`. Typst can't call a function stored in a dict field via +// `g.code()` (that's method-call syntax), but a module member call works. +// Kept explicit (not a loop) because Typst can't create bindings dynamically. +#let spaced-repetition = _term.with(TERMS.spaced-repetition) +#let sm2 = _term.with(TERMS.sm2) +#let note = _term.with(TERMS.note) +#let card = _term.with(TERMS.card) +#let grade = _term.with(TERMS.grade) +#let review-queue = _term.with(TERMS.review-queue) +#let streak = _term.with(TERMS.streak) +#let acceptance-tests = _term.with(TERMS.acceptance-tests) +#let openapi = _term.with(TERMS.openapi) +#let hurl = _term.with(TERMS.hurl) +#let playwright = _term.with(TERMS.playwright) +#let static-analysis = _term.with(TERMS.static-analysis) +#let ci = _term.with(TERMS.ci) +#let lsp = _term.with(TERMS.lsp) diff --git a/www/utils/page.typ b/www/utils/page.typ index 9d941c1..75cf525 100644 --- a/www/utils/page.typ +++ b/www/utils/page.typ @@ -1,15 +1,15 @@ -// Page wrapper used by every content file. -// -// Usage: -// #import "../utils/page.typ": page -// #show: page.with(title: "...") - -#import "../templates/tola.typ": wrap-page -#import "../templates/base.typ": base -#import "../templates/layout.typ": layout, make-head - -#let page = wrap-page( - base: base, - head: make-head, - view: (body, m) => layout(body, meta: m), -) +// Page wrapper used by every content file. +// +// Usage: +// #import "../utils/page.typ": page +// #show: page.with(title: "...") + +#import "../templates/tola.typ": wrap-page +#import "../templates/base.typ": base +#import "../templates/layout.typ": layout, make-head + +#let page = wrap-page( + base: base, + head: make-head, + view: (body, m) => layout(body, meta: m), +) diff --git a/www/utils/repo.typ b/www/utils/repo.typ index 7aeeb14..34bdb3b 100644 --- a/www/utils/repo.typ +++ b/www/utils/repo.typ @@ -1,25 +1,25 @@ -// Loads source artifacts from the repo so the guide renders real values -// instead of duplicating them. Reads through the www/root/ symlink farm — see -// www/utils/env.typ for why the farm exists (Tola pins Typst's root to www/). -// -// Versions are pinned in the manifests (e.g. slim/slim "4.15.2", php "8.5.*") -// and rendered here trimmed to the precision the prose uses (major / major.minor), -// so a bump in the manifest flows into the guide automatically. -// -// Usage: -// #import "../utils/repo.typ": php-version, slim-version, logbook-cap - -#let _composer = json("../root/app/backend/composer.json") -#let _linecop = yaml("../root/.linecop.yaml") - -// Trim a version spec ("8.5.*", "^4", "4.15.2") to the first `parts` segments. -#let trim-version(spec, parts: 2) = { - let segs = spec.replace(regex("[^0-9.]"), "").split(".").filter(s => s != "") - segs.slice(0, calc.min(parts, segs.len())).join(".") -} - -#let php-version = trim-version(_composer.require.php) // 8.5 -#let slim-version = trim-version(_composer.require.at("slim/slim"), parts: 1) // 4 - -// "≤80 lines" cap comes from the linecop override for LOGBOOK.md. -#let logbook-cap = str(_linecop.overrides.find(o => o.pattern == "LOGBOOK.md").limit) +// Loads source artifacts from the repo so the guide renders real values +// instead of duplicating them. Reads through the www/root/ symlink farm — see +// www/utils/env.typ for why the farm exists (Tola pins Typst's root to www/). +// +// Versions are pinned in the manifests (e.g. slim/slim "4.15.2", php "8.5.*") +// and rendered here trimmed to the precision the prose uses (major / major.minor), +// so a bump in the manifest flows into the guide automatically. +// +// Usage: +// #import "../utils/repo.typ": php-version, slim-version, logbook-cap + +#let _composer = json("../root/app/backend/composer.json") +#let _linecop = yaml("../root/.linecop.yaml") + +// Trim a version spec ("8.5.*", "^4", "4.15.2") to the first `parts` segments. +#let trim-version(spec, parts: 2) = { + let segs = spec.replace(regex("[^0-9.]"), "").split(".").filter(s => s != "") + segs.slice(0, calc.min(parts, segs.len())).join(".") +} + +#let php-version = trim-version(_composer.require.php) // 8.5 +#let slim-version = trim-version(_composer.require.at("slim/slim"), parts: 1) // 4 + +// "≤80 lines" cap comes from the linecop override for LOGBOOK.md. +#let logbook-cap = str(_linecop.overrides.find(o => o.pattern == "LOGBOOK.md").limit) diff --git a/www/utils/site.typ b/www/utils/site.typ index f7a1a5c..2508e29 100644 --- a/www/utils/site.typ +++ b/www/utils/site.typ @@ -1,41 +1,41 @@ -// Site-wide constants for the Recall guide. - -// Path prefix the site is served under (GitHub Pages project page). -// Keep in sync with `site.info.url` in tola.toml. Single source for links. -#let PREFIX = "/practice" - -// Build an internal URL: u("/brief/") -> "/practice/brief/". -#let u(path) = PREFIX + path - -// Canonical site URL (GitHub Pages project page). Keep in sync with `site.info.url`. -#let SITE-URL = "https://maximaster.github.io/practice" - -// Автор шаблона и ссылка на него — для атрибуции в шапке, подвале и SEO-метатегах. -#let AUTHOR = "Максимастер" -#let AUTHOR-URL = "https://maximaster.ru" - -#let SITE = ( - title: "Recall — практика", - tagline: "Recall — учебное задание для практики", - // Базовое описание для <meta description> и Open Graph. - description: ( - "Recall — учебное задание для практики от Максимастер: персональная база " - + "знаний с заметками и интервальным повторением. Бриф заказчика, стек, " - + "критерии приёмки." - ), - // Хвост для <title>: имя проекта + автор, чтобы выдача была самоописательной. - title-suffix: "Recall · " + AUTHOR, - // Полный <title> главной страницы. - title-home: "Recall — задание для практики от " + AUTHOR, - url: SITE-URL, - author: AUTHOR, - author-url: AUTHOR-URL, -) - -#let NAV = ( - (label: "Обзор", href: "/"), - (label: "Бриф", href: "/brief/"), - (label: "Задание", href: "/task/"), - (label: "Стек", href: "/stack/"), - (label: "Термины", href: "/glossary/"), -) +// Site-wide constants for the Recall guide. + +// Path prefix the site is served under (GitHub Pages project page). +// Keep in sync with `site.info.url` in tola.toml. Single source for links. +#let PREFIX = "/practice" + +// Build an internal URL: u("/brief/") -> "/practice/brief/". +#let u(path) = PREFIX + path + +// Canonical site URL (GitHub Pages project page). Keep in sync with `site.info.url`. +#let SITE-URL = "https://maximaster.github.io/practice" + +// Автор шаблона и ссылка на него — для атрибуции в шапке, подвале и SEO-метатегах. +#let AUTHOR = "Максимастер" +#let AUTHOR-URL = "https://maximaster.ru" + +#let SITE = ( + title: "Recall — практика", + tagline: "Recall — учебное задание для практики", + // Базовое описание для <meta description> и Open Graph. + description: ( + "Recall — учебное задание для практики от Максимастер: персональная база " + + "знаний с заметками и интервальным повторением. Бриф заказчика, стек, " + + "критерии приёмки." + ), + // Хвост для <title>: имя проекта + автор, чтобы выдача была самоописательной. + title-suffix: "Recall · " + AUTHOR, + // Полный <title> главной страницы. + title-home: "Recall — задание для практики от " + AUTHOR, + url: SITE-URL, + author: AUTHOR, + author-url: AUTHOR-URL, +) + +#let NAV = ( + (label: "Обзор", href: "/"), + (label: "Бриф", href: "/brief/"), + (label: "Задание", href: "/task/"), + (label: "Стек", href: "/stack/"), + (label: "Термины", href: "/glossary/"), +) diff --git a/www/utils/tola.typ b/www/utils/tola.typ index 8db38c9..cd7ff64 100644 --- a/www/utils/tola.typ +++ b/www/utils/tola.typ @@ -1,293 +1,293 @@ -// Tola SSG utility functions (v0.7.1) -// -// AUTO-GENERATED - Avoid modifying this file directly. -// Instead, extend it or create your own copy to reduce migration -// difficulty when upgrading to future versions with breaking changes. -// -// Helper functions for common operations in Typst - -// ============================================================================ -// CSS Class Utilities -// ============================================================================ - -/// Join CSS classes with automatic space handling. -/// Accepts strings, arrays, or none. Filters out empty/none values. -/// Normalizes multiple spaces to single space. -/// -/// Example: -/// ```typst -/// cls("text-2xl", "font-bold") // => "text-2xl font-bold" -/// cls("text-2xl font-bold", "mt-4") // => "text-2xl font-bold mt-4" -/// cls("base", if active { "active" }) // => "base active" or "base" -/// cls("a", none, "b", "") // => "a b" -/// cls(("a", "b"), "c") // => "a b c" -/// cls("a b", "c") // => "a b c" (normalizes spaces) -/// ``` -#let cls(..args) = { - let flatten(items) = { - let result = () - for item in items { - if type(item) == array { result += flatten(item) } else { result.push(item) } - } - result - } - let raw = flatten(args.pos()).filter(x => x != none and x != "").map(x => str(x)).join(" ") - raw.split(" ").filter(x => x != "").join(" ") -} - -/// Remove classes from a class string. -/// -/// Example: -/// ```typst -/// cls-rm("a b c", "b") // => "a c" -/// cls-rm("a b c", "b", "c") // => "a" -/// cls-rm("a b c", ("a", "c")) // => "b" -/// cls-rm("a b c", "b") // => "a c" (normalizes spaces) -/// ``` -#let cls-rm(base, ..remove) = { - let to-remove = cls(..remove).split(" ") - cls(base).split(" ").filter(x => x not in to-remove).join(" ") -} - -/// Toggle a class: add if missing, remove if present. -/// -/// Example: -/// ```typst -/// cls-toggle("a b", "c") // => "a b c" -/// cls-toggle("a b c", "b") // => "a c" -/// ``` -#let cls-toggle(base, class) = { - let classes = cls(base).split(" ") - if class in classes { - classes.filter(x => x != class).join(" ") - } else { - cls(base, class) - } -} - -/// Check if a class exists in a class string. -/// -/// Example: -/// ```typst -/// cls-has("a b c", "b") // => true -/// cls-has("a b c", "d") // => false -/// cls-has("a b c", "b") // => true (handles extra spaces) -/// ``` -#let cls-has(base, class) = { - class in cls(base).split(" ") -} - -// ============================================================================ -// Path Utilities -// ============================================================================ - -/// Extract trailing number from the last path segment (for natural sorting). -/// Returns `none` if no trailing number found. -/// -/// Example: -/// ```typst -/// trailing-num("/blog/post-1/") // => 1 -/// trailing-num("/blog/post-01/") // => 1 -/// trailing-num("/blog/post-1x2/") // => 2 -/// trailing-num("/blog/post-10/") // => 10 -/// trailing-num("/blog/post-0/") // => 0 -/// trailing-num("/blog/intro/") // => none -/// ``` -#let trailing-num(s) = { - let parts = s.split("/").filter(x => x != "") - if parts.len() == 0 { return none } - let chars = parts.last().clusters().rev() - let digits = () - for c in chars { - if c >= "0" and c <= "9" { digits.push(c) } else { break } - } - if digits.len() == 0 { none } else { int(digits.rev().join()) } -} - -// ============================================================================ -// Content Utilities -// ============================================================================ - -/// Convert content to plain string. -/// Recursively extracts text from content elements. -/// -/// Example: -/// ```typst -/// to-string[Hello *world*] // => "Hello world" -/// to-string(none) // => "" -/// to-string(42) // => "42" -/// ``` -#let to-string(it) = { - if it == none { "" } else if type(it) == str { it } else if type(it) != content { str(it) } else if it.has("text") { - if type(it.text) == str { it.text } else { to-string(it.text) } - } else if it.has("children") { it.children.map(to-string).join() } else if it.has("body") { - to-string(it.body) - } else if it == [ ] { " " } else { "" } -} - -// ============================================================================ -// Date Utilities -// ============================================================================ - -/// Parse a date string into a datetime object. -/// Only supports format: "YYYY-MM-DD" (e.g., "2024-01-15", "2024-1-5") -/// -/// Example: -/// ```typst -/// parse-date("2024-01-15") // => datetime(year: 2024, month: 1, day: 15) -/// parse-date("2024-1-5") // => datetime(year: 2024, month: 1, day: 5) -/// parse-date(none) // => none -/// ``` -#let parse-date(s) = { - if s == none { return none } - if type(s) == datetime { return s } - let s = str(s).split("T").at(0) - let parts = s.split("-") - assert(parts.len() == 3, message: "Invalid date format: '" + s + "', expected YYYY-MM-DD") - datetime(year: int(parts.at(0)), month: int(parts.at(1)), day: int(parts.at(2))) -} - -// ============================================================================ -// HTML Utilities -// ============================================================================ - -/// Set the browser tab title via inline script. -/// -/// Example: -/// ```typst -/// #import "@tola/site:0.0.0": info -/// #set-tab-title(page-title + " | " + info.author + "'s blog") -/// ``` -#let set-tab-title(title) = { - let s = title.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "") - html.script("document.title=\"" + s + "\";") -} - -// ============================================================================ -// SEO Utilities (Open Graph / Twitter Cards) -// ============================================================================ - -/// Generate a single OG meta tag. -/// - `prop`: property name (e.g., "og:title", "twitter:card") -/// - `content`: content value -#let _og-meta(prop, content) = { - if content == none or content == "" { return none } - let c = if type(content) == datetime { - content.display("[year]-[month]-[day]") - } else { - to-string(content) - } - // OG/article/book/profile use "property" attribute, not supported by html.meta - if ( - prop.starts-with("og:") or prop.starts-with("article:") or prop.starts-with("book:") or prop.starts-with("profile:") - ) { - html.elem("meta", attrs: (property: prop, content: c)) - } else { - html.meta(name: prop, content: c) - } -} - -/// Generate Open Graph and Twitter Card meta tags. -/// -/// All parameters are optional. Missing values will be omitted from output. -/// -/// Supported og:type values: -/// - "website" (default for pages) -/// - "article" (default for posts) -/// - "book" -/// - "profile" -/// -/// Parameters: -/// - `title`: Page title (og:title, twitter:title) -/// - `description`: Page description (og:description, twitter:description) -/// - `url`: Canonical URL (og:url) -/// - `image`: Preview image URL (og:image, twitter:image) -/// - `type`: Content type (og:type) -/// - `site-name`: Site name (og:site_name) -/// - `locale`: Locale string e.g. "en_US" (og:locale) -/// - Article-specific (type="article"): -/// - `author`: Author name or URL (article:author) -/// - `section`: Section/category (article:section) -/// - `published`: Published date ISO string (article:published_time) -/// - `modified`: Modified date ISO string (article:modified_time) -/// - `tags`: Array of tags (article:tag) -/// - Book-specific (type="book"): -/// - `author`: Author name or URL (book:author) -/// - `isbn`: ISBN number (book:isbn) -/// - `release-date`: Release date (book:release_date) -/// - `tags`: Array of tags (book:tag) -/// - Profile-specific (type="profile"): -/// - `first-name`: First name (profile:first_name) -/// - `last-name`: Last name (profile:last_name) -/// - `username`: Username (profile:username) -/// - `gender`: Gender (profile:gender) -/// - Twitter Card: -/// - `twitter-card`: Card type, default "summary_large_image" -/// - `twitter-site`: Twitter @username for the site -/// - `twitter-creator`: Twitter @username for the author -#let og-tags( - title: none, - description: none, - url: none, - image: none, - type: "article", - site-name: none, - locale: none, - // Article - author: none, - section: none, - published: none, - modified: none, - tags: (), - // Book - isbn: none, - release-date: none, - // Profile - first-name: none, - last-name: none, - username: none, - gender: none, - // Twitter - twitter-card: "summary_large_image", - twitter-site: none, - twitter-creator: none, -) = context { - if target() != "html" { return } - - // Open Graph basic - _og-meta("og:title", title) - _og-meta("og:description", description) - _og-meta("og:url", url) - _og-meta("og:image", image) - _og-meta("og:type", type) - _og-meta("og:site_name", site-name) - _og-meta("og:locale", locale) - - // Type-specific metadata - if type == "article" { - _og-meta("article:author", author) - _og-meta("article:section", section) - _og-meta("article:published_time", published) - _og-meta("article:modified_time", modified) - for tag in tags { _og-meta("article:tag", tag) } - } else if type == "book" { - _og-meta("book:author", author) - _og-meta("book:isbn", isbn) - _og-meta("book:release_date", release-date) - for tag in tags { _og-meta("book:tag", tag) } - } else if type == "profile" { - _og-meta("profile:first_name", first-name) - _og-meta("profile:last_name", last-name) - _og-meta("profile:username", username) - _og-meta("profile:gender", gender) - } - // "website" has no additional properties - - // Twitter Cards - _og-meta("twitter:card", twitter-card) - _og-meta("twitter:title", title) - _og-meta("twitter:description", description) - _og-meta("twitter:image", image) - _og-meta("twitter:site", twitter-site) - _og-meta("twitter:creator", twitter-creator) -} +// Tola SSG utility functions (v0.7.1) +// +// AUTO-GENERATED - Avoid modifying this file directly. +// Instead, extend it or create your own copy to reduce migration +// difficulty when upgrading to future versions with breaking changes. +// +// Helper functions for common operations in Typst + +// ============================================================================ +// CSS Class Utilities +// ============================================================================ + +/// Join CSS classes with automatic space handling. +/// Accepts strings, arrays, or none. Filters out empty/none values. +/// Normalizes multiple spaces to single space. +/// +/// Example: +/// ```typst +/// cls("text-2xl", "font-bold") // => "text-2xl font-bold" +/// cls("text-2xl font-bold", "mt-4") // => "text-2xl font-bold mt-4" +/// cls("base", if active { "active" }) // => "base active" or "base" +/// cls("a", none, "b", "") // => "a b" +/// cls(("a", "b"), "c") // => "a b c" +/// cls("a b", "c") // => "a b c" (normalizes spaces) +/// ``` +#let cls(..args) = { + let flatten(items) = { + let result = () + for item in items { + if type(item) == array { result += flatten(item) } else { result.push(item) } + } + result + } + let raw = flatten(args.pos()).filter(x => x != none and x != "").map(x => str(x)).join(" ") + raw.split(" ").filter(x => x != "").join(" ") +} + +/// Remove classes from a class string. +/// +/// Example: +/// ```typst +/// cls-rm("a b c", "b") // => "a c" +/// cls-rm("a b c", "b", "c") // => "a" +/// cls-rm("a b c", ("a", "c")) // => "b" +/// cls-rm("a b c", "b") // => "a c" (normalizes spaces) +/// ``` +#let cls-rm(base, ..remove) = { + let to-remove = cls(..remove).split(" ") + cls(base).split(" ").filter(x => x not in to-remove).join(" ") +} + +/// Toggle a class: add if missing, remove if present. +/// +/// Example: +/// ```typst +/// cls-toggle("a b", "c") // => "a b c" +/// cls-toggle("a b c", "b") // => "a c" +/// ``` +#let cls-toggle(base, class) = { + let classes = cls(base).split(" ") + if class in classes { + classes.filter(x => x != class).join(" ") + } else { + cls(base, class) + } +} + +/// Check if a class exists in a class string. +/// +/// Example: +/// ```typst +/// cls-has("a b c", "b") // => true +/// cls-has("a b c", "d") // => false +/// cls-has("a b c", "b") // => true (handles extra spaces) +/// ``` +#let cls-has(base, class) = { + class in cls(base).split(" ") +} + +// ============================================================================ +// Path Utilities +// ============================================================================ + +/// Extract trailing number from the last path segment (for natural sorting). +/// Returns `none` if no trailing number found. +/// +/// Example: +/// ```typst +/// trailing-num("/blog/post-1/") // => 1 +/// trailing-num("/blog/post-01/") // => 1 +/// trailing-num("/blog/post-1x2/") // => 2 +/// trailing-num("/blog/post-10/") // => 10 +/// trailing-num("/blog/post-0/") // => 0 +/// trailing-num("/blog/intro/") // => none +/// ``` +#let trailing-num(s) = { + let parts = s.split("/").filter(x => x != "") + if parts.len() == 0 { return none } + let chars = parts.last().clusters().rev() + let digits = () + for c in chars { + if c >= "0" and c <= "9" { digits.push(c) } else { break } + } + if digits.len() == 0 { none } else { int(digits.rev().join()) } +} + +// ============================================================================ +// Content Utilities +// ============================================================================ + +/// Convert content to plain string. +/// Recursively extracts text from content elements. +/// +/// Example: +/// ```typst +/// to-string[Hello *world*] // => "Hello world" +/// to-string(none) // => "" +/// to-string(42) // => "42" +/// ``` +#let to-string(it) = { + if it == none { "" } else if type(it) == str { it } else if type(it) != content { str(it) } else if it.has("text") { + if type(it.text) == str { it.text } else { to-string(it.text) } + } else if it.has("children") { it.children.map(to-string).join() } else if it.has("body") { + to-string(it.body) + } else if it == [ ] { " " } else { "" } +} + +// ============================================================================ +// Date Utilities +// ============================================================================ + +/// Parse a date string into a datetime object. +/// Only supports format: "YYYY-MM-DD" (e.g., "2024-01-15", "2024-1-5") +/// +/// Example: +/// ```typst +/// parse-date("2024-01-15") // => datetime(year: 2024, month: 1, day: 15) +/// parse-date("2024-1-5") // => datetime(year: 2024, month: 1, day: 5) +/// parse-date(none) // => none +/// ``` +#let parse-date(s) = { + if s == none { return none } + if type(s) == datetime { return s } + let s = str(s).split("T").at(0) + let parts = s.split("-") + assert(parts.len() == 3, message: "Invalid date format: '" + s + "', expected YYYY-MM-DD") + datetime(year: int(parts.at(0)), month: int(parts.at(1)), day: int(parts.at(2))) +} + +// ============================================================================ +// HTML Utilities +// ============================================================================ + +/// Set the browser tab title via inline script. +/// +/// Example: +/// ```typst +/// #import "@tola/site:0.0.0": info +/// #set-tab-title(page-title + " | " + info.author + "'s blog") +/// ``` +#let set-tab-title(title) = { + let s = title.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "") + html.script("document.title=\"" + s + "\";") +} + +// ============================================================================ +// SEO Utilities (Open Graph / Twitter Cards) +// ============================================================================ + +/// Generate a single OG meta tag. +/// - `prop`: property name (e.g., "og:title", "twitter:card") +/// - `content`: content value +#let _og-meta(prop, content) = { + if content == none or content == "" { return none } + let c = if type(content) == datetime { + content.display("[year]-[month]-[day]") + } else { + to-string(content) + } + // OG/article/book/profile use "property" attribute, not supported by html.meta + if ( + prop.starts-with("og:") or prop.starts-with("article:") or prop.starts-with("book:") or prop.starts-with("profile:") + ) { + html.elem("meta", attrs: (property: prop, content: c)) + } else { + html.meta(name: prop, content: c) + } +} + +/// Generate Open Graph and Twitter Card meta tags. +/// +/// All parameters are optional. Missing values will be omitted from output. +/// +/// Supported og:type values: +/// - "website" (default for pages) +/// - "article" (default for posts) +/// - "book" +/// - "profile" +/// +/// Parameters: +/// - `title`: Page title (og:title, twitter:title) +/// - `description`: Page description (og:description, twitter:description) +/// - `url`: Canonical URL (og:url) +/// - `image`: Preview image URL (og:image, twitter:image) +/// - `type`: Content type (og:type) +/// - `site-name`: Site name (og:site_name) +/// - `locale`: Locale string e.g. "en_US" (og:locale) +/// - Article-specific (type="article"): +/// - `author`: Author name or URL (article:author) +/// - `section`: Section/category (article:section) +/// - `published`: Published date ISO string (article:published_time) +/// - `modified`: Modified date ISO string (article:modified_time) +/// - `tags`: Array of tags (article:tag) +/// - Book-specific (type="book"): +/// - `author`: Author name or URL (book:author) +/// - `isbn`: ISBN number (book:isbn) +/// - `release-date`: Release date (book:release_date) +/// - `tags`: Array of tags (book:tag) +/// - Profile-specific (type="profile"): +/// - `first-name`: First name (profile:first_name) +/// - `last-name`: Last name (profile:last_name) +/// - `username`: Username (profile:username) +/// - `gender`: Gender (profile:gender) +/// - Twitter Card: +/// - `twitter-card`: Card type, default "summary_large_image" +/// - `twitter-site`: Twitter @username for the site +/// - `twitter-creator`: Twitter @username for the author +#let og-tags( + title: none, + description: none, + url: none, + image: none, + type: "article", + site-name: none, + locale: none, + // Article + author: none, + section: none, + published: none, + modified: none, + tags: (), + // Book + isbn: none, + release-date: none, + // Profile + first-name: none, + last-name: none, + username: none, + gender: none, + // Twitter + twitter-card: "summary_large_image", + twitter-site: none, + twitter-creator: none, +) = context { + if target() != "html" { return } + + // Open Graph basic + _og-meta("og:title", title) + _og-meta("og:description", description) + _og-meta("og:url", url) + _og-meta("og:image", image) + _og-meta("og:type", type) + _og-meta("og:site_name", site-name) + _og-meta("og:locale", locale) + + // Type-specific metadata + if type == "article" { + _og-meta("article:author", author) + _og-meta("article:section", section) + _og-meta("article:published_time", published) + _og-meta("article:modified_time", modified) + for tag in tags { _og-meta("article:tag", tag) } + } else if type == "book" { + _og-meta("book:author", author) + _og-meta("book:isbn", isbn) + _og-meta("book:release_date", release-date) + for tag in tags { _og-meta("book:tag", tag) } + } else if type == "profile" { + _og-meta("profile:first_name", first-name) + _og-meta("profile:last_name", last-name) + _og-meta("profile:username", username) + _og-meta("profile:gender", gender) + } + // "website" has no additional properties + + // Twitter Cards + _og-meta("twitter:card", twitter-card) + _og-meta("twitter:title", title) + _og-meta("twitter:description", description) + _og-meta("twitter:image", image) + _og-meta("twitter:site", twitter-site) + _og-meta("twitter:creator", twitter-creator) +}