Initial Commit
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
---
|
||||
description: 코드 커밋 → (피처브랜치면) main 머지 → 푸시 한 방에
|
||||
---
|
||||
|
||||
지금 작업물을 커밋하고 main 에 반영하고 푸시해. 순서:
|
||||
|
||||
1. `git status` + `git diff` 로 변경 내용 파악. 스테이징 안 된 것 포함 전부 볼 것.
|
||||
2. worklog 미기재면 먼저 `python z-my-docs/work-log/gen_worklog.py add "<한 일>"` 로 남긴다 (git 작업 자체는 worklog 대상 아님 — 이번 turn 의 코드 작업이 아직 기록 안 됐을 때만).
|
||||
3. `git add -A` 후, 변경 내용에 맞는 커밋 메시지를 CLAUDE.md 컨벤션대로 작성해 커밋. (co-author 트레일러 포함)
|
||||
4. **브랜치 판단:**
|
||||
- 지금 `main` 이면 → 머지 단계 건너뛰고 바로 push.
|
||||
- 피처 브랜치면 → `git checkout main` → `git merge --no-ff <피처브랜치>` → push. 머지 충돌 나면 멈추고 나한테 물어봐.
|
||||
5. `git push`.
|
||||
6. 끝나면 최종 상태(어느 브랜치, 커밋 해시, push 결과) 한 줄로 보고.
|
||||
|
||||
$ARGUMENTS 가 있으면 그걸 커밋 메시지로 우선 사용.
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python D:/project/021.code-assistant-v2/z-my-docs/work-log/worklog_guard.py",
|
||||
"timeout": 15,
|
||||
"statusMessage": "작업로그 누락 체크"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"feature_directory": "specs/005-chat-image-input"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ai": "claude",
|
||||
"ai_skills": true,
|
||||
"feature_numbering": "sequential",
|
||||
"here": true,
|
||||
"integration": "claude",
|
||||
"script": "ps",
|
||||
"speckit_version": "0.14.3.dev0"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"version": "0.14.3.dev0",
|
||||
"integration_state_schema": 1,
|
||||
"installed_integrations": [
|
||||
"claude"
|
||||
],
|
||||
"integration_settings": {
|
||||
"claude": {
|
||||
"script": "ps",
|
||||
"invoke_separator": "-"
|
||||
}
|
||||
},
|
||||
"integration": "claude",
|
||||
"default_integration": "claude"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"integration": "claude",
|
||||
"version": "0.14.3.dev0",
|
||||
"installed_at": "2026-07-27T03:19:03.825886+00:00",
|
||||
"files": {
|
||||
".claude/skills/speckit-analyze/SKILL.md": "0a5989b3502705af505a04f5786daa18e7ee832c7cf7261ca523300622c44c04",
|
||||
".claude/skills/speckit-clarify/SKILL.md": "3f2b388505df9270aef3331e944bf45998334d10c4a3c690750fb6c45ecf746a",
|
||||
".claude/skills/speckit-constitution/SKILL.md": "70a37fbeeb741ac4ecd5d9a73860ecc7d397ec61d665d304b5212e9c06749481",
|
||||
".claude/skills/speckit-implement/SKILL.md": "b3406bfa211fd1ab1d31c5210799fe4c8f3c5630008dc06ae5221d41d6f6065c",
|
||||
".claude/skills/speckit-converge/SKILL.md": "9eba139257c3b2e9680efb0924304eeeb60ceb211af0da301680cc9680ae5d04",
|
||||
".claude/skills/speckit-plan/SKILL.md": "99feb27a4640389ab4669ba76b892f6876285b872121aa9ae7bb3322f6cc2647",
|
||||
".claude/skills/speckit-checklist/SKILL.md": "e860d69567f07bb90a721a722af2b98dd705c51ad01459a2e38f9ab9a10cc454",
|
||||
".claude/skills/speckit-specify/SKILL.md": "333e492196f033890734161a16e93639080956f7e74f8a64b0d4f2a7a0aff3fe",
|
||||
".claude/skills/speckit-tasks/SKILL.md": "5b36c6b63d3732462da8cffb64d411a8e33f181e301971aa6f848efe800a1ab5",
|
||||
".claude/skills/speckit-taskstoissues/SKILL.md": "2a7f6d9485ae92712c8022c8c345ed769752ef16c876a259b7535dc338e9a744"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"integration": "speckit",
|
||||
"version": "0.14.3.dev0",
|
||||
"installed_at": "2026-07-27T03:19:04.016959+00:00",
|
||||
"files": {
|
||||
".specify/scripts/powershell/check-prerequisites.ps1": "d7771e53f07d45b7b820e1659a41f58d570dd07d05ad47556abb3953023037f2",
|
||||
".specify/scripts/powershell/common.ps1": "9c78cd7165e4646087742b384ac3b67142ca4c719552150501e2591ec39e7252",
|
||||
".specify/scripts/powershell/create-new-feature.ps1": "dbc275bc999ebc0d7022f9725bf022674cf97a435fa57c32439d161b3e9844c1",
|
||||
".specify/scripts/powershell/setup-plan.ps1": "76262f13b36c02244aa4217b076997c1489da27528e3d4eb01fcf639bff50eaf",
|
||||
".specify/scripts/powershell/setup-tasks.ps1": "401462bf60e2e4a4c5ccd1b4d1a67b0b3f249557eb9cf956a998e64904fc953f",
|
||||
".specify/templates/checklist-template.md": "709d8ab8384a3a49f5e0f64479f71553ef6d6f8bb4f00281b05f47837993b536",
|
||||
".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3",
|
||||
".specify/templates/plan-template.md": "7e637502d41eccf0ca672496636365691fdca62ef37b27ec07fcb412dbfa90d4",
|
||||
".specify/templates/spec-template.md": "3945437fc35cd30a5b2bf7beea680337c3516826d3efa5a6b92c4a7eca1ba28e",
|
||||
".specify/templates/tasks-template.md": "fc29a233f6f5a27ca31f1aa46b596af6500c627441c6e62b2bc4a1d721525842"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"sha256": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3",
|
||||
"source": "core"
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
# [PROJECT_NAME] Constitution
|
||||
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->
|
||||
|
||||
## Core Principles
|
||||
|
||||
### [PRINCIPLE_1_NAME]
|
||||
<!-- Example: I. Library-First -->
|
||||
[PRINCIPLE_1_DESCRIPTION]
|
||||
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->
|
||||
|
||||
### [PRINCIPLE_2_NAME]
|
||||
<!-- Example: II. CLI Interface -->
|
||||
[PRINCIPLE_2_DESCRIPTION]
|
||||
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->
|
||||
|
||||
### [PRINCIPLE_3_NAME]
|
||||
<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
|
||||
[PRINCIPLE_3_DESCRIPTION]
|
||||
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
|
||||
|
||||
### [PRINCIPLE_4_NAME]
|
||||
<!-- Example: IV. Integration Testing -->
|
||||
[PRINCIPLE_4_DESCRIPTION]
|
||||
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
|
||||
|
||||
### [PRINCIPLE_5_NAME]
|
||||
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
|
||||
[PRINCIPLE_5_DESCRIPTION]
|
||||
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
|
||||
|
||||
## [SECTION_2_NAME]
|
||||
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->
|
||||
|
||||
[SECTION_2_CONTENT]
|
||||
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
|
||||
|
||||
## [SECTION_3_NAME]
|
||||
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->
|
||||
|
||||
[SECTION_3_CONTENT]
|
||||
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->
|
||||
|
||||
## Governance
|
||||
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
|
||||
|
||||
[GOVERNANCE_RULES]
|
||||
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
|
||||
|
||||
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
|
||||
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->
|
||||
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env pwsh
|
||||
|
||||
# Consolidated prerequisite checking script (PowerShell)
|
||||
#
|
||||
# This script provides unified prerequisite checking for Spec-Driven Development workflow.
|
||||
# It replaces the functionality previously spread across multiple scripts.
|
||||
#
|
||||
# Usage: ./check-prerequisites.ps1 [OPTIONS]
|
||||
#
|
||||
# OPTIONS:
|
||||
# -Json Output in JSON format
|
||||
# -RequireTasks Require tasks.md to exist (for implementation phase)
|
||||
# -IncludeTasks Include tasks.md in AVAILABLE_DOCS list
|
||||
# -PathsOnly Only output path variables (no validation)
|
||||
# -Help, -h Show help message
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Json,
|
||||
[switch]$RequireTasks,
|
||||
[switch]$IncludeTasks,
|
||||
[switch]$PathsOnly,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Show help if requested
|
||||
if ($Help) {
|
||||
Write-Output @"
|
||||
Usage: check-prerequisites.ps1 [OPTIONS]
|
||||
|
||||
Consolidated prerequisite checking for Spec-Driven Development workflow.
|
||||
|
||||
OPTIONS:
|
||||
-Json Output in JSON format
|
||||
-RequireTasks Require tasks.md to exist (for implementation phase)
|
||||
-IncludeTasks Include tasks.md in AVAILABLE_DOCS list
|
||||
-PathsOnly Only output path variables (no prerequisite validation)
|
||||
-Help, -h Show this help message
|
||||
|
||||
EXAMPLES:
|
||||
# Check task prerequisites (plan.md required)
|
||||
.\check-prerequisites.ps1 -Json
|
||||
|
||||
# Check implementation prerequisites (plan.md + tasks.md required)
|
||||
.\check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
|
||||
|
||||
# Get feature paths only (no validation)
|
||||
.\check-prerequisites.ps1 -PathsOnly
|
||||
|
||||
"@
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Source common functions
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
# Get feature paths.
|
||||
# In -PathsOnly mode this is pure resolution, so pass -NoPersist to opt out of
|
||||
# the feature.json write side effect (issue #3025).
|
||||
if ($PathsOnly) {
|
||||
$paths = Get-FeaturePathsEnv -NoPersist
|
||||
} else {
|
||||
$paths = Get-FeaturePathsEnv
|
||||
}
|
||||
|
||||
# If paths-only mode, output paths and exit (no validation)
|
||||
if ($PathsOnly) {
|
||||
if ($Json) {
|
||||
[PSCustomObject]@{
|
||||
REPO_ROOT = $paths.REPO_ROOT
|
||||
BRANCH = $paths.CURRENT_BRANCH
|
||||
FEATURE_DIR = $paths.FEATURE_DIR
|
||||
FEATURE_SPEC = $paths.FEATURE_SPEC
|
||||
IMPL_PLAN = $paths.IMPL_PLAN
|
||||
TASKS = $paths.TASKS
|
||||
} | ConvertTo-Json -Compress
|
||||
} else {
|
||||
Write-Output "REPO_ROOT: $($paths.REPO_ROOT)"
|
||||
Write-Output "BRANCH: $($paths.CURRENT_BRANCH)"
|
||||
Write-Output "FEATURE_DIR: $($paths.FEATURE_DIR)"
|
||||
Write-Output "FEATURE_SPEC: $($paths.FEATURE_SPEC)"
|
||||
Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)"
|
||||
Write-Output "TASKS: $($paths.TASKS)"
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Validate required directories and files
|
||||
if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {
|
||||
[Console]::Error.WriteLine("ERROR: Feature directory not found: $($paths.FEATURE_DIR)")
|
||||
$specifyCommand = '/speckit-specify'
|
||||
[Console]::Error.WriteLine("Run $specifyCommand first to create the feature structure.")
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
|
||||
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
|
||||
$planCommand = '/speckit-plan'
|
||||
[Console]::Error.WriteLine("Run $planCommand first to create the implementation plan.")
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Check for tasks.md if required
|
||||
if ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) {
|
||||
[Console]::Error.WriteLine("ERROR: tasks.md not found in $($paths.FEATURE_DIR)")
|
||||
$tasksCommand = '/speckit-tasks'
|
||||
[Console]::Error.WriteLine("Run $tasksCommand first to create the task list.")
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Build list of available documents
|
||||
$docs = @()
|
||||
|
||||
# Always check these optional docs
|
||||
if (Test-Path $paths.RESEARCH) { $docs += 'research.md' }
|
||||
if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' }
|
||||
|
||||
# Check contracts directory (only if it exists and has files)
|
||||
if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
|
||||
$docs += 'contracts/'
|
||||
}
|
||||
|
||||
if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
|
||||
|
||||
# Include tasks.md if requested and it exists
|
||||
if ($IncludeTasks -and (Test-Path $paths.TASKS)) {
|
||||
$docs += 'tasks.md'
|
||||
}
|
||||
|
||||
# Output results
|
||||
if ($Json) {
|
||||
# JSON output
|
||||
[PSCustomObject]@{
|
||||
FEATURE_DIR = $paths.FEATURE_DIR
|
||||
AVAILABLE_DOCS = $docs
|
||||
} | ConvertTo-Json -Compress
|
||||
} else {
|
||||
# Text output
|
||||
Write-Output "FEATURE_DIR:$($paths.FEATURE_DIR)"
|
||||
Write-Output "AVAILABLE_DOCS:"
|
||||
|
||||
# Show status of each potential document
|
||||
Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Out-Null
|
||||
Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Out-Null
|
||||
Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Out-Null
|
||||
Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Out-Null
|
||||
|
||||
if ($IncludeTasks) {
|
||||
Test-FileExists -Path $paths.TASKS -Description 'tasks.md' | Out-Null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Common PowerShell functions analogous to common.sh
|
||||
|
||||
# Find repository root by searching upward for .specify directory
|
||||
# This is the primary marker for spec-kit projects
|
||||
function Find-SpecifyRoot {
|
||||
param([string]$StartDir = (Get-Location).Path)
|
||||
|
||||
# Normalize to absolute path to prevent issues with relative paths
|
||||
# Use -LiteralPath to handle paths with wildcard characters ([, ], *, ?)
|
||||
$resolved = Resolve-Path -LiteralPath $StartDir -ErrorAction SilentlyContinue
|
||||
$current = if ($resolved) { $resolved.Path } else { $null }
|
||||
if (-not $current) { return $null }
|
||||
|
||||
while ($true) {
|
||||
if (Test-Path -LiteralPath (Join-Path $current ".specify") -PathType Container) {
|
||||
return $current
|
||||
}
|
||||
$parent = Split-Path $current -Parent
|
||||
if ([string]::IsNullOrEmpty($parent) -or $parent -eq $current) {
|
||||
return $null
|
||||
}
|
||||
$current = $parent
|
||||
}
|
||||
}
|
||||
|
||||
# Resolve an explicit SPECIFY_INIT_DIR project override (the directory that
|
||||
# *contains* .specify/), for non-interactive / CI use -- e.g. running a Spec Kit
|
||||
# command against a member project from a monorepo root without cd.
|
||||
#
|
||||
# Precondition: $env:SPECIFY_INIT_DIR is set. Returns the validated project root,
|
||||
# or writes an error and exits 1 unless -ReturnNullOnError is set. Strict by
|
||||
# design: the path must exist and
|
||||
# contain .specify/, with no silent fallback. (An empty string is falsy, so the
|
||||
# caller's `if ($env:SPECIFY_INIT_DIR)` guard treats empty as unset.)
|
||||
#
|
||||
# This is the single resolver: bundled extensions inherit it by sourcing core
|
||||
# (e.g. the git extension's create-new-feature-branch) rather than duplicating it.
|
||||
function Resolve-SpecifyInitDir {
|
||||
param([switch]$ReturnNullOnError)
|
||||
|
||||
$initDir = $env:SPECIFY_INIT_DIR
|
||||
# Normalize: relative paths resolve against the current directory.
|
||||
if (-not [System.IO.Path]::IsPathRooted($initDir)) {
|
||||
$initDir = Join-Path (Get-Location).Path $initDir
|
||||
}
|
||||
$resolved = Resolve-Path -LiteralPath $initDir -ErrorAction SilentlyContinue
|
||||
# Resolve-Path also succeeds for files, so check the resolved path is a
|
||||
# directory; otherwise a file value would slip through to the less accurate
|
||||
# "not a Spec Kit project" error below.
|
||||
if (-not $resolved -or -not (Test-Path -LiteralPath $resolved.Path -PathType Container)) {
|
||||
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $($env:SPECIFY_INIT_DIR)")
|
||||
if ($ReturnNullOnError) { return $null }
|
||||
exit 1
|
||||
}
|
||||
# Resolve-Path echoes back any trailing separator from the input; trim it so
|
||||
# the returned root matches the bash resolver, whose `cd && pwd` never yields
|
||||
# one. TrimEndingDirectorySeparator is a no-op on a bare root and on a path
|
||||
# that already has no trailing separator.
|
||||
$initRoot = [System.IO.Path]::TrimEndingDirectorySeparator($resolved.Path)
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $initRoot '.specify') -PathType Container)) {
|
||||
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $initRoot")
|
||||
if ($ReturnNullOnError) { return $null }
|
||||
exit 1
|
||||
}
|
||||
return $initRoot
|
||||
}
|
||||
|
||||
# Get repository root, prioritizing .specify directory
|
||||
# This prevents using a parent repository when spec-kit is initialized in a subdirectory
|
||||
function Get-RepoRoot {
|
||||
param([switch]$ReturnNullOnError)
|
||||
|
||||
# Explicit project override wins (see Resolve-SpecifyInitDir).
|
||||
if ($env:SPECIFY_INIT_DIR) {
|
||||
return (Resolve-SpecifyInitDir -ReturnNullOnError:$ReturnNullOnError)
|
||||
}
|
||||
|
||||
# First, look for .specify directory (spec-kit's own marker)
|
||||
$specifyRoot = Find-SpecifyRoot
|
||||
if ($specifyRoot) {
|
||||
return $specifyRoot
|
||||
}
|
||||
|
||||
# Final fallback to script location
|
||||
# Use -LiteralPath to handle paths with wildcard characters
|
||||
return (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "../../..")).Path
|
||||
}
|
||||
|
||||
function Get-CurrentBranch {
|
||||
# Return feature name from explicit state only.
|
||||
# Feature state is set by SPECIFY_FEATURE (from create-new-feature or
|
||||
# the git extension) or implicitly via .specify/feature.json.
|
||||
if ($env:SPECIFY_FEATURE) {
|
||||
return $env:SPECIFY_FEATURE
|
||||
}
|
||||
|
||||
# No explicit feature set - return empty to signal "unknown".
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
|
||||
# Persist a feature_directory value to .specify/feature.json.
|
||||
# Writes only when the file is missing or the value differs from what's stored.
|
||||
function Save-FeatureJson {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RepoRoot,
|
||||
[Parameter(Mandatory = $true)][string]$FeatureDirectory
|
||||
)
|
||||
|
||||
# Strip repo root prefix if the value is absolute and under repo root.
|
||||
# Use case-insensitive comparison on Windows only (case-sensitive filesystems elsewhere).
|
||||
$prefix = $RepoRoot + [System.IO.Path]::DirectorySeparatorChar
|
||||
if ($null -ne $IsWindows) { $onWin = $IsWindows } else { $onWin = $true }
|
||||
if ($onWin) {
|
||||
$cmp = [System.StringComparison]::OrdinalIgnoreCase
|
||||
} else {
|
||||
$cmp = [System.StringComparison]::Ordinal
|
||||
}
|
||||
if ($FeatureDirectory.StartsWith($prefix, $cmp)) {
|
||||
$FeatureDirectory = $FeatureDirectory.Substring($prefix.Length)
|
||||
}
|
||||
|
||||
$fjPath = Join-Path (Join-Path $RepoRoot '.specify') 'feature.json'
|
||||
|
||||
# Read current value and skip write when unchanged
|
||||
if (Test-Path -LiteralPath $fjPath -PathType Leaf) {
|
||||
try {
|
||||
$raw = Get-Content -LiteralPath $fjPath -Raw
|
||||
$cfg = $raw | ConvertFrom-Json
|
||||
if ($cfg.feature_directory -eq $FeatureDirectory) {
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
# File is corrupt or unreadable - overwrite it
|
||||
}
|
||||
}
|
||||
|
||||
# Ensure .specify/ directory exists
|
||||
$specifyDir = Join-Path $RepoRoot '.specify'
|
||||
if (-not (Test-Path -LiteralPath $specifyDir -PathType Container)) {
|
||||
New-Item -ItemType Directory -Path $specifyDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# Write feature.json
|
||||
$json = @{ feature_directory = $FeatureDirectory } | ConvertTo-Json -Compress
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($fjPath, $json, $utf8NoBom)
|
||||
}
|
||||
|
||||
function Get-FeaturePathsEnv {
|
||||
# Read-only callers (e.g. check-prerequisites.ps1 -PathsOnly) pass -NoPersist
|
||||
# so pure path resolution never writes .specify/feature.json, which would
|
||||
# dirty the working tree or overwrite a pinned value (issue #3025).
|
||||
param(
|
||||
[switch]$NoPersist,
|
||||
[switch]$ReturnNullOnError
|
||||
)
|
||||
|
||||
$repoRoot = Get-RepoRoot -ReturnNullOnError:$ReturnNullOnError
|
||||
if (-not $repoRoot) { return $null }
|
||||
$currentBranch = Get-CurrentBranch
|
||||
|
||||
# Resolve feature directory. Priority:
|
||||
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
|
||||
# 2. .specify/feature.json "feature_directory" key (persisted by specify command)
|
||||
# 3. Error - no feature context available
|
||||
$featureJson = Join-Path $repoRoot '.specify/feature.json'
|
||||
if ($env:SPECIFY_FEATURE_DIRECTORY) {
|
||||
$featureDir = $env:SPECIFY_FEATURE_DIRECTORY
|
||||
# Normalize relative paths to absolute under repo root
|
||||
if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
|
||||
$featureDir = Join-Path $repoRoot $featureDir
|
||||
}
|
||||
# Persist to feature.json so future sessions without the env var still
|
||||
# work - unless the caller opted out for read-only resolution (#3025).
|
||||
if (-not $NoPersist) {
|
||||
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $env:SPECIFY_FEATURE_DIRECTORY
|
||||
}
|
||||
} elseif (Test-Path $featureJson) {
|
||||
$featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw
|
||||
try {
|
||||
$featureConfig = $featureJsonRaw | ConvertFrom-Json
|
||||
} catch {
|
||||
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.")
|
||||
if ($ReturnNullOnError) { return $null }
|
||||
exit 1
|
||||
}
|
||||
if ($featureConfig.feature_directory) {
|
||||
$featureDir = $featureConfig.feature_directory
|
||||
# Normalize relative paths to absolute under repo root
|
||||
if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
|
||||
$featureDir = Join-Path $repoRoot $featureDir
|
||||
}
|
||||
} else {
|
||||
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.")
|
||||
if ($ReturnNullOnError) { return $null }
|
||||
exit 1
|
||||
}
|
||||
} else {
|
||||
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json.")
|
||||
if ($ReturnNullOnError) { return $null }
|
||||
exit 1
|
||||
}
|
||||
|
||||
# When no branch context exists (no SPECIFY_FEATURE, feature resolved via
|
||||
# SPECIFY_FEATURE_DIRECTORY or feature.json), fall back to the feature
|
||||
# directory basename so CURRENT_BRANCH is a usable identifier rather than
|
||||
# an empty, misleading value (issue #3026).
|
||||
if (-not $currentBranch) {
|
||||
# TrimEnd (not [Path]::TrimEndingDirectorySeparator, which is .NET Core
|
||||
# only) keeps this working on Windows PowerShell 5.1 / .NET Framework.
|
||||
$featureDirTrimmed = $featureDir.TrimEnd('/', '\')
|
||||
$currentBranch = Split-Path -Leaf $featureDirTrimmed
|
||||
}
|
||||
|
||||
[PSCustomObject]@{
|
||||
REPO_ROOT = $repoRoot
|
||||
CURRENT_BRANCH = $currentBranch
|
||||
FEATURE_DIR = $featureDir
|
||||
FEATURE_SPEC = Join-Path $featureDir 'spec.md'
|
||||
IMPL_PLAN = Join-Path $featureDir 'plan.md'
|
||||
TASKS = Join-Path $featureDir 'tasks.md'
|
||||
RESEARCH = Join-Path $featureDir 'research.md'
|
||||
DATA_MODEL = Join-Path $featureDir 'data-model.md'
|
||||
QUICKSTART = Join-Path $featureDir 'quickstart.md'
|
||||
CONTRACTS_DIR = Join-Path $featureDir 'contracts'
|
||||
}
|
||||
}
|
||||
|
||||
function Test-FileExists {
|
||||
param([string]$Path, [string]$Description)
|
||||
if (Test-Path -Path $Path -PathType Leaf) {
|
||||
Write-Output " [OK] $Description"
|
||||
return $true
|
||||
} else {
|
||||
Write-Output " [FAIL] $Description"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Test-DirHasFiles {
|
||||
param([string]$Path, [string]$Description)
|
||||
# A directory counts as non-empty when Get-ChildItem returns any entry
|
||||
# (files or subdirectories) -- matching the JSON contracts checks in
|
||||
# check-prerequisites.ps1 / setup-tasks.ps1, and treating a directory whose
|
||||
# only contents are subdirectories (e.g. contracts/v1/openapi.yaml) as
|
||||
# non-empty like bash check_dir. Filtering out subdirectories would
|
||||
# mis-report such a directory as empty.
|
||||
if ((Test-Path -Path $Path -PathType Container) -and (Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Select-Object -First 1)) {
|
||||
Write-Output " [OK] $Description"
|
||||
return $true
|
||||
} else {
|
||||
Write-Output " [FAIL] $Description"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Get-InvokeSeparator {
|
||||
param([string]$RepoRoot = (Get-RepoRoot))
|
||||
|
||||
if ($null -eq $script:SpecKitInvokeSeparatorCache) {
|
||||
$script:SpecKitInvokeSeparatorCache = @{}
|
||||
}
|
||||
if ($script:SpecKitInvokeSeparatorCache.ContainsKey($RepoRoot)) {
|
||||
return $script:SpecKitInvokeSeparatorCache[$RepoRoot]
|
||||
}
|
||||
|
||||
$separator = '.'
|
||||
$integrationJson = Join-Path $RepoRoot '.specify/integration.json'
|
||||
if (Test-Path -LiteralPath $integrationJson -PathType Leaf) {
|
||||
try {
|
||||
$state = Get-Content -LiteralPath $integrationJson -Raw | ConvertFrom-Json
|
||||
$key = if ($state.default_integration) { [string]$state.default_integration } elseif ($state.integration) { [string]$state.integration } else { '' }
|
||||
if ($key -and $state.integration_settings) {
|
||||
$settingProperty = $state.integration_settings.PSObject.Properties[$key]
|
||||
if ($settingProperty) {
|
||||
$setting = $settingProperty.Value
|
||||
if ($setting -and ($setting.invoke_separator -eq '.' -or $setting.invoke_separator -eq '-')) {
|
||||
$separator = [string]$setting.invoke_separator
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
$separator = '.'
|
||||
}
|
||||
}
|
||||
|
||||
$script:SpecKitInvokeSeparatorCache[$RepoRoot] = $separator
|
||||
return $separator
|
||||
}
|
||||
|
||||
function Format-SpecKitCommand {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$CommandName,
|
||||
[string]$RepoRoot = (Get-RepoRoot)
|
||||
)
|
||||
|
||||
$separator = Get-InvokeSeparator -RepoRoot $RepoRoot
|
||||
$name = $CommandName.TrimStart('/')
|
||||
if ($name.StartsWith('speckit.')) {
|
||||
$name = $name.Substring(8)
|
||||
} elseif ($name.StartsWith('speckit-')) {
|
||||
$name = $name.Substring(8)
|
||||
}
|
||||
$name = $name -replace '\.', $separator
|
||||
|
||||
return "/speckit$separator$name"
|
||||
}
|
||||
|
||||
# Find a usable Python 3 executable (python3, python, or py -3).
|
||||
# Returns the command/arguments as an array, or $null if none found.
|
||||
function Get-Python3Command {
|
||||
if (Get-Command python3 -ErrorAction SilentlyContinue) { return @('python3') }
|
||||
if (Get-Command python -ErrorAction SilentlyContinue) {
|
||||
$ver = & python --version 2>&1
|
||||
if ($ver -match 'Python 3') { return @('python') }
|
||||
}
|
||||
if (Get-Command py -ErrorAction SilentlyContinue) {
|
||||
$ver = & py -3 --version 2>&1
|
||||
if ($ver -match 'Python 3') { return @('py', '-3') }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# Resolve a template name to a file path using the priority stack:
|
||||
# 1. .specify/templates/overrides/
|
||||
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
|
||||
# 3. .specify/extensions/<ext-id>/templates/
|
||||
# 4. .specify/templates/ (core)
|
||||
function Resolve-Template {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$TemplateName,
|
||||
[Parameter(Mandatory=$true)][string]$RepoRoot
|
||||
)
|
||||
|
||||
$base = Join-Path $RepoRoot '.specify/templates'
|
||||
|
||||
# Priority 1: Project overrides
|
||||
$override = Join-Path $base "overrides/$TemplateName.md"
|
||||
if (Test-Path $override) { return $override }
|
||||
|
||||
# Priority 2: Installed presets (sorted by priority from .registry)
|
||||
$presetsDir = Join-Path $RepoRoot '.specify/presets'
|
||||
if (Test-Path $presetsDir) {
|
||||
$registryFile = Join-Path $presetsDir '.registry'
|
||||
$sortedPresets = @()
|
||||
$registryParsed = $false
|
||||
if (Test-Path $registryFile) {
|
||||
try {
|
||||
$registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
|
||||
if ($null -eq $registryData -or $registryData -isnot [PSCustomObject]) {
|
||||
throw 'Registry root must be an object'
|
||||
}
|
||||
$presetsProperty = $registryData.PSObject.Properties['presets']
|
||||
if ($presetsProperty) {
|
||||
$presets = $presetsProperty.Value
|
||||
if ($null -eq $presets -or $presets -isnot [PSCustomObject]) {
|
||||
throw 'Registry presets must be an object'
|
||||
}
|
||||
$presetEntries = @($presets.PSObject.Properties)
|
||||
$priorityFor = {
|
||||
param($Entry)
|
||||
if ($Entry.Value -is [PSCustomObject]) {
|
||||
$priorityProperty = $Entry.Value.PSObject.Properties['priority']
|
||||
if ($priorityProperty) { return $priorityProperty.Value }
|
||||
}
|
||||
return 10
|
||||
}
|
||||
if ($presetEntries.Count -gt 1) {
|
||||
$allNumeric = $true
|
||||
$allStrings = $true
|
||||
foreach ($entry in $presetEntries) {
|
||||
$priority = & $priorityFor $entry
|
||||
if ($null -eq $priority -or $priority -isnot [ValueType]) {
|
||||
$allNumeric = $false
|
||||
}
|
||||
if ($null -eq $priority -or $priority -isnot [string]) {
|
||||
$allStrings = $false
|
||||
}
|
||||
}
|
||||
if (-not $allNumeric -and -not $allStrings) {
|
||||
throw 'Registry priorities are not mutually orderable'
|
||||
}
|
||||
}
|
||||
$sortedPresets = $presetEntries |
|
||||
Where-Object { $_.Value -is [PSCustomObject] } |
|
||||
Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
|
||||
Sort-Object { & $priorityFor $_ } |
|
||||
ForEach-Object { $_.Name }
|
||||
}
|
||||
$registryParsed = $true
|
||||
} catch {
|
||||
$registryParsed = $false
|
||||
}
|
||||
}
|
||||
|
||||
if ($registryParsed) {
|
||||
foreach ($presetId in $sortedPresets) {
|
||||
$candidate = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
|
||||
if (Test-Path $candidate) { return $candidate }
|
||||
}
|
||||
} else {
|
||||
# Fallback: alphabetical directory order
|
||||
foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
|
||||
$candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
|
||||
if (Test-Path $candidate) { return $candidate }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Priority 3: Extension-provided templates
|
||||
$extDir = Join-Path $RepoRoot '.specify/extensions'
|
||||
if (Test-Path $extDir) {
|
||||
foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
|
||||
$candidate = Join-Path $ext.FullName "templates/$TemplateName.md"
|
||||
if (Test-Path $candidate) { return $candidate }
|
||||
}
|
||||
}
|
||||
|
||||
# Priority 4: Core templates
|
||||
$core = Join-Path $base "$TemplateName.md"
|
||||
if (Test-Path $core) { return $core }
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
# Resolve a template name to composed content using composition strategies.
|
||||
# Reads strategy metadata from preset manifests and composes content
|
||||
# from multiple layers using prepend, append, or wrap strategies.
|
||||
function Resolve-TemplateContent {
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$TemplateName,
|
||||
[Parameter(Mandatory=$true)][string]$RepoRoot
|
||||
)
|
||||
|
||||
$base = Join-Path $RepoRoot '.specify/templates'
|
||||
|
||||
# Collect all layers (highest priority first)
|
||||
$layerPaths = @()
|
||||
$layerStrategies = @()
|
||||
|
||||
# Priority 1: Project overrides (always "replace")
|
||||
$override = Join-Path $base "overrides/$TemplateName.md"
|
||||
if (Test-Path $override) {
|
||||
$layerPaths += $override
|
||||
$layerStrategies += 'replace'
|
||||
}
|
||||
|
||||
# Priority 2: Installed presets (sorted by priority from .registry)
|
||||
$presetsDir = Join-Path $RepoRoot '.specify/presets'
|
||||
if (Test-Path $presetsDir) {
|
||||
$registryFile = Join-Path $presetsDir '.registry'
|
||||
$sortedPresets = @()
|
||||
if (Test-Path $registryFile) {
|
||||
try {
|
||||
$registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
|
||||
$presets = $registryData.presets
|
||||
if ($presets) {
|
||||
$sortedPresets = $presets.PSObject.Properties |
|
||||
Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
|
||||
Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
|
||||
ForEach-Object { $_.Name }
|
||||
}
|
||||
} catch {
|
||||
$sortedPresets = @()
|
||||
}
|
||||
}
|
||||
|
||||
if ($sortedPresets.Count -gt 0) {
|
||||
$pyCmd = Get-Python3Command
|
||||
if (-not $pyCmd) {
|
||||
# Check if any preset has strategy fields that would be ignored
|
||||
foreach ($pid in $sortedPresets) {
|
||||
$mf = Join-Path $presetsDir "$pid/preset.yml"
|
||||
if ((Test-Path $mf) -and (Select-String -Path $mf -Pattern 'strategy:' -Quiet -ErrorAction SilentlyContinue)) {
|
||||
Write-Warning "No Python 3 found; preset composition strategies will be ignored"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
$yamlWarned = $false
|
||||
foreach ($presetId in $sortedPresets) {
|
||||
# Read strategy and file path from preset manifest
|
||||
$strategy = 'replace'
|
||||
$manifestFilePath = ''
|
||||
$manifest = Join-Path $presetsDir "$presetId/preset.yml"
|
||||
if ((Test-Path $manifest) -and $pyCmd) {
|
||||
try {
|
||||
# Use Python to parse YAML manifest for strategy and file path
|
||||
$pyArgs = if ($pyCmd.Count -gt 1) { $pyCmd[1..($pyCmd.Count-1)] } else { @() }
|
||||
$pyStderrFile = [System.IO.Path]::GetTempFileName()
|
||||
$stratResult = & $pyCmd[0] @pyArgs -c @"
|
||||
import sys
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print('yaml_missing', file=sys.stderr)
|
||||
print('replace\t')
|
||||
sys.exit(0)
|
||||
try:
|
||||
with open(sys.argv[1]) as f:
|
||||
data = yaml.safe_load(f)
|
||||
for t in data.get('provides', {}).get('templates', []):
|
||||
if t.get('name') == sys.argv[2] and t.get('type', 'template') == 'template':
|
||||
print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
|
||||
sys.exit(0)
|
||||
print('replace\t')
|
||||
except Exception:
|
||||
print('replace\t')
|
||||
"@ $manifest $TemplateName 2>$pyStderrFile
|
||||
if ($stratResult) {
|
||||
$parts = $stratResult.Trim() -split "`t", 2
|
||||
$strategy = $parts[0].ToLowerInvariant()
|
||||
if ($parts.Count -gt 1 -and $parts[1]) { $manifestFilePath = $parts[1] }
|
||||
}
|
||||
if (-not $yamlWarned -and (Test-Path $pyStderrFile) -and (Get-Content $pyStderrFile -Raw -ErrorAction SilentlyContinue) -match 'yaml_missing') {
|
||||
Write-Warning "PyYAML not available; composition strategies may be ignored"
|
||||
$yamlWarned = $true
|
||||
}
|
||||
Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue
|
||||
} catch {
|
||||
$strategy = 'replace'
|
||||
if ($pyStderrFile) { Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
}
|
||||
# Try manifest file path first, then convention path
|
||||
$candidate = $null
|
||||
if ($manifestFilePath) {
|
||||
# Reject absolute paths and parent traversal
|
||||
if ([System.IO.Path]::IsPathRooted($manifestFilePath) -or $manifestFilePath -match '\.\.[\\/]') {
|
||||
$manifestFilePath = ''
|
||||
}
|
||||
}
|
||||
if ($manifestFilePath) {
|
||||
$mf = Join-Path $presetsDir "$presetId/$manifestFilePath"
|
||||
if (Test-Path $mf) { $candidate = $mf }
|
||||
}
|
||||
if (-not $candidate) {
|
||||
$cf = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
|
||||
if (Test-Path $cf) { $candidate = $cf }
|
||||
}
|
||||
if ($candidate) {
|
||||
$layerPaths += $candidate
|
||||
$layerStrategies += $strategy
|
||||
}
|
||||
}
|
||||
} else {
|
||||
# Fallback: alphabetical directory order (no registry or parse failure)
|
||||
foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) {
|
||||
$candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
|
||||
if (Test-Path $candidate) {
|
||||
$layerPaths += $candidate
|
||||
$layerStrategies += 'replace'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Priority 3: Extension-provided templates (always "replace")
|
||||
$extDir = Join-Path $RepoRoot '.specify/extensions'
|
||||
if (Test-Path $extDir) {
|
||||
foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
|
||||
$candidate = Join-Path $ext.FullName "templates/$TemplateName.md"
|
||||
if (Test-Path $candidate) {
|
||||
$layerPaths += $candidate
|
||||
$layerStrategies += 'replace'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Priority 4: Core templates (always "replace")
|
||||
$core = Join-Path $base "$TemplateName.md"
|
||||
if (Test-Path $core) {
|
||||
$layerPaths += $core
|
||||
$layerStrategies += 'replace'
|
||||
}
|
||||
|
||||
if ($layerPaths.Count -eq 0) { return $null }
|
||||
|
||||
# If the top (highest-priority) layer is replace, it wins entirely --
|
||||
# lower layers are irrelevant regardless of their strategies.
|
||||
if ($layerStrategies[0] -eq 'replace') {
|
||||
return (Get-Content $layerPaths[0] -Raw)
|
||||
}
|
||||
|
||||
# Check if any layer uses a non-replace strategy
|
||||
$hasComposition = $false
|
||||
foreach ($s in $layerStrategies) {
|
||||
if ($s -ne 'replace') { $hasComposition = $true; break }
|
||||
}
|
||||
|
||||
if (-not $hasComposition) {
|
||||
return (Get-Content $layerPaths[0] -Raw)
|
||||
}
|
||||
|
||||
# Find the effective base: scan from highest priority (index 0) downward
|
||||
# to find the nearest replace layer. Only compose layers above that base.
|
||||
$baseIdx = -1
|
||||
for ($i = 0; $i -lt $layerPaths.Count; $i++) {
|
||||
if ($layerStrategies[$i] -eq 'replace') {
|
||||
$baseIdx = $i
|
||||
break
|
||||
}
|
||||
}
|
||||
if ($baseIdx -lt 0) { return $null }
|
||||
|
||||
$content = Get-Content $layerPaths[$baseIdx] -Raw
|
||||
|
||||
for ($i = $baseIdx - 1; $i -ge 0; $i--) {
|
||||
$path = $layerPaths[$i]
|
||||
$strat = $layerStrategies[$i]
|
||||
$layerContent = Get-Content $path -Raw
|
||||
|
||||
switch ($strat) {
|
||||
'replace' { $content = $layerContent }
|
||||
'prepend' { $content = "$layerContent`n`n$content" }
|
||||
'append' { $content = "$content`n`n$layerContent" }
|
||||
'wrap' {
|
||||
if (-not $layerContent.Contains('{CORE_TEMPLATE}')) {
|
||||
throw "Wrap strategy missing {CORE_TEMPLATE} placeholder"
|
||||
}
|
||||
$content = $layerContent.Replace('{CORE_TEMPLATE}', $content)
|
||||
}
|
||||
default { throw "Unknown strategy: $strat" }
|
||||
}
|
||||
}
|
||||
|
||||
return $content
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Create a new feature
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Json,
|
||||
[switch]$AllowExistingBranch,
|
||||
[switch]$DryRun,
|
||||
[string]$ShortName,
|
||||
[Parameter()]
|
||||
[string]$Number = '',
|
||||
[switch]$Timestamp,
|
||||
[switch]$Help,
|
||||
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
|
||||
[string[]]$FeatureDescription
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$maxBranchLength = 244
|
||||
|
||||
# Show help if requested
|
||||
if ($Help) {
|
||||
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||
Write-Host ""
|
||||
Write-Host "Options:"
|
||||
Write-Host " -Json Output in JSON format"
|
||||
Write-Host " -DryRun Compute feature name and paths without creating directories or files"
|
||||
Write-Host " -AllowExistingBranch Reuse an existing feature directory if it already exists"
|
||||
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the feature"
|
||||
Write-Host " -Number N Prefer a feature number (auto-corrected if its specs prefix exists)"
|
||||
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||
Write-Host " -Help Show this help message"
|
||||
Write-Host ""
|
||||
Write-Host "Examples:"
|
||||
Write-Host " ./create-new-feature.ps1 'Add user authentication system' -ShortName 'user-auth'"
|
||||
Write-Host " ./create-new-feature.ps1 'Implement OAuth2 integration for API'"
|
||||
Write-Host " ./create-new-feature.ps1 -Timestamp -ShortName 'user-auth' 'Add user authentication'"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Check if feature description provided
|
||||
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
|
||||
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$featureDesc = ($FeatureDescription -join ' ').Trim()
|
||||
|
||||
# Validate description is not empty after trimming (e.g., user passed only whitespace)
|
||||
if ([string]::IsNullOrWhiteSpace($featureDesc)) {
|
||||
Write-Error "Error: Feature description cannot be empty or contain only whitespace"
|
||||
exit 1
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromSpecs {
|
||||
param([string]$SpecsDir)
|
||||
|
||||
[long]$highest = 0
|
||||
if (Test-Path $SpecsDir) {
|
||||
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
|
||||
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
|
||||
if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') {
|
||||
[long]$num = 0
|
||||
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
||||
$highest = $num
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return $highest
|
||||
}
|
||||
|
||||
function Test-SpecPrefixInUse {
|
||||
param(
|
||||
[string]$SpecsDir,
|
||||
[string]$FeatureNum
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $SpecsDir -PathType Container)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
return $null -ne (Get-ChildItem -LiteralPath $SpecsDir -Directory -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -like "$FeatureNum-*" } |
|
||||
Select-Object -First 1)
|
||||
}
|
||||
|
||||
function ConvertTo-CleanBranchName {
|
||||
param([string]$Name)
|
||||
|
||||
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
|
||||
}
|
||||
|
||||
function Get-FittedBranchName {
|
||||
param(
|
||||
[string]$FeatureNum,
|
||||
[string]$BranchSuffix
|
||||
)
|
||||
|
||||
$fittedName = "$FeatureNum-$BranchSuffix"
|
||||
if ($fittedName.Length -gt $maxBranchLength) {
|
||||
$prefixLength = $FeatureNum.Length + 1
|
||||
$maxSuffixLength = $maxBranchLength - $prefixLength
|
||||
$truncatedSuffix = $BranchSuffix.Substring(0, [Math]::Min($BranchSuffix.Length, $maxSuffixLength))
|
||||
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
|
||||
$fittedName = "$FeatureNum-$truncatedSuffix"
|
||||
}
|
||||
|
||||
return $fittedName
|
||||
}
|
||||
# Load common functions (includes Get-RepoRoot and Resolve-Template)
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
# Use common.ps1 functions which prioritize .specify
|
||||
$repoRoot = Get-RepoRoot
|
||||
|
||||
Set-Location $repoRoot
|
||||
|
||||
$specsDir = Join-Path $repoRoot 'specs'
|
||||
if (-not $DryRun) {
|
||||
New-Item -ItemType Directory -Path $specsDir -Force | Out-Null
|
||||
}
|
||||
|
||||
# Function to generate branch name with stop word filtering and length filtering
|
||||
function Get-BranchName {
|
||||
param([string]$Description)
|
||||
|
||||
# Common stop words to filter out
|
||||
$stopWords = @(
|
||||
'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from',
|
||||
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
|
||||
'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall',
|
||||
'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their',
|
||||
'want', 'need', 'add', 'get', 'set'
|
||||
)
|
||||
|
||||
# Convert to lowercase and extract words (alphanumeric only)
|
||||
$cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' '
|
||||
$words = $cleanName -split '\s+' | Where-Object { $_ }
|
||||
|
||||
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
|
||||
$meaningfulWords = @()
|
||||
foreach ($word in $words) {
|
||||
# Skip stop words
|
||||
if ($stopWords -contains $word) { continue }
|
||||
|
||||
# Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms)
|
||||
if ($word.Length -ge 3) {
|
||||
$meaningfulWords += $word
|
||||
} elseif ($Description -cmatch "\b$($word.ToUpper())\b") {
|
||||
# Keep short words only if they appear as uppercase in original (likely
|
||||
# acronyms). Use -cmatch so the comparison is case-sensitive, matching the
|
||||
# bash script's case-sensitive grep; -match would be case-insensitive and
|
||||
# would keep every short word.
|
||||
$meaningfulWords += $word
|
||||
}
|
||||
}
|
||||
|
||||
# If we have meaningful words, use first 3-4 of them
|
||||
if ($meaningfulWords.Count -gt 0) {
|
||||
$maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 }
|
||||
$result = ($meaningfulWords | Select-Object -First $maxWords) -join '-'
|
||||
return $result
|
||||
} else {
|
||||
# Fallback to original logic if no meaningful words found
|
||||
$result = ConvertTo-CleanBranchName -Name $Description
|
||||
$fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3
|
||||
return [string]::Join('-', $fallbackWords)
|
||||
}
|
||||
}
|
||||
|
||||
# Generate branch name
|
||||
if ($ShortName) {
|
||||
# Use provided short name, just clean it up
|
||||
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
|
||||
} else {
|
||||
# Generate from description with smart filtering
|
||||
$branchSuffix = Get-BranchName -Description $featureDesc
|
||||
}
|
||||
|
||||
# Treat an explicit empty string as omitted, matching the bash and Python twins.
|
||||
$hasNumber = $PSBoundParameters.ContainsKey('Number') -and $Number -ne ''
|
||||
|
||||
# Warn if -Number and -Timestamp are both specified.
|
||||
if ($Timestamp -and $hasNumber) {
|
||||
[Console]::Error.WriteLine("[specify] Warning: -Number is ignored when -Timestamp is used")
|
||||
$Number = ''
|
||||
}
|
||||
|
||||
# Determine branch prefix
|
||||
if ($Timestamp) {
|
||||
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$branchName = "$featureNum-$branchSuffix"
|
||||
} else {
|
||||
# Determine branch number from existing feature directories. Auto-detect only
|
||||
# when -Number was not supplied; an explicit value (including 0) is honored,
|
||||
# matching the bash twin's `[ -z "$BRANCH_NUMBER" ]` check.
|
||||
[long]$resolvedNumber = 0
|
||||
if (-not $hasNumber) {
|
||||
$highestNumber = Get-HighestNumberFromSpecs -SpecsDir $specsDir
|
||||
if ($highestNumber -eq [long]::MaxValue) {
|
||||
Write-Error "Error: feature number must be between 0 and $([long]::MaxValue), got '9223372036854775808'"
|
||||
exit 1
|
||||
}
|
||||
$resolvedNumber = $highestNumber + 1
|
||||
} elseif ($Number -notmatch '^[0-9]+$') {
|
||||
Write-Error "Error: -Number must be an unsigned integer, got '$Number'"
|
||||
exit 1
|
||||
} elseif (-not [long]::TryParse($Number, [ref]$resolvedNumber)) {
|
||||
Write-Error "Error: -Number must be between 0 and $([long]::MaxValue), got '$Number'"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$featureNum = ('{0:000}' -f $resolvedNumber)
|
||||
|
||||
# Treat an explicit number as a preference when its prefix is already used
|
||||
# by a feature directory. Auto-detected numbers are already conflict-free.
|
||||
$specConflict = $false
|
||||
if ($hasNumber -and (Test-Path -LiteralPath $specsDir -PathType Container)) {
|
||||
$requestedBranchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
|
||||
$requestedDir = Join-Path $specsDir $requestedBranchName
|
||||
if (-not $AllowExistingBranch -or -not (Test-Path -LiteralPath $requestedDir -PathType Container)) {
|
||||
$specConflict = Test-SpecPrefixInUse -SpecsDir $specsDir -FeatureNum $featureNum
|
||||
}
|
||||
}
|
||||
|
||||
if ($specConflict) {
|
||||
$requestedNum = $featureNum
|
||||
$highestNumber = Get-HighestNumberFromSpecs -SpecsDir $specsDir
|
||||
$resolvedNumber = $highestNumber
|
||||
do {
|
||||
if ($resolvedNumber -eq [long]::MaxValue) {
|
||||
Write-Error "Error: feature number must be between 0 and $([long]::MaxValue), got '9223372036854775808'"
|
||||
exit 1
|
||||
}
|
||||
$resolvedNumber++
|
||||
$featureNum = ('{0:000}' -f $resolvedNumber)
|
||||
} while (Test-SpecPrefixInUse -SpecsDir $specsDir -FeatureNum $featureNum)
|
||||
[Console]::Error.WriteLine("[specify] Warning: -Number $requestedNum conflicts with an existing spec directory; using $featureNum instead")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
# GitHub enforces a 244-byte limit on branch names
|
||||
# Validate and truncate if necessary
|
||||
$originalBranchName = "$featureNum-$branchSuffix"
|
||||
$branchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
|
||||
if ($branchName -ne $originalBranchName) {
|
||||
[Console]::Error.WriteLine("[specify] Warning: Branch name exceeded GitHub's 244-byte limit")
|
||||
[Console]::Error.WriteLine("[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)")
|
||||
[Console]::Error.WriteLine("[specify] Truncated to: $branchName ($($branchName.Length) bytes)")
|
||||
}
|
||||
|
||||
$featureDir = Join-Path $specsDir $branchName
|
||||
$specFile = Join-Path $featureDir 'spec.md'
|
||||
|
||||
if (-not $DryRun) {
|
||||
if ((Test-Path -LiteralPath $featureDir -PathType Container) -and -not $AllowExistingBranch) {
|
||||
if ($Timestamp) {
|
||||
Write-Error "Error: Feature directory '$featureDir' already exists. Rerun to get a new timestamp or use a different -ShortName."
|
||||
} else {
|
||||
Write-Error "Error: Feature directory '$featureDir' already exists. Please use a different feature name or specify a different number with -Number."
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
|
||||
|
||||
if (-not (Test-Path -PathType Leaf $specFile)) {
|
||||
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
|
||||
if ($template -and (Test-Path $template)) {
|
||||
# Read the template content and write it to the spec file with UTF-8 encoding without BOM
|
||||
$content = [System.IO.File]::ReadAllText($template)
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($specFile, $content, $utf8NoBom)
|
||||
} else {
|
||||
# Match the bash twin (create-new-feature.sh): warn on stderr that no
|
||||
# spec template was found before creating an empty spec file, so the
|
||||
# missing-template signal is not silently swallowed on Windows.
|
||||
[Console]::Error.WriteLine("Warning: Spec template not found; created empty spec file")
|
||||
New-Item -ItemType File -Path $specFile -Force | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
# Persist to .specify/feature.json so downstream commands can find the feature
|
||||
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $featureDir
|
||||
|
||||
# Set environment variables for the current session
|
||||
$env:SPECIFY_FEATURE = $branchName
|
||||
$env:SPECIFY_FEATURE_DIRECTORY = $featureDir
|
||||
|
||||
$quotedBranchName = "'" + $branchName.Replace("'", "''") + "'"
|
||||
$quotedFeatureDir = "'" + $featureDir.Replace("'", "''") + "'"
|
||||
$featureAssignment = '$env:SPECIFY_FEATURE = ' + $quotedBranchName
|
||||
$directoryAssignment = '$env:SPECIFY_FEATURE_DIRECTORY = ' + $quotedFeatureDir
|
||||
[Console]::Error.WriteLine("# To persist: $featureAssignment")
|
||||
[Console]::Error.WriteLine("# $directoryAssignment")
|
||||
}
|
||||
|
||||
if ($Json) {
|
||||
$obj = [PSCustomObject]@{
|
||||
BRANCH_NAME = $branchName
|
||||
SPEC_FILE = $specFile
|
||||
FEATURE_NUM = $featureNum
|
||||
}
|
||||
if ($DryRun) {
|
||||
$obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
|
||||
}
|
||||
$obj | ConvertTo-Json -Compress
|
||||
} else {
|
||||
Write-Output "BRANCH_NAME: $branchName"
|
||||
Write-Output "SPEC_FILE: $specFile"
|
||||
Write-Output "FEATURE_NUM: $featureNum"
|
||||
if (-not $DryRun) {
|
||||
Write-Output "# To persist in your shell: $featureAssignment"
|
||||
Write-Output "# $directoryAssignment"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Setup implementation plan for a feature
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Json,
|
||||
[switch]$Help,
|
||||
# Capture extra positional arguments to match Bash/Python behavior.
|
||||
[Parameter(ValueFromRemainingArguments = $true)]
|
||||
[string[]]$RemainingArgs
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Show help if requested
|
||||
if ($Help) {
|
||||
Write-Output "Usage: ./setup-plan.ps1 [-Json] [-Help]"
|
||||
Write-Output " -Json Output results in JSON format"
|
||||
Write-Output " -Help Show this help message"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Load common functions
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
# Get all paths and variables from common functions
|
||||
$paths = Get-FeaturePathsEnv -ReturnNullOnError
|
||||
if (-not $paths) {
|
||||
[Console]::Error.WriteLine("ERROR: Failed to resolve feature paths")
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Ensure the feature directory exists
|
||||
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
|
||||
|
||||
# Copy plan template if plan doesn't already exist
|
||||
if (Test-Path $paths.IMPL_PLAN -PathType Leaf) {
|
||||
if ($Json) {
|
||||
[Console]::Error.WriteLine("Plan already exists at $($paths.IMPL_PLAN), skipping template copy")
|
||||
} else {
|
||||
Write-Output "Plan already exists at $($paths.IMPL_PLAN), skipping template copy"
|
||||
}
|
||||
} else {
|
||||
$template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
|
||||
if ($template -and (Test-Path $template)) {
|
||||
# Read the template content and write it to the implementation plan file with UTF-8 encoding without BOM
|
||||
$content = [System.IO.File]::ReadAllText($template)
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($paths.IMPL_PLAN, $content, $utf8NoBom)
|
||||
# Emit the copy status like the bash twin (setup-plan.sh); route to stderr
|
||||
# in -Json mode so stdout stays pure JSON, matching the sibling messages.
|
||||
if ($Json) {
|
||||
[Console]::Error.WriteLine("Copied plan template to $($paths.IMPL_PLAN)")
|
||||
} else {
|
||||
Write-Output "Copied plan template to $($paths.IMPL_PLAN)"
|
||||
}
|
||||
} else {
|
||||
# Match the bash twin's wording and stream routing (stderr in -Json so
|
||||
# stdout stays pure JSON, stdout otherwise), consistent with the sibling
|
||||
# "Copied plan template" message above.
|
||||
if ($Json) {
|
||||
[Console]::Error.WriteLine("Warning: Plan template not found")
|
||||
} else {
|
||||
Write-Output "Warning: Plan template not found"
|
||||
}
|
||||
# Create a basic plan file if template doesn't exist
|
||||
New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
# Output results
|
||||
if ($Json) {
|
||||
$result = [PSCustomObject]@{
|
||||
FEATURE_SPEC = $paths.FEATURE_SPEC
|
||||
IMPL_PLAN = $paths.IMPL_PLAN
|
||||
SPECS_DIR = $paths.FEATURE_DIR
|
||||
BRANCH = $paths.CURRENT_BRANCH
|
||||
}
|
||||
$result | ConvertTo-Json -Compress
|
||||
} else {
|
||||
Write-Output "FEATURE_SPEC: $($paths.FEATURE_SPEC)"
|
||||
Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)"
|
||||
Write-Output "SPECS_DIR: $($paths.FEATURE_DIR)"
|
||||
Write-Output "BRANCH: $($paths.CURRENT_BRANCH)"
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env pwsh
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Json,
|
||||
[switch]$Help,
|
||||
[Parameter(ValueFromRemainingArguments = $true)]
|
||||
[string[]]$RemainingArgs
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Help wins over unknown-argument validation to match the Bash/Python
|
||||
# variants, which stop at --help and exit 0.
|
||||
if ($Help) {
|
||||
Write-Output "Usage: setup-tasks.ps1 [-Json] [-Help]"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($RemainingArgs.Count -gt 0) {
|
||||
[Console]::Error.WriteLine("ERROR: Unknown option '$($RemainingArgs[0])'")
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Source common functions
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
# Get feature paths
|
||||
$paths = Get-FeaturePathsEnv -ReturnNullOnError
|
||||
if (-not $paths) {
|
||||
[Console]::Error.WriteLine("ERROR: Failed to resolve feature paths")
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
|
||||
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
|
||||
$planCommand = '/speckit-plan'
|
||||
[Console]::Error.WriteLine("Run $planCommand first to create the implementation plan.")
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) {
|
||||
[Console]::Error.WriteLine("ERROR: spec.md not found in $($paths.FEATURE_DIR)")
|
||||
$specifyCommand = '/speckit-specify'
|
||||
[Console]::Error.WriteLine("Run $specifyCommand first to create the feature structure.")
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Build available docs list
|
||||
$docs = @()
|
||||
if (Test-Path $paths.RESEARCH) { $docs += 'research.md' }
|
||||
if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' }
|
||||
if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
|
||||
$docs += 'contracts/'
|
||||
}
|
||||
if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
|
||||
|
||||
# Resolve tasks template through override stack
|
||||
$tasksTemplate = Resolve-Template -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT
|
||||
if (-not $tasksTemplate -or -not (Test-Path -LiteralPath $tasksTemplate -PathType Leaf)) {
|
||||
[Console]::Error.WriteLine("ERROR: Could not resolve required tasks-template from the template override stack for $($paths.REPO_ROOT)")
|
||||
[Console]::Error.WriteLine("Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template.")
|
||||
exit 1
|
||||
}
|
||||
$tasksTemplate = (Resolve-Path -LiteralPath $tasksTemplate).Path
|
||||
|
||||
# Output results
|
||||
if ($Json) {
|
||||
[PSCustomObject]@{
|
||||
FEATURE_DIR = $paths.FEATURE_DIR
|
||||
AVAILABLE_DOCS = $docs
|
||||
TASKS_TEMPLATE = $tasksTemplate
|
||||
} | ConvertTo-Json -Compress
|
||||
} else {
|
||||
Write-Output "FEATURE_DIR: $($paths.FEATURE_DIR)"
|
||||
Write-Output "TASKS_TEMPLATE: $(if ($tasksTemplate) { $tasksTemplate } else { 'not found' })"
|
||||
Write-Output "AVAILABLE_DOCS:"
|
||||
Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Out-Null
|
||||
Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Out-Null
|
||||
Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Out-Null
|
||||
Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Out-Null
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# [CHECKLIST TYPE] Checklist: [FEATURE NAME]
|
||||
|
||||
**Purpose**: [Brief description of what this checklist covers]
|
||||
**Created**: [DATE]
|
||||
**Feature**: [Link to spec.md or relevant documentation]
|
||||
|
||||
**Note**: This checklist is generated by the `/speckit-checklist` command based on feature context and requirements.
|
||||
|
||||
<!--
|
||||
============================================================================
|
||||
IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only.
|
||||
|
||||
The /speckit-checklist command MUST replace these with actual items based on:
|
||||
- User's specific checklist request
|
||||
- Feature requirements from spec.md
|
||||
- Technical context from plan.md
|
||||
- Implementation details from tasks.md
|
||||
|
||||
DO NOT keep these sample items in the generated checklist file.
|
||||
============================================================================
|
||||
-->
|
||||
|
||||
## [Category 1]
|
||||
|
||||
- [ ] CHK001 First checklist item with clear action
|
||||
- [ ] CHK002 Second checklist item
|
||||
- [ ] CHK003 Third checklist item
|
||||
|
||||
## [Category 2]
|
||||
|
||||
- [ ] CHK004 Another category item
|
||||
- [ ] CHK005 Item with specific criteria
|
||||
- [ ] CHK006 Final item in this category
|
||||
|
||||
## Notes
|
||||
|
||||
- Check items off as completed: `[x]`
|
||||
- Add comments or findings inline
|
||||
- Link to relevant resources or documentation
|
||||
- Items are numbered sequentially for easy reference
|
||||
@@ -0,0 +1,50 @@
|
||||
# [PROJECT_NAME] Constitution
|
||||
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->
|
||||
|
||||
## Core Principles
|
||||
|
||||
### [PRINCIPLE_1_NAME]
|
||||
<!-- Example: I. Library-First -->
|
||||
[PRINCIPLE_1_DESCRIPTION]
|
||||
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->
|
||||
|
||||
### [PRINCIPLE_2_NAME]
|
||||
<!-- Example: II. CLI Interface -->
|
||||
[PRINCIPLE_2_DESCRIPTION]
|
||||
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->
|
||||
|
||||
### [PRINCIPLE_3_NAME]
|
||||
<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
|
||||
[PRINCIPLE_3_DESCRIPTION]
|
||||
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
|
||||
|
||||
### [PRINCIPLE_4_NAME]
|
||||
<!-- Example: IV. Integration Testing -->
|
||||
[PRINCIPLE_4_DESCRIPTION]
|
||||
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
|
||||
|
||||
### [PRINCIPLE_5_NAME]
|
||||
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
|
||||
[PRINCIPLE_5_DESCRIPTION]
|
||||
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
|
||||
|
||||
## [SECTION_2_NAME]
|
||||
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->
|
||||
|
||||
[SECTION_2_CONTENT]
|
||||
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
|
||||
|
||||
## [SECTION_3_NAME]
|
||||
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->
|
||||
|
||||
[SECTION_3_CONTENT]
|
||||
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->
|
||||
|
||||
## Governance
|
||||
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
|
||||
|
||||
[GOVERNANCE_RULES]
|
||||
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
|
||||
|
||||
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
|
||||
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->
|
||||
@@ -0,0 +1,113 @@
|
||||
# Implementation Plan: [FEATURE]
|
||||
|
||||
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
|
||||
|
||||
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit-plan` command; its definition describes the execution workflow.
|
||||
|
||||
## Summary
|
||||
|
||||
[Extract from feature spec: primary requirement + technical approach from research]
|
||||
|
||||
## Technical Context
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: Replace the content in this section with the technical details
|
||||
for the project. The structure here is presented in advisory capacity to guide
|
||||
the iteration process.
|
||||
-->
|
||||
|
||||
**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION]
|
||||
|
||||
**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION]
|
||||
|
||||
**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
||||
|
||||
**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION]
|
||||
|
||||
**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION]
|
||||
|
||||
**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION]
|
||||
|
||||
**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION]
|
||||
|
||||
**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION]
|
||||
|
||||
**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION]
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
[Gates determined based on constitution file]
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/[###-feature]/
|
||||
├── plan.md # This file (/speckit-plan command output)
|
||||
├── research.md # Phase 0 output (/speckit-plan command)
|
||||
├── data-model.md # Phase 1 output (/speckit-plan command)
|
||||
├── quickstart.md # Phase 1 output (/speckit-plan command)
|
||||
├── contracts/ # Phase 1 output (/speckit-plan command)
|
||||
└── tasks.md # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
<!--
|
||||
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
|
||||
for this feature. Delete unused options and expand the chosen structure with
|
||||
real paths (e.g., apps/admin, packages/something). The delivered plan must
|
||||
not include Option labels.
|
||||
-->
|
||||
|
||||
```text
|
||||
# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT)
|
||||
src/
|
||||
├── models/
|
||||
├── services/
|
||||
├── cli/
|
||||
└── lib/
|
||||
|
||||
tests/
|
||||
├── contract/
|
||||
├── integration/
|
||||
└── unit/
|
||||
|
||||
# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected)
|
||||
backend/
|
||||
├── src/
|
||||
│ ├── models/
|
||||
│ ├── services/
|
||||
│ └── api/
|
||||
└── tests/
|
||||
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ ├── pages/
|
||||
│ └── services/
|
||||
└── tests/
|
||||
|
||||
# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected)
|
||||
api/
|
||||
└── [same as backend above]
|
||||
|
||||
ios/ or android/
|
||||
└── [platform-specific structure: feature modules, UI flows, platform tests]
|
||||
```
|
||||
|
||||
**Structure Decision**: [Document the selected structure and reference the real
|
||||
directories captured above]
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
||||
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
||||
@@ -0,0 +1,131 @@
|
||||
# Feature Specification: [FEATURE NAME]
|
||||
|
||||
**Feature Branch**: `[###-feature-name]`
|
||||
|
||||
**Created**: [DATE]
|
||||
|
||||
**Status**: Draft
|
||||
|
||||
**Input**: User description: "$ARGUMENTS"
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
<!--
|
||||
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
|
||||
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
|
||||
you should still have a viable MVP (Minimum Viable Product) that delivers value.
|
||||
|
||||
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
|
||||
Think of each story as a standalone slice of functionality that can be:
|
||||
- Developed independently
|
||||
- Tested independently
|
||||
- Deployed independently
|
||||
- Demonstrated to users independently
|
||||
-->
|
||||
|
||||
### User Story 1 - [Brief Title] (Priority: P1)
|
||||
|
||||
[Describe this user journey in plain language]
|
||||
|
||||
**Why this priority**: [Explain the value and why it has this priority level]
|
||||
|
||||
**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by [specific action] and delivers [specific value]"]
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||
2. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - [Brief Title] (Priority: P2)
|
||||
|
||||
[Describe this user journey in plain language]
|
||||
|
||||
**Why this priority**: [Explain the value and why it has this priority level]
|
||||
|
||||
**Independent Test**: [Describe how this can be tested independently]
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - [Brief Title] (Priority: P3)
|
||||
|
||||
[Describe this user journey in plain language]
|
||||
|
||||
**Why this priority**: [Explain the value and why it has this priority level]
|
||||
|
||||
**Independent Test**: [Describe how this can be tested independently]
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
|
||||
|
||||
---
|
||||
|
||||
[Add more user stories as needed, each with an assigned priority]
|
||||
|
||||
### Edge Cases
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: The content in this section represents placeholders.
|
||||
Fill them out with the right edge cases.
|
||||
-->
|
||||
|
||||
- What happens when [boundary condition]?
|
||||
- How does system handle [error scenario]?
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: The content in this section represents placeholders.
|
||||
Fill them out with the right functional requirements.
|
||||
-->
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"]
|
||||
- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"]
|
||||
- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"]
|
||||
- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"]
|
||||
- **FR-005**: System MUST [behavior, e.g., "log all security events"]
|
||||
|
||||
*Example of marking unclear requirements:*
|
||||
|
||||
- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?]
|
||||
- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified]
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **[Entity 1]**: [What it represents, key attributes without implementation]
|
||||
- **[Entity 2]**: [What it represents, relationships to other entities]
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: Define measurable success criteria.
|
||||
These must be technology-agnostic and measurable.
|
||||
-->
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"]
|
||||
- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"]
|
||||
- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"]
|
||||
- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"]
|
||||
|
||||
## Assumptions
|
||||
|
||||
<!--
|
||||
ACTION REQUIRED: The content in this section represents placeholders.
|
||||
Fill them out with the right assumptions based on reasonable defaults
|
||||
chosen when the feature description did not specify certain details.
|
||||
-->
|
||||
|
||||
- [Assumption about target users, e.g., "Users have stable internet connectivity"]
|
||||
- [Assumption about scope boundaries, e.g., "Mobile support is out of scope for v1"]
|
||||
- [Assumption about data/environment, e.g., "Existing authentication system will be reused"]
|
||||
- [Dependency on existing system/service, e.g., "Requires access to the existing user profile API"]
|
||||
@@ -0,0 +1,252 @@
|
||||
---
|
||||
|
||||
description: "Task list template for feature implementation"
|
||||
---
|
||||
|
||||
# Tasks: [FEATURE NAME]
|
||||
|
||||
**Input**: Design documents from `/specs/[###-feature-name]/`
|
||||
|
||||
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
||||
|
||||
**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification.
|
||||
|
||||
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
## Path Conventions
|
||||
|
||||
- **Single project**: `src/`, `tests/` at repository root
|
||||
- **Web app**: `backend/src/`, `frontend/src/`
|
||||
- **Mobile**: `api/src/`, `ios/src/` or `android/src/`
|
||||
- Paths shown below assume single project - adjust based on plan.md structure
|
||||
|
||||
<!--
|
||||
============================================================================
|
||||
IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only.
|
||||
|
||||
The /speckit-tasks command MUST replace these with actual tasks based on:
|
||||
- User stories from spec.md (with their priorities P1, P2, P3...)
|
||||
- Feature requirements from plan.md
|
||||
- Entities from data-model.md
|
||||
- Endpoints from contracts/
|
||||
|
||||
Tasks MUST be organized by user story so each story can be:
|
||||
- Implemented independently
|
||||
- Tested independently
|
||||
- Delivered as an MVP increment
|
||||
|
||||
DO NOT keep these sample tasks in the generated tasks.md file.
|
||||
============================================================================
|
||||
-->
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Project initialization and basic structure
|
||||
|
||||
- [ ] T001 Create project structure per implementation plan
|
||||
- [ ] T002 Initialize [language] project with [framework] dependencies
|
||||
- [ ] T003 [P] Configure linting and formatting tools
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
|
||||
|
||||
Examples of foundational tasks (adjust based on your project):
|
||||
|
||||
- [ ] T004 Setup database schema and migrations framework
|
||||
- [ ] T005 [P] Implement authentication/authorization framework
|
||||
- [ ] T006 [P] Setup API routing and middleware structure
|
||||
- [ ] T007 Create base models/entities that all stories depend on
|
||||
- [ ] T008 Configure error handling and logging infrastructure
|
||||
- [ ] T009 Setup environment configuration management
|
||||
|
||||
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - [Title] (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: [Brief description of what this story delivers]
|
||||
|
||||
**Independent Test**: [How to verify this story works on its own]
|
||||
|
||||
### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️
|
||||
|
||||
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
|
||||
|
||||
- [ ] T010 [P] [US1] Contract test for [endpoint] in tests/contract/test_[name].py
|
||||
- [ ] T011 [P] [US1] Integration test for [user journey] in tests/integration/test_[name].py
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [ ] T012 [P] [US1] Create [Entity1] model in src/models/[entity1].py
|
||||
- [ ] T013 [P] [US1] Create [Entity2] model in src/models/[entity2].py
|
||||
- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013)
|
||||
- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py
|
||||
- [ ] T016 [US1] Add validation and error handling
|
||||
- [ ] T017 [US1] Add logging for user story 1 operations
|
||||
|
||||
**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - [Title] (Priority: P2)
|
||||
|
||||
**Goal**: [Brief description of what this story delivers]
|
||||
|
||||
**Independent Test**: [How to verify this story works on its own]
|
||||
|
||||
### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️
|
||||
|
||||
- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test_[name].py
|
||||
- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test_[name].py
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [ ] T020 [P] [US2] Create [Entity] model in src/models/[entity].py
|
||||
- [ ] T021 [US2] Implement [Service] in src/services/[service].py
|
||||
- [ ] T022 [US2] Implement [endpoint/feature] in src/[location]/[file].py
|
||||
- [ ] T023 [US2] Integrate with User Story 1 components (if needed)
|
||||
|
||||
**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - [Title] (Priority: P3)
|
||||
|
||||
**Goal**: [Brief description of what this story delivers]
|
||||
|
||||
**Independent Test**: [How to verify this story works on its own]
|
||||
|
||||
### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️
|
||||
|
||||
- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test_[name].py
|
||||
- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test_[name].py
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [ ] T026 [P] [US3] Create [Entity] model in src/models/[entity].py
|
||||
- [ ] T027 [US3] Implement [Service] in src/services/[service].py
|
||||
- [ ] T028 [US3] Implement [endpoint/feature] in src/[location]/[file].py
|
||||
|
||||
**Checkpoint**: All user stories should now be independently functional
|
||||
|
||||
---
|
||||
|
||||
[Add more user story phases as needed, following the same pattern]
|
||||
|
||||
---
|
||||
|
||||
## Phase N: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Improvements that affect multiple user stories
|
||||
|
||||
- [ ] TXXX [P] Documentation updates in docs/
|
||||
- [ ] TXXX Code cleanup and refactoring
|
||||
- [ ] TXXX Performance optimization across all stories
|
||||
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
|
||||
- [ ] TXXX Security hardening
|
||||
- [ ] TXXX Run quickstart.md validation
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies - can start immediately
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories
|
||||
- **User Stories (Phase 3+)**: All depend on Foundational phase completion
|
||||
- User stories can then proceed in parallel (if staffed)
|
||||
- Or sequentially in priority order (P1 → P2 → P3)
|
||||
- **Polish (Final Phase)**: Depends on all desired user stories being complete
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories
|
||||
- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable
|
||||
- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but should be independently testable
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Tests (if included) MUST be written and FAIL before implementation
|
||||
- Models before services
|
||||
- Services before endpoints
|
||||
- Core implementation before integration
|
||||
- Story complete before moving to next priority
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- All Setup tasks marked [P] can run in parallel
|
||||
- All Foundational tasks marked [P] can run in parallel (within Phase 2)
|
||||
- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows)
|
||||
- All tests for a user story marked [P] can run in parallel
|
||||
- Models within a story marked [P] can run in parallel
|
||||
- Different user stories can be worked on in parallel by different team members
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Launch all tests for User Story 1 together (if tests requested):
|
||||
Task: "Contract test for [endpoint] in tests/contract/test_[name].py"
|
||||
Task: "Integration test for [user journey] in tests/integration/test_[name].py"
|
||||
|
||||
# Launch all models for User Story 1 together:
|
||||
Task: "Create [Entity1] model in src/models/[entity1].py"
|
||||
Task: "Create [Entity2] model in src/models/[entity2].py"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup
|
||||
2. Complete Phase 2: Foundational (CRITICAL - blocks all stories)
|
||||
3. Complete Phase 3: User Story 1
|
||||
4. **STOP and VALIDATE**: Test User Story 1 independently
|
||||
5. Deploy/demo if ready
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Complete Setup + Foundational → Foundation ready
|
||||
2. Add User Story 1 → Test independently → Deploy/Demo (MVP!)
|
||||
3. Add User Story 2 → Test independently → Deploy/Demo
|
||||
4. Add User Story 3 → Test independently → Deploy/Demo
|
||||
5. Each story adds value without breaking previous stories
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
With multiple developers:
|
||||
|
||||
1. Team completes Setup + Foundational together
|
||||
2. Once Foundational is done:
|
||||
- Developer A: User Story 1
|
||||
- Developer B: User Story 2
|
||||
- Developer C: User Story 3
|
||||
3. Stories complete and integrate independently
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks = different files, no dependencies
|
||||
- [Story] label maps task to specific user story for traceability
|
||||
- Each user story should be independently completable and testable
|
||||
- Verify tests fail before implementing
|
||||
- Commit after each task or logical group
|
||||
- Stop at any checkpoint to validate story independently
|
||||
- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence
|
||||
@@ -0,0 +1,77 @@
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "speckit"
|
||||
name: "Full SDD Cycle"
|
||||
version: "1.0.0"
|
||||
author: "GitHub"
|
||||
description: "Runs specify → plan → tasks → implement with review gates"
|
||||
|
||||
requires:
|
||||
# 0.8.5 is the first release with engine-side resolution of the
|
||||
# ``integration: "auto"`` default. Older versions would treat "auto"
|
||||
# as a literal integration key and fail at dispatch.
|
||||
speckit_version: ">=0.8.5"
|
||||
integrations:
|
||||
# The four commands below (specify, plan, tasks, implement) are core
|
||||
# spec-kit commands provided by every integration. The list here is an
|
||||
# advisory, non-exhaustive compatibility hint following the documented
|
||||
# ``any: [...]`` schema -- it is NOT a closed set. The workflow runs
|
||||
# against any integration the project was initialized with, including
|
||||
# ones not listed below, as long as that integration provides the four
|
||||
# core commands referenced in ``steps``.
|
||||
any:
|
||||
- "claude"
|
||||
- "copilot"
|
||||
- "gemini"
|
||||
- "opencode"
|
||||
|
||||
inputs:
|
||||
spec:
|
||||
type: string
|
||||
required: true
|
||||
prompt: "Describe what you want to build"
|
||||
integration:
|
||||
type: string
|
||||
default: "auto"
|
||||
prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)"
|
||||
scope:
|
||||
type: string
|
||||
default: "full"
|
||||
enum: ["full", "backend-only", "frontend-only"]
|
||||
|
||||
steps:
|
||||
- id: specify
|
||||
command: speckit.specify
|
||||
integration: "{{ inputs.integration }}"
|
||||
input:
|
||||
args: "{{ inputs.spec }}"
|
||||
|
||||
- id: review-spec
|
||||
type: gate
|
||||
message: "Review the generated spec before planning."
|
||||
options: [approve, reject]
|
||||
on_reject: abort
|
||||
|
||||
- id: plan
|
||||
command: speckit.plan
|
||||
integration: "{{ inputs.integration }}"
|
||||
input:
|
||||
args: "{{ inputs.spec }}"
|
||||
|
||||
- id: review-plan
|
||||
type: gate
|
||||
message: "Review the plan before generating tasks."
|
||||
options: [approve, reject]
|
||||
on_reject: abort
|
||||
|
||||
- id: tasks
|
||||
command: speckit.tasks
|
||||
integration: "{{ inputs.integration }}"
|
||||
input:
|
||||
args: "{{ inputs.spec }}"
|
||||
|
||||
- id: implement
|
||||
command: speckit.implement
|
||||
integration: "{{ inputs.integration }}"
|
||||
input:
|
||||
args: "{{ inputs.spec }}"
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflows": {
|
||||
"speckit": {
|
||||
"name": "Full SDD Cycle",
|
||||
"version": "1.0.0",
|
||||
"description": "Runs specify \u2192 plan \u2192 tasks \u2192 implement with review gates",
|
||||
"source": "bundled",
|
||||
"installed_at": "2026-07-27T03:19:04.090168+00:00",
|
||||
"updated_at": "2026-07-27T03:19:04.090177+00:00"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# Snap 백엔드 연결 — 진행 ledger
|
||||
|
||||
plan: docs/superpowers/plans/2026-07-18-snap-backend-connect.md
|
||||
branch: feat/snap-backend-connect
|
||||
merge-base(main): 997621822e551be36992a521a2dcf339b5d6ac6f
|
||||
plan-committed HEAD: a205fbf
|
||||
|
||||
## Tasks
|
||||
(완료 시 한 줄씩 append: "Task N: complete (commits <base7>..<head7>, review clean)")
|
||||
Task 1: complete (commits 7e808b7..7e808b7, review clean)
|
||||
Task 2: complete (commits 4b78e21..4b78e21, review clean)
|
||||
Task 3: complete (commits 89f7015..89f7015, review clean; Minor: useCreateSession 테스트 공백 — 의도적, final review 에서 triage)
|
||||
Task 4: complete (commits fa7b62c..fa7b62c, review clean)
|
||||
Task 5: complete (commits 6a1f106..6a1f106, review clean)
|
||||
Task 6: complete (commits c838ec5..c838ec5, review clean; Minor: test inline type 길이 — non-blocking)
|
||||
Task 7: complete (commits 5b0b632..5b0b632, review clean)
|
||||
Task 8: complete (commit e965b07, deletion verified: 4 mock files gone, tsc clean, snap tests pass, no dangling refs)
|
||||
|
||||
## 최종
|
||||
- 모든 8 태스크 완료. 최종 whole-branch 리뷰(opus): MERGE WITH FIXES.
|
||||
- Important 1(빈 assistant 버블 미제거) → fix 커밋 21adcf7 (snap 14/14).
|
||||
- Important 2(세션 목록 10개 truncate) → 사용자 결정 "최근 10개 유지", 코드 주석만.
|
||||
- Minor 3건 non-blocking.
|
||||
- 전체 스위트 8 실패(authStore/MePage/themeStore)는 base a205fbf 에서도 동일 → 기존 baseline(zustand persist storage), 우리 브랜치 무관. 우리 surface(snap/streaming/api/auth) 전부 green + build ✓.
|
||||
- 미커밋: abap 하이라이트 작업(CodeBlock/Message/abapHljs.ts/abapLight.css/package) — 이번 백엔드연결과 별개.
|
||||
|
||||
## 중단 UX (feat/snap-stop-ux) — 진행
|
||||
plan-committed HEAD: 89dff3d
|
||||
S1: complete (a7eeeff, cancelStream, 4/4, self-verified)
|
||||
S2: complete (ae74eb2, isRevealing store, 9/9, self-verified)
|
||||
S3: complete (ecaba5d, stop+retry+cancel, 7/7, self-verified)
|
||||
S4: complete (1ba4e94, page wiring, tsc/lint/build ok, vitest 22/22, self-verified)
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
// 검색 오염 방지: junction(스킬 repo 전체가 딸려옴)·대용량 문서·생성물 제외
|
||||
"search.exclude": {
|
||||
"**/.claude/skills": true,
|
||||
"**/docs-lib": true,
|
||||
"**/.specify": true,
|
||||
"**/dist": true,
|
||||
"**/coverage": true
|
||||
},
|
||||
// 파일 감시도 같은 대상 제외 (junction 순환·불필요한 리로드 방지)
|
||||
"files.watcherExclude": {
|
||||
"**/.claude/skills/**": true,
|
||||
"**/docs-lib/**": true,
|
||||
"**/.specify/**": true,
|
||||
"**/node_modules/**": true,
|
||||
"**/dist/**": true
|
||||
},
|
||||
"files.encoding": "utf8"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
# Node 빌드/캐시
|
||||
node_modules/
|
||||
dist/
|
||||
dist-tsbuild/
|
||||
tsconfig.tsbuildinfo
|
||||
.vitest-cache/
|
||||
|
||||
# 테스트/E2E 결과물
|
||||
e2e/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# Env
|
||||
# Vite는 빌드 타임에 .env / .env.[mode]를 읽어 VITE_* 값을 번들에 박음.
|
||||
# 따라서 빌드 컨테이너 안에 .env 가 있어야 함. (VITE_* 는 정의상 public)
|
||||
# 개발자 머신 전용 오버라이드(.local)만 image에서 제외.
|
||||
.env.local
|
||||
.env.*.local
|
||||
@@ -0,0 +1,9 @@
|
||||
# API base URL. 풀 URL 또는 '/' 로 시작하는 절대경로.
|
||||
# dev/prd 모두 single-origin 전제라 상대경로가 기본.
|
||||
VITE_API_BASE_URL=/api/v1
|
||||
|
||||
# dev 서버 '/api' proxy 대상 백엔드 origin (기본값: http://localhost:8001)
|
||||
VITE_DEV_API_TARGET=http://localhost:8001
|
||||
|
||||
# Entra Graph scope (선택, 기본값: User.Read)
|
||||
# VITE_ENTRA_GRAPH_SCOPE=User.Read
|
||||
@@ -0,0 +1,9 @@
|
||||
# API base URL. 풀 URL 또는 '/' 로 시작하는 절대경로.
|
||||
# dev/prd 모두 single-origin 전제라 상대경로가 기본.
|
||||
VITE_API_BASE_URL=/api/v1
|
||||
|
||||
# dev 서버 '/api' proxy 대상 백엔드 origin (기본값: http://localhost:8001)
|
||||
VITE_DEV_API_TARGET=http://localhost:8001
|
||||
|
||||
# Entra Graph scope (선택, 기본값: User.Read)
|
||||
# VITE_ENTRA_GRAPH_SCOPE=User.Read
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
dist-tsbuild
|
||||
*.local
|
||||
.DS_Store
|
||||
.idea
|
||||
coverage
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
coverage
|
||||
package-lock.json
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# 2_frontend — React SPA 템플릿
|
||||
|
||||
Vite + React + TypeScript + Tailwind + shadcn/ui + Zustand + TanStack Query 기반.
|
||||
백엔드(`1_backend/`, FastAPI + SSE + JWT)와 연동.
|
||||
|
||||
## 시작하기
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env
|
||||
npm run dev # http://localhost:5173
|
||||
```
|
||||
|
||||
백엔드도 같이 띄울 것: `cd ../1_backend && uv run uvicorn main:app --reload --reload-dir src --port 8001 --app-dir src`
|
||||
|
||||
## 주요 명령
|
||||
|
||||
| 명령 | 설명 |
|
||||
| ---------------- | ------------------ |
|
||||
| `npm run dev` | dev 서버 (5173) |
|
||||
| `npm run build` | 프로덕션 빌드 |
|
||||
| `npm run lint` | ESLint |
|
||||
| `npm run test` | Vitest 단위 테스트 |
|
||||
| `npm run format` | Prettier 포맷 |
|
||||
|
||||
자세한 설계: `../docs/superpowers/specs/2026-05-05-frontend-template-design.md`
|
||||
|
||||
## 검증 완료 (2026-05-05)
|
||||
|
||||
- 부트스트랩 (Vite + TS + Tailwind + ESLint + Prettier 설정)
|
||||
- core lib (tokenStore, api client + 401 refresh, SSE wrapper, queryClient, cn)
|
||||
- shared (shadcn/ui core, Layout, ProtectedRoute, ErrorBoundary)
|
||||
- features/auth (schemas, store, api, hooks, components, pages)
|
||||
- features/users (api, hooks, MePage)
|
||||
- features/chat (store, SSE wrapper, hook, components, ChatPage)
|
||||
- routing + Provider 와이어업
|
||||
|
||||
테스트 34개 통과, lint 깨끗, build 성공.
|
||||
|
||||
수동 시나리오 검증 (백엔드 8001 + 프론트 5173 같이 띄우고):
|
||||
|
||||
1. `/` → ProtectedRoute가 `/login` 으로 redirect
|
||||
2. `happy@pwc.com / a1234` 로그인 → `/chat` 이동
|
||||
3. 헤더에 이메일 보임
|
||||
4. `/me` 클릭 → 내 정보 표시
|
||||
5. `/chat` 메시지 보내면 SSE echo로 토큰 단위 누적
|
||||
6. Logout → `/login` 이동
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/shared",
|
||||
"utils": "@/lib/utils/cn",
|
||||
"ui": "@/shared/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/lib/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# docs-lib
|
||||
|
||||
이 모듈(2_frontend)이 쓰는 라이브러리 참조 문서 모음. 구현 전에 여기 먼저 보고 감 — 기억으로 API 쓰지 말 것. 파일이 크면 통째로 읽지 말고 Grep 으로 필요한 부분만 꺼내 쓰기.
|
||||
|
||||
## 카탈로그
|
||||
|
||||
| 파일 | 라이브러리 | 출처 | 갱신법 |
|
||||
| ------------------- | ----------------------- | ------------------------------------------------------------------------------- | ------------------------------------ |
|
||||
| `react-markdown.md` | react-markdown v10 | https://raw.githubusercontent.com/remarkjs/react-markdown/main/readme.md | curl 로 다시 받기 |
|
||||
| `tauri-api.md` | @tauri-apps/api v2.11.1 | **패키지 실물** `node_modules/@tauri-apps/api/*.d.ts`·`*.js` 에서 직접 확인 | 버전 올라가면 `.d.ts` 다시 보고 갱신 |
|
||||
| `tanstack-query.md` | TanStack Query v5 | https://tanstack.com/query/latest/docs/framework/react/guides/disabling-queries | 공식 문서에서 필요한 부분 갱신 |
|
||||
| `axios.md` | Axios v1 | https://axios-http.com/docs/interceptors | 공식 문서에서 필요한 부분 갱신 |
|
||||
| `react-router.md` | React Router | https://reactrouter.com/api/hooks/useRoutes | 공식 문서에서 필요한 부분 갱신 |
|
||||
|
||||
## 갱신법
|
||||
|
||||
출처 URL 을 curl 로 다시 받아 덮어쓰면 됨. 새 라이브러리 문서 추가하면 위 표에 한 줄 등록.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Axios v1 인터셉터 참조
|
||||
|
||||
출처: https://axios-http.com/docs/interceptors
|
||||
확인일: 2026-09-11
|
||||
|
||||
## 응답 인터셉터
|
||||
|
||||
2xx 밖 응답은 response interceptor의 reject handler로 들어옴. 오류를 처리하지 않을 때는 reject/throw로 다음 호출자에게 전달해야 함.
|
||||
|
||||
```ts
|
||||
instance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
```
|
||||
|
||||
여러 response interceptor는 등록 순서(FIFO)로 실행됨.
|
||||
@@ -0,0 +1,879 @@
|
||||
<!--
|
||||
Notes for maintaining this document:
|
||||
|
||||
* update the version of the link for `commonmark-html` once in a while
|
||||
-->
|
||||
|
||||
# react-markdown
|
||||
|
||||
[![Build][badge-build-image]][badge-build-url]
|
||||
[![Coverage][badge-coverage-image]][badge-coverage-url]
|
||||
[![Downloads][badge-downloads-image]][badge-downloads-url]
|
||||
[![Size][badge-size-image]][badge-size-url]
|
||||
|
||||
React component to render markdown.
|
||||
|
||||
## Feature highlights
|
||||
|
||||
- [x] **[safe][section-security] by default**
|
||||
(no `dangerouslySetInnerHTML` or XSS attacks)
|
||||
- [x] **[components][section-components]**
|
||||
(pass your own component to use instead of `<h2>` for `## hi`)
|
||||
- [x] **[plugins][section-plugins]**
|
||||
(many plugins you can pick and choose from)
|
||||
- [x] **[compliant][section-syntax]**
|
||||
(100% to CommonMark, 100% to GFM with a plugin)
|
||||
|
||||
## Contents
|
||||
|
||||
- [What is this?](#what-is-this)
|
||||
- [When should I use this?](#when-should-i-use-this)
|
||||
- [Install](#install)
|
||||
- [Use](#use)
|
||||
- [API](#api)
|
||||
- [`Markdown`](#markdown)
|
||||
- [`MarkdownAsync`](#markdownasync)
|
||||
- [`MarkdownHooks`](#markdownhooks)
|
||||
- [`defaultUrlTransform(url)`](#defaulturltransformurl)
|
||||
- [`AllowElement`](#allowelement)
|
||||
- [`Components`](#components)
|
||||
- [`ExtraProps`](#extraprops)
|
||||
- [`HooksOptions`](#hooksoptions)
|
||||
- [`Options`](#options)
|
||||
- [`UrlTransform`](#urltransform)
|
||||
- [Examples](#examples)
|
||||
- [Use a plugin](#use-a-plugin)
|
||||
- [Use a plugin with options](#use-a-plugin-with-options)
|
||||
- [Use custom components (syntax highlight)](#use-custom-components-syntax-highlight)
|
||||
- [Use remark and rehype plugins (math)](#use-remark-and-rehype-plugins-math)
|
||||
- [Plugins](#plugins)
|
||||
- [Syntax](#syntax)
|
||||
- [Compatibility](#compatibility)
|
||||
- [Architecture](#architecture)
|
||||
- [Appendix A: HTML in markdown](#appendix-a-html-in-markdown)
|
||||
- [Appendix B: Components](#appendix-b-components)
|
||||
- [Appendix C: line endings in markdown (and JSX)](#appendix-c-line-endings-in-markdown-and-jsx)
|
||||
- [Security](#security)
|
||||
- [Related](#related)
|
||||
- [Contribute](#contribute)
|
||||
- [License](#license)
|
||||
|
||||
## What is this?
|
||||
|
||||
This package is a [React][] component that can be given a string of markdown
|
||||
that it’ll safely render to React elements.
|
||||
You can pass plugins to change how markdown is transformed and pass components
|
||||
that will be used instead of normal HTML elements.
|
||||
|
||||
- to learn markdown, see this [cheatsheet and tutorial][commonmark-help]
|
||||
- to try out `react-markdown`, see [our demo][github-io-react-markdown]
|
||||
|
||||
## When should I use this?
|
||||
|
||||
There are other ways to use markdown in React out there so why use this one?
|
||||
The three main reasons are that they often rely on `dangerouslySetInnerHTML`,
|
||||
have bugs with how they handle markdown, or don’t let you swap elements for
|
||||
components.
|
||||
`react-markdown` builds a virtual DOM, so React only replaces what changed,
|
||||
from a syntax tree.
|
||||
That’s supported because we use [unified][github-unified],
|
||||
specifically [remark][github-remark] for markdown and [rehype][github-rehype]
|
||||
for HTML,
|
||||
which are popular tools to transform content with plugins.
|
||||
|
||||
This package focusses on making it easy for beginners to safely use markdown in
|
||||
React.
|
||||
When you’re familiar with unified, you can use a modern hooks based alternative
|
||||
[`react-remark`][github-react-remark] or [`rehype-react`][github-rehype-react]
|
||||
manually.
|
||||
If you instead want to use JavaScript and JSX _inside_ markdown files, use
|
||||
[MDX][github-mdx].
|
||||
|
||||
## Install
|
||||
|
||||
This package is [ESM only][esm].
|
||||
In Node.js (version 16+), install with [npm][npm-install]:
|
||||
|
||||
```sh
|
||||
npm install react-markdown
|
||||
```
|
||||
|
||||
In Deno with [`esm.sh`][esmsh]:
|
||||
|
||||
```js
|
||||
import Markdown from "https://esm.sh/react-markdown@10"
|
||||
```
|
||||
|
||||
In browsers with [`esm.sh`][esmsh]:
|
||||
|
||||
```html
|
||||
<script type="module">
|
||||
import Markdown from "https://esm.sh/react-markdown@10?bundle"
|
||||
</script>
|
||||
```
|
||||
|
||||
## Use
|
||||
|
||||
A basic hello world:
|
||||
|
||||
```js
|
||||
import React from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import Markdown from "react-markdown"
|
||||
|
||||
const markdown = "# Hi, *Pluto*!"
|
||||
|
||||
createRoot(document.body).render(<Markdown>{markdown}</Markdown>)
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Show equivalent JSX</summary>
|
||||
|
||||
```js
|
||||
<h1>
|
||||
Hi, <em>Pluto</em>!
|
||||
</h1>
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Here is an example that shows how to use a plugin
|
||||
([`remark-gfm`][github-remark-gfm],
|
||||
which adds support for footnotes, strikethrough, tables, tasklists and
|
||||
URLs directly):
|
||||
|
||||
```js
|
||||
import React from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import Markdown from "react-markdown"
|
||||
import remarkGfm from "remark-gfm"
|
||||
|
||||
const markdown = `Just a link: www.nasa.gov.`
|
||||
|
||||
createRoot(document.body).render(<Markdown remarkPlugins={[remarkGfm]}>{markdown}</Markdown>)
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Show equivalent JSX</summary>
|
||||
|
||||
```js
|
||||
<p>
|
||||
Just a link: <a href="http://www.nasa.gov">www.nasa.gov</a>.
|
||||
</p>
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## API
|
||||
|
||||
This package exports the identifiers
|
||||
[`MarkdownAsync`][api-markdown-async],
|
||||
[`MarkdownHooks`][api-markdown-hooks],
|
||||
and
|
||||
[`defaultUrlTransform`][api-default-url-transform].
|
||||
The default export is [`Markdown`][api-markdown].
|
||||
|
||||
It also exports the additional [TypeScript][] types
|
||||
[`AllowElement`][api-allow-element],
|
||||
[`Components`][api-components],
|
||||
[`ExtraProps`][api-extra-props],
|
||||
[`HooksOptions`][api-hooks-options],
|
||||
[`Options`][api-options],
|
||||
and
|
||||
[`UrlTransform`][api-url-transform].
|
||||
|
||||
### `Markdown`
|
||||
|
||||
Component to render markdown.
|
||||
|
||||
This is a synchronous component.
|
||||
When using async plugins,
|
||||
see [`MarkdownAsync`][api-markdown-async] or
|
||||
[`MarkdownHooks`][api-markdown-hooks].
|
||||
|
||||
###### Parameters
|
||||
|
||||
- `options` ([`Options`][api-options])
|
||||
— props
|
||||
|
||||
###### Returns
|
||||
|
||||
React element (`ReactElement`).
|
||||
|
||||
### `MarkdownAsync`
|
||||
|
||||
Component to render markdown with support for async plugins
|
||||
through async/await.
|
||||
|
||||
Components returning promises are supported on the server.
|
||||
For async support on the client,
|
||||
see [`MarkdownHooks`][api-markdown-hooks].
|
||||
|
||||
###### Parameters
|
||||
|
||||
- `options` ([`Options`][api-options])
|
||||
— props
|
||||
|
||||
###### Returns
|
||||
|
||||
Promise to a React element (`Promise<ReactElement>`).
|
||||
|
||||
### `MarkdownHooks`
|
||||
|
||||
Component to render markdown with support for async plugins through hooks.
|
||||
|
||||
This uses `useEffect` and `useState` hooks.
|
||||
Hooks run on the client and do not immediately render something.
|
||||
For async support on the server,
|
||||
see [`MarkdownAsync`][api-markdown-async].
|
||||
|
||||
###### Parameters
|
||||
|
||||
- `options` ([`Options`][api-options])
|
||||
— props
|
||||
|
||||
###### Returns
|
||||
|
||||
React node (`ReactNode`).
|
||||
|
||||
### `defaultUrlTransform(url)`
|
||||
|
||||
Make a URL safe.
|
||||
|
||||
This follows how GitHub works.
|
||||
It allows the protocols `http`, `https`, `irc`, `ircs`, `mailto`, and `xmpp`,
|
||||
and URLs relative to the current protocol (such as `/something`).
|
||||
|
||||
###### Parameters
|
||||
|
||||
- `url` (`string`)
|
||||
— URL
|
||||
|
||||
###### Returns
|
||||
|
||||
Safe URL (`string`).
|
||||
|
||||
### `AllowElement`
|
||||
|
||||
Filter elements (TypeScript type).
|
||||
|
||||
###### Parameters
|
||||
|
||||
- `node` ([`Element` from `hast`][github-hast-element])
|
||||
— element to check
|
||||
- `index` (`number | undefined`)
|
||||
— index of `element` in `parent`
|
||||
- `parent` ([`Node` from `hast`][github-hast-nodes])
|
||||
— parent of `element`
|
||||
|
||||
###### Returns
|
||||
|
||||
Whether to allow `element` (`boolean`, optional).
|
||||
|
||||
### `Components`
|
||||
|
||||
Map tag names to components (TypeScript type).
|
||||
|
||||
###### Type
|
||||
|
||||
```ts
|
||||
import type { ExtraProps } from "react-markdown"
|
||||
import type { ComponentProps, ElementType } from "react"
|
||||
|
||||
type Components = {
|
||||
[Key in Extract<ElementType, string>]?: ElementType<ComponentProps<Key> & ExtraProps>
|
||||
}
|
||||
```
|
||||
|
||||
### `ExtraProps`
|
||||
|
||||
Extra fields we pass to components (TypeScript type).
|
||||
|
||||
###### Fields
|
||||
|
||||
- `node` ([`Element` from `hast`][github-hast-element], optional)
|
||||
— original node
|
||||
|
||||
### `HooksOptions`
|
||||
|
||||
Configuration for [`MarkdownHooks`][api-markdown-hooks] (TypeScript type);
|
||||
extends the regular [`Options`][api-options] with a `fallback` prop.
|
||||
|
||||
###### Extends
|
||||
|
||||
[`Options`][api-options].
|
||||
|
||||
###### Fields
|
||||
|
||||
- `fallback` (`ReactNode`, optional)
|
||||
— content to render while the processor processing the markdown
|
||||
|
||||
### `Options`
|
||||
|
||||
Configuration (TypeScript type).
|
||||
|
||||
###### Fields
|
||||
|
||||
- `allowElement` ([`AllowElement`][api-allow-element], optional)
|
||||
— filter elements;
|
||||
`allowedElements` / `disallowedElements` is used first
|
||||
- `allowedElements` (`Array<string>`, default: all tag names)
|
||||
— tag names to allow;
|
||||
cannot combine w/ `disallowedElements`
|
||||
- `children` (`string`, optional)
|
||||
— markdown
|
||||
- `components` ([`Components`][api-components], optional)
|
||||
— map tag names to components
|
||||
- `disallowedElements` (`Array<string>`, default: `[]`)
|
||||
— tag names to disallow;
|
||||
cannot combine w/ `allowedElements`
|
||||
- `rehypePlugins` (`Array<Plugin>`, optional)
|
||||
— list of [rehype plugins][github-rehype-plugins] to use
|
||||
- `remarkPlugins` (`Array<Plugin>`, optional)
|
||||
— list of [remark plugins][github-remark-plugins] to use
|
||||
- `remarkRehypeOptions`
|
||||
([`Options` from `remark-rehype`][github-remark-rehype-options],
|
||||
optional)
|
||||
— options to pass through to `remark-rehype`
|
||||
- `skipHtml` (`boolean`, default: `false`)
|
||||
— ignore HTML in markdown completely
|
||||
- `unwrapDisallowed` (`boolean`, default: `false`)
|
||||
— extract (unwrap) what’s in disallowed elements;
|
||||
normally when say `strong` is not allowed, it and it’s children are dropped,
|
||||
with `unwrapDisallowed` the element itself is replaced by its children
|
||||
- `urlTransform` ([`UrlTransform`][api-url-transform], default:
|
||||
[`defaultUrlTransform`][api-default-url-transform])
|
||||
— change URLs
|
||||
|
||||
### `UrlTransform`
|
||||
|
||||
Transform URLs (TypeScript type).
|
||||
|
||||
###### Parameters
|
||||
|
||||
- `url` (`string`)
|
||||
— URL
|
||||
- `key` (`string`, example: `'href'`)
|
||||
— property name
|
||||
- `node` ([`Element` from `hast`][github-hast-element])
|
||||
— element to check
|
||||
|
||||
###### Returns
|
||||
|
||||
Transformed URL (`string`, optional).
|
||||
|
||||
## Examples
|
||||
|
||||
### Use a plugin
|
||||
|
||||
This example shows how to use a remark plugin.
|
||||
In this case, [`remark-gfm`][github-remark-gfm],
|
||||
which adds support for strikethrough, tables, tasklists and URLs directly:
|
||||
|
||||
```js
|
||||
import React from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import Markdown from "react-markdown"
|
||||
import remarkGfm from "remark-gfm"
|
||||
|
||||
const markdown = `A paragraph with *emphasis* and **strong importance**.
|
||||
|
||||
> A block quote with ~strikethrough~ and a URL: https://reactjs.org.
|
||||
|
||||
* Lists
|
||||
* [ ] todo
|
||||
* [x] done
|
||||
|
||||
A table:
|
||||
|
||||
| a | b |
|
||||
| - | - |
|
||||
`
|
||||
|
||||
createRoot(document.body).render(<Markdown remarkPlugins={[remarkGfm]}>{markdown}</Markdown>)
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Show equivalent JSX</summary>
|
||||
|
||||
```js
|
||||
<>
|
||||
<p>
|
||||
A paragraph with <em>emphasis</em> and <strong>strong importance</strong>.
|
||||
</p>
|
||||
<blockquote>
|
||||
<p>
|
||||
A block quote with <del>strikethrough</del> and a URL:{" "}
|
||||
<a href="https://reactjs.org">https://reactjs.org</a>.
|
||||
</p>
|
||||
</blockquote>
|
||||
<ul className="contains-task-list">
|
||||
<li>Lists</li>
|
||||
<li className="task-list-item">
|
||||
<input type="checkbox" disabled /> todo
|
||||
</li>
|
||||
<li className="task-list-item">
|
||||
<input type="checkbox" disabled checked /> done
|
||||
</li>
|
||||
</ul>
|
||||
<p>A table:</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>a</th>
|
||||
<th>b</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</>
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Use a plugin with options
|
||||
|
||||
This example shows how to use a plugin and give it options.
|
||||
To do that, use an array with the plugin at the first place, and the options
|
||||
second.
|
||||
[`remark-gfm`][github-remark-gfm] has an option to allow only double tildes for
|
||||
strikethrough:
|
||||
|
||||
```js
|
||||
import React from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import Markdown from "react-markdown"
|
||||
import remarkGfm from "remark-gfm"
|
||||
|
||||
const markdown = "This ~is not~ strikethrough, but ~~this is~~!"
|
||||
|
||||
createRoot(document.body).render(
|
||||
<Markdown remarkPlugins={[[remarkGfm, { singleTilde: false }]]}>{markdown}</Markdown>
|
||||
)
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Show equivalent JSX</summary>
|
||||
|
||||
```js
|
||||
<p>
|
||||
This ~is not~ strikethrough, but <del>this is</del>!
|
||||
</p>
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Use custom components (syntax highlight)
|
||||
|
||||
This example shows how you can overwrite the normal handling of an element by
|
||||
passing a component.
|
||||
In this case, we apply syntax highlighting with the seriously super amazing
|
||||
[`react-syntax-highlighter`][github-react-syntax-highlighter] by
|
||||
[**@conorhastings**][github-conorhastings]:
|
||||
|
||||
<!-- To do: currently broken on actual ESM; let’s find an alternative? -->
|
||||
|
||||
```js
|
||||
import React from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import Markdown from "react-markdown"
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"
|
||||
import { dark } from "react-syntax-highlighter/dist/esm/styles/prism"
|
||||
|
||||
// Did you know you can use tildes instead of backticks for code in markdown? ✨
|
||||
const markdown = `Here is some JavaScript code:
|
||||
|
||||
~~~js
|
||||
console.log('It works!')
|
||||
~~~
|
||||
`
|
||||
|
||||
createRoot(document.body).render(
|
||||
<Markdown
|
||||
children={markdown}
|
||||
components={{
|
||||
code(props) {
|
||||
const { children, className, node, ...rest } = props
|
||||
const match = /language-(\w+)/.exec(className || "")
|
||||
return match ? (
|
||||
<SyntaxHighlighter
|
||||
{...rest}
|
||||
PreTag="div"
|
||||
children={String(children).replace(/\n$/, "")}
|
||||
language={match[1]}
|
||||
style={dark}
|
||||
/>
|
||||
) : (
|
||||
<code {...rest} className={className}>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Show equivalent JSX</summary>
|
||||
|
||||
```js
|
||||
<>
|
||||
<p>Here is some JavaScript code:</p>
|
||||
<pre>
|
||||
<SyntaxHighlighter
|
||||
language="js"
|
||||
style={dark}
|
||||
PreTag="div"
|
||||
children="console.log('It works!')"
|
||||
/>
|
||||
</pre>
|
||||
</>
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Use remark and rehype plugins (math)
|
||||
|
||||
This example shows how a syntax extension
|
||||
(through [`remark-math`][github-remark-math])
|
||||
is used to support math in markdown, and a transform plugin
|
||||
([`rehype-katex`][github-rehype-katex]) to render that math.
|
||||
|
||||
```js
|
||||
import React from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import Markdown from "react-markdown"
|
||||
import rehypeKatex from "rehype-katex"
|
||||
import remarkMath from "remark-math"
|
||||
import "katex/dist/katex.min.css" // `rehype-katex` does not import the CSS for you
|
||||
|
||||
const markdown = `The lift coefficient ($C_L$) is a dimensionless coefficient.`
|
||||
|
||||
createRoot(document.body).render(
|
||||
<Markdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>
|
||||
{markdown}
|
||||
</Markdown>
|
||||
)
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Show equivalent JSX</summary>
|
||||
|
||||
```js
|
||||
<p>
|
||||
The lift coefficient (
|
||||
<span className="katex">
|
||||
<span className="katex-mathml">
|
||||
<math xmlns="http://www.w3.org/1998/Math/MathML">{/* … */}</math>
|
||||
</span>
|
||||
<span className="katex-html" aria-hidden="true">
|
||||
{/* … */}
|
||||
</span>
|
||||
</span>
|
||||
) is a dimensionless coefficient.
|
||||
</p>
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Plugins
|
||||
|
||||
We use [unified][github-unified],
|
||||
specifically [remark][github-remark] for markdown and
|
||||
[rehype][github-rehype] for HTML,
|
||||
which are tools to transform content with plugins.
|
||||
Here are three good ways to find plugins:
|
||||
|
||||
- [`awesome-remark`][github-awesome-remark] and
|
||||
[`awesome-rehype`][github-awesome-rehype]
|
||||
— selection of the most awesome projects
|
||||
- [List of remark plugins][github-remark-plugins] and
|
||||
[list of rehype plugins][github-rehype-plugins]
|
||||
— list of all plugins
|
||||
- [`remark-plugin`][github-topic-remark-plugin] and
|
||||
[`rehype-plugin`][github-topic-rehype-plugin] topics
|
||||
— any tagged repo on GitHub
|
||||
|
||||
## Syntax
|
||||
|
||||
`react-markdown` follows CommonMark, which standardizes the differences between
|
||||
markdown implementations, by default.
|
||||
Some syntax extensions are supported through plugins.
|
||||
|
||||
We use [`micromark`][github-micromark] under the hood for our parsing.
|
||||
See its documentation for more information on markdown, CommonMark, and
|
||||
extensions.
|
||||
|
||||
## Compatibility
|
||||
|
||||
Projects maintained by the unified collective are compatible with maintained
|
||||
versions of Node.js.
|
||||
|
||||
When we cut a new major release, we drop support for unmaintained versions of
|
||||
Node.
|
||||
This means we try to keep the current release line, `react-markdown@10`,
|
||||
compatible with Node.js 16.
|
||||
|
||||
They work in all modern browsers (essentially: everything not IE 11).
|
||||
You can use a bundler (such as esbuild, webpack, or Rollup) to use this package
|
||||
in your project, and use its options (or plugins) to add support for legacy
|
||||
browsers.
|
||||
|
||||
## Architecture
|
||||
|
||||
<pre><code> react-markdown
|
||||
+----------------------------------------------------------------------------------------------------------------+
|
||||
| |
|
||||
| +----------+ +----------------+ +---------------+ +----------------+ +------------+ |
|
||||
| | | | | | | | | | | |
|
||||
<a href="https://commonmark.org">markdown</a>-+->+ <a href="https://github.com/remarkjs/remark">remark</a> +-<a href="https://github.com/syntax-tree/mdast">mdast</a>->+ <a href="https://github.com/remarkjs/remark/blob/main/doc/plugins.md">remark plugins</a> +-<a href="https://github.com/syntax-tree/mdast">mdast</a>->+ <a href="https://github.com/remarkjs/remark-rehype">remark-rehype</a> +-<a href="https://github.com/syntax-tree/hast">hast</a>->+ <a href="https://github.com/rehypejs/rehype/blob/main/doc/plugins.md">rehype plugins</a> +-<a href="https://github.com/syntax-tree/hast">hast</a>->+ <a href="#appendix-b-components">components</a> +-+->react elements
|
||||
| | | | | | | | | | | |
|
||||
| +----------+ +----------------+ +---------------+ +----------------+ +------------+ |
|
||||
| |
|
||||
+----------------------------------------------------------------------------------------------------------------+
|
||||
</code></pre>
|
||||
|
||||
To understand what this project does, it’s important to first understand what
|
||||
unified does: please read through the [`unifiedjs/unified`][github-unified]
|
||||
readme
|
||||
(the part until you hit the API section is required reading).
|
||||
|
||||
`react-markdown` is a unified pipeline — wrapped so that most folks don’t need
|
||||
to directly interact with unified.
|
||||
The processor goes through these steps:
|
||||
|
||||
- parse markdown to mdast (markdown syntax tree)
|
||||
- transform through remark (markdown ecosystem)
|
||||
- transform mdast to hast (HTML syntax tree)
|
||||
- transform through rehype (HTML ecosystem)
|
||||
- render hast to React with components
|
||||
|
||||
## Appendix A: HTML in markdown
|
||||
|
||||
`react-markdown` typically escapes HTML (or ignores it, with `skipHtml`)
|
||||
because it is dangerous and defeats the purpose of this library.
|
||||
|
||||
However, if you are in a trusted environment (you trust the markdown), and
|
||||
can spare the bundle size (±60kb minzipped), then you can use
|
||||
[`rehype-raw`][github-rehype-raw]:
|
||||
|
||||
```js
|
||||
import React from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import Markdown from "react-markdown"
|
||||
import rehypeRaw from "rehype-raw"
|
||||
|
||||
const markdown = `<div class="note">
|
||||
|
||||
Some *emphasis* and <strong>strong</strong>!
|
||||
|
||||
</div>`
|
||||
|
||||
createRoot(document.body).render(<Markdown rehypePlugins={[rehypeRaw]}>{markdown}</Markdown>)
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary>Show equivalent JSX</summary>
|
||||
|
||||
```js
|
||||
<div className="note">
|
||||
<p>
|
||||
Some <em>emphasis</em> and <strong>strong</strong>!
|
||||
</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
**Note**: HTML in markdown is still bound by how [HTML works in
|
||||
CommonMark][commonmark-html].
|
||||
Make sure to use blank lines around block-level HTML that again contains
|
||||
markdown!
|
||||
|
||||
## Appendix B: Components
|
||||
|
||||
You can also change the things that come from markdown:
|
||||
|
||||
```js
|
||||
<Markdown
|
||||
components={{
|
||||
// Map `h1` (`# heading`) to use `h2`s.
|
||||
h1: "h2",
|
||||
// Rewrite `em`s (`*like so*`) to `i` with a red foreground color.
|
||||
em(props) {
|
||||
const { node, ...rest } = props
|
||||
return <i style={{ color: "red" }} {...rest} />
|
||||
},
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
The keys in components are HTML equivalents for the things you write with
|
||||
markdown (such as `h1` for `# heading`).
|
||||
Normally, in markdown, those are: `a`, `blockquote`, `br`, `code`, `em`, `h1`,
|
||||
`h2`, `h3`, `h4`, `h5`, `h6`, `hr`, `img`, `li`, `ol`, `p`, `pre`, `strong`, and
|
||||
`ul`.
|
||||
With [`remark-gfm`][github-remark-gfm],
|
||||
you can also use `del`, `input`, `table`, `tbody`, `td`, `th`, `thead`, and `tr`.
|
||||
Other remark or rehype plugins that add support for new constructs will also
|
||||
work with `react-markdown`.
|
||||
|
||||
The props that are passed are what you probably would expect: an `a` (link) will
|
||||
get `href` (and `title`) props, and `img` (image) an `src`, `alt` and `title`,
|
||||
etc.
|
||||
|
||||
Every component will receive a `node`.
|
||||
This is the original [`Element` from `hast`][github-hast-element] element being
|
||||
turned into a React element.
|
||||
|
||||
## Appendix C: line endings in markdown (and JSX)
|
||||
|
||||
You might have trouble with how line endings work in markdown and JSX.
|
||||
We recommend the following, which solves all line ending problems:
|
||||
|
||||
```js
|
||||
// If you write actual markdown in your code, put your markdown in a variable;
|
||||
// **do not indent markdown**:
|
||||
const markdown = `
|
||||
# This is perfect!
|
||||
`
|
||||
|
||||
// Pass the value as an expression as an only child:
|
||||
const result = <Markdown>{markdown}</Markdown>
|
||||
```
|
||||
|
||||
👆 That works.
|
||||
Read on for what doesn’t and why that is.
|
||||
|
||||
You might try to write markdown directly in your JSX and find that it **does
|
||||
not** work:
|
||||
|
||||
```js
|
||||
<Markdown># Hi This is **not** a paragraph.</Markdown>
|
||||
```
|
||||
|
||||
The is because in JSX the whitespace (including line endings) is collapsed to
|
||||
a single space.
|
||||
So the above example is equivalent to:
|
||||
|
||||
```js
|
||||
<Markdown> # Hi This is **not** a paragraph. </Markdown>
|
||||
```
|
||||
|
||||
Instead, to pass markdown to `Markdown`, you can use an expression:
|
||||
with a template literal:
|
||||
|
||||
```js
|
||||
<Markdown>{`
|
||||
# Hi
|
||||
|
||||
This is a paragraph.
|
||||
`}</Markdown>
|
||||
```
|
||||
|
||||
Template literals have another potential problem, because they keep whitespace
|
||||
(including indentation) inside them.
|
||||
That means that the following **does not** turn into a heading:
|
||||
|
||||
```js
|
||||
<Markdown>{`
|
||||
# This is **not** a heading, it’s an indented code block
|
||||
`}</Markdown>
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
Use of `react-markdown` is secure by default.
|
||||
Overwriting `urlTransform` to something insecure will open you up to XSS
|
||||
vectors.
|
||||
Furthermore, the `remarkPlugins`, `rehypePlugins`, and `components` you use may
|
||||
be insecure.
|
||||
|
||||
To make sure the content is completely safe, even after what plugins do,
|
||||
use [`rehype-sanitize`][github-rehype-sanitize].
|
||||
It lets you define your own schema of what is and isn’t allowed.
|
||||
|
||||
## Related
|
||||
|
||||
- [`MDX`][github-mdx]
|
||||
— JSX _in_ markdown
|
||||
- [`remark-gfm`][github-remark-gfm]
|
||||
— add support for GitHub flavored markdown support
|
||||
- [`react-remark`][github-react-remark]
|
||||
— hook based alternative
|
||||
- [`rehype-react`][github-rehype-react]
|
||||
— turn HTML into React elements
|
||||
|
||||
## Contribute
|
||||
|
||||
See [`contributing.md`][health-contributing] in [`remarkjs/.github`][health]
|
||||
for ways to get started.
|
||||
See [`support.md`][health-support] for ways to get help.
|
||||
|
||||
This project has a [code of conduct][health-coc].
|
||||
By interacting with this repository, organization, or community you agree to
|
||||
abide by its terms.
|
||||
|
||||
## License
|
||||
|
||||
[MIT][file-license] © [Espen Hovlandsdal][author]
|
||||
|
||||
[api-allow-element]: #allowelement
|
||||
[api-components]: #components
|
||||
[api-default-url-transform]: #defaulturltransformurl
|
||||
[api-extra-props]: #extraprops
|
||||
[api-hooks-options]: #hooksoptions
|
||||
[api-markdown]: #markdown
|
||||
[api-markdown-async]: #markdownasync
|
||||
[api-markdown-hooks]: #markdownhooks
|
||||
[api-options]: #options
|
||||
[api-url-transform]: #urltransform
|
||||
[author]: https://espen.codes/
|
||||
[badge-build-image]: https://github.com/remarkjs/react-markdown/workflows/main/badge.svg
|
||||
[badge-build-url]: https://github.com/remarkjs/react-markdown/actions
|
||||
[badge-coverage-image]: https://img.shields.io/codecov/c/github/remarkjs/react-markdown.svg
|
||||
[badge-coverage-url]: https://codecov.io/github/remarkjs/react-markdown
|
||||
[badge-downloads-image]: https://img.shields.io/npm/dm/react-markdown.svg
|
||||
[badge-downloads-url]: https://www.npmjs.com/package/react-markdown
|
||||
[badge-size-image]: https://img.shields.io/bundlejs/size/react-markdown
|
||||
[badge-size-url]: https://bundlejs.com/?q=react-markdown
|
||||
[commonmark-help]: https://commonmark.org/help/
|
||||
[commonmark-html]: https://spec.commonmark.org/0.31.2/#html-blocks
|
||||
[esm]: https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c
|
||||
[esmsh]: https://esm.sh
|
||||
[file-license]: license
|
||||
[github-awesome-rehype]: https://github.com/rehypejs/awesome-rehype
|
||||
[github-awesome-remark]: https://github.com/remarkjs/awesome-remark
|
||||
[github-conorhastings]: https://github.com/conorhastings
|
||||
[github-hast-element]: https://github.com/syntax-tree/hast#element
|
||||
[github-hast-nodes]: https://github.com/syntax-tree/hast#nodes
|
||||
[github-io-react-markdown]: https://remarkjs.github.io/react-markdown/
|
||||
[github-mdx]: https://github.com/mdx-js/mdx/
|
||||
[github-micromark]: https://github.com/micromark/micromark
|
||||
[github-react-remark]: https://github.com/remarkjs/react-remark
|
||||
[github-react-syntax-highlighter]: https://github.com/react-syntax-highlighter/react-syntax-highlighter
|
||||
[github-rehype]: https://github.com/rehypejs/rehype
|
||||
[github-rehype-katex]: https://github.com/remarkjs/remark-math/tree/main/packages/rehype-katex
|
||||
[github-rehype-plugins]: https://github.com/rehypejs/rehype/blob/main/doc/plugins.md#list-of-plugins
|
||||
[github-rehype-raw]: https://github.com/rehypejs/rehype-raw
|
||||
[github-rehype-react]: https://github.com/rehypejs/rehype-react
|
||||
[github-rehype-sanitize]: https://github.com/rehypejs/rehype-sanitize
|
||||
[github-remark]: https://github.com/remarkjs/remark
|
||||
[github-remark-gfm]: https://github.com/remarkjs/remark-gfm
|
||||
[github-remark-math]: https://github.com/remarkjs/remark-math
|
||||
[github-remark-plugins]: https://github.com/remarkjs/remark/blob/main/doc/plugins.md#list-of-plugins
|
||||
[github-remark-rehype-options]: https://github.com/remarkjs/remark-rehype#options
|
||||
[github-topic-rehype-plugin]: https://github.com/topics/rehype-plugin
|
||||
[github-topic-remark-plugin]: https://github.com/topics/remark-plugin
|
||||
[github-unified]: https://github.com/unifiedjs/unified
|
||||
[health]: https://github.com/remarkjs/.github
|
||||
[health-coc]: https://github.com/remarkjs/.github/blob/main/code-of-conduct.md
|
||||
[health-contributing]: https://github.com/remarkjs/.github/blob/main/contributing.md
|
||||
[health-support]: https://github.com/remarkjs/.github/blob/main/support.md
|
||||
[npm-install]: https://docs.npmjs.com/cli/install
|
||||
[react]: http://reactjs.org
|
||||
[section-components]: #appendix-b-components
|
||||
[section-plugins]: #plugins
|
||||
[section-security]: #security
|
||||
[section-syntax]: #syntax
|
||||
[typescript]: https://www.typescriptlang.org
|
||||
@@ -0,0 +1,10 @@
|
||||
# React Router route 정리 참고
|
||||
|
||||
출처: https://reactrouter.com/api/hooks/useRoutes
|
||||
|
||||
- `useRoutes(routes)`는 `RouteObject[]`로 route 트리를 만들고 현재 URL과 맞는 element를 반환함.
|
||||
- 부모 route의 `children`은 중첩 UI를 구성하며 부모 element의 `Outlet` 위치에 렌더됨.
|
||||
- 선언한 route와 맞지 않는 URL은 마지막 `path: "*"` route에서 `Navigate`로 기본 화면에 보낼 수 있음.
|
||||
- `Navigate`의 `replace`는 잘못된 URL을 브라우저 방문 기록에 남기지 않을 때 사용함.
|
||||
|
||||
이 프로젝트에서는 `routes.tsx`가 유일한 route 트리이며, `PATHS`가 주소 문자열의 진실원천임.
|
||||
@@ -0,0 +1,18 @@
|
||||
# TanStack Query v5 참조
|
||||
|
||||
출처: https://tanstack.com/query/latest/docs/framework/react/guides/disabling-queries
|
||||
확인일: 2026-09-11
|
||||
|
||||
## enabled와 초기 로딩
|
||||
|
||||
- `enabled: false`이고 캐시가 없으면 `status === "pending"`, `fetchStatus === "idle"`임.
|
||||
- `isLoading`은 `isPending && isFetching`이라 실제 첫 fetch 중일 때만 참임.
|
||||
- 조건이 충족된 뒤 자동 fetch해야 하면 `enabled`에 조건을 넘김.
|
||||
|
||||
```tsx
|
||||
const query = useQuery({
|
||||
queryKey: ["todos", filter],
|
||||
queryFn: () => fetchTodos(filter),
|
||||
enabled: Boolean(filter),
|
||||
})
|
||||
```
|
||||
@@ -0,0 +1,192 @@
|
||||
# @tauri-apps/api v2 — 프론트가 쓸 표면 박제
|
||||
|
||||
**작성**: 2026-08-15 | **근거**: `specs/004-tauri-shell/research.md` R3
|
||||
|
||||
**여기 적힌 건 전부 `node_modules/@tauri-apps/api/` 의 실제 `.d.ts`·`.js` 에서 뽑은 것이다.** 공식 가이드 예제는 옛 버전이 섞여 있어 시그니처 근거로 안 쓴다.
|
||||
|
||||
**박제 기준 버전: 2.11.1** (`package.json` 은 `^2.11.1`)
|
||||
|
||||
우리가 쓰는 건 딱 두 개 — `invoke`(JS→Rust)와 `listen`(Rust→JS). 나머지 모듈(`window`, `menu`, `tray`, `path` …)은 **안 쓴다.** 창·트레이·핫키는 전부 Rust 쪽에서 하고, 프론트는 우리가 만든 `#[tauri::command]` 만 부른다.
|
||||
|
||||
---
|
||||
|
||||
## 1. `invoke` — JS → Rust
|
||||
|
||||
```ts
|
||||
// node_modules/@tauri-apps/api/core.d.ts:127
|
||||
declare function invoke<T>(cmd: string, args?: InvokeArgs, options?: InvokeOptions): Promise<T>
|
||||
|
||||
// core.d.ts:105
|
||||
type InvokeArgs = Record<string, unknown> | number[] | ArrayBuffer | Uint8Array
|
||||
```
|
||||
|
||||
```ts
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
|
||||
await invoke<Snippet[]>("snippets_list")
|
||||
await invoke("paste_code", { text })
|
||||
```
|
||||
|
||||
- **`Promise` 를 돌려준다** → 요청↔응답 짝맞춤이 공짜. `snippetBridge.ts` 의 `reqId`·`pending` Map 이 새 껍데기 경로에선 통째로 필요 없어지는 근거 (research R6)
|
||||
- Rust 커맨드가 `Err(String)` 을 주면 **그 문자열로 reject** 된다 → 한글 오류 메시지가 그대로 `Error` 로 올라와 sonner 토스트까지 흐름 (`contracts/transport-mapping.md` §3)
|
||||
- 인자 이름은 Rust 커맨드의 파라미터 이름과 맞아야 한다 (`paste_code(text: String)` ↔ `{ text }`)
|
||||
|
||||
### 속을 보면
|
||||
|
||||
```js
|
||||
// core.js:201
|
||||
async function invoke(cmd, args = {}, options) {
|
||||
return window.__TAURI_INTERNALS__.invoke(cmd, args, options)
|
||||
}
|
||||
```
|
||||
|
||||
**`window.__TAURI_INTERNALS__.invoke` 를 그대로 부른다.** 이게 아래 호스트 판별에서 중요해진다.
|
||||
|
||||
---
|
||||
|
||||
## 2. `listen` — Rust → JS
|
||||
|
||||
```ts
|
||||
// event.d.ts:87
|
||||
declare function listen<T>(
|
||||
event: EventName,
|
||||
handler: EventCallback<T>,
|
||||
options?: Options
|
||||
): Promise<UnlistenFn>
|
||||
|
||||
// event.d.ts:25,33,34
|
||||
interface Event<T> {
|
||||
event: string
|
||||
id: number
|
||||
payload: T
|
||||
}
|
||||
type EventCallback<T> = (event: Event<T>) => void
|
||||
type UnlistenFn = () => void
|
||||
```
|
||||
|
||||
```ts
|
||||
import { listen } from "@tauri-apps/api/event"
|
||||
|
||||
const unlisten = await listen<BridgeMessage>("bridge", (e) => {
|
||||
handle(e.payload) // ← 페이로드는 e.payload 안에 있다
|
||||
})
|
||||
```
|
||||
|
||||
### ⚠️ 함정 2개
|
||||
|
||||
**(1) 페이로드가 한 겹 더 들어있다.** WebView2 는 `event.data` 가 곧 메시지인데, Tauri 는 `Event<T>` 로 감싸서 `event.payload` 안에 있다. `bridgeNavigate.ts` 의 분기 본문을 그대로 재사용하려면 **transport 층에서 `e.payload` 를 벗겨서** 넘겨야 한다 (research R5 가 노린 "분기 로직 무변경"이 이 한 줄에 달림).
|
||||
|
||||
**(2) `listen` 은 async 다.** `Promise<UnlistenFn>` 을 돌려준다.
|
||||
|
||||
지금 `bridgeNavigate.ts` 의 `initBridgeNavigate()` 는 **동기 함수**고, WebView2 의 `addEventListener` 는 즉시 붙는다. Tauri 경로는 그렇지 않아서:
|
||||
|
||||
- `initBridgeNavigate()` 를 async 로 바꾸면 **호출부(앱 루트)가 바뀐다** → SC-001("통로 파일 바깥 변경 0줄") 위반
|
||||
- 그래서 **transport 안에서 Promise 를 삼키고**(fire-and-forget) 밖에는 동기 시그니처를 유지하는 쪽이 맞다
|
||||
- 대신 **리스너 붙기 전에 도착한 푸시는 놓친다.** 다행히 이 코드에는 이미 대비가 있다 — `lastPasteTarget` 스냅샷과 `pendingCaptureImage` 의 read-once 소비가 마운트 레이스를 막으려고 들어간 것이라, 같은 장치가 여기서도 먹는다
|
||||
|
||||
관련: `once`(1회), `emit`/`emitTo`(JS→Rust 이벤트) 도 있지만 **우리는 안 쓴다.** 계약상 JS→Rust 는 전부 `invoke` (transport-mapping §1).
|
||||
|
||||
---
|
||||
|
||||
## 3. 호스트 판별 — `isTauri()` 를 쓰지 말 것
|
||||
|
||||
패키지가 공식 함수를 하나 주긴 한다:
|
||||
|
||||
```js
|
||||
// core.js:278
|
||||
function isTauri() {
|
||||
return !!(globalThis || window).isTauri
|
||||
}
|
||||
```
|
||||
|
||||
**이건 `window.isTauri` 라는 별개 플래그를 볼 뿐, `invoke` 가 실제로 쓰는 `__TAURI_INTERNALS__` 와 다르다.**
|
||||
|
||||
research R4 가 정한 `__TAURI_INTERNALS__` 기준이 맞다 — `invoke` 가 바로 그걸 부르므로, **"invoke 를 부를 수 있는가"를 직접 재는 셈**이라 한 단계 더 정확하다.
|
||||
|
||||
### 그런데 `"__TAURI_INTERNALS__" in window` 도 부족하다 ⚠️
|
||||
|
||||
테스트용 `clearMocks()` 를 뜯어보면:
|
||||
|
||||
```js
|
||||
// mocks.js:4 — mockIPC 가 부르는 것
|
||||
function mockInternals() {
|
||||
window.__TAURI_INTERNALS__ = window.__TAURI_INTERNALS__ ?? {}
|
||||
window.__TAURI_EVENT_PLUGIN_INTERNALS__ = window.__TAURI_EVENT_PLUGIN_INTERNALS__ ?? {}
|
||||
}
|
||||
|
||||
// mocks.js:267 — clearMocks
|
||||
function clearMocks() {
|
||||
if (typeof window.__TAURI_INTERNALS__ !== "object") return
|
||||
delete window.__TAURI_INTERNALS__.invoke
|
||||
delete window.__TAURI_INTERNALS__.transformCallback
|
||||
// ... 속성만 지운다. window.__TAURI_INTERNALS__ 객체 자체는 안 지움
|
||||
}
|
||||
```
|
||||
|
||||
**`clearMocks()` 는 속성만 지우고 빈 객체를 남긴다.** 그래서:
|
||||
|
||||
| 판별식 | mockIPC 후 | clearMocks 후 | 판정 |
|
||||
| ---------------------------------------------------------- | ---------- | ---------------- | ----------------------------------------- |
|
||||
| `"__TAURI_INTERNALS__" in window` | true | **true (틀림)** | ❌ 브라우저 모드 테스트가 tauri 로 오판됨 |
|
||||
| `typeof window.__TAURI_INTERNALS__?.invoke === "function"` | true | **false (맞음)** | ✅ |
|
||||
|
||||
`in` 으로 재면 "브라우저에서는 조용히 no-op / 즉시 reject"(FR-003) 테스트가 **tauri 경로를 타서 `__TAURI_INTERNALS__.invoke is not a function` 으로 터진다.** 우리가 의도한 한글 "데스크톱 전용" reject 가 아니라 엉뚱한 TypeError.
|
||||
|
||||
**결론: `.invoke` 가 함수인지로 판별한다.** 실제 앱에서도 Tauri 가 앱 스크립트보다 먼저 내부 객체를 통째로 주입하므로 안전하고, 의미도 더 정확하다("통로가 있나"가 아니라 "통로가 작동하나").
|
||||
|
||||
---
|
||||
|
||||
## 4. 테스트 — 공식 mock 이 있다
|
||||
|
||||
```ts
|
||||
// mocks.d.ts
|
||||
export declare function mockIPC(
|
||||
cb: (cmd: string, payload?: InvokeArgs) => unknown,
|
||||
options?: MockIPCOptions
|
||||
): void
|
||||
export declare function clearMocks(): void
|
||||
export declare function mockWindows(current: string, ..._additionalWindows: string[]): void
|
||||
export declare function mockConvertFileSrc(osName: string): void
|
||||
|
||||
export interface MockIPCOptions {
|
||||
shouldMockEvents?: boolean
|
||||
} // 2.7.0+
|
||||
```
|
||||
|
||||
```ts
|
||||
import { mockIPC, clearMocks } from "@tauri-apps/api/mocks"
|
||||
|
||||
afterEach(() => clearMocks())
|
||||
|
||||
it("snippets_list 를 부른다", async () => {
|
||||
mockIPC((cmd) => (cmd === "snippets_list" ? [] : undefined))
|
||||
await expect(snippetsApi.list()).resolves.toEqual([])
|
||||
})
|
||||
```
|
||||
|
||||
**`shouldMockEvents: true` 를 주면 `listen`/`emit` 도 mock 된다** — Rust 푸시(`emit("bridge", ...)`)를 흉내내 `bridgeNavigate` 분기를 테스트할 수 있다.
|
||||
|
||||
```ts
|
||||
mockIPC(() => {}, { shouldMockEvents: true })
|
||||
// 이제 emit('bridge', {...}) 하면 listen 핸들러가 불림
|
||||
```
|
||||
|
||||
> ⚠️ 주의: `plugin:event|` 로 시작하는 invoke 를 **전부 가로챈다**(mocks.js:89). `shouldMockEvents` 를 켠 테스트에서는 이벤트 관련 invoke 가 우리 mock 콜백에 안 온다.
|
||||
|
||||
### 세 호스트 테스트할 때 (T012)
|
||||
|
||||
`mockIPC` 는 **테스트 본문에서 런타임에** `window.__TAURI_INTERNALS__` 를 심는다. 기존 브릿지 테스트가 `window.chrome` 을 런타임에 심는 것과 똑같은 방식이다.
|
||||
|
||||
→ **transport 가 모듈 로드 시점에 호스트를 상수로 굳히면 이 mock 들이 전부 안 먹는다.** research R4 의 "한 번 판별하고 굳힌다"를 문자 그대로 구현하면 테스트 3개 + 신규 테스트가 다 깨진다. 판별 시점을 **최초 사용 시 1회**로 하거나, 굳히되 테스트용 재판별 훅을 같이 내야 한다 (`specs/004-tauri-shell/tasks.md` T015 가 이 결정을 먼저 하라고 박아둔 이유).
|
||||
|
||||
`clearMocks()` 를 쓸 거면 위 §3 의 `.invoke` 판별식이 **필수**다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 안 쓰는 것
|
||||
|
||||
패키지에 이만큼 더 있지만 이 기능에서는 **안 쓴다** — 창·트레이·핫키·경로는 전부 Rust 담당이고, 프론트는 우리 커맨드만 부른다 (`contracts/transport-mapping.md`).
|
||||
|
||||
`window`, `webviewWindow`, `webview`, `menu`, `tray`, `path`, `app`, `dpi`, `image`
|
||||
|
||||
혹시 쓰게 되면 **`capabilities/default.json` 에 권한을 추가해야 한다.** 지금은 `core:default` 하나뿐이라 그것들은 **런타임에 조용히 거부**된다 (컴파일 에러 안 남). 자세한 건 `4_rust_tauri/docs-lib/tauri-v2.md` §9.
|
||||
@@ -0,0 +1,53 @@
|
||||
import js from "@eslint/js"
|
||||
import globals from "globals"
|
||||
import reactHooks from "eslint-plugin-react-hooks"
|
||||
import reactRefresh from "eslint-plugin-react-refresh"
|
||||
import jsxA11y from "eslint-plugin-jsx-a11y"
|
||||
import tseslint from "typescript-eslint"
|
||||
import prettierConfig from "eslint-config-prettier"
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist", "dist-tsbuild", "node_modules", "coverage"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: { ecmaVersion: 2022, globals: globals.browser },
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
"jsx-a11y": jsxA11y,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
...jsxA11y.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.test.{ts,tsx}", "**/*.spec.{ts,tsx}"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
// shadcn/ui generated components — third-party patterns, a11y rules relaxed
|
||||
files: ["src/shared/ui/**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
"jsx-a11y/heading-has-content": "off",
|
||||
"jsx-a11y/click-events-have-key-events": "off",
|
||||
"jsx-a11y/no-noninteractive-element-interactions": "off",
|
||||
"jsx-a11y/no-static-element-interactions": "off",
|
||||
"react-refresh/only-export-components": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
// TanStack Table column defs — meta cell renderers can have click handlers
|
||||
files: ["src/features/**/components/*Columns.tsx"],
|
||||
rules: {
|
||||
"jsx-a11y/click-events-have-key-events": "off",
|
||||
"jsx-a11y/no-static-element-interactions": "off",
|
||||
},
|
||||
},
|
||||
prettierConfig
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Frontend Template</title>
|
||||
<script>
|
||||
// FOUC 방지 — React 마운트 전 [data-theme] + .dark 미리 박음.
|
||||
// 팔레트는 localStorage 'theme' 키, 다크모드는 'theme-store' (zustand persist).
|
||||
;(function () {
|
||||
try {
|
||||
var pal = localStorage.getItem("theme") || "clean-blue"
|
||||
// 이전 기본 베이지는 한 번만 교체. 이후 직접 고른 팔레트는 유지.
|
||||
if (!localStorage.getItem("clean-blue-default-v1")) {
|
||||
if (pal === "warm-tan") {
|
||||
pal = "clean-blue"
|
||||
localStorage.setItem("theme", pal)
|
||||
}
|
||||
localStorage.setItem("clean-blue-default-v1", "1")
|
||||
}
|
||||
document.documentElement.setAttribute("data-theme", pal)
|
||||
var raw = localStorage.getItem("theme-store")
|
||||
var mode = "light"
|
||||
if (raw) {
|
||||
try {
|
||||
mode = (JSON.parse(raw).state || {}).theme || "light"
|
||||
} catch (e) {}
|
||||
}
|
||||
var dark =
|
||||
mode === "dark" ||
|
||||
(mode === "system" &&
|
||||
window.matchMedia &&
|
||||
matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
if (dark) document.documentElement.classList.add("dark")
|
||||
document.documentElement.style.colorScheme = dark ? "dark" : "light"
|
||||
} catch (e) {}
|
||||
})()
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+10694
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"name": "frontend-template",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"e2e": "playwright test",
|
||||
"e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^3.30.0",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@microsoft/fetch-event-source": "^2.0.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"@tauri-apps/api": "^2.11.1",
|
||||
"axios": "^1.16.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"idb-keyval": "^6.3.0",
|
||||
"lucide-react": "^0.453.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.53.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^2.5.4",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tailwindcss/postcss": "^4.2.4",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^22.7.5",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"axios-mock-adapter": "^2.1.0",
|
||||
"eslint": "^9.12.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.0",
|
||||
"eslint-plugin-react": "^7.37.1",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.12",
|
||||
"globals": "^15.11.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"msw": "^2.14.3",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.8",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.8.1",
|
||||
"vite": "^5.4.8",
|
||||
"vitest": "^2.1.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig, devices } from "@playwright/test"
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 5173)
|
||||
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${PORT}`
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: process.env.CI ? "github" : "list",
|
||||
use: {
|
||||
baseURL: BASE_URL,
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
webServer: {
|
||||
command: "npm run dev",
|
||||
url: BASE_URL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
})
|
||||
Generated
+9101
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#0f172a"/>
|
||||
<text x="50%" y="50%" dy="0.35em" text-anchor="middle"
|
||||
font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif"
|
||||
font-weight="700" font-size="36" fill="#38bdf8">F</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 344 B |
@@ -0,0 +1,31 @@
|
||||
import { useEffect } from "react"
|
||||
import { useLocation, useNavigate, useRoutes } from "react-router-dom"
|
||||
import { routes } from "./routes"
|
||||
import { ErrorBoundary } from "./shared/components/ErrorBoundary"
|
||||
import { initBridgeNavigate, setBridgeNavigate } from "@/lib/bridge/bridgeNavigate"
|
||||
import { reportRoute } from "@/lib/bridge/webviewBridge"
|
||||
import { DesktopWindowFrame } from "@/shared/components/DesktopWindowFrame"
|
||||
|
||||
export default function App() {
|
||||
const element = useRoutes(routes)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
// C#가 navigate 푸시(예: Ctrl+Shift+7 → /snippet)를 보내면 이 콜백으로 라우팅.
|
||||
useEffect(() => {
|
||||
setBridgeNavigate(navigate)
|
||||
initBridgeNavigate()
|
||||
return () => setBridgeNavigate(null)
|
||||
}, [navigate])
|
||||
|
||||
// route 바뀔 때마다 호스트에 보고 — C#가 마지막 챗봇 위치 기억(Ctrl+Shift+8 복귀용).
|
||||
useEffect(() => {
|
||||
reportRoute(location.pathname)
|
||||
}, [location.pathname])
|
||||
|
||||
return (
|
||||
<DesktopWindowFrame>
|
||||
<ErrorBoundary>{element}</ErrorBoundary>
|
||||
</DesktopWindowFrame>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { parseEnv } from "./env"
|
||||
|
||||
describe("parseEnv", () => {
|
||||
it("VITE_API_BASE_URL 필수", () => {
|
||||
expect(() => parseEnv({})).toThrow()
|
||||
})
|
||||
|
||||
it("올바른 풀 URL 통과", () => {
|
||||
const env = parseEnv({ VITE_API_BASE_URL: "http://localhost:8001/api" })
|
||||
expect(env.apiBaseUrl).toBe("http://localhost:8001/api")
|
||||
})
|
||||
|
||||
it("'/'로 시작하는 상대경로 통과 (vite proxy 전제)", () => {
|
||||
const env = parseEnv({ VITE_API_BASE_URL: "/api/v1" })
|
||||
expect(env.apiBaseUrl).toBe("/api/v1")
|
||||
})
|
||||
|
||||
it("URL도 '/' 시작도 아니면 실패", () => {
|
||||
expect(() => parseEnv({ VITE_API_BASE_URL: "not-a-url" })).toThrow()
|
||||
})
|
||||
|
||||
it("앞 '/' 없는 상대경로 실패", () => {
|
||||
expect(() => parseEnv({ VITE_API_BASE_URL: "api/v1" })).toThrow()
|
||||
})
|
||||
|
||||
it("빈 문자열 실패", () => {
|
||||
expect(() => parseEnv({ VITE_API_BASE_URL: "" })).toThrow()
|
||||
})
|
||||
|
||||
it("VITE_ENTRA_GRAPH_SCOPE 미지정 시 'User.Read' 기본값", () => {
|
||||
const env = parseEnv({ VITE_API_BASE_URL: "/api/v1" })
|
||||
expect(env.entraGraphScope).toBe("User.Read")
|
||||
})
|
||||
|
||||
it("VITE_ENTRA_GRAPH_SCOPE 커스텀 값 통과", () => {
|
||||
const env = parseEnv({
|
||||
VITE_API_BASE_URL: "/api/v1",
|
||||
VITE_ENTRA_GRAPH_SCOPE: "User.ReadBasic.All",
|
||||
})
|
||||
expect(env.entraGraphScope).toBe("User.ReadBasic.All")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { z } from "zod"
|
||||
|
||||
// dev/prd 모두 single-origin 전제라 '/api/v1' 같은 상대경로가 기본.
|
||||
// 다른 origin 직접 호출 시나리오만 풀 URL 허용.
|
||||
const envSchema = z.object({
|
||||
VITE_API_BASE_URL: z.union([
|
||||
z.string().url(),
|
||||
z.string().regex(/^\/[^\s]*$/, "must be absolute URL or path starting with '/'"),
|
||||
]),
|
||||
VITE_ENTRA_GRAPH_SCOPE: z.string().min(1).default("User.Read"),
|
||||
})
|
||||
|
||||
export interface AppEnv {
|
||||
apiBaseUrl: string
|
||||
entraGraphScope: string
|
||||
}
|
||||
|
||||
export function parseEnv(raw: Record<string, unknown>): AppEnv {
|
||||
const parsed = envSchema.parse(raw)
|
||||
return {
|
||||
apiBaseUrl: parsed.VITE_API_BASE_URL,
|
||||
entraGraphScope: parsed.VITE_ENTRA_GRAPH_SCOPE,
|
||||
}
|
||||
}
|
||||
|
||||
export const env: AppEnv = parseEnv(import.meta.env)
|
||||
@@ -0,0 +1,10 @@
|
||||
export const PATHS = {
|
||||
HOME: "/snap",
|
||||
LOGIN: "/login",
|
||||
SNAP: "/snap",
|
||||
SNAP_NEW: "/snap/new",
|
||||
SNAP_SESSION: "/snap/s/:id",
|
||||
SNIPPET: "/snippet",
|
||||
} as const
|
||||
|
||||
export type Path = (typeof PATHS)[keyof typeof PATHS]
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest"
|
||||
import MockAdapter from "axios-mock-adapter"
|
||||
import { authApi } from "./auth.api"
|
||||
import { apiClient } from "@/lib/api/client"
|
||||
|
||||
const fakeToken = {
|
||||
token: "a",
|
||||
tokenExpirationTime: 0,
|
||||
refreshToken: "r",
|
||||
refreshTokenExpirationTime: 0,
|
||||
tokenType: "bearer",
|
||||
user: { id: "u", email: "x@x.com", userName: null, role: "USER" as const },
|
||||
}
|
||||
|
||||
function envelope<T>(data: T) {
|
||||
return {
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
code: null,
|
||||
message: null,
|
||||
data,
|
||||
counts: null,
|
||||
errors: [] as string[],
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
meta: null,
|
||||
}
|
||||
}
|
||||
|
||||
let mock: MockAdapter
|
||||
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(apiClient)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
describe("authApi", () => {
|
||||
it("login POST /auth/login → user 반환 (token은 쿠키로 처리)", async () => {
|
||||
let body: unknown
|
||||
mock.onPost("/auth/login").reply((config) => {
|
||||
body = JSON.parse(config.data as string)
|
||||
return [200, envelope(fakeToken)]
|
||||
})
|
||||
|
||||
const user = await authApi.login({ email: "x@x.com", password: "abcd" })
|
||||
expect(user).toEqual(fakeToken.user)
|
||||
expect(body).toEqual({ email: "x@x.com", password: "abcd" })
|
||||
})
|
||||
|
||||
it("refresh POST /auth/refresh — body 없음", async () => {
|
||||
let body: unknown
|
||||
mock.onPost("/auth/refresh").reply((config) => {
|
||||
body = config.data
|
||||
return [200, envelope(fakeToken)]
|
||||
})
|
||||
await authApi.refresh()
|
||||
expect(body).toBeUndefined()
|
||||
})
|
||||
|
||||
it("logout POST /auth/logout — __skipAuth로 401에서도 인터셉터 우회", async () => {
|
||||
let called = false
|
||||
mock.onPost("/auth/logout").reply(() => {
|
||||
called = true
|
||||
return [200, envelope(null)]
|
||||
})
|
||||
await authApi.logout()
|
||||
expect(called).toBe(true)
|
||||
})
|
||||
|
||||
it("getMe GET /users/me", async () => {
|
||||
mock.onGet("/users/me").reply(200, envelope({ ...fakeToken.user, isActive: true }))
|
||||
const me = await authApi.getMe()
|
||||
expect(me.email).toBe("x@x.com")
|
||||
})
|
||||
})
|
||||
|
||||
describe("entraLogin", () => {
|
||||
it("POST /auth/entra/login 후 응답 user를 반환", async () => {
|
||||
const user = {
|
||||
id: "u1",
|
||||
email: "a@b.com",
|
||||
userName: "A",
|
||||
role: "USER" as const,
|
||||
employeeId: "EMP1",
|
||||
department: "IT",
|
||||
authProvider: "entra" as const,
|
||||
}
|
||||
mock.onPost("/auth/entra/login").reply(200, {
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
code: null,
|
||||
message: null,
|
||||
data: {
|
||||
token: "t",
|
||||
tokenExpirationTime: 1,
|
||||
refreshToken: "r",
|
||||
refreshTokenExpirationTime: 2,
|
||||
tokenType: "bearer",
|
||||
user,
|
||||
},
|
||||
counts: null,
|
||||
errors: [],
|
||||
timestamp: "",
|
||||
meta: null,
|
||||
})
|
||||
const result = await authApi.entraLogin({ idToken: "ID", graphAccessToken: "G" })
|
||||
expect(result).toEqual(user)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getEntraConfig", () => {
|
||||
it("200 응답 → config 반환", async () => {
|
||||
const cfg = { clientId: "c", authority: "https://...", tenantId: "t" }
|
||||
mock.onGet("/auth/entra/config").reply(200, {
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
code: null,
|
||||
message: null,
|
||||
data: cfg,
|
||||
counts: null,
|
||||
errors: [],
|
||||
timestamp: "",
|
||||
meta: null,
|
||||
})
|
||||
const result = await authApi.getEntraConfig()
|
||||
expect(result).toEqual(cfg)
|
||||
})
|
||||
|
||||
it("501 응답 → null 반환 (throw 안 함)", async () => {
|
||||
mock.onGet("/auth/entra/config").reply(501, {
|
||||
success: false,
|
||||
statusCode: 501,
|
||||
code: "ENTRA_NOT_CONFIGURED",
|
||||
message: "Entra ID SSO 가 설정되지 않았습니다.",
|
||||
data: null,
|
||||
counts: null,
|
||||
errors: [],
|
||||
timestamp: "",
|
||||
meta: null,
|
||||
})
|
||||
const result = await authApi.getEntraConfig()
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("500 응답 → throw", async () => {
|
||||
mock.onGet("/auth/entra/config").reply(500)
|
||||
await expect(authApi.getEntraConfig()).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { apiPost, apiGet, type CallerConfig } from "@/lib/api/client"
|
||||
import { ApiError } from "@/lib/api/errors"
|
||||
import type {
|
||||
LoginRequest,
|
||||
TokenResponse,
|
||||
UserResponse,
|
||||
UserPayload,
|
||||
EntraLoginRequest,
|
||||
EntraConfigResponse,
|
||||
} from "@/types/api"
|
||||
|
||||
const SKIP_AUTH: CallerConfig = { __skipAuth: true }
|
||||
|
||||
/**
|
||||
* 인증 API. 모두 axios apiClient 사용 (쿠키 자동 전송).
|
||||
*
|
||||
* - login: 응답 body의 user만 사용 (토큰은 Set-Cookie로 처리됨)
|
||||
* - refresh: 쿠키만으로 진행 (body 없음). 호출자는 결과 신경 X — interceptor에서 자동 처리
|
||||
* - logout: 인증 불필요. 쿠키 삭제 + 클라 store도 함께 비우기
|
||||
* - getMe: 마운트 시 서버 진실값으로 user 복원
|
||||
*/
|
||||
export const authApi = {
|
||||
/**
|
||||
* __skipAuth: 로그인 전이므로 401 응답 시 refresh 인터셉터가 개입하면 안 됨.
|
||||
* 잘못된 credentials → 백엔드 401 → 인터셉터 refresh 시도 → sessionExpiry 모달 오발사 방지.
|
||||
*/
|
||||
login: async (req: LoginRequest): Promise<UserPayload> => {
|
||||
const tokens = await apiPost<TokenResponse>("/auth/login", req, SKIP_AUTH)
|
||||
return tokens.user
|
||||
},
|
||||
|
||||
/**
|
||||
* refresh — 쿠키 기반. 인터셉터의 자동 refresh와 별개로 명시적 호출용(sliding refresh 등).
|
||||
* 실패 시 ApiError throw. 성공 시 새 쿠키가 Set-Cookie로 갱신됨.
|
||||
*/
|
||||
refresh: async (): Promise<void> => {
|
||||
await apiPost<TokenResponse>("/auth/refresh", undefined)
|
||||
},
|
||||
|
||||
/** 인증 불필요 (`__skipAuth`) — 401에서 모달 안 뜨도록 우회. */
|
||||
logout: () => apiPost<null>("/auth/logout", undefined, SKIP_AUTH),
|
||||
|
||||
getMe: () => apiGet<UserResponse>("/users/me", { __skipSessionExpiry: true }),
|
||||
|
||||
/**
|
||||
* __skipAuth: ENTRA_TOKEN_INVALID(401) 응답 시 refresh 인터셉터가 개입하면 안 됨.
|
||||
* Microsoft 토큰 검증 실패 → 백엔드 401 → 인터셉터 refresh → sessionExpiry 모달 오발사 방지.
|
||||
*/
|
||||
entraLogin: async (req: EntraLoginRequest): Promise<UserPayload> => {
|
||||
const tokens = await apiPost<TokenResponse>("/auth/entra/login", req, SKIP_AUTH)
|
||||
return tokens.user
|
||||
},
|
||||
|
||||
getEntraConfig: async (): Promise<EntraConfigResponse | null> => {
|
||||
try {
|
||||
return await apiGet<EntraConfigResponse>("/auth/entra/config", SKIP_AUTH)
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 501) return null
|
||||
throw e
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { AuthError } from "@azure/msal-browser"
|
||||
import { ApiError } from "@/lib/api/errors"
|
||||
import { PATHS } from "@/config/routes"
|
||||
import EntraLoginButton from "./EntraLoginButton"
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { error: vi.fn() } }))
|
||||
|
||||
const navigateMock = vi.fn()
|
||||
const invalidateQueries = vi.fn()
|
||||
vi.mock("react-router-dom", async (orig) => {
|
||||
const m: any = await orig()
|
||||
return { ...m, useNavigate: () => navigateMock }
|
||||
})
|
||||
|
||||
const mutate = vi.fn()
|
||||
let mutationState = { isPending: false }
|
||||
vi.mock("../hooks/useEntraLogin", () => ({
|
||||
useEntraLogin: () => ({
|
||||
mutate,
|
||||
get isPending() {
|
||||
return mutationState.isPending
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("@tanstack/react-query", async (orig) => {
|
||||
const m: any = await orig()
|
||||
return { ...m, useQueryClient: () => ({ invalidateQueries }) }
|
||||
})
|
||||
|
||||
function renderBtn(search = "") {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter initialEntries={[`/login${search}`]}>
|
||||
<EntraLoginButton />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mutationState = { isPending: false }
|
||||
})
|
||||
|
||||
describe("EntraLoginButton", () => {
|
||||
it("버튼 렌더 + 텍스트", () => {
|
||||
renderBtn()
|
||||
expect(screen.getByRole("button", { name: /Microsoft로 로그인/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("isPending 동안 disabled + '로그인 중...'", () => {
|
||||
mutationState.isPending = true
|
||||
renderBtn()
|
||||
const btn = screen.getByRole("button")
|
||||
expect(btn).toBeDisabled()
|
||||
expect(btn).toHaveTextContent("로그인 중...")
|
||||
})
|
||||
|
||||
it("클릭 → mutate 호출 (from 정상 → onSuccess에서 navigate)", async () => {
|
||||
renderBtn("?from=/snap/new")
|
||||
await userEvent.click(screen.getByRole("button"))
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
|
||||
// onSuccess 콜백 실행 시뮬레이션
|
||||
const [, opts] = mutate.mock.calls[0]
|
||||
opts.onSuccess({ id: "u" })
|
||||
expect(navigateMock).toHaveBeenCalledWith(PATHS.SNAP_NEW, { replace: true })
|
||||
})
|
||||
|
||||
it("from=외부 도메인 → navigate PATHS.SNAP (safeRedirectPath 보호)", async () => {
|
||||
renderBtn("?from=//evil.com")
|
||||
await userEvent.click(screen.getByRole("button"))
|
||||
const [, opts] = mutate.mock.calls[0]
|
||||
opts.onSuccess({ id: "u" })
|
||||
expect(navigateMock).toHaveBeenCalledWith(PATHS.SNAP, { replace: true })
|
||||
})
|
||||
|
||||
it("onError: user_cancelled → toast 안 띄움", async () => {
|
||||
renderBtn()
|
||||
await userEvent.click(screen.getByRole("button"))
|
||||
const [, opts] = mutate.mock.calls[0]
|
||||
opts.onError({ errorCode: "user_cancelled" })
|
||||
expect(toast.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("onError: popup_window_error → 팝업 차단 토스트", async () => {
|
||||
renderBtn()
|
||||
await userEvent.click(screen.getByRole("button"))
|
||||
const [, opts] = mutate.mock.calls[0]
|
||||
opts.onError(new AuthError("popup_window_error"))
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/팝업/))
|
||||
})
|
||||
|
||||
it("onError: ApiError 401 → 인증 실패 안내 토스트", async () => {
|
||||
renderBtn()
|
||||
await userEvent.click(screen.getByRole("button"))
|
||||
const [, opts] = mutate.mock.calls[0]
|
||||
const err = new ApiError(401, "Microsoft 인증 실패", [], "AUTH_FAIL")
|
||||
opts.onError(err)
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/Microsoft 인증에 실패/))
|
||||
expect(invalidateQueries).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("onError: ApiError 501 → invalidateQueries + 비활성 안내 토스트", async () => {
|
||||
renderBtn()
|
||||
await userEvent.click(screen.getByRole("button"))
|
||||
const [, opts] = mutate.mock.calls[0]
|
||||
const err = new ApiError(501, "Entra 비활성", [], "ENTRA_NOT_CONFIGURED")
|
||||
opts.onError(err)
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["entra-config"] })
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/비활성화/))
|
||||
})
|
||||
|
||||
it("onError: 모르는 에러 → '로그인 오류: ...' 토스트", async () => {
|
||||
renderBtn()
|
||||
await userEvent.click(screen.getByRole("button"))
|
||||
const [, opts] = mutate.mock.calls[0]
|
||||
opts.onError(new Error("???"))
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/로그인 오류.*\?{3}/))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useNavigate, useSearchParams } from "react-router-dom"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { AuthError } from "@azure/msal-browser"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
import { ApiError } from "@/lib/api/errors"
|
||||
import { safeRedirectPath } from "../utils/safeRedirectPath"
|
||||
import { useEntraLogin } from "../hooks/useEntraLogin"
|
||||
|
||||
export default function EntraLoginButton() {
|
||||
const login = useEntraLogin()
|
||||
const navigate = useNavigate()
|
||||
const [params] = useSearchParams()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const onClick = () => {
|
||||
const from = safeRedirectPath(params.get("from"))
|
||||
login.mutate(undefined, {
|
||||
onSuccess: () => navigate(from, { replace: true }),
|
||||
onError: (err: unknown) => {
|
||||
console.error("[EntraLogin] error:", err)
|
||||
|
||||
// ── MSAL 팝업/브라우저 에러 ──────────────────────────
|
||||
if (err instanceof AuthError) {
|
||||
const code = err.errorCode
|
||||
if (code === "user_cancelled") return
|
||||
if (code === "popup_window_error") {
|
||||
toast.error("팝업이 차단되었습니다. 브라우저 설정을 확인하십시오.")
|
||||
return
|
||||
}
|
||||
if (code === "interaction_in_progress") {
|
||||
toast.error("로그인이 진행 중입니다. 잠시 후 다시 시도하십시오.")
|
||||
return
|
||||
}
|
||||
if (code === "monitor_window_timeout" || code === "empty_window_error") {
|
||||
toast.error("팝업 창이 닫혔습니다. 다시 시도하십시오.")
|
||||
return
|
||||
}
|
||||
toast.error(`Microsoft 인증 오류: ${err.message}`)
|
||||
return
|
||||
}
|
||||
|
||||
// ── 구형 방식(errorCode 프로퍼티만 있는 객체) 호환 ──
|
||||
const legacyCode = (err as { errorCode?: string } | null)?.errorCode
|
||||
if (legacyCode === "user_cancelled") return
|
||||
|
||||
// ── 백엔드 API 에러 ──────────────────────────────────
|
||||
if (err instanceof ApiError) {
|
||||
if (err.status === 501) {
|
||||
queryClient.invalidateQueries({ queryKey: ["entra-config"] })
|
||||
toast.error("Microsoft 로그인이 서버에서 비활성화되어 있습니다.")
|
||||
return
|
||||
}
|
||||
if (err.status === 401) {
|
||||
toast.error("Microsoft 인증에 실패했습니다. 다시 시도하십시오.")
|
||||
return
|
||||
}
|
||||
toast.error(err.message || "Microsoft 로그인 실패")
|
||||
return
|
||||
}
|
||||
|
||||
// ── 네트워크 또는 예상치 못한 에러 ──────────────────
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
toast.error(`로그인 오류: ${msg}`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onClick}
|
||||
disabled={login.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{login.isPending ? "로그인 중..." : "Microsoft로 로그인"}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import EntraLoginSection from "./EntraLoginSection"
|
||||
|
||||
let mockState = { enabled: false, isLoading: false }
|
||||
vi.mock("../hooks/useEntraEnabled", () => ({
|
||||
useEntraEnabled: () => mockState,
|
||||
}))
|
||||
vi.mock("./EntraLoginButton", () => ({
|
||||
default: () => <button data-testid="entra-btn">stub</button>,
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
mockState = { enabled: false, isLoading: false }
|
||||
})
|
||||
|
||||
describe("EntraLoginSection", () => {
|
||||
it("enabled=false → null 렌더", () => {
|
||||
mockState = { enabled: false, isLoading: false }
|
||||
const { container } = render(<EntraLoginSection />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it("isLoading=true → null 렌더", () => {
|
||||
mockState = { enabled: false, isLoading: true }
|
||||
const { container } = render(<EntraLoginSection />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it("enabled=true → 버튼 + divider '또는' 같이 노출", () => {
|
||||
mockState = { enabled: true, isLoading: false }
|
||||
render(<EntraLoginSection />)
|
||||
expect(screen.getByTestId("entra-btn")).toBeInTheDocument()
|
||||
expect(screen.getByText("또는")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import EntraLoginButton from "./EntraLoginButton"
|
||||
import { useEntraEnabled } from "../hooks/useEntraEnabled"
|
||||
|
||||
export default function EntraLoginSection() {
|
||||
const { enabled, isLoading } = useEntraEnabled()
|
||||
if (isLoading || !enabled) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<EntraLoginButton />
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background text-muted-foreground px-2">또는</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import { fireEvent, render, screen } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { toast } from "sonner"
|
||||
import { PATHS } from "@/config/routes"
|
||||
import LoginForm from "./LoginForm"
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { error: vi.fn(), info: vi.fn() } }))
|
||||
|
||||
const navigateMock = vi.fn()
|
||||
vi.mock("react-router-dom", async (orig) => {
|
||||
const m: any = await orig()
|
||||
return { ...m, useNavigate: () => navigateMock }
|
||||
})
|
||||
|
||||
const mutate = vi.fn()
|
||||
vi.mock("../hooks/useLogin", () => ({
|
||||
useLogin: () => ({ mutate, isPending: false }),
|
||||
}))
|
||||
|
||||
function renderForm(search = "") {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter initialEntries={[`/login${search}`]}>
|
||||
<LoginForm />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
describe("LoginForm 회귀", () => {
|
||||
it("이메일/비번 입력 + submit → useLogin.mutate 호출", async () => {
|
||||
renderForm()
|
||||
await userEvent.type(screen.getByLabelText("이메일"), "a@b.com")
|
||||
await userEvent.type(screen.getByLabelText("비번"), "secret")
|
||||
await userEvent.click(screen.getByRole("button", { name: /로그인/ }))
|
||||
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
expect(mutate.mock.calls[0][0]).toMatchObject({ email: "a@b.com", password: "secret" })
|
||||
})
|
||||
|
||||
it("무효 이메일 → 에러 메시지 + mutate 호출 X", async () => {
|
||||
const { container } = renderForm()
|
||||
await userEvent.type(screen.getByLabelText("이메일"), "not-email")
|
||||
await userEvent.type(screen.getByLabelText("비번"), "secret")
|
||||
// type="email" 의 HTML5 native validation 이 jsdom 에서 submit 을 막아서
|
||||
// userEvent.click(submit) 으로는 zod 까지 안 감 → form.submit 직접 발사
|
||||
const form = container.querySelector("form")!
|
||||
fireEvent.submit(form)
|
||||
|
||||
// "올바른 이메일을 입력하십시오" 에러 메시지 — Label "이메일"과 구분되도록 더 구체적인 regex
|
||||
// zod resolver 가 async 라 findByText (대기) 사용
|
||||
expect(await screen.findByText(/올바른 이메일/)).toBeInTheDocument()
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("?expired=1 → 세션 만료 토스트 1회", async () => {
|
||||
renderForm("?expired=1")
|
||||
expect(toast.info).toHaveBeenCalledOnce()
|
||||
expect(toast.info).toHaveBeenCalledWith(expect.stringMatching(/세션/))
|
||||
})
|
||||
|
||||
it("from=/snap/new + 성공 → navigate('/snap/new')", async () => {
|
||||
renderForm("?from=/snap/new")
|
||||
await userEvent.type(screen.getByLabelText("이메일"), "a@b.com")
|
||||
await userEvent.type(screen.getByLabelText("비번"), "secret")
|
||||
await userEvent.click(screen.getByRole("button", { name: /로그인/ }))
|
||||
|
||||
const [, opts] = mutate.mock.calls[0]
|
||||
opts.onSuccess({})
|
||||
expect(navigateMock).toHaveBeenCalledWith(PATHS.SNAP_NEW, { replace: true })
|
||||
})
|
||||
|
||||
it("from=외부 도메인 → navigate PATHS.SNAP (safeRedirectPath 가드)", async () => {
|
||||
renderForm("?from=//evil.com")
|
||||
await userEvent.type(screen.getByLabelText("이메일"), "a@b.com")
|
||||
await userEvent.type(screen.getByLabelText("비번"), "secret")
|
||||
await userEvent.click(screen.getByRole("button", { name: /로그인/ }))
|
||||
|
||||
const [, opts] = mutate.mock.calls[0]
|
||||
opts.onSuccess({})
|
||||
expect(navigateMock).toHaveBeenCalledWith(PATHS.SNAP, { replace: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useRef } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useNavigate, useSearchParams } from "react-router-dom"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
import { Input } from "@/shared/ui/input"
|
||||
import { Label } from "@/shared/ui/label"
|
||||
import { loginSchema, type LoginInput } from "../schemas"
|
||||
import { useLogin } from "../hooks/useLogin"
|
||||
import { safeRedirectPath } from "../utils/safeRedirectPath"
|
||||
import { ApiError } from "@/lib/api/errors"
|
||||
import type { LoginRequest } from "@/types/api"
|
||||
|
||||
export default function LoginForm() {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<LoginInput>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: { email: "", password: "" },
|
||||
})
|
||||
const login = useLogin()
|
||||
const navigate = useNavigate()
|
||||
const [params] = useSearchParams()
|
||||
const expiredToastedRef = useRef(false)
|
||||
|
||||
// ?expired=1 → 토스트 1회 (StrictMode 이중 렌더 가드)
|
||||
useEffect(() => {
|
||||
if (params.get("expired") === "1" && !expiredToastedRef.current) {
|
||||
expiredToastedRef.current = true
|
||||
toast.info("세션이 만료되어 로그아웃되었습니다. 다시 로그인하십시오.")
|
||||
}
|
||||
}, [params])
|
||||
|
||||
const onSubmit = (data: LoginInput) => {
|
||||
const from = safeRedirectPath(params.get("from"))
|
||||
login.mutate(data as LoginRequest, {
|
||||
onSuccess: () => navigate(from, { replace: true }),
|
||||
onError: (err) => {
|
||||
const msg = err instanceof ApiError ? err.message : "로그인 실패"
|
||||
toast.error(msg)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="max-w-sm space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">이메일</Label>
|
||||
<Input id="email" type="email" autoComplete="email" {...register("email")} />
|
||||
{errors.email && <p className="text-destructive text-sm">{errors.email.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">비번</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
{...register("password")}
|
||||
/>
|
||||
{errors.password && <p className="text-destructive text-sm">{errors.password.message}</p>}
|
||||
</div>
|
||||
<Button type="submit" disabled={login.isPending} className="w-full">
|
||||
{login.isPending ? "로그인 중..." : "로그인"}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
import { Input } from "@/shared/ui/input"
|
||||
import { Label } from "@/shared/ui/label"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog"
|
||||
import { ApiError } from "@/lib/api/errors"
|
||||
import { PATHS } from "@/config/routes"
|
||||
import type { LoginRequest } from "@/types/api"
|
||||
import { useSessionExpiryStore } from "../store/sessionExpiryStore"
|
||||
import { loginSchema, type LoginInput } from "../schemas"
|
||||
import { useLogin } from "../hooks/useLogin"
|
||||
import { useAuthStore } from "../store/authStore"
|
||||
|
||||
/**
|
||||
* 세션 만료(401 + refresh 실패) 시 자동으로 뜨는 모달.
|
||||
*
|
||||
* - 강제 닫기 불가(인증 안 된 상태로 화면 노출되면 안 되니까)
|
||||
* - 인라인 재로그인 폼 → 성공하면 큐의 retry 발사 → 모달 닫힘
|
||||
* - "로그아웃하고 로그인 페이지로" 옵션 — 큐 폐기 후 `/login?expired=1`로 이동
|
||||
*
|
||||
* 보호된 팔레트 화면을 감싸는 `PaletteShell`에 1회 마운트.
|
||||
*/
|
||||
export function SessionExpiryDialog() {
|
||||
const open = useSessionExpiryStore((s) => s.open)
|
||||
const cancel = useSessionExpiryStore((s) => s.cancel)
|
||||
const closeAndFlush = useSessionExpiryStore((s) => s.closeAndFlush)
|
||||
const clearUser = useAuthStore((s) => s.clearUser)
|
||||
const login = useLogin()
|
||||
const navigate = useNavigate()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
reset,
|
||||
} = useForm<LoginInput>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: { email: user?.email ?? "", password: "" },
|
||||
})
|
||||
|
||||
const onSubmit = (data: LoginInput) => {
|
||||
login.mutate(data as LoginRequest, {
|
||||
onSuccess: async () => {
|
||||
reset({ email: user?.email ?? "", password: "" })
|
||||
await closeAndFlush()
|
||||
},
|
||||
onError: (err) => {
|
||||
const msg = err instanceof ApiError ? err.message : "로그인 실패"
|
||||
toast.error(msg)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const goToLogin = () => {
|
||||
cancel()
|
||||
clearUser()
|
||||
navigate(`${PATHS.LOGIN}?expired=1`, { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open}>
|
||||
<DialogContent
|
||||
// 외부 클릭·ESC로 닫기 차단 — 강제 재로그인 또는 명시적 로그아웃 두 길만
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
className="sm:max-w-sm"
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>세션이 만료됨</DialogTitle>
|
||||
<DialogDescription>
|
||||
로그인 정보가 만료되었습니다. 비밀번호를 다시 입력하면 하던 작업이 이어집니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="session-email">이메일</Label>
|
||||
<Input
|
||||
id="session-email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
readOnly={!!user?.email}
|
||||
{...register("email")}
|
||||
/>
|
||||
{errors.email && <p className="text-destructive text-sm">{errors.email.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="session-password">비번</Label>
|
||||
<Input
|
||||
id="session-password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
{...register("password")}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-destructive text-sm">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button type="button" variant="ghost" onClick={goToLogin}>
|
||||
로그아웃
|
||||
</Button>
|
||||
<Button type="submit" disabled={login.isPending}>
|
||||
{login.isPending ? "로그인 중..." : "다시 로그인"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import { renderHook, waitFor } from "@testing-library/react"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import type { ReactNode } from "react"
|
||||
import { useEntraEnabled } from "./useEntraEnabled"
|
||||
|
||||
vi.mock("../api/auth.api", () => ({
|
||||
authApi: { getEntraConfig: vi.fn() },
|
||||
}))
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
describe("useEntraEnabled", () => {
|
||||
it("config null → enabled=false, isLoading 결국 false", async () => {
|
||||
const { authApi } = await import("../api/auth.api")
|
||||
;(authApi.getEntraConfig as any).mockResolvedValue(null)
|
||||
const { result } = renderHook(() => useEntraEnabled(), { wrapper })
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||
expect(result.current.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it("config 있음 → enabled=true", async () => {
|
||||
const { authApi } = await import("../api/auth.api")
|
||||
;(authApi.getEntraConfig as any).mockResolvedValue({
|
||||
clientId: "c",
|
||||
authority: "https://a",
|
||||
tenantId: "t",
|
||||
})
|
||||
const { result } = renderHook(() => useEntraEnabled(), { wrapper })
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||
expect(result.current.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it("로딩 중 → isLoading=true, enabled=false", async () => {
|
||||
const { authApi } = await import("../api/auth.api")
|
||||
;(authApi.getEntraConfig as any).mockReturnValue(new Promise(() => {}))
|
||||
const { result } = renderHook(() => useEntraEnabled(), { wrapper })
|
||||
expect(result.current.isLoading).toBe(true)
|
||||
expect(result.current.enabled).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { authApi } from "../api/auth.api"
|
||||
|
||||
/**
|
||||
* Entra SSO 활성 여부 — `/auth/entra/config` 응답으로 판단.
|
||||
* - config 객체 있음 → enabled=true
|
||||
* - config null (501) → enabled=false
|
||||
* staleTime Infinity: 세션 동안 한 번만 받아옴.
|
||||
*/
|
||||
export function useEntraEnabled() {
|
||||
const q = useQuery({
|
||||
queryKey: ["entra-config"],
|
||||
queryFn: () => authApi.getEntraConfig(),
|
||||
staleTime: Infinity,
|
||||
retry: false,
|
||||
})
|
||||
return {
|
||||
enabled: q.data !== null && q.data !== undefined,
|
||||
isLoading: q.isLoading,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest"
|
||||
import { renderHook, waitFor, act } from "@testing-library/react"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
vi.mock("@/lib/auth/msal", () => ({
|
||||
loginWithMicrosoft: vi.fn(),
|
||||
}))
|
||||
vi.mock("../api/auth.api", () => ({
|
||||
authApi: { entraLogin: vi.fn() },
|
||||
}))
|
||||
vi.mock("../store/authStore", () => ({
|
||||
useAuthStore: vi.fn(),
|
||||
}))
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
describe("useEntraLogin", () => {
|
||||
it("정상 흐름 → MSAL → entraLogin → setUser 순서 호출", async () => {
|
||||
const setUser = vi.fn()
|
||||
const { useAuthStore } = await import("../store/authStore")
|
||||
;(useAuthStore as any).mockImplementation((sel: any) => sel({ setUser }))
|
||||
|
||||
const { loginWithMicrosoft } = await import("@/lib/auth/msal")
|
||||
;(loginWithMicrosoft as any).mockResolvedValue({
|
||||
idToken: "ID",
|
||||
graphAccessToken: "G",
|
||||
})
|
||||
|
||||
const user = {
|
||||
id: "u1",
|
||||
email: "a@b.com",
|
||||
userName: "A",
|
||||
role: "USER",
|
||||
employeeId: "E",
|
||||
department: "D",
|
||||
authProvider: "entra",
|
||||
}
|
||||
const { authApi } = await import("../api/auth.api")
|
||||
;(authApi.entraLogin as any).mockResolvedValue(user)
|
||||
|
||||
const { useEntraLogin } = await import("./useEntraLogin")
|
||||
const { result } = renderHook(() => useEntraLogin(), { wrapper })
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync()
|
||||
})
|
||||
|
||||
expect(loginWithMicrosoft).toHaveBeenCalledOnce()
|
||||
expect(authApi.entraLogin).toHaveBeenCalledWith({
|
||||
idToken: "ID",
|
||||
graphAccessToken: "G",
|
||||
})
|
||||
expect(setUser).toHaveBeenCalledWith(user)
|
||||
})
|
||||
|
||||
it("graphAccessToken null → payload에서 graphAccessToken 누락(undefined)", async () => {
|
||||
const setUser = vi.fn()
|
||||
const { useAuthStore } = await import("../store/authStore")
|
||||
;(useAuthStore as any).mockImplementation((sel: any) => sel({ setUser }))
|
||||
|
||||
const { loginWithMicrosoft } = await import("@/lib/auth/msal")
|
||||
;(loginWithMicrosoft as any).mockResolvedValue({
|
||||
idToken: "ID",
|
||||
graphAccessToken: null,
|
||||
})
|
||||
const { authApi } = await import("../api/auth.api")
|
||||
;(authApi.entraLogin as any).mockResolvedValue({ id: "u", email: "x" })
|
||||
|
||||
const { useEntraLogin } = await import("./useEntraLogin")
|
||||
const { result } = renderHook(() => useEntraLogin(), { wrapper })
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync()
|
||||
})
|
||||
|
||||
expect(authApi.entraLogin).toHaveBeenCalledWith({
|
||||
idToken: "ID",
|
||||
graphAccessToken: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it("MSAL throw → mutation isError + setUser 호출 X", async () => {
|
||||
const setUser = vi.fn()
|
||||
const { useAuthStore } = await import("../store/authStore")
|
||||
;(useAuthStore as any).mockImplementation((sel: any) => sel({ setUser }))
|
||||
|
||||
const { loginWithMicrosoft } = await import("@/lib/auth/msal")
|
||||
const err: any = new Error("popup_window_error")
|
||||
err.errorCode = "popup_window_error"
|
||||
;(loginWithMicrosoft as any).mockRejectedValue(err)
|
||||
|
||||
const { useEntraLogin } = await import("./useEntraLogin")
|
||||
const { result } = renderHook(() => useEntraLogin(), { wrapper })
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.mutateAsync()
|
||||
} catch {
|
||||
// mutation 실패 의도된 케이스
|
||||
}
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||
expect(setUser).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { loginWithMicrosoft } from "@/lib/auth/msal"
|
||||
import { authApi } from "../api/auth.api"
|
||||
import { useAuthStore } from "../store/authStore"
|
||||
|
||||
export function useEntraLogin() {
|
||||
const setUser = useAuthStore((s) => s.setUser)
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const { idToken, graphAccessToken } = await loginWithMicrosoft()
|
||||
return authApi.entraLogin({
|
||||
idToken,
|
||||
graphAccessToken: graphAccessToken ?? undefined,
|
||||
})
|
||||
},
|
||||
onSuccess: (user) => setUser(user),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { authApi } from "../api/auth.api"
|
||||
import { useAuthStore } from "../store/authStore"
|
||||
import type { LoginRequest } from "@/types/api"
|
||||
|
||||
export function useLogin() {
|
||||
const setUser = useAuthStore((s) => s.setUser)
|
||||
return useMutation({
|
||||
mutationFn: (input: LoginRequest) => authApi.login(input),
|
||||
onSuccess: (user) => setUser(user),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { authApi } from "../api/auth.api"
|
||||
import { useAuthStore } from "../store/authStore"
|
||||
|
||||
/**
|
||||
* 서버 logout(쿠키 삭제) → 클라 store 초기화 → react-query 캐시 정리.
|
||||
*
|
||||
* 서버 호출 실패해도 클라 상태는 비우는 것이 안전 (오프라인·네트워크 끊김 시).
|
||||
*/
|
||||
export function useLogout() {
|
||||
const clearUser = useAuthStore((s) => s.clearUser)
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: () => authApi.logout(),
|
||||
onSettled: () => {
|
||||
clearUser()
|
||||
qc.clear()
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { authApi } from "../api/auth.api"
|
||||
import { useAuthStore } from "../store/authStore"
|
||||
|
||||
/**
|
||||
* 서버 진실값(`/users/me`)으로 현재 user를 동기화.
|
||||
* persist된 user(localStorage)는 UX 부트스트랩용일 뿐 — 마운트 시 이 훅으로 갱신.
|
||||
*/
|
||||
export function useMe(enabled = true) {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
return useQuery({
|
||||
queryKey: ["auth", "me"],
|
||||
queryFn: () => authApi.getMe(),
|
||||
enabled: enabled && user !== null,
|
||||
staleTime: 5 * 60_000,
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEffect } from "react"
|
||||
import { authApi } from "../api/auth.api"
|
||||
import { useAuthStore } from "../store/authStore"
|
||||
|
||||
const COOKIE_NAME = "accessTokenExp"
|
||||
const REFRESH_BEFORE_MS = 60_000
|
||||
|
||||
function readExpCookie(): number | null {
|
||||
if (typeof document === "undefined") return null
|
||||
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${COOKIE_NAME}=([^;]+)`))
|
||||
if (!match) return null
|
||||
const n = Number(match[1])
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
/**
|
||||
* accessToken 만료 60초 전에 백그라운드 refresh.
|
||||
*
|
||||
* - 백엔드가 `accessTokenExp` non-httpOnly 쿠키로 만료 시각(ms epoch)을 노출
|
||||
* - 인증된 동안만 동작. 로그아웃 시 자동 정리
|
||||
* - refresh 실패 시 인터셉터가 401 + sessionExpiryStore 흐름으로 처리
|
||||
*
|
||||
* 보호된 팔레트 화면을 감싸는 `PaletteShell`에 1회만 마운트.
|
||||
*/
|
||||
export function useSlidingRefresh() {
|
||||
const isAuthed = useAuthStore((s) => s.isAuthenticated())
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthed) return
|
||||
|
||||
let timerId: ReturnType<typeof setTimeout> | null = null
|
||||
let cancelled = false
|
||||
|
||||
const schedule = () => {
|
||||
const exp = readExpCookie()
|
||||
if (exp === null) return // 쿠키 없으면 다음 cycle에서 다시 시도하지 않음
|
||||
const delay = Math.max(0, exp - Date.now() - REFRESH_BEFORE_MS)
|
||||
timerId = setTimeout(async () => {
|
||||
if (cancelled) return
|
||||
try {
|
||||
await authApi.refresh()
|
||||
} catch {
|
||||
// 인터셉터가 401 → sessionExpiryStore로 이미 처리. 여기선 무시.
|
||||
return
|
||||
}
|
||||
if (!cancelled) schedule()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
schedule()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (timerId !== null) clearTimeout(timerId)
|
||||
}
|
||||
}, [isAuthed])
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as LoginPage } from "./pages/LoginPage"
|
||||
export { useAuthStore } from "./store/authStore"
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import LoginPage from "./LoginPage"
|
||||
|
||||
vi.mock("../components/EntraLoginSection", () => ({
|
||||
default: () => <div data-testid="entra-section">stub</div>,
|
||||
}))
|
||||
vi.mock("../components/LoginForm", () => ({
|
||||
default: () => <form data-testid="login-form">stub</form>,
|
||||
}))
|
||||
|
||||
function renderPage() {
|
||||
const qc = new QueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>
|
||||
<LoginPage />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe("LoginPage", () => {
|
||||
it("EntraLoginSection + LoginForm 둘 다 마운트", () => {
|
||||
renderPage()
|
||||
expect(screen.getByTestId("entra-section")).toBeInTheDocument()
|
||||
expect(screen.getByTestId("login-form")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("EntraLoginSection이 LoginForm보다 먼저 옴 (DOM 순서)", () => {
|
||||
renderPage()
|
||||
const section = screen.getByTestId("entra-section")
|
||||
const form = screen.getByTestId("login-form")
|
||||
expect(section.compareDocumentPosition(form) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
})
|
||||
|
||||
it("h1 '로그인' 표시", () => {
|
||||
renderPage()
|
||||
expect(screen.getByRole("heading", { level: 1, name: "로그인" })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("로그인 입력 영역을 화면 가운데 정렬", () => {
|
||||
const { container } = renderPage()
|
||||
expect(container.firstElementChild).toHaveClass(
|
||||
"flex",
|
||||
"flex-1",
|
||||
"items-center",
|
||||
"justify-center"
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import LoginForm from "../components/LoginForm"
|
||||
import EntraLoginSection from "../components/EntraLoginSection"
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
<h1 className="text-2xl font-semibold">로그인</h1>
|
||||
<EntraLoginSection />
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email("올바른 이메일을 입력하십시오"),
|
||||
password: z.string().min(4, "최소 4자"),
|
||||
})
|
||||
export type LoginInput = z.infer<typeof loginSchema>
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest"
|
||||
import { useAuthStore } from "./authStore"
|
||||
|
||||
const sampleUser = {
|
||||
id: "u1",
|
||||
email: "x@x.com",
|
||||
userName: null,
|
||||
role: "USER" as const,
|
||||
employeeId: null,
|
||||
department: null,
|
||||
authProvider: "local" as const,
|
||||
}
|
||||
|
||||
describe("authStore", () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ user: null })
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it("초기 상태: user null, isAuthenticated false", () => {
|
||||
const s = useAuthStore.getState()
|
||||
expect(s.user).toBeNull()
|
||||
expect(s.isAuthenticated()).toBe(false)
|
||||
})
|
||||
|
||||
it("setUser: user 저장 + isAuthenticated true", () => {
|
||||
useAuthStore.getState().setUser(sampleUser)
|
||||
expect(useAuthStore.getState().user).toEqual(sampleUser)
|
||||
expect(useAuthStore.getState().isAuthenticated()).toBe(true)
|
||||
})
|
||||
|
||||
it("clearUser: user null", () => {
|
||||
useAuthStore.setState({ user: sampleUser })
|
||||
useAuthStore.getState().clearUser()
|
||||
expect(useAuthStore.getState().user).toBeNull()
|
||||
expect(useAuthStore.getState().isAuthenticated()).toBe(false)
|
||||
})
|
||||
|
||||
// anti-pattern 가드: token/accessToken/refreshToken 필드는 절대 추가하지 않음 (httpOnly 쿠키만 사용)
|
||||
it("[anti-pattern] state에 token 관련 필드가 없음", () => {
|
||||
const state = useAuthStore.getState() as unknown as Record<string, unknown>
|
||||
expect(state).not.toHaveProperty("token")
|
||||
expect(state).not.toHaveProperty("accessToken")
|
||||
expect(state).not.toHaveProperty("refreshToken")
|
||||
})
|
||||
|
||||
it("[anti-pattern] setUser 후 persist에 token이 포함되지 않음", () => {
|
||||
useAuthStore.getState().setUser(sampleUser)
|
||||
const raw = localStorage.getItem("auth-store")
|
||||
expect(raw).toBeTruthy()
|
||||
const parsed = JSON.parse(raw!) as { state: Record<string, unknown> }
|
||||
expect(parsed.state).not.toHaveProperty("token")
|
||||
expect(parsed.state).not.toHaveProperty("accessToken")
|
||||
expect(parsed.state).not.toHaveProperty("refreshToken")
|
||||
expect(parsed.state.user).toEqual(sampleUser)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { create } from "zustand"
|
||||
import { persist, createJSONStorage } from "zustand/middleware"
|
||||
import type { UserPayload } from "@/types/api"
|
||||
|
||||
/**
|
||||
* 인증 상태. **토큰은 절대 저장하지 않음** (httpOnly 쿠키만 사용).
|
||||
*
|
||||
* `token`/`accessToken`/`refreshToken` 필드 추가 금지 — anti-pattern test로 가드.
|
||||
*
|
||||
* `user`만 localStorage에 영속 (UX용. 서버는 매번 쿠키로 인증).
|
||||
* 마운트 시 `useMe()` 훅으로 서버 진실값과 동기화하면 안전.
|
||||
*/
|
||||
interface AuthState {
|
||||
user: UserPayload | null
|
||||
isAuthenticated: () => boolean
|
||||
setUser: (user: UserPayload | null) => void
|
||||
clearUser: () => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
user: null,
|
||||
isAuthenticated: () => get().user !== null,
|
||||
setUser: (user) => set({ user }),
|
||||
clearUser: () => set({ user: null }),
|
||||
}),
|
||||
{
|
||||
name: "auth-store",
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
partialize: (state) => ({ user: state.user }),
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest"
|
||||
import { useSessionExpiryStore } from "./sessionExpiryStore"
|
||||
|
||||
describe("sessionExpiryStore", () => {
|
||||
beforeEach(() => {
|
||||
useSessionExpiryStore.setState({ open: false, queue: [] })
|
||||
})
|
||||
|
||||
it("초기 상태: 닫힘 + 큐 비어있음", () => {
|
||||
const s = useSessionExpiryStore.getState()
|
||||
expect(s.open).toBe(false)
|
||||
expect(s.queue).toEqual([])
|
||||
})
|
||||
|
||||
it("openDialog: open=true", () => {
|
||||
useSessionExpiryStore.getState().openDialog()
|
||||
expect(useSessionExpiryStore.getState().open).toBe(true)
|
||||
})
|
||||
|
||||
it("pushFailure: retry를 큐에 push + open=true + 호출자 promise 보관", () => {
|
||||
const retry = vi.fn().mockResolvedValue("ok")
|
||||
const promise = useSessionExpiryStore.getState().pushFailure(retry)
|
||||
|
||||
const state = useSessionExpiryStore.getState()
|
||||
expect(state.open).toBe(true)
|
||||
expect(state.queue.length).toBe(1)
|
||||
expect(retry).not.toHaveBeenCalled() // flush 전엔 호출 안 됨
|
||||
|
||||
// dangling promise 정리
|
||||
state.cancel()
|
||||
promise.catch(() => {})
|
||||
})
|
||||
|
||||
it("closeAndFlush: 큐에 쌓인 retry 모두 실행 + open=false + queue=[]", async () => {
|
||||
const retry1 = vi.fn().mockResolvedValue("a")
|
||||
const retry2 = vi.fn().mockResolvedValue("b")
|
||||
|
||||
const p1 = useSessionExpiryStore.getState().pushFailure(retry1)
|
||||
const p2 = useSessionExpiryStore.getState().pushFailure(retry2)
|
||||
|
||||
await useSessionExpiryStore.getState().closeAndFlush()
|
||||
|
||||
expect(retry1).toHaveBeenCalledTimes(1)
|
||||
expect(retry2).toHaveBeenCalledTimes(1)
|
||||
await expect(p1).resolves.toBe("a")
|
||||
await expect(p2).resolves.toBe("b")
|
||||
|
||||
const state = useSessionExpiryStore.getState()
|
||||
expect(state.open).toBe(false)
|
||||
expect(state.queue).toEqual([])
|
||||
})
|
||||
|
||||
it("flush 중 retry 한 건이 실패해도 나머지 진행", async () => {
|
||||
const retry1 = vi.fn().mockResolvedValue("a")
|
||||
const retry2 = vi.fn().mockRejectedValue(new Error("boom"))
|
||||
const retry3 = vi.fn().mockResolvedValue("c")
|
||||
|
||||
const p1 = useSessionExpiryStore.getState().pushFailure(retry1)
|
||||
const p2 = useSessionExpiryStore.getState().pushFailure(retry2)
|
||||
const p3 = useSessionExpiryStore.getState().pushFailure(retry3)
|
||||
|
||||
await useSessionExpiryStore.getState().closeAndFlush()
|
||||
|
||||
await expect(p1).resolves.toBe("a")
|
||||
await expect(p2).rejects.toThrow("boom")
|
||||
await expect(p3).resolves.toBe("c")
|
||||
})
|
||||
|
||||
it("cancel: open=false + 큐 폐기 (호출자 promise는 dangling)", () => {
|
||||
const retry = vi.fn()
|
||||
useSessionExpiryStore.getState().pushFailure(retry)
|
||||
|
||||
useSessionExpiryStore.getState().cancel()
|
||||
|
||||
const state = useSessionExpiryStore.getState()
|
||||
expect(state.open).toBe(false)
|
||||
expect(state.queue).toEqual([])
|
||||
expect(retry).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { create } from "zustand"
|
||||
|
||||
/**
|
||||
* 세션 만료(401 + refresh 실패) 감지 시 모달 띄움.
|
||||
* 만료 시점에 진행 중이던 요청은 큐에 보관 → 재로그인 성공하면 일괄 retry.
|
||||
*
|
||||
* 이 store는 user 정보를 가지지 않음. 인증 상태는 `authStore`에 분리.
|
||||
*/
|
||||
|
||||
export type RetryFn = () => Promise<unknown>
|
||||
|
||||
interface SessionExpiryState {
|
||||
open: boolean
|
||||
/** refresh 실패로 reject 직전에 대기 중인 요청들의 retry 함수 큐. */
|
||||
queue: RetryFn[]
|
||||
/** 모달 열기. 동일 사이클에서 여러 401이 와도 1회만 열림. */
|
||||
openDialog: () => void
|
||||
/** 재로그인 성공 시: 큐 전부 retry → resolve/reject 각자에게 위임 → 모달 닫음. */
|
||||
closeAndFlush: () => Promise<void>
|
||||
/** 사용자가 명시적으로 모달 닫기(취소·로그아웃 등). 큐도 폐기. */
|
||||
cancel: () => void
|
||||
/** 401 + refresh 실패 직전에 retry 함수 push. 호출자는 반환된 promise로 결과 받음. */
|
||||
pushFailure: (retry: RetryFn) => Promise<unknown>
|
||||
}
|
||||
|
||||
export const useSessionExpiryStore = create<SessionExpiryState>((set, get) => ({
|
||||
open: false,
|
||||
queue: [],
|
||||
openDialog: () => set({ open: true }),
|
||||
cancel: () => set({ open: false, queue: [] }),
|
||||
closeAndFlush: async () => {
|
||||
const { queue } = get()
|
||||
set({ open: false, queue: [] })
|
||||
// 각 retry는 독립 실행 — 한 건 실패해도 나머지 진행
|
||||
await Promise.allSettled(queue.map((fn) => fn()))
|
||||
},
|
||||
pushFailure: (retry) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const wrapped: RetryFn = async () => {
|
||||
try {
|
||||
const result = await retry()
|
||||
resolve(result)
|
||||
return result
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
set((s) => ({ queue: [...s.queue, wrapped], open: true }))
|
||||
})
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { safeRedirectPath } from "./safeRedirectPath"
|
||||
import { PATHS } from "@/config/routes"
|
||||
|
||||
describe("safeRedirectPath", () => {
|
||||
it("null → SNAP", () => {
|
||||
expect(safeRedirectPath(null)).toBe(PATHS.SNAP)
|
||||
})
|
||||
|
||||
it("빈 문자열 → SNAP", () => {
|
||||
expect(safeRedirectPath("")).toBe(PATHS.SNAP)
|
||||
})
|
||||
|
||||
it("'/'로 시작 안 함 → SNAP", () => {
|
||||
expect(safeRedirectPath("snap/new")).toBe(PATHS.SNAP)
|
||||
})
|
||||
|
||||
it("'//' protocol-relative URL → SNAP", () => {
|
||||
expect(safeRedirectPath("//evil.com/path")).toBe(PATHS.SNAP)
|
||||
})
|
||||
|
||||
it("정상 절대 경로 → 그대로 반환", () => {
|
||||
expect(safeRedirectPath(PATHS.SNAP_NEW)).toBe(PATHS.SNAP_NEW)
|
||||
})
|
||||
|
||||
it("쿼리스트링 포함된 경로 → 그대로", () => {
|
||||
expect(safeRedirectPath("/users?page=2")).toBe("/users?page=2")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { PATHS } from "@/config/routes"
|
||||
|
||||
/**
|
||||
* 로그인 후 redirect할 path 검증.
|
||||
* - 반드시 `/`로 시작하는 상대 경로
|
||||
* - `//` 시작은 protocol-relative URL이라 외부 도메인으로 빠질 수 있어 차단
|
||||
*/
|
||||
export function safeRedirectPath(raw: string | null): string {
|
||||
if (!raw) return PATHS.SNAP
|
||||
if (!raw.startsWith("/") || raw.startsWith("//")) return PATHS.SNAP
|
||||
return raw
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { renderHook, waitFor } from "@testing-library/react"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { useSessionList, useSessionMessages } from "./snap.api"
|
||||
import * as client from "@/lib/api/client"
|
||||
|
||||
vi.mock("@/lib/api/client")
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
}
|
||||
|
||||
const SESSION = {
|
||||
id: "s1",
|
||||
title: "t",
|
||||
titleLlm: null,
|
||||
isGenerating: false,
|
||||
createdAt: "2026-07-18T00:00:00Z",
|
||||
updatedAt: "2026-07-18T00:00:00Z",
|
||||
}
|
||||
|
||||
describe("snap.api (real)", () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it("useSessionList 는 GET /chat/sessions 를 페이지네이션으로 호출", async () => {
|
||||
vi.mocked(client.apiList).mockResolvedValue({ items: [SESSION], meta: null, counts: 1 })
|
||||
const { result } = renderHook(() => useSessionList(1), { wrapper })
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(client.apiList).toHaveBeenCalledWith("/chat/sessions", {
|
||||
params: { page: 1, limit: 3 },
|
||||
})
|
||||
expect(result.current.data!.items[0].id).toBe("s1")
|
||||
})
|
||||
|
||||
it("useSessionMessages 는 GET /chat/sessions/{id}/messages 를 호출", async () => {
|
||||
vi.mocked(client.apiGet).mockResolvedValue({ ...SESSION, messages: [] })
|
||||
const { result } = renderHook(() => useSessionMessages("s1"), { wrapper })
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(client.apiGet).toHaveBeenCalledWith("/chat/sessions/s1/messages")
|
||||
expect(result.current.data!.messages).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query"
|
||||
import { apiGet, apiList, apiPost } from "@/lib/api/client"
|
||||
import type { SnapMessage, SnapSession, SnapSessionDetail } from "../contract/types"
|
||||
|
||||
const PAGE_SIZE = 3 // 첫 화면 "최근 진행 대화"는 3개만 peek — 나머지는 검색으로
|
||||
const SEARCH_PAGE_SIZE = 20
|
||||
|
||||
// 세션 목록 — 서버 페이지네이션. page 단위로 목록 교체(더보기 append 아님).
|
||||
export function useSessionList(page: number) {
|
||||
return useQuery({
|
||||
queryKey: ["snap", "sessions", page],
|
||||
queryFn: () => apiList<SnapSession>("/chat/sessions", { params: { page, limit: PAGE_SIZE } }),
|
||||
placeholderData: keepPreviousData,
|
||||
// 전역 staleTime 30s 끔 — 새 세션 만들고 30초 내 목록 복귀 시 캐시가 그대로 나와
|
||||
// "방금 만든 세션이 목록에 없음"이 되던 원인. 목록 진입마다 refetch.
|
||||
staleTime: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// 메시지 본문 검색 — 서버 /chat/sessions/search (ILIKE, 소유 세션 전체 대상). 매칭된 메시지 반환.
|
||||
export function useSearchMessages(query: string, page: number) {
|
||||
return useQuery({
|
||||
queryKey: ["snap", "search", query, page],
|
||||
enabled: query.trim().length > 0,
|
||||
staleTime: 0, // 같은 검색어 재검색 시에도 최신 메시지 반영
|
||||
queryFn: () =>
|
||||
apiList<SnapMessage>("/chat/sessions/search", {
|
||||
params: { query, page, limit: SEARCH_PAGE_SIZE },
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
|
||||
export function useSessionMessages(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ["snap", "session", id],
|
||||
enabled: !!id,
|
||||
queryFn: (): Promise<SnapSessionDetail> =>
|
||||
apiGet<SnapSessionDetail>(`/chat/sessions/${id}/messages`),
|
||||
// 전역 staleTime 30s 를 끔 — 대화 내용은 스트리밍으로 계속 바뀌어서 30초 캐시가
|
||||
// "재진입하면 빈/옛 대화" 버그의 뿌리였음. 재진입(마운트)마다 무조건 refetch.
|
||||
staleTime: 0,
|
||||
// 생성 중이면(스트림을 이 탭에서 잃었어도) 백엔드가 끝내는 순간 답변이 DB 에 뜨므로
|
||||
// 그때까지 폴링 → 완료되면 isGenerating=false 로 폴링 멈춤. 재진입 시 답변 유실 자가복구.
|
||||
refetchInterval: (query) => (query.state.data?.isGenerating ? 1500 : false),
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateSession() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (): Promise<SnapSession> => apiPost<SnapSession>("/chat/sessions", {}),
|
||||
// 생성 즉시 목록 캐시 무효화 — 목록으로 돌아가면 새 세션이 바로 보이게.
|
||||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["snap", "sessions"] }),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { snapStream, cancelStream } from "./snap.stream"
|
||||
import * as streaming from "@/lib/streaming"
|
||||
import * as client from "@/lib/api/client"
|
||||
|
||||
vi.mock("@/lib/streaming", () => ({ streamLLM: vi.fn() }))
|
||||
vi.mock("@/lib/api/client", () => ({ apiPost: vi.fn() }))
|
||||
|
||||
describe("snapStream (real)", () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it("streamLLM 을 /chat/stream 계약으로 호출한다", async () => {
|
||||
const streamLLM = streaming.streamLLM as ReturnType<typeof vi.fn>
|
||||
streamLLM.mockResolvedValue(undefined)
|
||||
|
||||
await snapStream({ sessionId: "s1", content: "hi" }, { onToken: vi.fn(), onDone: vi.fn() })
|
||||
|
||||
expect(streamLLM).toHaveBeenCalledTimes(1)
|
||||
const arg = streamLLM.mock.calls[0][0]
|
||||
expect(arg.path).toBe("/chat/stream")
|
||||
expect(arg.body).toEqual({ sessionId: "s1", content: "hi" })
|
||||
})
|
||||
|
||||
it("이미지 계약을 요청 body에 그대로 전달한다", async () => {
|
||||
const streamLLM = streaming.streamLLM as ReturnType<typeof vi.fn>
|
||||
streamLLM.mockResolvedValue(undefined)
|
||||
const images = [{ mediaType: "image/png" as const, data: "data:image/png;base64,eA==" }]
|
||||
|
||||
await snapStream(
|
||||
{ sessionId: "s1", content: "", images },
|
||||
{ onToken: vi.fn(), onDone: vi.fn() }
|
||||
)
|
||||
|
||||
expect(streamLLM.mock.calls[0][0].body).toEqual({ sessionId: "s1", content: "", images })
|
||||
})
|
||||
|
||||
it("onToken/onTitle/onDone/onError 를 그대로 배선한다", async () => {
|
||||
const streamLLM = streaming.streamLLM as ReturnType<typeof vi.fn>
|
||||
streamLLM.mockImplementation(
|
||||
async (opts: {
|
||||
handlers: {
|
||||
onToken: (d: string) => void
|
||||
onTitle?: (t: string) => void
|
||||
onDone: (p: object) => void
|
||||
}
|
||||
}) => {
|
||||
opts.handlers.onToken("a")
|
||||
opts.handlers.onTitle?.("제목")
|
||||
opts.handlers.onDone({})
|
||||
}
|
||||
)
|
||||
const onToken = vi.fn()
|
||||
const onTitle = vi.fn()
|
||||
const onDone = vi.fn()
|
||||
|
||||
await snapStream({ sessionId: "s1", content: "x" }, { onToken, onDone, onTitle })
|
||||
|
||||
expect(onToken).toHaveBeenCalledWith("a")
|
||||
expect(onTitle).toHaveBeenCalledWith("제목")
|
||||
expect(onDone).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("cancelStream", () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it("POST /chat/sessions/{id}/cancel 를 호출한다", async () => {
|
||||
vi.mocked(client.apiPost).mockResolvedValue(null)
|
||||
await cancelStream("s1")
|
||||
expect(client.apiPost).toHaveBeenCalledWith("/chat/sessions/s1/cancel", {})
|
||||
})
|
||||
|
||||
it("실패해도 throw 하지 않는다(best-effort)", async () => {
|
||||
vi.mocked(client.apiPost).mockRejectedValue(new Error("404"))
|
||||
await expect(cancelStream("s1")).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { streamLLM, type LLMUsagePayload } from "@/lib/streaming"
|
||||
import { apiPost } from "@/lib/api/client"
|
||||
import type { SnapStreamRequest } from "../contract/types"
|
||||
|
||||
export interface SnapStreamHandlers {
|
||||
onToken: (delta: string) => void
|
||||
onDone: () => void
|
||||
onTitle?: (title: string) => void
|
||||
onUsage?: (usage: LLMUsagePayload) => void
|
||||
onError?: (e: Error) => void
|
||||
}
|
||||
|
||||
// base-backend POST /chat/stream (SSE) 직결. token/done/error/title 이벤트 소비.
|
||||
export function snapStream(
|
||||
req: SnapStreamRequest,
|
||||
handlers: SnapStreamHandlers,
|
||||
opts?: { signal?: AbortSignal }
|
||||
): Promise<void> {
|
||||
return streamLLM({
|
||||
path: "/chat/stream",
|
||||
body: req,
|
||||
signal: opts?.signal,
|
||||
handlers: {
|
||||
onToken: handlers.onToken,
|
||||
onDone: () => handlers.onDone(),
|
||||
onTitle: handlers.onTitle,
|
||||
onUsage: handlers.onUsage,
|
||||
onError: handlers.onError,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 백엔드에 생성 취소를 알린다(best-effort). 엔드포인트 미구현/실패면 조용히 무시 —
|
||||
// 로컬 stop(abort+freeze)은 호출 측에서 이미 적용됨.
|
||||
export async function cancelStream(sessionId: string): Promise<void> {
|
||||
try {
|
||||
await apiPost(`/chat/sessions/${sessionId}/cancel`, {})
|
||||
} catch {
|
||||
// 취소 엔드포인트 아직 없거나 실패 — degrade. 백엔드는 기존대로 끝까지 생성.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ChevronLeft } from "lucide-react"
|
||||
import { useShallow } from "zustand/react/shallow"
|
||||
import { Kbd } from "@/shared/components/Kbd"
|
||||
import type { SnapSession } from "../contract/types"
|
||||
import { useSnapChatStore } from "../store/snapChatStore"
|
||||
import { fmtTokens } from "../lib/format"
|
||||
|
||||
interface Props {
|
||||
session: SnapSession
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export function ChatHeader({ session, onBack }: Props) {
|
||||
const title = session.titleLlm ?? session.title ?? "새 대화 세션"
|
||||
const { used, limit } = useSnapChatStore(
|
||||
useShallow((s) => ({ used: s.sessionUsed, limit: s.sessionLimit }))
|
||||
)
|
||||
const ratio = limit > 0 ? Math.min(1, used / limit) : 0
|
||||
// 게이지 색 — 90%↑ 빨강, 70%↑ 주황, 그 외 기본.
|
||||
const barColor = ratio >= 0.9 ? "bg-red-500" : ratio >= 0.7 ? "bg-amber-500" : "bg-primary"
|
||||
return (
|
||||
<div className="border-border bg-card flex flex-none items-center gap-2 border-b px-3 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
title="목록으로 (Esc)"
|
||||
className="border-border bg-background text-muted-foreground hover:border-ring hover:text-foreground inline-flex items-center gap-1 rounded-md border px-2 py-1 font-mono text-[11px]"
|
||||
>
|
||||
<ChevronLeft className="size-3" />
|
||||
목록
|
||||
<Kbd>Esc</Kbd>
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-serif text-base font-semibold italic">{title}</div>
|
||||
<div className="text-muted-foreground font-mono text-[9.5px] tracking-wide uppercase">
|
||||
{(session.tag ?? "SESSION").toUpperCase()} · 대화 세션
|
||||
</div>
|
||||
</div>
|
||||
{/* 세션 토큰 게이지 — 현재 점유 / 한도 */}
|
||||
<div
|
||||
className="flex flex-none flex-col items-end gap-1"
|
||||
title={`${used.toLocaleString()} / ${limit.toLocaleString()} tokens`}
|
||||
>
|
||||
<span className="text-muted-foreground font-mono text-[10px] tabular-nums">
|
||||
{fmtTokens(used)}
|
||||
<span className="text-muted-foreground/50"> / {fmtTokens(limit)}</span>
|
||||
</span>
|
||||
<div className="bg-border h-1 w-24 overflow-hidden rounded-full">
|
||||
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${ratio * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { get, set } from "idb-keyval"
|
||||
import { Clipboard, Type } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip"
|
||||
|
||||
// IndexedDB 에 저장하는 형태 — 이미지는 Blob 그대로(base64 불필요, 용량 절약).
|
||||
type StoredItem =
|
||||
| { id: string; kind: "text"; value: string }
|
||||
| { id: string; kind: "image"; blob: Blob }
|
||||
|
||||
// 렌더용 — 이미지는 <img> 에 물릴 object URL 을 얹은 형태.
|
||||
type ClipItem =
|
||||
| { id: string; kind: "text"; value: string }
|
||||
| { id: string; kind: "image"; blob: Blob; url: string }
|
||||
|
||||
const STORE_KEY = "snap-clip-history"
|
||||
const CAP = 30 // 최대 저장 개수. 화면은 스크롤로 ~10개 보이고 나머지는 굴려서.
|
||||
|
||||
function newId(): string {
|
||||
return typeof crypto.randomUUID === "function" ? crypto.randomUUID() : String(Date.now())
|
||||
}
|
||||
|
||||
// 저장형 → 렌더형(이미지에 object URL 부여). 반대는 url 만 떼면 됨.
|
||||
function toClip(stored: StoredItem[]): ClipItem[] {
|
||||
return stored.map((i) => (i.kind === "image" ? { ...i, url: URL.createObjectURL(i.blob) } : i))
|
||||
}
|
||||
function toStored(items: ClipItem[]): StoredItem[] {
|
||||
return items.map((i) => (i.kind === "image" ? { id: i.id, kind: "image", blob: i.blob } : i))
|
||||
}
|
||||
|
||||
export function ClipboardHistory() {
|
||||
const [items, setItems] = useState<ClipItem[]>([])
|
||||
|
||||
// 마운트 시 IndexedDB 에서 로드. hydrate 전엔 persist 를 막아 빈 배열로 덮어쓰는 레이스 방지.
|
||||
const hydrated = useRef(false)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
get<StoredItem[]>(STORE_KEY)
|
||||
.then((stored) => {
|
||||
if (!cancelled && stored?.length) setItems(toClip(stored))
|
||||
})
|
||||
.finally(() => {
|
||||
hydrated.current = true
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 이력 변경 시 IndexedDB 에 통째로 저장(텍스트+이미지 blob 둘 다 로컬 영속).
|
||||
useEffect(() => {
|
||||
if (!hydrated.current) return
|
||||
set(STORE_KEY, toStored(items)).catch(() => {
|
||||
// storage 실패 — degrade
|
||||
})
|
||||
}, [items])
|
||||
|
||||
// 새 clip 을 맨 앞에. 직전과 같은 텍스트면 스킵(중복 방지). CAP 초과분은 잘라내며 object URL 정리.
|
||||
const add = (item: ClipItem) =>
|
||||
setItems((prev) => {
|
||||
if (item.kind === "text" && prev[0]?.kind === "text" && prev[0].value === item.value)
|
||||
return prev
|
||||
const next = [item, ...prev]
|
||||
for (const dropped of next.slice(CAP)) {
|
||||
if (dropped.kind === "image") URL.revokeObjectURL(dropped.url)
|
||||
}
|
||||
return next.slice(0, CAP)
|
||||
})
|
||||
|
||||
// 클립보드 소스 2입구(paste 폴백 + 네이티브 postMessage).
|
||||
// 계약: { type: "clipboard", payload: {kind:"text", value} | {kind:"image", dataUrl} }
|
||||
const addRef = useRef(add)
|
||||
addRef.current = add
|
||||
useEffect(() => {
|
||||
const imageItem = (blob: Blob): ClipItem => ({
|
||||
id: newId(),
|
||||
kind: "image",
|
||||
blob,
|
||||
url: URL.createObjectURL(blob),
|
||||
})
|
||||
const onPaste = (e: ClipboardEvent) => {
|
||||
const dt = e.clipboardData
|
||||
if (!dt) return
|
||||
for (let i = 0; i < dt.items.length; i++) {
|
||||
if (dt.items[i].type.startsWith("image/")) {
|
||||
const blob = dt.items[i].getAsFile()
|
||||
if (!blob) return
|
||||
addRef.current(imageItem(blob))
|
||||
return
|
||||
}
|
||||
}
|
||||
const text = dt.getData("text/plain")
|
||||
if (text) addRef.current({ id: newId(), kind: "text", value: text })
|
||||
}
|
||||
const onMessage = (e: MessageEvent) => {
|
||||
const d = e.data
|
||||
if (!d || d.type !== "clipboard") return
|
||||
const p = d.payload
|
||||
if (p?.kind === "image" && typeof p.dataUrl === "string") {
|
||||
// data URL → Blob 로 변환해 저장(paste 와 동일 취급).
|
||||
fetch(p.dataUrl)
|
||||
.then((r) => r.blob())
|
||||
.then((blob) => addRef.current(imageItem(blob)))
|
||||
.catch(() => {})
|
||||
} else if (p?.kind === "text" && typeof p.value === "string") {
|
||||
addRef.current({ id: newId(), kind: "text", value: p.value })
|
||||
}
|
||||
}
|
||||
document.addEventListener("paste", onPaste)
|
||||
window.addEventListener("message", onMessage)
|
||||
return () => {
|
||||
document.removeEventListener("paste", onPaste)
|
||||
window.removeEventListener("message", onMessage)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 언마운트 시 남은 object URL 정리.
|
||||
const itemsRef = useRef(items)
|
||||
itemsRef.current = items
|
||||
useEffect(
|
||||
() => () => {
|
||||
for (const it of itemsRef.current) {
|
||||
if (it.kind === "image") URL.revokeObjectURL(it.url)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// 이력 클릭 → OS 클립보드로 다시 복사(다른 앱에 붙이게).
|
||||
const copyText = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
toast.success("클립보드에 복사됨")
|
||||
} catch {
|
||||
toast.error("복사 실패")
|
||||
}
|
||||
}
|
||||
const copyImage = async (blob: Blob) => {
|
||||
try {
|
||||
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })])
|
||||
toast.success("이미지 클립보드에 복사됨")
|
||||
} catch {
|
||||
toast.error("이미지 복사 실패")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-border flex max-h-72 flex-none flex-col border-b">
|
||||
<div className="flex flex-none items-center gap-1.5 px-3 py-2.5">
|
||||
<Clipboard className="text-muted-foreground size-3" />
|
||||
<span className="text-muted-foreground font-mono text-[10px] tracking-widest uppercase">
|
||||
Clipboard
|
||||
</span>
|
||||
{items.length > 0 && (
|
||||
<span className="text-muted-foreground/60 ml-auto font-mono text-[9px]">
|
||||
{items.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<p className="border-border text-muted-foreground/60 mx-2.5 mb-2.5 rounded border border-dashed px-2 py-3 text-center font-mono text-[10px] leading-relaxed">
|
||||
Ctrl+V 로 붙여넣으면
|
||||
<br />
|
||||
여기 이력에 쌓임
|
||||
</p>
|
||||
) : (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="flex flex-col gap-1 overflow-y-auto px-1.5 pb-1.5">
|
||||
{items.map((it) =>
|
||||
it.kind === "image" ? (
|
||||
<Tooltip key={it.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyImage(it.blob)}
|
||||
title="클릭하면 이미지 클립보드에 복사"
|
||||
className="border-border hover:border-ring relative overflow-hidden rounded border"
|
||||
>
|
||||
<img
|
||||
src={it.url}
|
||||
alt="클립보드 이미지"
|
||||
className="bg-muted/40 max-h-20 w-full object-contain"
|
||||
/>
|
||||
<span className="bg-background/80 text-muted-foreground absolute top-1 right-1 rounded px-1 font-mono text-[8px]">
|
||||
IMG
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
{/* hover 미리보기 — 큰 이미지 */}
|
||||
<TooltipContent side="right" align="start" className="p-1">
|
||||
<img
|
||||
src={it.url}
|
||||
alt="클립보드 이미지 미리보기"
|
||||
className="max-h-64 max-w-xs rounded object-contain"
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip key={it.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(it.value)}
|
||||
title="클릭하면 클립보드에 복사"
|
||||
className="hover:bg-accent flex items-start gap-1.5 rounded px-2 py-1.5 text-left"
|
||||
>
|
||||
<Type className="text-muted-foreground/70 mt-0.5 size-3 flex-none" />
|
||||
<span className="text-foreground/80 line-clamp-2 font-mono text-[10px] leading-snug break-words whitespace-pre-wrap">
|
||||
{it.value.slice(0, 160)}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
{/* hover 미리보기 — 전체 텍스트(길면 스크롤) */}
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="start"
|
||||
className="max-h-72 max-w-md overflow-auto"
|
||||
>
|
||||
<pre className="font-mono text-[11px] leading-snug break-words whitespace-pre-wrap">
|
||||
{it.value}
|
||||
</pre>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { Check, ClipboardPaste, Copy } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { isWebView, pasteToApp } from "@/lib/bridge/webviewBridge"
|
||||
import hljs from "highlight.js/lib/core"
|
||||
import sql from "highlight.js/lib/languages/sql"
|
||||
import json from "highlight.js/lib/languages/json"
|
||||
import diff from "highlight.js/lib/languages/diff"
|
||||
import yaml from "highlight.js/lib/languages/yaml"
|
||||
import plaintext from "highlight.js/lib/languages/plaintext"
|
||||
import abap from "./abapHljs"
|
||||
import "highlight.js/styles/github-dark.css"
|
||||
import "./abapLight.css"
|
||||
|
||||
// 코어 빌드에 필요한 언어만 등록(번들 최소화). ABAP 은 커스텀 문법.
|
||||
hljs.registerLanguage("sql", sql)
|
||||
hljs.registerLanguage("json", json)
|
||||
hljs.registerLanguage("diff", diff)
|
||||
hljs.registerLanguage("yaml", yaml)
|
||||
hljs.registerLanguage("plaintext", plaintext)
|
||||
hljs.registerLanguage("abap", abap)
|
||||
|
||||
// 펜스 언어 라벨 → 등록된 문법 매핑.
|
||||
const ALIAS: Record<string, string> = {
|
||||
cds: "abap",
|
||||
ddl: "abap",
|
||||
abapsql: "abap",
|
||||
sqlscript: "sql",
|
||||
text: "plaintext",
|
||||
txt: "plaintext",
|
||||
yml: "yaml",
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c] ?? c)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
code: string
|
||||
lang?: string
|
||||
/** NavRail 점프 대상 식별용 인덱스. */
|
||||
index?: number
|
||||
/** 주변 UI가 제목과 액션을 담당하는 독립 프리뷰에서는 코드만 표시함. */
|
||||
plain?: boolean
|
||||
}
|
||||
|
||||
export function CodeBlock({ code, lang, index, plain = false }: Props) {
|
||||
const key = (lang ?? "").toLowerCase()
|
||||
const resolved = ALIAS[key] ?? key
|
||||
// ABAP 은 SAP 에디터처럼 밝은 배경으로 렌더 — 나머지 언어는 어두운 테마 유지.
|
||||
const isAbap = resolved === "abap"
|
||||
// 언어 없는 펜스(ASCII 다이어그램 등)도 밝게 — 어두운 코드 테마는 언어 지정 블록만.
|
||||
const isLight = isAbap || resolved === ""
|
||||
const html = useMemo(() => {
|
||||
if (resolved && hljs.getLanguage(resolved)) {
|
||||
return hljs.highlight(code, { language: resolved, ignoreIllegals: true }).value
|
||||
}
|
||||
return escapeHtml(code)
|
||||
}, [code, resolved])
|
||||
|
||||
const lineCount = useMemo(() => code.replace(/\n$/, "").split("\n").length, [code])
|
||||
|
||||
return (
|
||||
<div
|
||||
data-code-block={index ?? 0}
|
||||
data-code-lang={lang ?? "code"}
|
||||
className={
|
||||
plain
|
||||
? `h-full min-h-0 overflow-hidden ${isLight ? "bg-white text-zinc-900" : "bg-[#0d1117] text-zinc-100"}`
|
||||
: isLight
|
||||
? "my-2.5 overflow-hidden rounded-lg border border-zinc-300 bg-white text-zinc-900 shadow-sm"
|
||||
: "my-2.5 overflow-hidden rounded-lg border border-zinc-700/60 bg-[#0d1117] text-zinc-100 shadow-sm"
|
||||
}
|
||||
>
|
||||
{!plain && (
|
||||
<div
|
||||
className={
|
||||
isLight
|
||||
? "flex items-center gap-2 border-b border-zinc-200 bg-zinc-50 px-3 py-1.5"
|
||||
: "flex items-center gap-2 border-b border-white/5 bg-white/[0.03] px-3 py-1.5"
|
||||
}
|
||||
>
|
||||
<span className="flex gap-1">
|
||||
<span className="size-2 rounded-full bg-rose-400/70" />
|
||||
<span className="size-2 rounded-full bg-amber-400/70" />
|
||||
<span className="size-2 rounded-full bg-emerald-400/70" />
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
isLight
|
||||
? "ml-1 font-mono text-[10px] tracking-wider text-zinc-500 uppercase"
|
||||
: "ml-1 font-mono text-[10px] tracking-wider text-zinc-400 uppercase"
|
||||
}
|
||||
>
|
||||
#<span data-code-num>{(index ?? 0) + 1}</span> · {lang ?? "code"}
|
||||
</span>
|
||||
<CodeActions code={code} isLight={isLight} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`flex overflow-auto text-xs leading-relaxed ${plain ? "h-full" : "max-h-96"}`}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className={
|
||||
isLight
|
||||
? "flex-none border-r border-zinc-200 px-2.5 py-3 text-right font-mono text-zinc-400 select-none"
|
||||
: "flex-none border-r border-white/5 px-2.5 py-3 text-right font-mono text-zinc-600 select-none"
|
||||
}
|
||||
>
|
||||
{Array.from({ length: lineCount }, (_, i) => (
|
||||
<div key={i}>{i + 1}</div>
|
||||
))}
|
||||
</div>
|
||||
<pre className="min-w-0 flex-1 px-3 py-3 font-mono whitespace-pre">
|
||||
<code
|
||||
className={isAbap ? "hljs-abap-light" : undefined}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 코드 카드와 스니펫 하단에서 같은 복사·붙여넣기 동작을 재사용함. */
|
||||
export function CodeActions({ code, isLight = true }: { code: string; isLight?: boolean }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
// 헤더 버튼 공통 스타일(복사·붙여넣기 공유). ml-auto 는 감싸는 컨테이너가 가짐.
|
||||
const btnClass = isLight
|
||||
? "inline-flex items-center gap-1 rounded border border-zinc-300 px-2 py-0.5 font-mono text-[10px] text-zinc-600 transition-colors hover:border-zinc-400 hover:text-zinc-900"
|
||||
: "inline-flex items-center gap-1 rounded border border-zinc-700 px-2 py-0.5 font-mono text-[10px] text-zinc-300 transition-colors hover:border-zinc-500 hover:text-white"
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code)
|
||||
setCopied(true)
|
||||
toast.success("클립보드에 복사됨")
|
||||
setTimeout(() => setCopied(false), 1400)
|
||||
} catch {
|
||||
toast.error("복사 실패")
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{isWebView() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pasteToApp(code)}
|
||||
className={btnClass}
|
||||
title="런처 소환 직전 앱에 붙여넣기(붙이고 창은 자동으로 숨김)"
|
||||
>
|
||||
<ClipboardPaste className="size-3" />
|
||||
앱에 붙여넣기
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={copy} className={btnClass}>
|
||||
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
|
||||
{copied ? "복사됨" : "복사"}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it, afterEach, vi } from "vitest"
|
||||
import { render, screen, cleanup, act, fireEvent } from "@testing-library/react"
|
||||
import { Composer } from "./Composer"
|
||||
import { initBridgeNavigate, consumePendingCaptureImage } from "@/lib/bridge/bridgeNavigate"
|
||||
|
||||
type Listener = (e: MessageEvent) => void
|
||||
|
||||
function mockWebview() {
|
||||
let listener: Listener | undefined
|
||||
;(window as unknown as { chrome?: unknown }).chrome = {
|
||||
webview: {
|
||||
postMessage: () => {},
|
||||
addEventListener: (type: string, cb: Listener) => {
|
||||
if (type === "message") listener = cb
|
||||
},
|
||||
},
|
||||
}
|
||||
return { emit: (data: unknown) => listener?.({ data } as MessageEvent) }
|
||||
}
|
||||
|
||||
describe("Composer 캡쳐 이미지 첨부", () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
delete (window as unknown as { chrome?: unknown }).chrome
|
||||
})
|
||||
|
||||
// 코드리뷰 Critical 회귀 재현: onCapture 가 이벤트 detail 만 읽고 pendingCaptureImage 를
|
||||
// 안 비우면, 리마운트(다른 대화 갔다가 새 대화 재진입) 시 마운트 이펙트가 옛 캡쳐를 재소비함.
|
||||
it("리마운트 시 이미 소비된 캡쳐 이미지를 다시 첨부하지 않음", () => {
|
||||
const wv = mockWebview()
|
||||
initBridgeNavigate()
|
||||
|
||||
const { unmount } = render(<Composer onSend={() => {}} acceptCapture />)
|
||||
act(() => wv.emit({ type: "capture.image", dataUrl: "data:image/png;base64,abc" }))
|
||||
expect(screen.getAllByRole("img")).toHaveLength(1)
|
||||
|
||||
unmount()
|
||||
render(<Composer onSend={() => {}} acceptCapture />) // 다른 대화로 이동했다가 새 대화 재진입 시뮬레이션
|
||||
expect(screen.queryAllByRole("img")).toHaveLength(0)
|
||||
})
|
||||
|
||||
// 코드리뷰 Important 회귀 재현: 기존 대화방(SessionChatPage, acceptCapture 없음)이 살아있는 채로
|
||||
// capture.image 가 오면, 리마운트 전에 그 Composer가 pending을 훔쳐가 새 대화 Composer가 못 받음.
|
||||
it("acceptCapture 없으면 capture 이벤트를 무시하고 pending을 안 건드림(출발지 도둑질 방지)", () => {
|
||||
const wv = mockWebview()
|
||||
initBridgeNavigate()
|
||||
|
||||
render(<Composer onSend={() => {}} />) // acceptCapture 없음 — 기존 대화방 시뮬레이션
|
||||
act(() => wv.emit({ type: "capture.image", dataUrl: "data:image/png;base64,xyz" }))
|
||||
|
||||
expect(screen.queryAllByRole("img")).toHaveLength(0) // 여기엔 안 붙음
|
||||
// pending이 안 비워졌어야 — 곧 마운트될 새 대화 Composer가 그대로 소비 가능해야 함
|
||||
expect(consumePendingCaptureImage()).toBe("data:image/png;base64,xyz")
|
||||
})
|
||||
|
||||
it("캡쳐 이미지만 있어도 이미지 계약으로 전송한다", () => {
|
||||
const wv = mockWebview()
|
||||
initBridgeNavigate()
|
||||
const onSend = vi.fn()
|
||||
render(<Composer onSend={onSend} acceptCapture />)
|
||||
act(() => wv.emit({ type: "capture.image", dataUrl: "data:image/png;base64,eA==" }))
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "전송" }))
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith("", [
|
||||
{ mediaType: "image/png", data: "data:image/png;base64,eA==" },
|
||||
])
|
||||
expect(screen.queryAllByRole("img")).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("이미지는 최대 4장까지만 첨부한다", () => {
|
||||
const wv = mockWebview()
|
||||
initBridgeNavigate()
|
||||
render(<Composer onSend={() => {}} acceptCapture />)
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
act(() => wv.emit({ type: "capture.image", dataUrl: `data:image/png;base64,eA${i}=` }))
|
||||
}
|
||||
|
||||
expect(screen.queryAllByRole("img")).toHaveLength(4)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,298 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { BookOpen, ClipboardPaste, Send, Square, X } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
import { cn } from "@/lib/utils/cn"
|
||||
import { consumePendingCaptureImage } from "@/lib/bridge/bridgeNavigate"
|
||||
import { useSnapChatStore } from "../store/snapChatStore"
|
||||
import type { SnapImageInput, SnapImageMediaType } from "../contract/types"
|
||||
|
||||
interface Props {
|
||||
onSend: (text: string, images: SnapImageInput[]) => void
|
||||
busy?: boolean
|
||||
onStop?: () => void
|
||||
placeholder?: string
|
||||
// 캡쳐 이미지 첨부를 받을지 — 새 대화(NewChatPage)만 true. 기존 대화방(SessionChatPage)이 켜져
|
||||
// 있으면 navigate(/snap/new)+capture.image 순서에서 리마운트 전에 여기가 pending을 훔쳐가
|
||||
// 정작 새 대화 Composer엔 이미지가 안 붙는 레이스가 생김 — 그래서 출발지는 아예 안 건드리게 게이팅.
|
||||
acceptCapture?: boolean
|
||||
}
|
||||
|
||||
// Blob → data URL(base64). 클립보드 이미지를 chat 계약으로 바꿀 때 씀.
|
||||
function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const r = new FileReader()
|
||||
r.onload = () => resolve(r.result as string)
|
||||
r.onerror = () => reject(r.error)
|
||||
r.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
// 붙여넣기 텍스트가 이 길이 이상이면 입력창에 안 넣고 접힌 칩(첨부)으로 보관.
|
||||
const PASTE_COLLAPSE = 100
|
||||
const IMAGE_TYPES = new Set<SnapImageMediaType>(["image/png", "image/jpeg", "image/webp"])
|
||||
const MAX_IMAGES = 4
|
||||
const MAX_IMAGE_BYTES = 5 * 1024 * 1024
|
||||
const MAX_TOTAL_IMAGE_BYTES = 15 * 1024 * 1024
|
||||
|
||||
function imageByteLength(dataUrl: string): number {
|
||||
const encoded = dataUrl.slice(dataUrl.indexOf(",") + 1)
|
||||
const padding = encoded.endsWith("==") ? 2 : encoded.endsWith("=") ? 1 : 0
|
||||
return Math.max(0, Math.floor((encoded.length * 3) / 4) - padding)
|
||||
}
|
||||
|
||||
function imageFromDataUrl(data: string): SnapImageInput | null {
|
||||
const match = /^data:(image\/(?:png|jpeg|webp));base64,/i.exec(data)
|
||||
if (!match || !IMAGE_TYPES.has(match[1].toLowerCase() as SnapImageMediaType)) return null
|
||||
return { mediaType: match[1].toLowerCase() as SnapImageMediaType, data }
|
||||
}
|
||||
|
||||
export function Composer({ onSend, busy, onStop, placeholder, acceptCapture }: Props) {
|
||||
const [value, setValue] = useState("")
|
||||
// 100자↑ 붙여넣기로 접어둔 텍스트들. 전송 시 입력값과 합쳐 보냄.
|
||||
const [attachments, setAttachments] = useState<string[]>([])
|
||||
// 캡쳐·클립보드에서 받은 일회성 이미지 첨부. 백엔드는 원본을 저장하지 않음.
|
||||
const [imageAttachments, setImageAttachments] = useState<SnapImageInput[]>([])
|
||||
const explain = useSnapChatStore((s) => s.explain)
|
||||
const setExplain = useSnapChatStore((s) => s.setExplain)
|
||||
|
||||
const addImage = (dataUrl: string) => {
|
||||
const image = imageFromDataUrl(dataUrl)
|
||||
if (!image) {
|
||||
toast.error("PNG, JPEG, WebP 이미지만 첨부할 수 있어")
|
||||
return
|
||||
}
|
||||
const bytes = imageByteLength(dataUrl)
|
||||
if (bytes > MAX_IMAGE_BYTES) {
|
||||
toast.error("이미지는 한 장당 5 MiB 이하여야 해")
|
||||
return
|
||||
}
|
||||
setImageAttachments((current) => {
|
||||
if (current.length >= MAX_IMAGES) {
|
||||
toast.error("이미지는 최대 4장까지 첨부할 수 있어")
|
||||
return current
|
||||
}
|
||||
const total = current.reduce((sum, item) => sum + imageByteLength(item.data), 0) + bytes
|
||||
if (total > MAX_TOTAL_IMAGE_BYTES) {
|
||||
toast.error("이미지 전체 크기는 15 MiB 이하여야 해")
|
||||
return current
|
||||
}
|
||||
return [...current, image]
|
||||
})
|
||||
}
|
||||
|
||||
// 마운트 시(새 대화·지난 대화 진입) 입력창에 커서. rAF로 webview 포커스 안정화 후.
|
||||
const taRef = useRef<HTMLTextAreaElement>(null)
|
||||
useEffect(() => {
|
||||
const raf = requestAnimationFrame(() => taRef.current?.focus())
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [])
|
||||
|
||||
// 캡쳐 이미지 수신 — 마운트 시 놓친 것 consume(네비 직후 이벤트를 놓쳐도 반영) + 이후는 이벤트로 누적(FR-008).
|
||||
// acceptCapture 아니면 아예 pending을 안 건드림 — 기존 대화방(SessionChatPage)이 새 대화
|
||||
// 마운트보다 먼저 훔쳐가 이미지가 유실되는 레이스 방지(출발지 게이팅).
|
||||
useEffect(() => {
|
||||
if (!acceptCapture) return
|
||||
const pending = consumePendingCaptureImage()
|
||||
if (pending) addImage(pending)
|
||||
const onCapture = () => {
|
||||
// detail 대신 consume — 같은 동기 스택이라 값은 동일, 이걸로 pending도 같이 비워야
|
||||
// 나중에 리마운트될 때(다른 대화→새 대화) stale 이미지가 재소비되지 않음.
|
||||
const dataUrl = consumePendingCaptureImage()
|
||||
if (dataUrl) addImage(dataUrl)
|
||||
}
|
||||
window.addEventListener("bridge:captureImage", onCapture)
|
||||
return () => window.removeEventListener("bridge:captureImage", onCapture)
|
||||
}, [acceptCapture])
|
||||
|
||||
const removeAttachment = (i: number) => setAttachments((a) => a.filter((_, j) => j !== i))
|
||||
const removeImageAttachment = (i: number) =>
|
||||
setImageAttachments((a) => a.filter((_, j) => j !== i))
|
||||
|
||||
const submit = () => {
|
||||
// 접어둔 첨부들 먼저, 그다음 입력값 — 빈 건 빼고 이어붙임.
|
||||
const combined = [...attachments, value.trim()].filter(Boolean).join("\n\n")
|
||||
if ((!combined && imageAttachments.length === 0) || busy) return
|
||||
setValue("")
|
||||
setAttachments([])
|
||||
const images = imageAttachments
|
||||
setImageAttachments([])
|
||||
onSend(combined, images)
|
||||
}
|
||||
// 이 환경이 클립보드 읽기(텍스트+이미지)를 지원하는가 — 비 https·구형 웹뷰면 read 가 없음.
|
||||
const clipboardSupported =
|
||||
typeof navigator !== "undefined" && typeof navigator.clipboard?.read === "function"
|
||||
|
||||
// 클릭(사용자 제스처)이라 read() 가 먹음. 텍스트·이미지만 지원.
|
||||
const pasteFromClipboard = async () => {
|
||||
if (!clipboardSupported) {
|
||||
toast.error("이 환경은 클립보드 읽기를 지원하지 않아")
|
||||
return
|
||||
}
|
||||
try {
|
||||
const items = await navigator.clipboard.read()
|
||||
let gotText = false
|
||||
let gotImage = false
|
||||
for (const item of items) {
|
||||
const imageType = item.types.find((t) => t.startsWith("image/"))
|
||||
if (imageType) {
|
||||
const dataUrl = await blobToDataUrl(await item.getType(imageType))
|
||||
addImage(dataUrl)
|
||||
gotImage = true
|
||||
} else if (item.types.includes("text/plain")) {
|
||||
const text = (await (await item.getType("text/plain")).text()).trim()
|
||||
if (text) {
|
||||
// 길면 접힌 칩으로, 짧으면 입력창에 그대로.
|
||||
if (text.length >= PASTE_COLLAPSE) setAttachments((a) => [...a, text])
|
||||
else setValue((v) => (v ? `${v}\n${text}` : text))
|
||||
gotText = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (gotImage && !gotText) toast.success("이미지를 질문에 첨부했어")
|
||||
else if (!gotText && !gotImage) toast.info("클립보드에 텍스트·이미지가 없어")
|
||||
} catch {
|
||||
// NotAllowedError 등 — 사용자가 권한을 막았거나 브라우저가 거부.
|
||||
toast.error("클립보드를 읽을 수 없어 (권한 거부됨)")
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="border-border bg-card flex-none border-t p-3">
|
||||
<div className="border-border bg-background focus-within:border-ring focus-within:ring-ring/20 rounded-lg border p-2 focus-within:ring-2">
|
||||
{/* 캡쳐·클립보드 이미지 첨부 — 썸네일 + X 로 제거 */}
|
||||
{imageAttachments.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{imageAttachments.map((image, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="border-border bg-muted/60 relative flex items-center rounded-md border p-1"
|
||||
>
|
||||
<img
|
||||
src={image.data}
|
||||
alt={`캡쳐 이미지 ${i + 1}`}
|
||||
className="h-12 w-12 rounded object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImageAttachment(i)}
|
||||
aria-label="캡쳐 이미지 제거"
|
||||
className="bg-background border-border text-muted-foreground hover:text-foreground absolute -top-1.5 -right-1.5 rounded-full border p-0.5"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 100자↑ 붙여넣기 첨부 — 일부만 보이고 X 로 제거 */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{attachments.map((a, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="border-border bg-muted/60 text-muted-foreground flex max-w-full items-center gap-1.5 rounded-md border px-2 py-1 text-[11px]"
|
||||
>
|
||||
<ClipboardPaste className="size-3.5 flex-none text-emerald-500" />
|
||||
<span className="min-w-0 truncate font-mono">
|
||||
{a.replace(/\s+/g, " ").slice(0, 40)}…
|
||||
</span>
|
||||
<span className="flex-none opacity-50">{a.length}자</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAttachment(i)}
|
||||
aria-label="첨부 제거"
|
||||
className="hover:text-foreground flex-none"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={taRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onPaste={(e) => {
|
||||
const imageFile = Array.from(e.clipboardData.files).find((file) =>
|
||||
file.type.startsWith("image/")
|
||||
)
|
||||
if (imageFile) {
|
||||
e.preventDefault()
|
||||
void blobToDataUrl(imageFile).then(addImage)
|
||||
return
|
||||
}
|
||||
const text = e.clipboardData.getData("text/plain")
|
||||
if (text.length >= PASTE_COLLAPSE) {
|
||||
e.preventDefault() // 길면 입력창에 안 넣고 접힌 칩으로
|
||||
setAttachments((a) => [...a, text])
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
disabled={busy}
|
||||
placeholder={busy ? "응답 중…" : (placeholder ?? "메시지를 입력하세요…")}
|
||||
className="placeholder:text-muted-foreground field-sizing-content max-h-32 min-h-10 w-full resize-none bg-transparent text-sm outline-none"
|
||||
/>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={pasteFromClipboard}
|
||||
disabled={busy || !clipboardSupported}
|
||||
title={clipboardSupported ? "클립보드에서 가져오기" : "이 환경은 클립보드 읽기 미지원"}
|
||||
className="border-border text-muted-foreground hover:border-ring hover:text-foreground inline-flex items-center gap-1 rounded-md border px-2 py-1 font-mono text-[10px] transition-colors disabled:opacity-50"
|
||||
>
|
||||
<ClipboardPaste className="size-3" />
|
||||
클립보드
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExplain(!explain)}
|
||||
aria-pressed={explain}
|
||||
title={explain ? "설명 모드 — 배경·원리까지" : "간결 모드 — 답만"}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md border px-2 py-1 font-mono text-[10px] transition-colors",
|
||||
explain
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:border-ring hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<BookOpen className="size-3" />
|
||||
설명
|
||||
</button>
|
||||
<span className="text-muted-foreground font-mono text-[10px] tracking-wide">
|
||||
Enter 전송 · Shift+Enter 줄바꿈
|
||||
</span>
|
||||
{busy ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="ml-auto"
|
||||
onClick={onStop}
|
||||
>
|
||||
<Square className="size-3.5 fill-current" />
|
||||
중단
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
disabled={!value.trim() && attachments.length === 0 && imageAttachments.length === 0}
|
||||
onClick={submit}
|
||||
>
|
||||
<Send className="size-3.5" />
|
||||
전송
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export function Hero() {
|
||||
return (
|
||||
<section className="flex flex-col items-center px-6 py-10 text-center">
|
||||
<div className="bg-primary text-primary-foreground mb-4 grid size-14 place-items-center rounded-xl font-serif text-2xl font-bold italic">
|
||||
S
|
||||
</div>
|
||||
<span className="text-muted-foreground mb-3 inline-flex items-center gap-1.5 font-mono text-[10px] tracking-widest uppercase">
|
||||
<span className="size-1.5 rounded-full bg-emerald-500" />
|
||||
New Session
|
||||
</span>
|
||||
<h1 className="mb-2 font-serif text-2xl font-semibold italic">무엇을 도와드릴까요?</h1>
|
||||
<p className="text-muted-foreground max-w-sm text-sm leading-relaxed">
|
||||
ABAP · CDS 뷰 · HANA · 에러 분석까지 — 코드나 로그를 붙여넣거나 질문을 입력해봐.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import { Message } from "./Message"
|
||||
import type { SnapRole } from "../contract/types"
|
||||
|
||||
// JSX 에 role 을 문자열 리터럴로 쓰면 jsx-a11y 가 ARIA role 로 오해함 — 변수로 우회
|
||||
const assistant: SnapRole = "assistant"
|
||||
|
||||
describe("Message 코드펜스 렌더", () => {
|
||||
it("언어 없는 코드펜스(ASCII 다이어그램)도 CodeBlock 으로 렌더된다", () => {
|
||||
// 언어 라벨 없는 펜스 — 인라인 code 로 새면 ─── 연속 문자가 말풍선 밖으로 넘침
|
||||
const content = [
|
||||
"구조 제안:",
|
||||
"",
|
||||
"```",
|
||||
"┌──────────────────────────────┐",
|
||||
"│ ABAP Productivity App │",
|
||||
"└──────────────────────────────┘",
|
||||
"```",
|
||||
].join("\n")
|
||||
const { container } = render(<Message role={assistant} content={content} />)
|
||||
|
||||
// 스크롤 컨테이너 있는 CodeBlock 으로 감싸져야 함
|
||||
const block = container.querySelector("[data-code-block]")
|
||||
expect(block).not.toBeNull()
|
||||
// 언어 없는 펜스는 다크 코드 테마가 아니라 라이트로
|
||||
expect(block?.className).toContain("bg-white")
|
||||
// 인라인 code 스타일(bg-black/10)로 새지 않아야 함
|
||||
expect(container.querySelector("code.rounded")).toBeNull()
|
||||
})
|
||||
|
||||
it("언어 있는 코드펜스는 기존대로 CodeBlock + 언어 라벨", () => {
|
||||
const content = "```sql\nSELECT * FROM t;\n```"
|
||||
const { container } = render(<Message role={assistant} content={content} />)
|
||||
expect(container.querySelector('[data-code-lang="sql"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it("인라인 code 는 그대로 인라인으로 렌더된다", () => {
|
||||
const { container } = render(<Message role={assistant} content="이건 `SMOINT` 임" />)
|
||||
expect(container.querySelector("code.rounded")).not.toBeNull()
|
||||
expect(container.querySelector("[data-code-block]")).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,209 @@
|
||||
import { isValidElement, type ReactElement, type ReactNode } from "react"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import remarkGfm from "remark-gfm"
|
||||
import { MoreHorizontal, Copy, Download } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { cn } from "@/lib/utils/cn"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/shared/ui/dropdown-menu"
|
||||
import type { SnapRole } from "../contract/types"
|
||||
import { CodeBlock } from "./CodeBlock"
|
||||
import { fmtElapsed, fmtTokens } from "../lib/format"
|
||||
|
||||
interface Props {
|
||||
role: SnapRole
|
||||
content: string
|
||||
/** 이 답변 전체토큰(assistant). 있으면 버블 밑에 표기. */
|
||||
totalTokens?: number
|
||||
/** 이 답변 소요시간(ms). 라이브만 있음(과거는 elapsed 미저장). */
|
||||
elapsedMs?: number
|
||||
}
|
||||
|
||||
// assistant 마크다운 본문 prose 스타일 — 목업이 표/헤딩/리스트/diff 를 섞어 써서 각 요소 명시 스타일링.
|
||||
const PROSE = cn(
|
||||
"first:[&>*]:mt-0 last:[&>*]:mb-0",
|
||||
"[&_p]:my-1.5 [&_p]:leading-[1.9]",
|
||||
"[&_strong]:font-semibold [&_strong]:text-foreground",
|
||||
"[&_ul]:my-1.5 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-1.5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-0.5",
|
||||
"[&_blockquote]:my-1.5 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground",
|
||||
"[&_a]:font-medium [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2",
|
||||
"[&_th]:border [&_th]:border-border [&_th]:bg-foreground/5 [&_th]:px-2.5 [&_th]:py-1 [&_th]:text-left [&_th]:font-semibold",
|
||||
"[&_td]:border [&_td]:border-border [&_td]:px-2.5 [&_td]:py-1"
|
||||
)
|
||||
|
||||
export function Message({ role, content, totalTokens, elapsedMs }: Props) {
|
||||
const isUser = role === "user"
|
||||
let local = 0
|
||||
|
||||
// 답변 본문(마크다운) 복사 / .md 다운로드.
|
||||
const copyMd = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content)
|
||||
toast.success("복사됨 (마크다운)")
|
||||
} catch {
|
||||
toast.error("복사 실패")
|
||||
}
|
||||
}
|
||||
const downloadMd = () => {
|
||||
const blob = new Blob([content], { type: "text/markdown;charset=utf-8" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = "snap-답변.md"
|
||||
document.body.appendChild(a) // Firefox/일부 Chrome 은 DOM 에 붙어야 클릭 먹음
|
||||
a.click()
|
||||
a.remove()
|
||||
// 즉시 revoke 하면 다운로드 시작 전에 blob 이 사라져 ERR_FILE_NOT_FOUND — 한 틱 미룸
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1", isUser ? "items-end" : "items-start")}>
|
||||
<div className={cn("flex items-center gap-1.5", !isUser && "w-full")}>
|
||||
{!isUser && (
|
||||
<span className="bg-primary text-primary-foreground grid size-4 place-items-center rounded font-serif text-[9px] leading-none font-bold italic">
|
||||
S
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground font-mono text-[9.5px] tracking-widest uppercase">
|
||||
{isUser ? "나 · YOU" : "SNAP MATE"}
|
||||
</span>
|
||||
{!isUser && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="답변 메뉴"
|
||||
className="text-muted-foreground hover:bg-accent hover:text-foreground ml-auto inline-flex size-6 items-center justify-center rounded transition-colors"
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={copyMd}>
|
||||
<Copy className="size-3.5" />
|
||||
복사 (MD)
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={downloadMd}>
|
||||
<Download className="size-3.5" />
|
||||
다운로드 (MD)
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-2 text-sm leading-[1.9]",
|
||||
isUser
|
||||
? "bg-secondary text-secondary-foreground max-w-[85%] whitespace-pre-wrap"
|
||||
: "border-border bg-card text-card-foreground w-full border"
|
||||
)}
|
||||
>
|
||||
{isUser ? (
|
||||
content
|
||||
) : (
|
||||
<div className={PROSE}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
// 코드펜스는 언어 유무와 무관하게 전부 CodeBlock 으로.
|
||||
// pre 를 그냥 언랩하면 언어 없는 펜스(ASCII 다이어그램 등)가
|
||||
// 인라인 code 로 렌더돼 ─── 연속 문자가 말풍선 밖으로 넘침.
|
||||
pre: ({ children }) => {
|
||||
const child = isValidElement(children)
|
||||
? (children as ReactElement<{ className?: string; children?: ReactNode }>)
|
||||
: null
|
||||
if (!child) return <pre>{children}</pre>
|
||||
const match = /language-(\w+)/.exec(child.props.className ?? "")
|
||||
const index = local++
|
||||
return (
|
||||
<CodeBlock
|
||||
code={String(child.props.children ?? "").replace(/\n$/, "")}
|
||||
lang={match?.[1]}
|
||||
index={index}
|
||||
/>
|
||||
)
|
||||
},
|
||||
// 헤딩·구분선 여백은 inline style 로 직접 박음(Tailwind 재생성/캐시 이슈 회피).
|
||||
// top 마진 크게 줘서 앞 섹션이랑 확실히 떨어짐.
|
||||
h1: ({ children }) => (
|
||||
<h1
|
||||
style={{
|
||||
marginTop: "1.5rem",
|
||||
marginBottom: "0.5rem",
|
||||
fontSize: "1.125rem",
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2
|
||||
style={{
|
||||
marginTop: "1.5rem",
|
||||
marginBottom: "0.5rem",
|
||||
fontSize: "1rem",
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3
|
||||
style={{
|
||||
marginTop: "1.25rem",
|
||||
marginBottom: "0.375rem",
|
||||
fontSize: "0.9375rem",
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
hr: () => (
|
||||
<hr
|
||||
style={{
|
||||
marginTop: "1rem",
|
||||
marginBottom: "1rem",
|
||||
border: 0,
|
||||
borderTop: "1px solid var(--border)",
|
||||
}}
|
||||
/>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<div className="my-2 overflow-x-auto">
|
||||
<table className="w-full border-collapse text-xs">{children}</table>
|
||||
</div>
|
||||
),
|
||||
// 블록 코드는 위 pre 에서 다 처리되니 여기 오는 건 인라인뿐.
|
||||
code: ({ children }) => (
|
||||
<code className="rounded bg-black/10 px-1 py-0.5 font-mono text-[0.85em]">
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isUser && totalTokens != null && (
|
||||
<span className="text-muted-foreground/60 px-1 font-mono text-[9px] tabular-nums">
|
||||
총 {fmtTokens(totalTokens)} tok
|
||||
{elapsedMs != null && ` · ${fmtElapsed(elapsedMs)}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState } from "react"
|
||||
import { Check, Code2, Copy } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { ClipboardHistory } from "./ClipboardHistory"
|
||||
|
||||
interface Block {
|
||||
index: number
|
||||
lang: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** 렌더된 코드블럭 목록(DOM 순서, index=data-code-block 과 동일). */
|
||||
blocks: Block[]
|
||||
/** 스트림 컨테이너 ref — 코드블럭으로 스크롤 점프. */
|
||||
containerRef: React.RefObject<HTMLDivElement>
|
||||
}
|
||||
|
||||
export function NavRail({ blocks, containerRef }: Props) {
|
||||
const [copiedIdx, setCopiedIdx] = useState<number | null>(null)
|
||||
|
||||
const jump = (index: number) => {
|
||||
const el = containerRef.current?.querySelector<HTMLElement>(`[data-code-block="${index}"]`)
|
||||
el?.scrollIntoView({ behavior: "smooth", block: "center" })
|
||||
}
|
||||
|
||||
// 점프와 같은 방식으로 해당 코드블럭 DOM 을 찾아 실제 코드 텍스트를 복사.
|
||||
const copy = async (index: number) => {
|
||||
const el = containerRef.current?.querySelector<HTMLElement>(
|
||||
`[data-code-block="${index}"] pre code`
|
||||
)
|
||||
const text = el?.textContent
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopiedIdx(index)
|
||||
toast.success("클립보드에 복사됨")
|
||||
setTimeout(() => setCopiedIdx(null), 1400)
|
||||
} catch {
|
||||
toast.error("복사 실패")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="border-border bg-card hidden w-52 flex-none flex-col border-r min-[540px]:flex">
|
||||
<ClipboardHistory />
|
||||
|
||||
<div className="border-border flex flex-none items-center gap-1.5 border-b px-3 py-2.5">
|
||||
<span className="size-1.5 rounded-full bg-emerald-500" />
|
||||
<span className="text-muted-foreground font-mono text-[10px] tracking-widest uppercase">
|
||||
Source Nav
|
||||
</span>
|
||||
</div>
|
||||
{blocks.length === 0 ? (
|
||||
<div className="text-muted-foreground p-4 font-mono text-[10px] leading-relaxed">
|
||||
코드블럭 없음
|
||||
<br />
|
||||
대화를 시작하면
|
||||
<br />
|
||||
여기에 표시됨.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-1.5">
|
||||
{blocks.map((b) => (
|
||||
<div key={b.index} className="group hover:bg-accent flex items-center rounded">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => jump(b.index)}
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 rounded px-2 py-1.5 text-left font-mono text-[11px]"
|
||||
>
|
||||
<Code2 className="text-muted-foreground size-3 flex-none" />
|
||||
<span className="text-muted-foreground flex-none tabular-nums">#{b.index + 1}</span>
|
||||
<span className="truncate">{b.lang}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copy(b.index)}
|
||||
aria-label="코드 복사"
|
||||
title="코드 복사"
|
||||
className="text-muted-foreground/50 hover:text-foreground flex-none rounded p-1.5 transition-colors"
|
||||
>
|
||||
{copiedIdx === b.index ? (
|
||||
<Check className="size-3 text-emerald-500" />
|
||||
) : (
|
||||
<Copy className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { CornerDownRight, MessageSquare } from "lucide-react"
|
||||
import type { ReactNode } from "react"
|
||||
import type { SnapMessage } from "../contract/types"
|
||||
import { formatRelativeKo } from "@/lib/utils/relativeTime"
|
||||
|
||||
interface Props {
|
||||
hit: SnapMessage
|
||||
query: string
|
||||
onOpen: (sessionId: string) => void
|
||||
/** 키보드 이동으로 선택된 항목 — 하이라이트 + 스크롤 대상. */
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
// 매칭 지점 주변만 잘라 보여주고, 검색어를 <mark> 로 강조.
|
||||
function snippet(text: string, query: string): ReactNode {
|
||||
const idx = text.toLowerCase().indexOf(query.toLowerCase())
|
||||
if (idx < 0) return text.slice(0, 120)
|
||||
const start = Math.max(0, idx - 40)
|
||||
const end = Math.min(text.length, idx + query.length + 80)
|
||||
return (
|
||||
<>
|
||||
{start > 0 && "…"}
|
||||
{text.slice(start, idx)}
|
||||
<mark className="bg-primary/20 text-foreground rounded px-0.5">
|
||||
{text.slice(idx, idx + query.length)}
|
||||
</mark>
|
||||
{text.slice(idx + query.length, end)}
|
||||
{end < text.length && "…"}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function SearchHitCard({ hit, query, onOpen, selected }: Props) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(hit.sessionId)}
|
||||
data-snap-selected={selected ? "true" : undefined}
|
||||
className={`focus-visible:ring-ring flex w-full items-start gap-3 rounded-lg p-3 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none ${
|
||||
selected ? "bg-accent text-accent-foreground" : "hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<MessageSquare className="text-muted-foreground mt-0.5 size-4 flex-none" />
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="line-clamp-2 text-xs leading-relaxed break-words whitespace-pre-wrap">
|
||||
{snippet(hit.content, query)}
|
||||
</span>
|
||||
<span className="text-muted-foreground mt-1.5 flex items-center gap-2 font-mono text-[9.5px]">
|
||||
<span className="tracking-wide uppercase">
|
||||
{hit.role === "user" ? "나" : "SNAP MATE"}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{formatRelativeKo(hit.createdAt)}</span>
|
||||
<CornerDownRight className="size-3" />
|
||||
<span>세션 열기</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { MessageSquare, ChevronRight } from "lucide-react"
|
||||
import type { SnapSession } from "../contract/types"
|
||||
import { formatRelativeKo } from "@/lib/utils/relativeTime"
|
||||
|
||||
interface Props {
|
||||
session: SnapSession
|
||||
onOpen: (id: string) => void
|
||||
/** 키보드 이동으로 선택된 항목 — 하이라이트 + 스크롤 대상. */
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
export function SessionCard({ session, onOpen, selected }: Props) {
|
||||
const title = session.titleLlm ?? session.title ?? "제목 없음"
|
||||
return (
|
||||
// 컴팩트 1행 — 세로를 1/3로. 제목·태그·시간·상태를 한 줄에, 스니펫은 생략(제목 title 로 노출).
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(session.id)}
|
||||
data-snap-selected={selected ? "true" : undefined}
|
||||
title={session.snippet ?? title}
|
||||
className={`focus-visible:ring-ring flex w-full items-center gap-3 rounded-lg px-3 py-3 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none ${
|
||||
selected ? "bg-accent text-accent-foreground" : "hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<span className="bg-muted text-muted-foreground grid size-8 flex-none place-items-center rounded-lg">
|
||||
<MessageSquare className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">{title}</span>
|
||||
{session.tag && (
|
||||
<span className="border-border text-muted-foreground flex-none rounded border px-1.5 font-mono text-[9.5px] tracking-wide uppercase">
|
||||
{session.tag}
|
||||
</span>
|
||||
)}
|
||||
{session.isGenerating && (
|
||||
<span className="size-1.5 flex-none rounded-full bg-emerald-500" title="생성 중" />
|
||||
)}
|
||||
<span className="text-muted-foreground flex-none font-mono text-[9.5px]">
|
||||
{formatRelativeKo(session.updatedAt)}
|
||||
</span>
|
||||
<ChevronRight className="text-muted-foreground size-3.5 flex-none" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useRef } from "react"
|
||||
import { Search, X } from "lucide-react"
|
||||
|
||||
interface Props {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
}
|
||||
|
||||
export function SessionSearch({ value, onChange }: Props) {
|
||||
// 런처 답게 뜨자마자 검색창에 포커스 — 여기서 타이핑=필터, ↑↓=목록 이동, Enter=열기.
|
||||
// 창이 다시 떠서(핫키 재소환) window 가 포커스 받을 때도 다시 잡아준다.
|
||||
const ref = useRef<HTMLInputElement>(null)
|
||||
useEffect(() => {
|
||||
const focus = () => ref.current?.focus()
|
||||
focus()
|
||||
window.addEventListener("focus", focus)
|
||||
return () => window.removeEventListener("focus", focus)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative flex min-w-0 flex-1 items-center">
|
||||
<Search className="text-muted-foreground pointer-events-none absolute left-0 size-5" />
|
||||
<input
|
||||
ref={ref}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-label="대화 내용 검색"
|
||||
placeholder="대화 내용 검색…"
|
||||
className="placeholder:text-muted-foreground/75 focus-visible:ring-ring/40 w-full rounded-md bg-transparent py-3 pr-9 pl-9 text-lg outline-none focus-visible:ring-2"
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="검색어 지우기"
|
||||
className="text-muted-foreground hover:bg-accent focus-visible:ring-ring absolute right-1 rounded p-1 focus-visible:ring-2"
|
||||
onClick={() => {
|
||||
onChange("")
|
||||
ref.current?.focus()
|
||||
}}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { hostKind } from "@/lib/bridge/transport"
|
||||
import { useEffect } from "react"
|
||||
import { Outlet, useNavigate } from "react-router-dom"
|
||||
import { PATHS } from "@/config/routes"
|
||||
import { startWindowDrag } from "@/lib/bridge/webviewBridge"
|
||||
import { SnapUserControls } from "./SnapUserControls"
|
||||
|
||||
/** 웹뷰 풀블리드 셸 — .NET 창이 진짜 크롬을 주므로 가짜 타이틀바는 없음. 얇은 브랜드 스트립만. */
|
||||
export function SnapLayout() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Ctrl+N → 새 대화(앱 안 어디서나). 브라우저 기본동작(새 창)은 막음.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.defaultPrevented || e.isComposing) return
|
||||
if (e.ctrlKey && !e.altKey && !e.shiftKey && (e.key === "n" || e.key === "N")) {
|
||||
e.preventDefault()
|
||||
navigate(PATHS.SNAP_NEW)
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKey)
|
||||
return () => window.removeEventListener("keydown", onKey)
|
||||
}, [navigate])
|
||||
|
||||
return (
|
||||
<div className="bg-background text-foreground flex h-screen flex-col">
|
||||
{/* 헤더 = 창 드래그 영역(프레임리스 제목표시줄 대체) — title-bar 드래그라 a11y 룰 한 줄 예외 */}
|
||||
{hostKind() !== "tauri" && (
|
||||
<header
|
||||
role="presentation"
|
||||
onMouseDown={startWindowDrag}
|
||||
className="border-border flex h-9 flex-none items-center gap-2 border-b px-4 select-none"
|
||||
>
|
||||
<span className="font-mono text-[11px] font-semibold tracking-[0.12em]">
|
||||
Chat Everywhere
|
||||
</span>
|
||||
</header>
|
||||
)}
|
||||
<SnapUserControls />
|
||||
<div className="min-h-0 flex-1">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { fireEvent, render, screen } from "@testing-library/react"
|
||||
import { MemoryRouter } from "react-router-dom"
|
||||
import { SnapUserControls } from "./SnapUserControls"
|
||||
|
||||
const mutate = vi.fn()
|
||||
|
||||
vi.mock("@/features/auth/hooks/useLogout", () => ({
|
||||
useLogout: () => ({ mutate, isPending: false }),
|
||||
}))
|
||||
|
||||
vi.mock("@/shared/components/ThemeToggle", () => ({
|
||||
ThemeToggle: () => <button type="button">테마 변경</button>,
|
||||
}))
|
||||
|
||||
describe("SnapUserControls", () => {
|
||||
beforeEach(() => mutate.mockClear())
|
||||
|
||||
it("사이드바 없이 테마 변경과 로그아웃을 표시", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SnapUserControls />
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
expect(screen.getByRole("button", { name: "테마 변경" })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole("button", { name: "로그아웃" }))
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { LogOut } from "lucide-react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { PATHS } from "@/config/routes"
|
||||
import { useLogout } from "@/features/auth/hooks/useLogout"
|
||||
import { ThemeToggle } from "@/shared/components/ThemeToggle"
|
||||
import { Button } from "@/shared/ui/button"
|
||||
|
||||
/** 사이드바 없는 Snap 창에서도 테마 변경과 로그아웃에 바로 접근하게 함. */
|
||||
export function SnapUserControls() {
|
||||
const navigate = useNavigate()
|
||||
const logout = useLogout()
|
||||
|
||||
const handleLogout = () => {
|
||||
logout.mutate(undefined, {
|
||||
onSettled: () => navigate(PATHS.LOGIN, { replace: true }),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-border bg-background/90 fixed top-11 right-3 z-40 flex items-center gap-1 rounded-lg border p-1 shadow-sm backdrop-blur">
|
||||
<ThemeToggle />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="로그아웃"
|
||||
title="로그아웃"
|
||||
disabled={logout.isPending}
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { HLJSApi, Language } from "highlight.js"
|
||||
|
||||
// highlight.js 코어엔 ABAP 이 없어서 경량 문법을 직접 등록한다.
|
||||
// ABAP OO + Open SQL + CDS(DDL) + RAP behavior 를 한 문법으로 커버 — 목업 렌더용이라 완벽 파서는 아님.
|
||||
export default function abap(hljs: HLJSApi): Language {
|
||||
const KEYWORDS =
|
||||
"select from into table where and or not as inner left right outer join on group by " +
|
||||
"order having distinct up to rows loop at endloop do enddo while endwhile if elseif " +
|
||||
"else endif case when others endcase data types constants field-symbols read append " +
|
||||
"modify insert update delete clear refresh sort binary search for all entries in " +
|
||||
"package size cond switch value new corresponding lines of exporting importing changing " +
|
||||
"returning raising method endmethod class endclass public protected private section " +
|
||||
"define view entity projection root key managed unmanaged implementation unique strict " +
|
||||
"behavior persistent lock master authorization instance create determination validation " +
|
||||
"association service expose annotate with begin end of is initial single"
|
||||
|
||||
return {
|
||||
name: "ABAP",
|
||||
case_insensitive: true,
|
||||
keywords: {
|
||||
keyword: KEYWORDS,
|
||||
built_in: "sy-subrc sy-tabix sy-index sy-datum sy-uzeit abap_true abap_false",
|
||||
},
|
||||
contains: [
|
||||
hljs.COMMENT("^\\*", "$"), // 전체 줄 주석 (* 로 시작)
|
||||
hljs.COMMENT('"', "$"), // 인라인 주석 (")
|
||||
{ className: "string", begin: "'", end: "'" },
|
||||
{ className: "string", begin: "`", end: "`" },
|
||||
{ className: "string", begin: "\\|", end: "\\|" }, // 문자열 템플릿 |...|
|
||||
{ className: "meta", begin: "@[A-Za-z][\\w.]*" }, // @UI.lineItem / @DATA 등
|
||||
hljs.C_NUMBER_MODE,
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/* SAP ABAP 에디터 룩: 흰 배경 + 파란 키워드 + 검정 본문.
|
||||
github-dark 의 전역 .hljs-* 색을 ABAP 블록에만 스코프로 덮어쓴다
|
||||
(.hljs-abap-light .hljs-keyword 는 specificity 로 전역 규칙을 이긴다). */
|
||||
.hljs-abap-light {
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.hljs-abap-light .hljs-keyword {
|
||||
color: #0033b3;
|
||||
font-weight: 600;
|
||||
}
|
||||
.hljs-abap-light .hljs-built_in {
|
||||
color: #0e7490;
|
||||
}
|
||||
.hljs-abap-light .hljs-string {
|
||||
color: #a31515;
|
||||
}
|
||||
.hljs-abap-light .hljs-comment {
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
.hljs-abap-light .hljs-meta {
|
||||
color: #7a3e9d;
|
||||
}
|
||||
.hljs-abap-light .hljs-number {
|
||||
color: #098658;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// base-backend modules/chat/schema.py 와 1:1 (camelCase). UI-only 필드는 명시 표기.
|
||||
export type SnapRole = "user" | "assistant" | "system"
|
||||
|
||||
export interface SnapSession {
|
||||
id: string
|
||||
title: string | null
|
||||
titleLlm: string | null
|
||||
isGenerating: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
// --- UI-only: 백엔드에 없음. real 스왑 시 드롭 또는 파생(snippet=마지막메시지) ---
|
||||
tag?: string
|
||||
snippet?: string
|
||||
tokens?: string
|
||||
}
|
||||
|
||||
export interface SnapMessage {
|
||||
sessionId: string
|
||||
role: SnapRole
|
||||
content: string // markdown
|
||||
createdAt: string
|
||||
// usage/timing — assistant 행에만 채워짐(user 는 null). 전체토큰 = input+output.
|
||||
inputTokens?: number | null
|
||||
outputTokens?: number | null
|
||||
costUsd?: number | null
|
||||
elapsedMs?: number | null
|
||||
}
|
||||
|
||||
export interface SnapSessionDetail extends SnapSession {
|
||||
messages: SnapMessage[]
|
||||
}
|
||||
|
||||
export type SnapImageMediaType = "image/png" | "image/jpeg" | "image/webp"
|
||||
|
||||
export interface SnapImageInput {
|
||||
mediaType: SnapImageMediaType
|
||||
data: string
|
||||
}
|
||||
|
||||
export interface SnapStreamRequest {
|
||||
sessionId: string
|
||||
content: string
|
||||
images?: SnapImageInput[]
|
||||
forcedSkill?: string
|
||||
// 설명 모드 토글 — true면 배경·원리까지, 기본(false)은 간결.
|
||||
explain?: boolean
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useEffect, useRef } from "react"
|
||||
|
||||
/**
|
||||
* Esc 전역 핸들러. 다이얼로그(Radix 등)가 이미 처리한 Esc(defaultPrevented)는 건너뛴다 —
|
||||
* 그건 다이얼로그 닫기 몫. handler 는 매 렌더 바뀌어도 ref 로 잡아 리스너 재등록 안 함.
|
||||
*/
|
||||
export function useEscapeKey(handler: () => void) {
|
||||
const ref = useRef(handler)
|
||||
ref.current = handler
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !e.defaultPrevented) ref.current()
|
||||
}
|
||||
window.addEventListener("keydown", onKey)
|
||||
return () => window.removeEventListener("keydown", onKey)
|
||||
}, [])
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user