From 130bd93f84161086bdf7f3574098250ef0950c4b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 04:04:01 +0000 Subject: [PATCH 01/29] feat: add Gitea support as third-party Git provider - Add GiteaService implementing BaseGitService/GitServiceInterface - Uses Gitea API v1 endpoints (/api/v1/repos/{owner}/{repo}/...) - POST for file creation, PUT for updates (differs from GitHub) - Authorization: token {token} header - Add giteaToken, giteaBaseUrl, giteaOwner, giteaRepo settings fields - Add Gitea option to service type dropdown in settings UI - Add displayGiteaSettings() panel in settings tab - Update initializeGitService() to instantiate GiteaService - Update getServiceName() to handle 'gitea' type - Add comprehensive test suite (gitea-service.test.ts, 15 test cases) - Update existing test fixtures to include new Gitea settings fields Closes #26 https://claude.ai/code/session_019Jbz6HpQvWU1wpm5M6MZpd --- src/main.ts | 11 ++ src/services/gitea-service.ts | 100 ++++++++++ src/settings.ts | 69 ++++++- tests/logic/sync-manager-mapping.test.ts | 4 + tests/logic/sync-manager.test.ts | 4 + tests/services/gitea-service.test.ts | 224 +++++++++++++++++++++++ 6 files changed, 409 insertions(+), 3 deletions(-) create mode 100644 src/services/gitea-service.ts create mode 100644 tests/services/gitea-service.test.ts diff --git a/src/main.ts b/src/main.ts index 5346996..2b41db1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import { Plugin, TFile, MarkdownView, Notice, Platform } from 'obsidian'; import { DEFAULT_SETTINGS, GitLabFilesPushSettings, GitLabSyncSettingTab, getServiceName } from "./settings"; import { GitLabService } from './services/gitlab-service'; import { GitHubService } from './services/github-service'; +import { GiteaService } from './services/gitea-service'; import { GitServiceInterface } from './services/git-service-interface'; import { SyncManager } from './logic/sync-manager'; import { SyncStatusView, SYNC_STATUS_VIEW_TYPE } from './ui/SyncStatusView'; @@ -235,6 +236,16 @@ export default class GitLabFilesPush extends Plugin { this.settings.rootPath ); this.gitService = service; + } else if (this.settings.serviceType === 'gitea') { + const service = new GiteaService(); + service.updateConfig( + this.settings.giteaBaseUrl, + this.settings.giteaToken, + this.settings.giteaOwner, + this.settings.giteaRepo, + this.settings.rootPath + ); + this.gitService = service; } else { const service = new GitHubService(); service.updateConfig( diff --git a/src/services/gitea-service.ts b/src/services/gitea-service.ts new file mode 100644 index 0000000..309054c --- /dev/null +++ b/src/services/gitea-service.ts @@ -0,0 +1,100 @@ +import { GitServiceInterface } from './git-service-interface'; +import { BaseGitService, GitFile, GitHubContentResponse, GitHubTreeResponse } from './git-service-base'; +import { logger } from '../utils/logger'; + +export class GiteaService extends BaseGitService implements GitServiceInterface { + private baseUrl: string = ''; + private owner: string = ''; + private repo: string = ''; + + updateConfig(baseUrl: string, token: string, owner: string, repo: string, rootPath: string = '') { + this.baseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; + this.token = token; + this.owner = owner; + this.repo = repo; + this.rootPath = rootPath; + } + + protected addAuthHeader(headers: Record): void { + headers['Authorization'] = `token ${this.token}`; + } + + private getApiUrl(path: string): string { + const fullPath = this.getFullPath(path); + return `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}/contents/${fullPath}`; + } + + async getFile(path: string, branch: string): Promise { + try { + const url = `${this.getApiUrl(path)}?ref=${branch}`; + const response = await this.safeRequest(url, 'GET'); + const data = response.json as GitHubContentResponse; + + return { + content: this.decodeContent(data.content, path), + sha: data.sha + }; + } catch (e) { + return this.handleFileNotFound(e); + } + } + + async pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string): Promise<{ path: string, sha?: string }> { + const url = this.getApiUrl(path); + const body = { + message, + content: this.encodeContent(content), + branch, + sha + }; + + const method = sha ? 'PUT' : 'POST'; + const response = await this.safeRequest(url, method, body); + const data = response.json as { content: { path: string, sha: string } }; + return { path: data.content.path, sha: data.content.sha }; + } + + async listFiles(branch: string, useFilter = true): Promise { + const url = `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}/git/trees/${branch}?recursive=1`; + const response = await this.safeRequest(url, 'GET'); + const data = response.json as GitHubTreeResponse; + + if (data.truncated) { + logger.warn('Gitea tree result is truncated. Some files might not be shown.'); + } + + const files = data.tree + .filter(item => item.type === 'blob') + .map(item => item.path); + + if (!useFilter) return files; + + return files.filter(p => { + if (!this.rootPath) return true; + const cleanRoot = this.rootPath.endsWith('/') ? this.rootPath : `${this.rootPath}/`; + return p === this.rootPath || p.startsWith(cleanRoot); + }); + } + + async deleteFile(path: string, branch: string, message: string): Promise { + const file = await this.getFile(path, branch); + const url = this.getApiUrl(path); + const body = { + message, + sha: file.sha, + branch + }; + + await this.safeRequest(url, 'DELETE', body); + } + + async testConnection(): Promise { + try { + const url = `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}`; + await this.safeRequest(url, 'GET'); + return true; + } catch { + return false; + } + } +} diff --git a/src/settings.ts b/src/settings.ts index 7ca0cfb..c0a8b0e 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -7,7 +7,7 @@ export interface SyncMetadata { lastKnownPath?: string; } -export type GitServiceType = 'gitlab' | 'github'; +export type GitServiceType = 'gitlab' | 'github' | 'gitea'; export interface GitLabFilesPushSettings { serviceType: GitServiceType; @@ -17,6 +17,10 @@ export interface GitLabFilesPushSettings { githubToken: string; githubOwner: string; githubRepo: string; + giteaToken: string; + giteaBaseUrl: string; + giteaOwner: string; + giteaRepo: string; branch: string; syncMetadata: Record; rootPath: string; @@ -24,7 +28,9 @@ export interface GitLabFilesPushSettings { } export function getServiceName(settings: GitLabFilesPushSettings): string { - return settings.serviceType === 'gitlab' ? 'GitLab' : 'GitHub'; + if (settings.serviceType === 'gitlab') return 'GitLab'; + if (settings.serviceType === 'gitea') return 'Gitea'; + return 'GitHub'; } export const DEFAULT_SETTINGS: GitLabFilesPushSettings = { @@ -35,6 +41,10 @@ export const DEFAULT_SETTINGS: GitLabFilesPushSettings = { githubToken: '', githubOwner: '', githubRepo: '', + giteaToken: '', + giteaBaseUrl: '', + giteaOwner: '', + giteaRepo: '', rootPath: "", branch: 'main', syncMetadata: {}, @@ -56,10 +66,11 @@ export class GitLabSyncSettingTab extends PluginSettingTab { new Setting(containerEl) .setName('Git service') - .setDesc('Choose between GitLab or GitHub') + .setDesc('Choose your Git hosting service') .addDropdown(dropdown => dropdown .addOption('gitlab', 'GitLab') .addOption('github', 'GitHub') + .addOption('gitea', 'Gitea') .setValue(this.plugin.settings.serviceType) .onChange((value: string) => { this.plugin.settings.serviceType = value as GitServiceType; @@ -72,6 +83,8 @@ export class GitLabSyncSettingTab extends PluginSettingTab { if (this.plugin.settings.serviceType === 'gitlab') { this.displayGitLabSettings(containerEl); + } else if (this.plugin.settings.serviceType === 'gitea') { + this.displayGiteaSettings(containerEl); } else { this.displayGitHubSettings(containerEl); } @@ -164,6 +177,56 @@ export class GitLabSyncSettingTab extends PluginSettingTab { })); } + private displayGiteaSettings(containerEl: HTMLElement): void { + new Setting(containerEl) + .setName('Gitea personal access token') + .setDesc('Create a token in user settings > applications > access tokens') + .addText(text => text + .setPlaceholder('Enter your token') + .setValue(this.plugin.settings.giteaToken) + .onChange((value) => { + this.plugin.settings.giteaToken = value; + void this.plugin.saveSettings(); + this.plugin.initializeGitService(); + })); + + new Setting(containerEl) + .setName('Gitea base URL') + .setDesc('URL of your Gitea instance (e.g. https://gitea.example.com)') + .addText(text => text + .setPlaceholder('https://gitea.example.com') + .setValue(this.plugin.settings.giteaBaseUrl) + .onChange((value) => { + this.plugin.settings.giteaBaseUrl = value; + void this.plugin.saveSettings(); + this.plugin.initializeGitService(); + })); + + new Setting(containerEl) + .setName('Repository owner') + .setDesc('Gitea username or organization name') + .addText(text => text + .setPlaceholder('Username') + .setValue(this.plugin.settings.giteaOwner) + .onChange((value) => { + this.plugin.settings.giteaOwner = value; + void this.plugin.saveSettings(); + this.plugin.initializeGitService(); + })); + + new Setting(containerEl) + .setName('Repository name') + .setDesc('Name of the repository') + .addText(text => text + .setPlaceholder('My notes') + .setValue(this.plugin.settings.giteaRepo) + .onChange((value) => { + this.plugin.settings.giteaRepo = value; + void this.plugin.saveSettings(); + this.plugin.initializeGitService(); + })); + } + private displayGitHubSettings(containerEl: HTMLElement): void { new Setting(containerEl) .setName('GitHub personal access token') diff --git a/tests/logic/sync-manager-mapping.test.ts b/tests/logic/sync-manager-mapping.test.ts index f69faa5..8a90ca3 100644 --- a/tests/logic/sync-manager-mapping.test.ts +++ b/tests/logic/sync-manager-mapping.test.ts @@ -44,6 +44,10 @@ const mockSettings: GitLabFilesPushSettings = { githubToken: '', githubOwner: '', githubRepo: '', + giteaToken: '', + giteaBaseUrl: '', + giteaOwner: '', + giteaRepo: '', branch: 'main', rootPath: 'notes', vaultFolder: 'Work', diff --git a/tests/logic/sync-manager.test.ts b/tests/logic/sync-manager.test.ts index e9dc277..f85b6ef 100644 --- a/tests/logic/sync-manager.test.ts +++ b/tests/logic/sync-manager.test.ts @@ -51,6 +51,10 @@ const mockSettings: GitLabFilesPushSettings = { githubToken: '', githubOwner: '', githubRepo: '', + giteaToken: '', + giteaBaseUrl: '', + giteaOwner: '', + giteaRepo: '', branch: 'main', rootPath: '', syncMetadata: {}, diff --git a/tests/services/gitea-service.test.ts b/tests/services/gitea-service.test.ts new file mode 100644 index 0000000..f366611 --- /dev/null +++ b/tests/services/gitea-service.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GiteaService } from '../../src/services/gitea-service'; +import { requestUrl, RequestUrlResponse, RequestUrlParam } from 'obsidian'; +import { getLastRequestCall, mockRequest, sharedTestConnection, sharedGetFileErrorHandling, sharedGetRepoGitignores } from './service-test-helpers'; + +describe('GiteaService', () => { + let service: GiteaService; + const baseUrl = 'https://gitea.example.com'; + const token = 'test-token'; + const owner = 'test-owner'; + const repo = 'test-repo'; + + beforeEach(() => { + vi.clearAllMocks(); + service = new GiteaService(); + service.updateConfig(baseUrl, token, owner, repo); + }); + + describe('updateConfig', () => { + it('should strip trailing slash from baseUrl', async () => { + service.updateConfig('https://gitea.example.com/', token, owner, repo); + mockRequest({ status: 200, json: { content: btoa('hello'), sha: 'sha' } }); + await service.getFile('test.md', 'main'); + const call = getLastRequestCall(); + expect(call.url).not.toContain('example.com//api'); + expect(call.url).toContain('example.com/api/v1'); + }); + }); + + describe('getFile', () => { + it('should fetch and decode file content correctly', async () => { + mockRequest({ status: 200, json: { content: btoa('hello world'), sha: 'test-sha' } }); + const result = await service.getFile('test.md', 'main'); + expect(result.content).toBe('hello world'); + expect(result.sha).toBe('test-sha'); + }); + + it('should call the correct Gitea contents API URL', async () => { + mockRequest({ status: 200, json: { content: btoa('data'), sha: 'sha1' } }); + await service.getFile('notes/hello.md', 'main'); + const call = getLastRequestCall(); + expect(call.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/contents/notes/hello.md?ref=main`); + }); + + it('should use Authorization token header', async () => { + mockRequest({ status: 200, json: { content: btoa('data'), sha: 'sha1' } }); + await service.getFile('test.md', 'main'); + const call = getLastRequestCall(); + expect(call.headers).toMatchObject({ 'Authorization': `token ${token}` }); + }); + + it('should handle 404 correctly and return empty content', async () => { + mockRequest({ status: 404, json: { message: 'Not Found' }, text: 'Not Found' }); + const result = await service.getFile('missing.md', 'main'); + expect(result.content).toBe(''); + expect(result.sha).toBe(''); + }); + + it('should bypass rootPath when path starts with / (absolute repo path)', async () => { + service.updateConfig(baseUrl, token, owner, repo, 'vault'); + mockRequest({ status: 200, json: { content: btoa('root content'), sha: 'root-sha' } }); + await service.getFile('/.gitignore', 'main'); + const call = getLastRequestCall(); + expect(call.url).toContain('/contents/.gitignore'); + expect(call.url).not.toContain('/contents/vault/.gitignore'); + }); + + it('should not double-prefix when path already starts with rootPath', async () => { + service.updateConfig(baseUrl, token, owner, repo, 'src/content'); + mockRequest({ status: 200, json: { content: btoa('hello'), sha: 'sha' } }); + await service.getFile('src/content/index.md', 'main'); + const call = getLastRequestCall(); + expect(call.url).toContain('/contents/src/content/index.md'); + expect(call.url).not.toContain('/contents/src/content/src/content/index.md'); + }); + + it('should prepend rootPath when set', async () => { + service.updateConfig(baseUrl, token, owner, repo, 'vault'); + mockRequest({ status: 200, json: { content: btoa('data'), sha: 'sha' } }); + await service.getFile('notes.md', 'main'); + const call = getLastRequestCall(); + expect(call.url).toContain('/contents/vault/notes.md'); + }); + }); + + describe('pushFile', () => { + it('should create new file with POST when no sha provided', async () => { + mockRequest({ status: 201, json: { content: { path: 'new.md', sha: 'new-sha' } } }); + const result = await service.pushFile('new.md', 'new content', 'main', 'create'); + expect(result).toEqual({ path: 'new.md', sha: 'new-sha' }); + const call = getLastRequestCall(); + expect(call.method).toBe('POST'); + }); + + it('should update existing file with PUT when sha provided', async () => { + mockRequest({ status: 200, json: { content: { path: 'existing.md', sha: 'updated-sha' } } }); + const result = await service.pushFile('existing.md', 'updated content', 'main', 'update', 'old-sha'); + expect(result).toEqual({ path: 'existing.md', sha: 'updated-sha' }); + const call = getLastRequestCall(); + expect(call.method).toBe('PUT'); + expect(call.body).toContain('"sha":"old-sha"'); + }); + + it('should send correct content URL for push', async () => { + mockRequest({ status: 201, json: { content: { path: 'notes/test.md', sha: 'sha' } } }); + await service.pushFile('notes/test.md', 'content', 'main', 'commit'); + const call = getLastRequestCall(); + expect(call.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/contents/notes/test.md`); + }); + + it('should base64 encode the file content', async () => { + mockRequest({ status: 201, json: { content: { path: 'test.md', sha: 'sha' } } }); + await service.pushFile('test.md', 'hello world', 'main', 'add file'); + const call = getLastRequestCall(); + const body = JSON.parse(call.body as string) as { content: string }; + expect(atob(body.content)).toContain('hello world'); + }); + }); + + describe('listFiles', () => { + it('should call the correct git trees URL with branch', async () => { + mockRequest({ status: 200, json: { tree: [], truncated: false } }); + await service.listFiles('main'); + const call = getLastRequestCall(); + expect(call.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/git/trees/main?recursive=1`); + }); + + it('should return only blob files from tree', async () => { + mockRequest({ status: 200, json: { tree: [ + { path: 'file1.md', type: 'blob' }, + { path: 'dir/file2.md', type: 'blob' }, + { path: 'subdir', type: 'tree' }, + ], truncated: false } }); + expect(await service.listFiles('main')).toEqual(['file1.md', 'dir/file2.md']); + }); + + it('should filter by rootPath when set', async () => { + service.updateConfig(baseUrl, token, owner, repo, 'vault'); + mockRequest({ status: 200, json: { tree: [ + { path: 'vault/file1.md', type: 'blob' }, + { path: 'other/file2.md', type: 'blob' }, + ], truncated: false } }); + expect(await service.listFiles('main')).toEqual(['vault/file1.md']); + }); + + it('should not match sibling paths with same prefix as rootPath', async () => { + service.updateConfig(baseUrl, token, owner, repo, 'src/content'); + mockRequest({ status: 200, json: { tree: [ + { path: 'src/content/index.md', type: 'blob' }, + { path: 'src/content.config.ts', type: 'blob' }, + { path: 'src/contentful.ts', type: 'blob' }, + ], truncated: false } }); + expect(await service.listFiles('main')).toEqual(['src/content/index.md']); + }); + + it('should return all files when useFilter is false regardless of rootPath', async () => { + service.updateConfig(baseUrl, token, owner, repo, 'vault'); + mockRequest({ status: 200, json: { tree: [ + { path: 'vault/file1.md', type: 'blob' }, + { path: 'other/file2.md', type: 'blob' }, + ], truncated: false } }); + expect(await service.listFiles('main', false)).toEqual(['vault/file1.md', 'other/file2.md']); + }); + + it('should log warning and return files when result is truncated', async () => { + mockRequest({ status: 200, json: { tree: [ + { path: 'file1.md', type: 'blob' }, + ], truncated: true } }); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const result = await service.listFiles('main'); + expect(result).toEqual(['file1.md']); + warnSpy.mockRestore(); + }); + }); + + describe('deleteFile', () => { + it('should first get file sha then send DELETE request', async () => { + vi.mocked(requestUrl) + .mockResolvedValueOnce({ status: 200, json: { content: btoa('content'), sha: 'file-sha' } } as unknown as RequestUrlResponse) + .mockResolvedValueOnce({ status: 200, json: {} } as unknown as RequestUrlResponse); + + await service.deleteFile('test.md', 'main', 'delete test.md'); + + const calls = vi.mocked(requestUrl).mock.calls; + expect(calls).toHaveLength(2); + const deleteCall = calls[1]?.[0] as RequestUrlParam; + expect(deleteCall.method).toBe('DELETE'); + expect(deleteCall.body).toContain('"sha":"file-sha"'); + expect(deleteCall.body).toContain('"message":"delete test.md"'); + expect(deleteCall.body).toContain('"branch":"main"'); + }); + + it('should use correct delete URL', async () => { + vi.mocked(requestUrl) + .mockResolvedValueOnce({ status: 200, json: { content: btoa('content'), sha: 'sha' } } as unknown as RequestUrlResponse) + .mockResolvedValueOnce({ status: 200, json: {} } as unknown as RequestUrlResponse); + + await service.deleteFile('notes/test.md', 'main', 'remove'); + + const calls = vi.mocked(requestUrl).mock.calls; + const deleteCall = calls[1]?.[0] as RequestUrlParam; + expect(deleteCall.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/contents/notes/test.md`); + }); + }); + + describe('testConnection', () => { + sharedTestConnection(() => service); + + it('should call the correct repo API URL', async () => { + mockRequest({ status: 200, json: {} }); + await service.testConnection(); + const call = getLastRequestCall(); + expect(call.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}`); + }); + }); + + describe('getRepoGitignores', () => { + sharedGetRepoGitignores(() => service, 'tree'); + }); + + describe('getFile error handling', () => { + sharedGetFileErrorHandling(() => service); + }); +}); From 7648eef279ed63a61561f217f206abf892995964 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 04:09:29 +0000 Subject: [PATCH 02/29] =?UTF-8?q?fix:=20use=20two-step=20branch=E2=86=92SH?= =?UTF-8?q?A=20resolution=20in=20GiteaService.listFiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gitea's git/trees endpoint requires a tree or commit SHA, not a branch name, on instances older than ~1.17. Resolve branch to commit SHA via /branches/{branch} first, then fetch /git/trees/{commitSha}?recursive=1. Also updates README with provider compatibility table and SVG icons for GitHub, GitLab, and Gitea. https://claude.ai/code/session_019Jbz6HpQvWU1wpm5M6MZpd --- README.md | 44 +++++++++++++++++------ src/services/gitea-service.ts | 17 ++++++--- tests/services/gitea-service.test.ts | 52 +++++++++++++++++++--------- 3 files changed, 81 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 80f808d..85db3e1 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg?style=flat-square)](https://conventionalcommits.org) [![License](https://img.shields.io/github/license/firstsun-dev/git-files-sync?style=flat-square)](LICENSE) -**Git File Sync** is a powerful Obsidian plugin that enables seamless synchronization of individual notes with GitLab or GitHub repositories. Unlike full-vault sync solutions, it gives you granular control over what gets pushed and pulled, making it perfect for shared projects, selective backups, and cross-platform workflows. +**Git File Sync** is a powerful Obsidian plugin that enables seamless synchronization of individual notes with GitLab, GitHub, or self-hosted Gitea repositories. Unlike full-vault sync solutions, it gives you granular control over what gets pushed and pulled, making it perfect for shared projects, selective backups, and cross-platform workflows. [繁體中文使用說明](USAGE_zh.md) @@ -22,6 +22,22 @@ --- +## Supported Providers + +GitHub   +GitLab   +Gitea + +| Provider | Hosting | Min. Version | +| :--- | :--- | :--- | +| GitHub | github.com · GitHub Enterprise | — | +| GitLab | gitlab.com · self-hosted | GitLab 13.0+ | +| Gitea | self-hosted | Gitea 1.12+ | + +> **Gitea compatibility note**: The plugin uses the Gitea API v1 (`/api/v1`). It resolves branch names to commit SHAs before fetching the file tree, which ensures compatibility with Gitea 1.12 and later. Versions before 1.12 are not supported. + +--- + ## Key Features ### Selective Synchronization @@ -31,7 +47,7 @@ Don't sync your whole vault. Selectively push or pull individual notes, or use b A comprehensive dashboard provides a bird's-eye view of your vault's status: - **Status Filtering**: Instantly see what's modified, new, or missing. - **Visual Diffs**: Compare local and remote changes line-by-line before syncing. -- **Remote-Only Detection**: Identify files existing on GitLab/GitHub that aren't in your vault yet. +- **Remote-Only Detection**: Identify files existing on GitLab/GitHub/Gitea that aren't in your vault yet. ### Intelligent Conflict Resolution When versions clash, Git File Sync provides a dedicated diff viewer to help you resolve conflicts manually. Choose the local version, the remote version, or merge them with confidence. @@ -62,13 +78,21 @@ Full support for Obsidian Mobile. Push and pull your notes on the go with a resp *Configure the plugin by selecting your preferred Git service and providing the necessary credentials.* ### 1. Choose Your Service -Go to **Settings** > **Git File Sync** and select either **GitLab** or **GitHub**. +Go to **Settings** > **Git File Sync** and select **GitLab**, **GitHub**, or **Gitea** from the dropdown. ### 2. Provider Setup -| Service | Required Info | Scope Needed | -| :--- | :--- | :--- | -| **GitLab** | Personal Access Token, Project ID, Base URL | `api` | -| **GitHub** | Personal Access Token, Owner, Repo Name | `repo` | + +| | Provider | Required Info | Token Scope | +| :---: | :--- | :--- | :--- | +| GitHub | **GitHub** | Personal Access Token, Owner, Repo Name | `repo` | +| GitLab | **GitLab** | Personal Access Token, Project ID, Base URL | `api` | +| Gitea | **Gitea** | Personal Access Token, Base URL, Owner, Repo Name | (all) | + +**GitHub token**: Settings → Developer settings → Personal access tokens → `repo` scope. + +**GitLab token**: User settings → Access tokens → `api` scope. The Base URL defaults to `https://gitlab.com`; change it for self-hosted instances. + +**Gitea token**: User settings → Applications → Access tokens. Set the Base URL to your Gitea instance (e.g. `https://gitea.example.com`). ### 3. Common Settings - **Branch**: Specify the target branch (default: `main`). @@ -87,9 +111,9 @@ Once configured, you should perform an initial status check: ### Daily Workflow: Pushing Changes When you finish editing a note and want to save it to Git: -- **Current Note**: Use the cloud icon in the ribbon or the command `Push current file to GitLab/GitHub`. +- **Current Note**: Use the cloud icon in the ribbon or the command `Push current file to GitLab/GitHub/Gitea`. - **Multiple Notes**: Open the Sync Status View, use the **Modified** filter, select the files you want to sync, and click **Push selected**. -- **Context Menu**: Right-click any file in the File Explorer and select `Push to GitLab/GitHub`. +- **Context Menu**: Right-click any file in the File Explorer and select `Push to GitLab/GitHub/Gitea`. ### Daily Workflow: Pulling Changes To get the latest updates from other devices: @@ -115,7 +139,7 @@ On mobile devices: ## Privacy and Security -- **Local Storage**: Your Personal Access Tokens (PAT) are stored locally in the plugin's data folder within your vault. They are never sent to any server other than GitLab/GitHub. +- **Local Storage**: Your Personal Access Tokens (PAT) are stored locally in the plugin's data folder within your vault. They are never sent to any server other than your configured Git provider. - **No Telemetry**: This plugin does not collect any data or usage analytics. --- diff --git a/src/services/gitea-service.ts b/src/services/gitea-service.ts index 309054c..1afe9aa 100644 --- a/src/services/gitea-service.ts +++ b/src/services/gitea-service.ts @@ -55,15 +55,22 @@ export class GiteaService extends BaseGitService implements GitServiceInterface } async listFiles(branch: string, useFilter = true): Promise { - const url = `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}/git/trees/${branch}?recursive=1`; - const response = await this.safeRequest(url, 'GET'); - const data = response.json as GitHubTreeResponse; + // Resolve branch name to commit SHA first for compatibility with all Gitea versions, + // since the git/trees endpoint requires a SHA (not a ref name) on older instances. + const branchUrl = `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}/branches/${branch}`; + const branchResponse = await this.safeRequest(branchUrl, 'GET'); + const branchData = branchResponse.json as { commit: { id: string } }; + const commitSha = branchData.commit.id; - if (data.truncated) { + const treeUrl = `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}/git/trees/${commitSha}?recursive=1`; + const treeResponse = await this.safeRequest(treeUrl, 'GET'); + const treeData = treeResponse.json as GitHubTreeResponse; + + if (treeData.truncated) { logger.warn('Gitea tree result is truncated. Some files might not be shown.'); } - const files = data.tree + const files = treeData.tree .filter(item => item.type === 'blob') .map(item => item.path); diff --git a/tests/services/gitea-service.test.ts b/tests/services/gitea-service.test.ts index f366611..c8cdae7 100644 --- a/tests/services/gitea-service.test.ts +++ b/tests/services/gitea-service.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { GiteaService } from '../../src/services/gitea-service'; import { requestUrl, RequestUrlResponse, RequestUrlParam } from 'obsidian'; -import { getLastRequestCall, mockRequest, sharedTestConnection, sharedGetFileErrorHandling, sharedGetRepoGitignores } from './service-test-helpers'; +import { getLastRequestCall, mockRequest, sharedTestConnection, sharedGetFileErrorHandling } from './service-test-helpers'; describe('GiteaService', () => { let service: GiteaService; @@ -118,54 +118,62 @@ describe('GiteaService', () => { }); describe('listFiles', () => { - it('should call the correct git trees URL with branch', async () => { - mockRequest({ status: 200, json: { tree: [], truncated: false } }); + const commitSha = 'abc123commit'; + + function mockListFiles(treeItems: { path: string; type: string }[], truncated = false): void { + vi.mocked(requestUrl) + .mockResolvedValueOnce({ status: 200, json: { commit: { id: commitSha } } } as unknown as RequestUrlResponse) + .mockResolvedValueOnce({ status: 200, json: { tree: treeItems, truncated } } as unknown as RequestUrlResponse); + } + + it('should first resolve branch to commit SHA then fetch tree', async () => { + mockListFiles([]); await service.listFiles('main'); - const call = getLastRequestCall(); - expect(call.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/git/trees/main?recursive=1`); + const calls = vi.mocked(requestUrl).mock.calls; + expect(calls).toHaveLength(2); + expect((calls[0]?.[0] as RequestUrlParam).url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/branches/main`); + expect((calls[1]?.[0] as RequestUrlParam).url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/git/trees/${commitSha}?recursive=1`); }); it('should return only blob files from tree', async () => { - mockRequest({ status: 200, json: { tree: [ + mockListFiles([ { path: 'file1.md', type: 'blob' }, { path: 'dir/file2.md', type: 'blob' }, { path: 'subdir', type: 'tree' }, - ], truncated: false } }); + ]); expect(await service.listFiles('main')).toEqual(['file1.md', 'dir/file2.md']); }); it('should filter by rootPath when set', async () => { service.updateConfig(baseUrl, token, owner, repo, 'vault'); - mockRequest({ status: 200, json: { tree: [ + mockListFiles([ { path: 'vault/file1.md', type: 'blob' }, { path: 'other/file2.md', type: 'blob' }, - ], truncated: false } }); + ]); expect(await service.listFiles('main')).toEqual(['vault/file1.md']); }); it('should not match sibling paths with same prefix as rootPath', async () => { service.updateConfig(baseUrl, token, owner, repo, 'src/content'); - mockRequest({ status: 200, json: { tree: [ + mockListFiles([ { path: 'src/content/index.md', type: 'blob' }, { path: 'src/content.config.ts', type: 'blob' }, { path: 'src/contentful.ts', type: 'blob' }, - ], truncated: false } }); + ]); expect(await service.listFiles('main')).toEqual(['src/content/index.md']); }); it('should return all files when useFilter is false regardless of rootPath', async () => { service.updateConfig(baseUrl, token, owner, repo, 'vault'); - mockRequest({ status: 200, json: { tree: [ + mockListFiles([ { path: 'vault/file1.md', type: 'blob' }, { path: 'other/file2.md', type: 'blob' }, - ], truncated: false } }); + ]); expect(await service.listFiles('main', false)).toEqual(['vault/file1.md', 'other/file2.md']); }); it('should log warning and return files when result is truncated', async () => { - mockRequest({ status: 200, json: { tree: [ - { path: 'file1.md', type: 'blob' }, - ], truncated: true } }); + mockListFiles([{ path: 'file1.md', type: 'blob' }], true); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const result = await service.listFiles('main'); expect(result).toEqual(['file1.md']); @@ -215,7 +223,17 @@ describe('GiteaService', () => { }); describe('getRepoGitignores', () => { - sharedGetRepoGitignores(() => service, 'tree'); + it('should return only .gitignore paths from file list', async () => { + const items = [ + { path: '.gitignore', type: 'blob' }, + { path: 'src/main.ts', type: 'blob' }, + { path: 'sub/.gitignore', type: 'blob' }, + ]; + vi.mocked(requestUrl) + .mockResolvedValueOnce({ status: 200, json: { commit: { id: 'sha123' } } } as unknown as RequestUrlResponse) + .mockResolvedValueOnce({ status: 200, json: { tree: items, truncated: false } } as unknown as RequestUrlResponse); + expect(await service.getRepoGitignores('main')).toEqual(['.gitignore', 'sub/.gitignore']); + }); }); describe('getFile error handling', () => { From 6afeaf673d7db2130e651b37cca69a28fab3c2dc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 04:10:00 +0000 Subject: [PATCH 03/29] chore: update package-lock.json https://claude.ai/code/session_019Jbz6HpQvWU1wpm5M6MZpd --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0dbdcac..739f060 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "git-file-sync", - "version": "1.0.6", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "git-file-sync", - "version": "1.0.6", + "version": "1.1.0", "license": "MIT", "dependencies": { "ignore": "^7.0.5", - "obsidian": "*" + "obsidian": "latest" }, "devDependencies": { "@eslint/js": "9.30.1", From 615819c39ebbaafe07c20634b191b11202683c3e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 04:12:06 +0000 Subject: [PATCH 04/29] docs: update USAGE_zh.md with Gitea support and provider compatibility table https://claude.ai/code/session_019Jbz6HpQvWU1wpm5M6MZpd --- USAGE_zh.md | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/USAGE_zh.md b/USAGE_zh.md index 216199c..a3f8876 100644 --- a/USAGE_zh.md +++ b/USAGE_zh.md @@ -2,7 +2,21 @@ -本指南將引導您如何使用 Git File Sync 插件,在行動裝置與桌面電腦之間,透過 GitLab 或 GitHub 輕鬆同步您的筆記。 +本指南將引導您如何使用 Git File Sync 插件,在行動裝置與桌面電腦之間,透過 GitLab、GitHub 或自架的 Gitea 輕鬆同步您的筆記。 + +--- + +## 支援的 Git 服務 + +GitHub   +GitLab   +Gitea + +| | 服務 | 適用情境 | 最低版本 | +| :---: | :--- | :--- | :--- | +| GitHub | **GitHub** | 公開 / 私人儲存庫 | — | +| GitLab | **GitLab** | gitlab.com 或自架 | GitLab 13.0+ | +| Gitea | **Gitea** | 自架 Git 伺服器 | Gitea 1.12+ | --- @@ -13,10 +27,11 @@ ![Plugin Settings](imgs/plugin-settings.png) *在設定面板選擇您的 Git 服務並填入對應的憑證與路徑。* -1. **選擇服務**:在 `設定` > `Git File Sync` 中選擇 GitLab 或 GitHub。 +1. **選擇服務**:在 `設定` > `Git File Sync` 中選擇 GitLab、GitHub 或 Gitea。 2. **填寫憑證**: - - **GitHub**:需要 個人存取權杖 (PAT)、帳號名稱、儲存庫名稱。 - - **GitLab**:需要 個人存取權杖 (PAT)、專案 ID、伺服器網址 (預設為 gitlab.com)。 + - **GitHub**:需要 個人存取權杖 (PAT)、帳號名稱、儲存庫名稱。權杖需具備 `repo` 權限。 + - **GitLab**:需要 個人存取權杖 (PAT)、專案 ID、伺服器網址(預設為 `https://gitlab.com`,自架請改為您的網址)。權杖需具備 `api` 權限。 + - **Gitea**:需要 個人存取權杖 (PAT)、伺服器網址(例如 `https://gitea.example.com`)、帳號名稱、儲存庫名稱。權杖在 `使用者設定` > `應用程式` > `存取權杖` 中建立。 3. **儲存庫路徑**:如果您想將筆記存放在儲存庫的特定資料夾(例如 `notes/`),請在 `Root Path` 中設定。 --- @@ -41,7 +56,7 @@ 當您寫完筆記,想備份到雲端時: - **單一檔案**: - 點擊左側功能列的 **雲端上傳圖示**。 - - 或者在檔案列表點擊右鍵,選擇 `Push to GitLab/GitHub`。 + - 或者在檔案列表點擊右鍵,選擇 `Push to GitLab/GitHub/Gitea`。 - **批量上傳**: - 在同步面板勾選多個檔案,點擊下方的 **Push selected**。 @@ -50,7 +65,7 @@ ### ⬇️ 如何下載(Pull) 當您在另一台裝置更新了筆記,想同步回目前裝置時: 1. 打開同步面板,點擊 **Refresh status**。 -2. 找到顯示為 **Remote only** 或 **Modified** (雲端版本較新) 的檔案。 +2. 找到顯示為 **Remote only** 或 **Modified**(雲端版本較新)的檔案。 3. 勾選後點擊 **Pull selected**。 4. **注意**:Pull 會覆蓋掉您本機的內容。如果有衝突,會自動開啟衝突解決視窗。 @@ -79,5 +94,5 @@ ## 🔒 隱私與安全 -- 您的存取權杖 (Token) 僅會儲存在您本機的 Obsidian 資料夾內。 +- 您的存取權杖 (Token) 僅會儲存在您本機的 Obsidian 資料夾內,不會傳送至任何第三方伺服器。 - 本插件不會收集任何個人數據或使用紀錄。 From 8024939a8904867a650512609c706b04dc088e68 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 04:15:01 +0000 Subject: [PATCH 05/29] ci: remove SonarQube from CI/CD pipeline https://claude.ai/code/session_019Jbz6HpQvWU1wpm5M6MZpd --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6677ab..b2fc5b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,6 @@ jobs: uses: firstsun-dev/.github/.github/workflows/obsidian-plugin-ci.yml@v1 with: plugin-id: "git-file-sync" - skip-sonar: ${{ vars.ENABLE_CI_SONAR != 'true' }} + skip-sonar: true secrets: RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} From c2c5dd584c0436fff6a6dd1c68568b9573d47d76 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 04:22:32 +0000 Subject: [PATCH 06/29] ci: upload build artifact on non-main branches for testing https://claude.ai/code/session_019Jbz6HpQvWU1wpm5M6MZpd --- .github/workflows/ci.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2fc5b1..61a9229 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,3 +21,28 @@ jobs: skip-sonar: true secrets: RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} + + build-artifact: + name: Upload build artifact + runs-on: ubuntu-latest + if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/master' + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - run: npm ci + + - run: npm run build + + - uses: actions/upload-artifact@v4 + with: + name: plugin-${{ github.ref_name }}-${{ github.sha }} + path: | + main.js + manifest.json + styles.css + retention-days: 7 From 21a6f984ee708cddda034446dd0e9eb164e676f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 04:23:56 +0000 Subject: [PATCH 07/29] ci: update GitHub Actions to latest versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - actions/setup-node: node 20 → 22 (Node 20 EOL Apr 2026) - github/codeql-action: v3 → v4 (v3 deprecated Dec 2026) https://claude.ai/code/session_019Jbz6HpQvWU1wpm5M6MZpd --- .github/workflows/ci.yml | 2 +- .github/workflows/codeql.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61a9229..d3ee95f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' cache: 'npm' - run: npm ci diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index f9e13da..0a6a6b2 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -27,16 +27,16 @@ jobs: uses: actions/checkout@v4 - name: Initialize CodeQL - uses: github/codeql-action/init@v3 + uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} # Queries can be 'security-extended' or 'security-and-quality' queries: security-extended - name: Auto-build - uses: github/codeql-action/autobuild@v3 + uses: github/codeql-action/autobuild@v4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 + uses: github/codeql-action/analyze@v4 with: category: "/language:${{matrix.language}}" From 8eb5903336313f27400c47c366e396c7b04f6cae Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 04:24:39 +0000 Subject: [PATCH 08/29] ci: fix artifact name by sanitizing branch name slashes Replace / with - in branch name and use short SHA (7 chars) e.g. plugin-claude-trusting-volta-qlg8bk-c2c5dd5 https://claude.ai/code/session_019Jbz6HpQvWU1wpm5M6MZpd --- .github/workflows/ci.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3ee95f..93fe7e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,9 +38,16 @@ jobs: - run: npm run build + - name: Set artifact name + id: artifact + run: | + BRANCH=$(echo "${{ github.ref_name }}" | tr '/' '-') + SHA=$(echo "${{ github.sha }}" | cut -c1-7) + echo "name=plugin-${BRANCH}-${SHA}" >> $GITHUB_OUTPUT + - uses: actions/upload-artifact@v4 with: - name: plugin-${{ github.ref_name }}-${{ github.sha }} + name: ${{ steps.artifact.outputs.name }} path: | main.js manifest.json From e4d72e0f8735cd46e86572fc932f5ff7b7600fe4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 04:26:39 +0000 Subject: [PATCH 09/29] ci: upgrade actions to Node 24 compatible versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - actions/checkout: v4 → v6 - actions/setup-node: v4 → v6 - actions/upload-artifact: v4 → v5 - actions/checkout (codeql.yml): v4 → v6 https://claude.ai/code/session_019Jbz6HpQvWU1wpm5M6MZpd --- .github/workflows/ci.yml | 6 +++--- .github/workflows/codeql.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93fe7e0..dff67a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,9 +27,9 @@ jobs: runs-on: ubuntu-latest if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/master' steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: '22' cache: 'npm' @@ -45,7 +45,7 @@ jobs: SHA=$(echo "${{ github.sha }}" | cut -c1-7) echo "name=plugin-${BRANCH}-${SHA}" >> $GITHUB_OUTPUT - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: name: ${{ steps.artifact.outputs.name }} path: | diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0a6a6b2..84d8741 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Initialize CodeQL uses: github/codeql-action/init@v4 From bcc5cda69ca7e4fa63474d0221f52f910a13076b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 06:02:40 +0000 Subject: [PATCH 10/29] fix(services): clear error when Git API returns HTML instead of JSON Refresh failed with the cryptic "Unexpected token '<', "= 400, so such bodies slipped through and crashed at response.json. Add BaseGitService.parseJson() which detects non-JSON/HTML bodies and throws an actionable message, and use it for all response.json reads in the GitHub and GitLab services. Also harden parseErrorResponse so HTML error pages produce a clear message instead of dumping the raw document. Add tests covering HTML 2xx responses, HTML detection by leading '<', malformed JSON, and HTML error pages. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DwioG4CNKUBuKiZdowLFWe --- package-lock.json | 6 +-- src/services/git-service-base.ts | 37 +++++++++++++- src/services/github-service.ts | 6 +-- src/services/gitlab-service.ts | 6 +-- tests/services/git-service-base.test.ts | 65 +++++++++++++++++++++++++ 5 files changed, 110 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0dbdcac..f69825f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "git-file-sync", - "version": "1.0.6", + "version": "1.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "git-file-sync", - "version": "1.0.6", + "version": "1.1.2", "license": "MIT", "dependencies": { "ignore": "^7.0.5", - "obsidian": "*" + "obsidian": "latest" }, "devDependencies": { "@eslint/js": "9.30.1", diff --git a/src/services/git-service-base.ts b/src/services/git-service-base.ts index 3cd997e..84f4eca 100644 --- a/src/services/git-service-base.ts +++ b/src/services/git-service-base.ts @@ -75,12 +75,47 @@ export abstract class BaseGitService { protected abstract addAuthHeader(headers: Record): void; + /** + * Safely parses a response body as JSON. + * + * Some servers/proxies return a 2xx (or redirect) response whose body is an + * HTML page — a login screen, an SSO redirect, or a proxy/error page — rather + * than JSON. Accessing `response.json` directly then throws a cryptic + * "Unexpected token '<', \"(response: RequestUrlResponse): T { + const contentType = (response.headers?.['content-type'] ?? response.headers?.['Content-Type'] ?? '').toLowerCase(); + const bodyText = (response.text ?? '').trimStart(); + const looksLikeHtml = bodyText.startsWith('<') || contentType.includes('html'); + + try { + const data = response.json as T; + if (data === undefined && looksLikeHtml) throw new Error('non-JSON response'); + return data; + } catch (e) { + if (looksLikeHtml) { + throw new Error( + 'Expected a JSON response from the Git server but received an HTML page ' + + '(likely a login, SSO redirect, or proxy/error page). ' + + 'Please check the server URL, your access token, and any network proxy or firewall.' + ); + } + throw new Error(`Failed to parse the Git server response as JSON: ${e instanceof Error ? e.message : String(e)}`); + } + } + protected parseErrorResponse(response: RequestUrlResponse): string { try { const data = response.json as { message?: string; error?: string }; return data.message || data.error || JSON.stringify(data); } catch { - return response.text || 'Unknown error'; + const bodyText = (response.text ?? '').trim(); + const contentType = (response.headers?.['content-type'] ?? response.headers?.['Content-Type'] ?? '').toLowerCase(); + if (bodyText.startsWith('<') || contentType.includes('html')) { + return 'Received an HTML page instead of a JSON error (likely a login, redirect, or proxy/error page). Check the server URL, access token, and network proxy.'; + } + return bodyText || 'Unknown error'; } } diff --git a/src/services/github-service.ts b/src/services/github-service.ts index 2d661b7..dbf888f 100644 --- a/src/services/github-service.ts +++ b/src/services/github-service.ts @@ -26,7 +26,7 @@ export class GitHubService extends BaseGitService implements GitServiceInterface try { const url = `${this.getApiUrl(path)}?ref=${branch}`; const response = await this.safeRequest(url, 'GET'); - const data = response.json as GitHubContentResponse; + const data = this.parseJson(response); return { content: this.decodeContent(data.content, path), @@ -47,14 +47,14 @@ export class GitHubService extends BaseGitService implements GitServiceInterface }; const response = await this.safeRequest(url, 'PUT', body); - const data = response.json as { content: { path: string, sha: string } }; + const data = this.parseJson<{ content: { path: string, sha: string } }>(response); return { path: data.content.path, sha: data.content.sha }; } async listFiles(branch: string, useFilter = true): Promise { const url = `https://api.github.com/repos/${this.owner}/${this.repo}/git/trees/${branch}?recursive=1`; const response = await this.safeRequest(url, 'GET'); - const data = response.json as GitHubTreeResponse; + const data = this.parseJson(response); if (data.truncated) { logger.warn('GitHub tree result is truncated. Some files might not be shown.'); diff --git a/src/services/gitlab-service.ts b/src/services/gitlab-service.ts index 281e0ef..443056a 100644 --- a/src/services/gitlab-service.ts +++ b/src/services/gitlab-service.ts @@ -27,7 +27,7 @@ export class GitLabService extends BaseGitService implements GitServiceInterface try { const url = `${this.getApiUrl(path)}?ref=${branch}`; const response = await this.safeRequest(url, 'GET'); - const data = response.json as GitLabFileResponse; + const data = this.parseJson(response); return { content: this.decodeContent(data.content, path), @@ -50,7 +50,7 @@ export class GitLabService extends BaseGitService implements GitServiceInterface const method = sha ? 'PUT' : 'POST'; const response = await this.safeRequest(url, method, body); - const data = response.json as GitLabFileResponse; + const data = this.parseJson(response); return { path: data.file_path }; } @@ -63,7 +63,7 @@ export class GitLabService extends BaseGitService implements GitServiceInterface while (true) { const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/tree?ref=${branch}&recursive=true&per_page=${perPage}&page=${page}`; const response = await this.safeRequest(url, 'GET'); - const data = response.json as GitLabTreeItem[]; + const data = this.parseJson(response); if (!data || data.length === 0) break; diff --git a/tests/services/git-service-base.test.ts b/tests/services/git-service-base.test.ts index 8cf6a52..ecb5057 100644 --- a/tests/services/git-service-base.test.ts +++ b/tests/services/git-service-base.test.ts @@ -41,6 +41,71 @@ describe('BaseGitService', () => { }); }); + describe('parseJson with non-JSON (HTML) responses', () => { + // Simulates Obsidian's real RequestUrlResponse.json getter, which throws + // SyntaxError when the body is not valid JSON (e.g. an HTML page). + function htmlResponse(status = 200): RequestUrlResponse { + return { + status, + headers: { 'content-type': 'text/html; charset=utf-8' }, + text: 'Login', + get json(): unknown { + throw new SyntaxError('Unexpected token \'<\', " { + vi.mocked(requestUrl).mockResolvedValue(htmlResponse(200)); + await expect(service.getFile('test.md', 'main')).rejects.toThrow(/received an HTML page/); + // The cryptic JSON parse error must not leak through. + await expect(service.getFile('test.md', 'main')).rejects.not.toThrow(/Unexpected token/); + }); + + it('listFiles: throws a clear error when the server returns an HTML page (2xx)', async () => { + vi.mocked(requestUrl).mockResolvedValue(htmlResponse(200)); + await expect(service.listFiles('main')).rejects.toThrow(/received an HTML page/); + }); + + it('detects HTML by leading "<" even without an html content-type', async () => { + vi.mocked(requestUrl).mockResolvedValue({ + status: 200, + headers: {}, + text: ' ...', + get json(): unknown { + throw new SyntaxError("Unexpected token '<'"); + }, + } as unknown as RequestUrlResponse); + await expect(service.listFiles('main')).rejects.toThrow(/received an HTML page/); + }); + + it('reports a generic JSON parse failure for malformed (non-HTML) JSON', async () => { + vi.mocked(requestUrl).mockResolvedValue({ + status: 200, + headers: { 'content-type': 'application/json' }, + text: '{ "tree": [', + get json(): unknown { + throw new SyntaxError('Unexpected end of JSON input'); + }, + } as unknown as RequestUrlResponse); + await expect(service.listFiles('main')).rejects.toThrow(/Failed to parse the Git server response as JSON/); + }); + + it('parseErrorResponse: surfaces a clear message for an HTML error page (>=400)', async () => { + vi.mocked(requestUrl).mockResolvedValue({ + status: 502, + headers: { 'content-type': 'text/html' }, + text: 'Bad Gateway', + get json(): unknown { + throw new SyntaxError("Unexpected token '<'"); + }, + } as unknown as RequestUrlResponse); + await expect(service.listFiles('main')).rejects.toThrow(/Received an HTML page instead of a JSON error/); + // The raw HTML document must not be dumped into the message. + await expect(service.listFiles('main')).rejects.not.toThrow(/DOCTYPE/); + }); + }); + describe('encodeContent / decodeContent round-trip', () => { it('should correctly encode and decode UTF-8 content', async () => { const original = 'Hello, 世界! 🌍'; From 339d5cbe775bc27ea4cca3e6d474366fdc017048 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 06:07:59 +0000 Subject: [PATCH 11/29] fix(services): omit blank sha so creating a new file doesn't 422 Pushing a new file via the single-file push button failed with GitHub HTTP 422. A 404 lookup returns sha === '' for new files, and the manual push path (sync-manager pushFile/conflict resolution) forwarded that empty string into the Contents API request body as "sha":"", which GitHub rejects. Batch push avoided this via `remote.sha || undefined`. Fix at the service layer so every caller is safe: only include sha in the GitHub request body when it is non-empty. Apply the same guard to the GitLab service's last_commit_id for symmetry. Add regression tests for blank-sha pushes on both services. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DwioG4CNKUBuKiZdowLFWe --- src/services/github-service.ts | 7 +++++-- src/services/gitlab-service.ts | 5 +++-- tests/services/github-service.test.ts | 15 +++++++++++++++ tests/services/gitlab-service.test.ts | 8 ++++++++ 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/services/github-service.ts b/src/services/github-service.ts index dbf888f..b086cf4 100644 --- a/src/services/github-service.ts +++ b/src/services/github-service.ts @@ -39,12 +39,15 @@ export class GitHubService extends BaseGitService implements GitServiceInterface async pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string): Promise<{ path: string, sha?: string }> { const url = this.getApiUrl(path); - const body = { + const body: { message: string; content: string; branch: string; sha?: string } = { message, content: this.encodeContent(content), branch, - sha }; + // GitHub's Contents API rejects a blank sha with HTTP 422. Only include + // it when updating an existing file; a 404 lookup yields sha === '' for + // new files, which must be created without a sha. + if (sha) body.sha = sha; const response = await this.safeRequest(url, 'PUT', body); const data = this.parseJson<{ content: { path: string, sha: string } }>(response); diff --git a/src/services/gitlab-service.ts b/src/services/gitlab-service.ts index 443056a..b172c0e 100644 --- a/src/services/gitlab-service.ts +++ b/src/services/gitlab-service.ts @@ -40,13 +40,14 @@ export class GitLabService extends BaseGitService implements GitServiceInterface async pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string): Promise<{ path: string, sha?: string }> { const url = this.getApiUrl(path); - const body = { + const body: { branch: string; content: string; encoding: string; commit_message: string; last_commit_id?: string } = { branch, content: this.encodeContent(content), encoding: 'base64', commit_message: message, - last_commit_id: sha }; + // A blank sha means the file is new: create it (POST) without last_commit_id. + if (sha) body.last_commit_id = sha; const method = sha ? 'PUT' : 'POST'; const response = await this.safeRequest(url, method, body); diff --git a/tests/services/github-service.test.ts b/tests/services/github-service.test.ts index 5854ea4..427f8da 100644 --- a/tests/services/github-service.test.ts +++ b/tests/services/github-service.test.ts @@ -70,6 +70,21 @@ describe('GitHubService', () => { expect(call.body).not.toContain('"sha":'); }); + it('should omit blank sha so creating a new file does not 422', async () => { + // A 404 lookup yields sha === '' for new files; an empty sha sent to + // GitHub causes HTTP 422, so it must be dropped from the request body. + vi.mocked(requestUrl).mockResolvedValueOnce({ + status: 201, + json: { content: { path: 'new.md', sha: 'new-sha' } } + } as unknown as RequestUrlResponse); + + const result = await service.pushFile('new.md', 'content', 'main', 'create', ''); + + expect(result).toEqual({ path: 'new.md', sha: 'new-sha' }); + const call = getLastRequestCall(); + expect(call.body).not.toContain('"sha":'); + }); + it('should update existing file correctly (sha provided)', async () => { mockRequest({ status: 200, json: { content: { path: 'existing.md', sha: 'updated-sha' } } }); diff --git a/tests/services/gitlab-service.test.ts b/tests/services/gitlab-service.test.ts index d468c13..9738008 100644 --- a/tests/services/gitlab-service.test.ts +++ b/tests/services/gitlab-service.test.ts @@ -50,6 +50,14 @@ describe('GitLabService', () => { expect(call.body).toContain(btoa('new content')); }); + it('should treat a blank sha as a new file (POST, no last_commit_id)', async () => { + mockRequest({ status: 201, json: { file_path: 'test.md' } }); + await service.pushFile('test.md', 'new content', 'main', 'initial commit', ''); + const call = getLastRequestCall(); + expect(call.method).toBe('POST'); + expect(call.body).not.toContain('last_commit_id'); + }); + it('should push file content correctly (PUT for existing file)', async () => { mockRequest({ status: 200, json: { file_path: 'test.md' } }); const result = await service.pushFile('test.md', 'updated content', 'main', 'update', 'old-sha'); From d8d6f9dcb11f01918be90b68ea697e283313de64 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 06:22:53 +0000 Subject: [PATCH 12/29] refactor(ui): unify Sync Status icons via Lucide setIcon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The view mixed hand-picked Unicode glyphs (✓ ⚠ ↑ ↓ ⟳ ↻ ✕ ≡ ⎇ 📁), duplicated across files, which rendered at inconsistent sizes/weights across platforms and could drift (e.g. the Refresh button used ↻ while the "checking" status used ⟳). Centralize every icon in src/ui/components/icons.ts as Lucide icon ids and render them with Obsidian's setIcon, so status, action-bar, tab, and info-strip icons all share one consistent icon set. Tabs now derive their icon from statusMeta so they can no longer diverge from the file list. Add CSS to size the SVGs uniformly. Add setIcon to the obsidian test mock and update statusMeta expectations. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DwioG4CNKUBuKiZdowLFWe --- src/ui/SyncStatusView.ts | 30 +++++++++++++++++----------- src/ui/components/ActionBar.ts | 13 ++++++------ src/ui/components/FileListItem.ts | 33 ++++++++++++++++--------------- src/ui/components/icons.ts | 21 ++++++++++++++++++++ styles.css | 32 +++++++++++++++++++++++++++--- tests/setup.ts | 2 ++ tests/ui/FileListItem.test.ts | 10 +++++----- 7 files changed, 100 insertions(+), 41 deletions(-) create mode 100644 src/ui/components/icons.ts diff --git a/src/ui/SyncStatusView.ts b/src/ui/SyncStatusView.ts index 64706d4..f890ff6 100644 --- a/src/ui/SyncStatusView.ts +++ b/src/ui/SyncStatusView.ts @@ -1,11 +1,12 @@ -import { ItemView, WorkspaceLeaf, TFile, Notice, Platform, setTooltip } from 'obsidian'; +import { ItemView, WorkspaceLeaf, TFile, Notice, Platform, setIcon, setTooltip } from 'obsidian'; import GitLabFilesPush from '../main'; import { getServiceName } from '../settings'; import { ConfirmModal } from './ConfirmModal'; import { logger } from '../utils/logger'; import { type FileStatus, type FilterValue } from './types'; import { renderActionBar } from './components/ActionBar'; -import { renderFileItem, type FileItemCallbacks } from './components/FileListItem'; +import { renderFileItem, statusMeta, type FileItemCallbacks } from './components/FileListItem'; +import { ICONS } from './components/icons'; import { isBinaryPath, contentsEqual } from '../utils/path'; export const SYNC_STATUS_VIEW_TYPE = 'sync-status-view'; @@ -92,12 +93,16 @@ export class SyncStatusView extends ItemView { if (!Platform.isMobile) { el.createSpan({ cls: 'ssv-info-sep', text: '·' }); - el.createSpan({ cls: 'ssv-info-item' }).textContent = `⎇ ${this.plugin.settings.branch}`; + const branchItem = el.createSpan({ cls: 'ssv-info-item' }); + setIcon(branchItem.createSpan({ cls: 'ssv-info-icon' }), ICONS.branch); + branchItem.createSpan({ text: ` ${this.plugin.settings.branch}` }); } if (this.plugin.settings.vaultFolder) { el.createSpan({ cls: 'ssv-info-sep', text: '·' }); - el.createSpan({ cls: 'ssv-info-item', text: `📁 ${this.plugin.settings.vaultFolder}` }); + const folderItem = el.createSpan({ cls: 'ssv-info-item' }); + setIcon(folderItem.createSpan({ cls: 'ssv-info-icon' }), ICONS.folder); + folderItem.createSpan({ text: ` ${this.plugin.settings.vaultFolder}` }); } if (this.lastSyncTime > 0) { @@ -123,12 +128,12 @@ export class SyncStatusView extends ItemView { 'remote-only': all.filter(s => s.status === 'remote-only').length, }; - const tabs: Array<{ value: FilterValue; label: string; icon: string }> = [ - { value: 'all', label: 'All', icon: '' }, - { value: 'synced', label: 'Synced', icon: '✓' }, - { value: 'modified', label: 'Changed', icon: '⚠' }, - { value: 'unsynced', label: 'Local only', icon: '↑' }, - { value: 'remote-only', label: 'Remote', icon: '↓' }, + const tabs: Array<{ value: FilterValue; label: string }> = [ + { value: 'all', label: 'All' }, + { value: 'synced', label: 'Synced' }, + { value: 'modified', label: 'Changed' }, + { value: 'unsynced', label: 'Local only' }, + { value: 'remote-only', label: 'Remote' }, ]; const tabsEl = container.createDiv({ cls: 'ssv-tabs' }); @@ -136,7 +141,10 @@ export class SyncStatusView extends ItemView { const btn = tabsEl.createEl('button', { cls: `ssv-tab${this.statusFilter === tab.value ? ' active' : ''}` }); - if (tab.icon) btn.createSpan({ text: tab.icon }); + // Share the status icon set with the file list so tabs never drift. + if (tab.value !== 'all') { + setIcon(btn.createSpan(), statusMeta(tab.value as FileStatus['status']).icon); + } btn.createSpan({ cls: 'ssv-tab-label', text: ` ${tab.label}` }); const count = counts[tab.value]; if (tab.value === 'all' || count > 0) { diff --git a/src/ui/components/ActionBar.ts b/src/ui/components/ActionBar.ts index 486e19d..e802e02 100644 --- a/src/ui/components/ActionBar.ts +++ b/src/ui/components/ActionBar.ts @@ -1,4 +1,5 @@ -import { setTooltip } from 'obsidian'; +import { setIcon, setTooltip } from 'obsidian'; +import { ICONS } from './icons'; export interface ActionBarProps { hasFiles: boolean; @@ -24,15 +25,15 @@ export function renderActionBar(container: HTMLElement, props: ActionBarProps, c if (props.hasFiles) { bar.createDiv({ cls: 'ssv-bar-spacer' }); renderSelectAllRow(bar, props.allSelected, props.indeterminate, callbacks.onSelectAll); - renderLargeButton(bar, '↑', ` Push (${props.canPush})`, `Push ${props.canPush} files`, callbacks.onPush, 'push', props.canPush === 0); - renderLargeButton(bar, '↓', ` Pull (${props.canPull})`, `Pull ${props.canPull} files`, callbacks.onPull, 'pull', props.canPull === 0); - renderLargeButton(bar, '✕', ` Delete (${props.canDelete})`, `Delete ${props.canDelete} files`, callbacks.onDelete, 'danger', props.canDelete === 0); + renderLargeButton(bar, ICONS.push, ` Push (${props.canPush})`, `Push ${props.canPush} files`, callbacks.onPush, 'push', props.canPush === 0); + renderLargeButton(bar, ICONS.pull, ` Pull (${props.canPull})`, `Pull ${props.canPull} files`, callbacks.onPull, 'pull', props.canPull === 0); + renderLargeButton(bar, ICONS.delete, ` Delete (${props.canDelete})`, `Delete ${props.canDelete} files`, callbacks.onDelete, 'danger', props.canDelete === 0); } } function renderRefreshButton(bar: HTMLElement, onRefresh: () => void): void { const btn = bar.createEl('button', { cls: 'ssv-btn ssv-btn-refresh' }); - btn.createSpan({ text: '↻' }); + setIcon(btn.createSpan(), ICONS.refresh); btn.createSpan({ cls: 'ssv-btn-label', text: ' Refresh' }); setTooltip(btn, 'Refresh all statuses'); btn.addEventListener('click', onRefresh); @@ -49,7 +50,7 @@ function renderSelectAllRow(bar: HTMLElement, allSelected: boolean, indeterminat function renderLargeButton(container: HTMLElement, icon: string, label: string, tooltip: string, onClick: () => void, cls: string, disabled: boolean): void { const btn = container.createEl('button', { cls: `ssv-btn ssv-btn-${cls}` }); - btn.createSpan({ text: icon }); + setIcon(btn.createSpan(), icon); btn.createSpan({ cls: 'ssv-btn-label', text: label }); btn.disabled = disabled; setTooltip(btn, tooltip); diff --git a/src/ui/components/FileListItem.ts b/src/ui/components/FileListItem.ts index 55d4f60..2498de4 100644 --- a/src/ui/components/FileListItem.ts +++ b/src/ui/components/FileListItem.ts @@ -1,6 +1,7 @@ -import { setTooltip } from 'obsidian'; +import { setIcon, setTooltip } from 'obsidian'; import { type FileStatus } from '../types'; import { renderDiffPanel } from './DiffPanel'; +import { ICONS } from './icons'; export interface FileItemCallbacks { onSelect: (path: string, selected: boolean) => void; @@ -9,13 +10,15 @@ export interface FileItemCallbacks { onDelete: (fileStatus: FileStatus) => void; } +// `icon` is a Lucide icon id (rendered via Obsidian's setIcon) so every status +// uses the same icon set and renders consistently across platforms. export function statusMeta(status: FileStatus['status']) { switch (status) { - case 'synced': return { icon: '✓', label: 'Synced', iconCls: 'ssv-icon-synced', badgeCls: 'ssv-badge-synced', fileCls: 'status-synced' }; - case 'modified': return { icon: '⚠', label: 'Changed', iconCls: 'ssv-icon-modified', badgeCls: 'ssv-badge-modified', fileCls: 'status-modified' }; - case 'unsynced': return { icon: '↑', label: 'Local only', iconCls: 'ssv-icon-unsynced', badgeCls: 'ssv-badge-unsynced', fileCls: 'status-unsynced' }; - case 'remote-only': return { icon: '↓', label: 'Remote', iconCls: 'ssv-icon-remote', badgeCls: 'ssv-badge-remote', fileCls: 'status-remote' }; - default: return { icon: '⟳', label: 'Checking', iconCls: 'ssv-icon-checking', badgeCls: 'ssv-badge-checking', fileCls: 'status-checking' }; + case 'synced': return { icon: ICONS.synced, label: 'Synced', iconCls: 'ssv-icon-synced', badgeCls: 'ssv-badge-synced', fileCls: 'status-synced' }; + case 'modified': return { icon: ICONS.modified, label: 'Changed', iconCls: 'ssv-icon-modified', badgeCls: 'ssv-badge-modified', fileCls: 'status-modified' }; + case 'unsynced': return { icon: ICONS.push, label: 'Local only', iconCls: 'ssv-icon-unsynced', badgeCls: 'ssv-badge-unsynced', fileCls: 'status-unsynced' }; + case 'remote-only': return { icon: ICONS.pull, label: 'Remote', iconCls: 'ssv-icon-remote', badgeCls: 'ssv-badge-remote', fileCls: 'status-remote' }; + default: return { icon: ICONS.checking, label: 'Checking', iconCls: 'ssv-icon-checking', badgeCls: 'ssv-badge-checking', fileCls: 'status-checking' }; } } @@ -33,7 +36,7 @@ export function renderFileItem( cb.checked = isSelected; cb.addEventListener('change', () => callbacks.onSelect(fileStatus.path, cb.checked)); - row.createSpan({ cls: `ssv-file-icon ${iconCls}`, text: icon }); + setIcon(row.createSpan({ cls: `ssv-file-icon ${iconCls}` }), icon); row.createSpan({ cls: 'ssv-file-path', text: fileStatus.path }); row.createSpan({ cls: `ssv-status-badge ${badgeCls}`, text: label }); @@ -50,21 +53,22 @@ function renderFileActions(fileEl: HTMLElement, fileStatus: FileStatus, callback } if (fileStatus.status === 'modified' || fileStatus.status === 'unsynced') { - renderActionBtn(actions, '↑', ' Push', 'Push to remote', () => callbacks.onPush(fileStatus), 'push'); + renderActionBtn(actions, ICONS.push, ' Push', 'Push to remote', () => callbacks.onPush(fileStatus), 'push'); } if (fileStatus.status === 'modified' || fileStatus.status === 'remote-only') { - renderActionBtn(actions, '↓', ' Pull', 'Pull from remote', () => callbacks.onPull(fileStatus), 'pull'); + renderActionBtn(actions, ICONS.pull, ' Pull', 'Pull from remote', () => callbacks.onPull(fileStatus), 'pull'); } if (fileStatus.status === 'unsynced') { - renderActionBtn(actions, '✕', ' Remove', 'Delete local file', () => callbacks.onDelete(fileStatus), 'danger'); + renderActionBtn(actions, ICONS.delete, ' Remove', 'Delete local file', () => callbacks.onDelete(fileStatus), 'danger'); } } function renderDiffToggleButton(actions: HTMLElement, fileEl: HTMLElement, fileStatus: FileStatus): void { const diffBtn = actions.createEl('button', { cls: 'ssv-action-btn diff' }); - diffBtn.createSpan({ text: '≡' }); + const iconEl = diffBtn.createSpan(); + setIcon(iconEl, ICONS.diff); const btnLabel = diffBtn.createSpan({ cls: 'ssv-btn-label', text: ' Diff' }); let diffEl: HTMLElement; @@ -80,16 +84,13 @@ function renderDiffToggleButton(actions: HTMLElement, fileEl: HTMLElement, fileS const open = diffEl.hasClass('visible'); diffEl.toggleClass('visible', !open); btnLabel.setText(open ? ' Diff' : ' Hide'); - const firstChild = diffBtn.firstChild; - if (firstChild instanceof HTMLElement || firstChild instanceof Text) { - firstChild.textContent = open ? '≡' : '▴'; - } + setIcon(iconEl, open ? ICONS.diff : ICONS.diffOpen); }); } function renderActionBtn(actions: HTMLElement, icon: string, label: string, tooltip: string, onClick: () => void, cls: string): void { const btn = actions.createEl('button', { cls: `ssv-action-btn ${cls}` }); - btn.createSpan({ text: icon }); + setIcon(btn.createSpan(), icon); btn.createSpan({ cls: 'ssv-btn-label', text: label }); setTooltip(btn, tooltip); btn.addEventListener('click', onClick); diff --git a/src/ui/components/icons.ts b/src/ui/components/icons.ts new file mode 100644 index 0000000..e2b87fd --- /dev/null +++ b/src/ui/components/icons.ts @@ -0,0 +1,21 @@ +// Single source of truth for every icon used in the Sync Status view. +// +// Values are Lucide icon ids rendered through Obsidian's `setIcon`, so all +// icons share one visual style (stroke weight, size, baseline) and stay +// consistent across desktop and mobile. Reuse these constants everywhere a +// status or action icon is shown instead of inlining Unicode glyphs. +export const ICONS = { + // Status / action + synced: 'check', + modified: 'pencil', + push: 'arrow-up', + pull: 'arrow-down', + checking: 'refresh-cw', + refresh: 'refresh-cw', + delete: 'trash-2', + diff: 'file-diff', + diffOpen: 'chevron-up', + // Info strip + branch: 'git-branch', + folder: 'folder', +} as const; diff --git a/styles.css b/styles.css index 73075c9..c1f8f7d 100644 --- a/styles.css +++ b/styles.css @@ -295,11 +295,11 @@ } .ssv-file-icon { + display: flex; + align-items: center; + justify-content: center; width: 18px; - text-align: center; flex-shrink: 0; - font-size: 0.95em; - font-weight: 600; } .ssv-icon-synced { color: var(--color-green); } @@ -346,6 +346,9 @@ } .ssv-action-btn { + display: inline-flex; + align-items: center; + gap: 4px; padding: 4px 10px; font-size: 0.76em; border-radius: 4px; @@ -358,6 +361,29 @@ transition: background 0.1s, opacity 0.1s; } +/* Consistent sizing for all Lucide icons used in the view */ +.ssv-file-icon .svg-icon { + width: 16px; + height: 16px; +} + +.ssv-btn .svg-icon, +.ssv-tab .svg-icon, +.ssv-action-btn .svg-icon { + width: 15px; + height: 15px; +} + +.ssv-info-icon { + display: inline-flex; + align-items: center; +} + +.ssv-info-icon .svg-icon { + width: 13px; + height: 13px; +} + .is-mobile .ssv-action-btn { padding: 8px 15px; font-size: 0.85em; diff --git a/tests/setup.ts b/tests/setup.ts index 805eed1..d7f73dc 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -56,6 +56,7 @@ export const App = class { export const TFile = class {}; export const requestUrl = vi.fn(); export const setTooltip = vi.fn(); +export const setIcon = vi.fn(); vi.mock('obsidian', () => ({ Plugin, @@ -69,4 +70,5 @@ vi.mock('obsidian', () => ({ TFile, requestUrl, setTooltip, + setIcon, })); diff --git a/tests/ui/FileListItem.test.ts b/tests/ui/FileListItem.test.ts index 7983c3c..acd55dd 100644 --- a/tests/ui/FileListItem.test.ts +++ b/tests/ui/FileListItem.test.ts @@ -14,11 +14,11 @@ function makeFileStatus(status: FileStatus['status'], overrides?: Partial { it.each([ - ['synced', '✓', 'Synced', 'status-synced'], - ['modified', '⚠', 'Changed', 'status-modified'], - ['unsynced', '↑', 'Local only', 'status-unsynced'], - ['remote-only', '↓', 'Remote', 'status-remote'], - ['checking', '⟳', 'Checking', 'status-checking'], + ['synced', 'check', 'Synced', 'status-synced'], + ['modified', 'pencil', 'Changed', 'status-modified'], + ['unsynced', 'arrow-up', 'Local only', 'status-unsynced'], + ['remote-only', 'arrow-down', 'Remote', 'status-remote'], + ['checking', 'refresh-cw', 'Checking', 'status-checking'], ] as const)('%s: returns correct icon, label, and fileCls', (status, icon, label, fileCls) => { const meta = statusMeta(status); expect(meta.icon).toBe(icon); From 76405cf2f796a18651dcae86a188c86a51f69270 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 06:28:25 +0000 Subject: [PATCH 13/29] fix(sync): fall back to adapter read for symlinked files Reading a symlinked file via Obsidian's cached vault.read(TFile) can fail (notably on mobile), which broke both refresh and push: the status check swallowed the error and mislabeled the file as 'unsynced', so the user saw a Push button that then failed re-reading the symlink. Fall back to vault.adapter.read/readBinary(path) when vault.read throws, in both the status view and the sync manager. Also stop silently swallowing the status-check error so the real cause is logged. Add a regression test covering the adapter fallback on push. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DwioG4CNKUBuKiZdowLFWe --- src/logic/sync-manager.ts | 17 +++++++++++++---- src/ui/SyncStatusView.ts | 18 ++++++++++++++---- tests/logic/sync-manager.test.ts | 20 ++++++++++++++++++++ 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/logic/sync-manager.ts b/src/logic/sync-manager.ts index d3af1c0..60061e7 100644 --- a/src/logic/sync-manager.ts +++ b/src/logic/sync-manager.ts @@ -352,13 +352,22 @@ export class SyncManager { const binary = this.isBinary(path); if (typeof fileOrPath === 'string') { - return binary + return binary ? await this.app.vault.adapter.readBinary(fileOrPath) : await this.app.vault.adapter.read(fileOrPath); } - return binary - ? await this.app.vault.readBinary(fileOrPath) - : await this.app.vault.read(fileOrPath); + try { + return binary + ? await this.app.vault.readBinary(fileOrPath) + : await this.app.vault.read(fileOrPath); + } catch (e) { + // Obsidian's cached vault.read can fail for symlinked files (notably + // on mobile); fall back to reading the path directly via the adapter. + logger.warn(`vault.read failed for ${path}; falling back to adapter`, e); + return binary + ? await this.app.vault.adapter.readBinary(path) + : await this.app.vault.adapter.read(path); + } } private async processSingleBatchPush(fileOrPath: TFile | string, path: string, name: string, isString: boolean): Promise { diff --git a/src/ui/SyncStatusView.ts b/src/ui/SyncStatusView.ts index f890ff6..dd50de2 100644 --- a/src/ui/SyncStatusView.ts +++ b/src/ui/SyncStatusView.ts @@ -449,8 +449,9 @@ export class SyncStatusView extends ItemView { const { status, diff } = this.determineFileStatus(binary, localContent, remote); this.fileStatuses.set(path, { file, path, status, localContent, remoteContent: remote.content, remoteSha: remote.sha, diff }); - } catch { + } catch (e) { const path = typeof fileOrPath === 'string' ? fileOrPath : fileOrPath.path; + logger.warn(`Failed to determine sync status for ${path}`, e); this.fileStatuses.set(path, { file: typeof fileOrPath === 'string' ? undefined : fileOrPath, path, @@ -466,9 +467,18 @@ export class SyncStatusView extends ItemView { : await this.app.vault.adapter.read(fileOrPath as string); } if (fileOrPath instanceof TFile) { - return binary - ? await this.app.vault.readBinary(fileOrPath) - : await this.app.vault.read(fileOrPath); + try { + return binary + ? await this.app.vault.readBinary(fileOrPath) + : await this.app.vault.read(fileOrPath); + } catch (e) { + // Obsidian's cached vault.read can fail for symlinked files + // (notably on mobile); fall back to reading the path directly. + logger.warn(`vault.read failed for ${fileOrPath.path}; falling back to adapter`, e); + return binary + ? await this.app.vault.adapter.readBinary(fileOrPath.path) + : await this.app.vault.adapter.read(fileOrPath.path); + } } // This should not happen if isStr is false and fileOrPath is TFile throw new Error('Expected TFile when isStr is false'); diff --git a/tests/logic/sync-manager.test.ts b/tests/logic/sync-manager.test.ts index e9dc277..4eda9cb 100644 --- a/tests/logic/sync-manager.test.ts +++ b/tests/logic/sync-manager.test.ts @@ -88,6 +88,26 @@ describe('SyncManager', () => { ); }); + it('falls back to the adapter when vault.read fails (e.g. symlinked file)', async () => { + const mockFile = Object.assign(new TFile(), { path: 'link.md', name: 'link.md' }); + const readSpy = vi.spyOn(mockApp.vault, 'read').mockRejectedValue(new Error('EINVAL: symlink')); + const adapterReadSpy = vi.spyOn(mockApp.vault.adapter, 'read').mockResolvedValue('linked content'); + vi.spyOn(mockGitLab, 'getFile').mockResolvedValue({ content: 'different content', sha: 'old-sha' }); + const pushSpy = vi.spyOn(mockGitLab, 'pushFile').mockResolvedValue({ path: 'link.md', sha: 'new-sha' }); + + await manager.pushFile(mockFile); + + expect(readSpy).toHaveBeenCalledWith(mockFile); + expect(adapterReadSpy).toHaveBeenCalledWith('link.md'); + expect(pushSpy).toHaveBeenCalledWith( + 'link.md', + 'linked content', + 'main', + 'Update link.md from Obsidian', + 'old-sha' + ); + }); + it('should detect conflict when remote SHA differs from last synced SHA', async () => { const mockFile = Object.assign(new TFile(), { path: 'test.md', name: 'test.md' }); From f2037f955a95122d01df1e53581eb8bcaa891abb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 09:10:45 +0000 Subject: [PATCH 14/29] ci: drop removed skip-sonar input and SONAR_TOKEN secret The shared workflow obsidian-plugin-ci.yml@v1 no longer defines the skip-sonar input or the SONAR_TOKEN secret, which made the caller workflow invalid ("Invalid input/secret ... is not defined in the referenced workflow"). Remove both so the workflow validates again. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DwioG4CNKUBuKiZdowLFWe --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6677ab..500d1ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,5 @@ jobs: uses: firstsun-dev/.github/.github/workflows/obsidian-plugin-ci.yml@v1 with: plugin-id: "git-file-sync" - skip-sonar: ${{ vars.ENABLE_CI_SONAR != 'true' }} secrets: RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} From 9bc8ab7d082a933adca973c2de59337166dc940f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 04:23:57 +0000 Subject: [PATCH 15/29] fix(ui): match Open sync status ribbon icon to the view icon The ribbon button used 'list-checks' while the Sync Status view itself uses 'git-compare' (getIcon). Use 'git-compare' for the ribbon too so the "Open sync status" icon matches the sync status view icon. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DwioG4CNKUBuKiZdowLFWe --- src/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.ts b/src/main.ts index 5346996..29d4c46 100644 --- a/src/main.ts +++ b/src/main.ts @@ -24,7 +24,7 @@ export default class GitLabFilesPush extends Plugin { (leaf) => new SyncStatusView(leaf, this) ); - this.addRibbonIcon('list-checks', 'Open sync status', async () => { + this.addRibbonIcon('git-compare', 'Open sync status', async () => { await this.activateSyncStatusView(); }); From c474a7e6c4da02fe44770b6d2e914f962a3455f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 04:27:25 +0000 Subject: [PATCH 16/29] fix(services): stop logging expected 404s as errors during refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Loading .gitignore files probes paths that often don't exist remotely. getFile already treats 404 as "empty file" (handleFileNotFound) and the gitignore lookup ignores failures, but safeRequest logged every 404 as an error — twice, because its own catch re-caught the error it had just thrown. This produced scary "Git Service Request Failed (404): Not Found" console output during a normal pull/refresh. Restructure safeRequest so the catch only wraps the network call (no double-logging of HTTP-status errors), and log an expected 404 at debug level instead of error. Non-404 statuses are still logged as errors and all statuses still throw so callers can handle them. Add a debug level to the logger and regression tests for 404 vs 500. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DwioG4CNKUBuKiZdowLFWe --- src/services/git-service-base.ts | 27 ++++++++++++------- src/utils/logger.ts | 1 + tests/services/git-service-base.test.ts | 36 +++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/services/git-service-base.ts b/src/services/git-service-base.ts index 84f4eca..7402da0 100644 --- a/src/services/git-service-base.ts +++ b/src/services/git-service-base.ts @@ -42,6 +42,7 @@ export abstract class BaseGitService { * Safely wraps requestUrl to handle potential throws from Obsidian and provide better error messages. */ protected async safeRequest(url: string, method: string, body?: unknown, extraHeaders?: Record, silent = false): Promise { + let response: RequestUrlResponse; try { const headers: Record = { ...extraHeaders, @@ -57,20 +58,28 @@ export abstract class BaseGitService { throw: false }; - const response = await requestUrl(options); - - if (response.status >= 400) { - const errorMsg = this.parseErrorResponse(response); - if (!silent) logger.error(`Git Service Request Failed (${response.status}): ${url}`, errorMsg); - throw new Error(`Git Service Error (${response.status}): ${errorMsg}`); - } - - return response; + response = await requestUrl(options); } catch (error) { + // Network-level failure (DNS, offline, TLS, etc.) if (!silent) logger.error('Git Service Request Failed:', error); if (error instanceof Error) throw error; throw new Error(`Network error or unexpected failure: ${String(error)}`); } + + if (response.status >= 400) { + const errorMsg = this.parseErrorResponse(response); + // 404 is routinely an expected "does not exist" probe (getFile treats + // it as an empty file, gitignore lookups ignore it). Log it at debug + // level so it doesn't surface as a failure, but still throw so callers + // can handle it. Other statuses are genuine errors. + if (!silent) { + if (response.status === 404) logger.debug(`Git Service 404 (not found): ${url}`); + else logger.error(`Git Service Request Failed (${response.status}): ${url}`, errorMsg); + } + throw new Error(`Git Service Error (${response.status}): ${errorMsg}`); + } + + return response; } protected abstract addAuthHeader(headers: Record): void; diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 2ccd5eb..5a91755 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -3,4 +3,5 @@ const PREFIX = '[git-file-sync]'; export const logger = { error: (message: string, ...args: unknown[]) => console.error(`${PREFIX} ${message}`, ...args), warn: (message: string, ...args: unknown[]) => console.warn(`${PREFIX} ${message}`, ...args), + debug: (message: string, ...args: unknown[]) => console.debug(`${PREFIX} ${message}`, ...args), }; diff --git a/tests/services/git-service-base.test.ts b/tests/services/git-service-base.test.ts index ecb5057..e5eb8ba 100644 --- a/tests/services/git-service-base.test.ts +++ b/tests/services/git-service-base.test.ts @@ -30,6 +30,42 @@ describe('BaseGitService', () => { }); }); + describe('safeRequest 404 handling', () => { + it('getFile returns empty and does not log an error on 404 (e.g. missing .gitignore)', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + vi.mocked(requestUrl).mockResolvedValue({ + status: 404, + text: 'Not Found', + json: { message: 'Not Found' }, + } as unknown as RequestUrlResponse); + + const result = await service.getFile('missing/.gitignore', 'main'); + + expect(result).toEqual({ content: '', sha: '' }); + // A 404 is an expected "does not exist" probe: never logged as an error… + expect(errorSpy).not.toHaveBeenCalled(); + // …and logged at most once at debug level (no double-logging). + expect(debugSpy).toHaveBeenCalledTimes(1); + + errorSpy.mockRestore(); + debugSpy.mockRestore(); + }); + + it('still logs non-404 failures as errors', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(requestUrl).mockResolvedValue({ + status: 500, + text: 'Internal Server Error', + json: { message: 'Internal Server Error' }, + } as unknown as RequestUrlResponse); + + await expect(service.listFiles('main')).rejects.toThrow('500'); + expect(errorSpy).toHaveBeenCalledTimes(1); + errorSpy.mockRestore(); + }); + }); + describe('safeRequest with non-Error exception', () => { it('should wrap non-Error throws in a new Error', async () => { // Throw a plain string (not an Error instance) from requestUrl From 62b475d6326c0705cc5120c77ba88719b8454e39 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 04:45:41 +0000 Subject: [PATCH 17/29] feat(sync): detect symbolic links and add a configurable handling setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symlinks are Git blobs with mode 120000 whose content is the target path. They were treated as ordinary files, which caused 404s on fetch and could corrupt the file on pull. Add detection and let the user choose behavior. - Settings: new "Symbolic links" option (symlinkHandling) with real (default) / follow / skip. - Services: listFilesDetailed() reports each entry's symlink flag from the tree mode (120000) for GitHub and GitLab; listFiles() now delegates to it. - Refresh/discovery: with "skip", remote symlinks are excluded from sync; with "follow"/"real" they are included and synced as the target content. Note: true OS-level symlink recreation on desktop and pushing files as symlinks (Git Data API) are not yet implemented — on all platforms "real" currently syncs the target content like "follow"; mobile has no symlink API regardless. Tracked as a follow-up. Add tests for symlink detection on both services and update settings fixtures. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DwioG4CNKUBuKiZdowLFWe --- src/services/git-service-base.ts | 14 +++++++++- src/services/git-service-interface.ts | 8 ++++++ src/services/github-service.ts | 18 ++++++------- src/services/gitlab-service.ts | 34 ++++++++++++------------ src/settings.ts | 26 +++++++++++++++++- src/ui/SyncStatusView.ts | 14 +++++----- tests/logic/sync-manager-mapping.test.ts | 1 + tests/logic/sync-manager.test.ts | 3 ++- tests/services/github-service.test.ts | 12 +++++++++ tests/services/gitlab-service.test.ts | 11 ++++++++ 10 files changed, 106 insertions(+), 35 deletions(-) diff --git a/src/services/git-service-base.ts b/src/services/git-service-base.ts index 7402da0..62722ce 100644 --- a/src/services/git-service-base.ts +++ b/src/services/git-service-base.ts @@ -1,11 +1,16 @@ import { requestUrl, RequestUrlResponse } from 'obsidian'; import { logger } from '../utils/logger'; +import { GitTreeEntry } from './git-service-interface'; export interface GitFile { content: string | ArrayBuffer; sha: string; } +// Git file mode for a symbolic link. Symlinks are stored as blobs whose content +// is the link target path, so they need special handling during sync. +export const GIT_SYMLINK_MODE = '120000'; + export interface GitHubContentResponse { content: string; sha: string; @@ -15,6 +20,7 @@ export interface GitHubContentResponse { export interface GitHubTreeItem { path: string; type: string; + mode?: string; } export interface GitHubTreeResponse { @@ -32,6 +38,7 @@ export interface GitLabFileResponse { export interface GitLabTreeItem { path: string; type: string; + mode?: string; } export abstract class BaseGitService { @@ -197,9 +204,14 @@ export abstract class BaseGitService { } } + async listFiles(branch: string, useFilter = true): Promise { + const entries = await this.listFilesDetailed(branch, useFilter); + return entries.map(e => e.path); + } + abstract getFile(path: string, branch: string): Promise; abstract pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string): Promise<{ path: string, sha?: string }>; - abstract listFiles(branch: string, useFilter?: boolean): Promise; + abstract listFilesDetailed(branch: string, useFilter?: boolean): Promise; abstract deleteFile(path: string, branch: string, message: string): Promise; abstract testConnection(): Promise; } diff --git a/src/services/git-service-interface.ts b/src/services/git-service-interface.ts index 3357260..e4ca517 100644 --- a/src/services/git-service-interface.ts +++ b/src/services/git-service-interface.ts @@ -3,12 +3,20 @@ export interface GitFile { sha: string; } +/** A file entry from the repository tree, with whether it is a symbolic link. */ +export interface GitTreeEntry { + path: string; + symlink: boolean; +} + export interface GitServiceInterface { updateConfig(...args: unknown[]): void; getFile(path: string, branch: string): Promise; pushFile(path: string, content: string | ArrayBuffer, branch: string, commitMessage: string, existingSha?: string): Promise<{ path: string, sha?: string }>; testConnection(): Promise; listFiles(branch: string, useFilter?: boolean): Promise; + /** Like listFiles but also reports which entries are symbolic links (mode 120000). */ + listFilesDetailed(branch: string, useFilter?: boolean): Promise; deleteFile(path: string, branch: string, commitMessage: string): Promise; getRepoGitignores(branch: string): Promise; } diff --git a/src/services/github-service.ts b/src/services/github-service.ts index b086cf4..416408a 100644 --- a/src/services/github-service.ts +++ b/src/services/github-service.ts @@ -1,5 +1,5 @@ -import { GitServiceInterface } from './git-service-interface'; -import { BaseGitService, GitFile, GitHubContentResponse, GitHubTreeResponse } from './git-service-base'; +import { GitServiceInterface, GitTreeEntry } from './git-service-interface'; +import { BaseGitService, GitFile, GitHubContentResponse, GitHubTreeResponse, GIT_SYMLINK_MODE } from './git-service-base'; import { logger } from '../utils/logger'; export class GitHubService extends BaseGitService implements GitServiceInterface { @@ -54,25 +54,25 @@ export class GitHubService extends BaseGitService implements GitServiceInterface return { path: data.content.path, sha: data.content.sha }; } - async listFiles(branch: string, useFilter = true): Promise { + async listFilesDetailed(branch: string, useFilter = true): Promise { const url = `https://api.github.com/repos/${this.owner}/${this.repo}/git/trees/${branch}?recursive=1`; const response = await this.safeRequest(url, 'GET'); const data = this.parseJson(response); - + if (data.truncated) { logger.warn('GitHub tree result is truncated. Some files might not be shown.'); } - const files = data.tree + const entries = data.tree .filter(item => item.type === 'blob') - .map(item => item.path); + .map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE })); - if (!useFilter) return files; + if (!useFilter) return entries; - return files.filter(p => { + return entries.filter(e => { if (!this.rootPath) return true; const cleanRoot = this.rootPath.endsWith('/') ? this.rootPath : `${this.rootPath}/`; - return p === this.rootPath || p.startsWith(cleanRoot); + return e.path === this.rootPath || e.path.startsWith(cleanRoot); }); } diff --git a/src/services/gitlab-service.ts b/src/services/gitlab-service.ts index b172c0e..1fbf001 100644 --- a/src/services/gitlab-service.ts +++ b/src/services/gitlab-service.ts @@ -1,5 +1,5 @@ -import { GitServiceInterface } from './git-service-interface'; -import { BaseGitService, GitFile, GitLabFileResponse, GitLabTreeItem } from './git-service-base'; +import { GitServiceInterface, GitTreeEntry } from './git-service-interface'; +import { BaseGitService, GitFile, GitLabFileResponse, GitLabTreeItem, GIT_SYMLINK_MODE } from './git-service-base'; export class GitLabService extends BaseGitService implements GitServiceInterface { private baseUrl: string = 'https://gitlab.com'; @@ -55,39 +55,39 @@ export class GitLabService extends BaseGitService implements GitServiceInterface return { path: data.file_path }; } - async listFiles(branch: string, useFilter = true): Promise { + async listFilesDetailed(branch: string, useFilter = true): Promise { const encodedProjectId = encodeURIComponent(this.projectId); - let allPaths: string[] = []; + let allEntries: GitTreeEntry[] = []; let page = 1; const perPage = 100; - + while (true) { const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/tree?ref=${branch}&recursive=true&per_page=${perPage}&page=${page}`; const response = await this.safeRequest(url, 'GET'); const data = this.parseJson(response); - + if (!data || data.length === 0) break; - - const paths = data + + const entries = data .filter(item => item.type === 'blob') - .map(item => item.path); - + .map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE })); + if (useFilter) { - const filtered = paths.filter(p => { + const filtered = entries.filter(e => { if (!this.rootPath) return true; const cleanRoot = this.rootPath.endsWith('/') ? this.rootPath : `${this.rootPath}/`; - return p === this.rootPath || p.startsWith(cleanRoot); + return e.path === this.rootPath || e.path.startsWith(cleanRoot); }); - allPaths = allPaths.concat(filtered); + allEntries = allEntries.concat(filtered); } else { - allPaths = allPaths.concat(paths); + allEntries = allEntries.concat(entries); } - + if (data.length < perPage) break; page++; } - - return allPaths; + + return allEntries; } async deleteFile(path: string, branch: string, message: string): Promise { diff --git a/src/settings.ts b/src/settings.ts index 7ca0cfb..bc6e886 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -9,6 +9,15 @@ export interface SyncMetadata { export type GitServiceType = 'gitlab' | 'github'; +/** + * How symbolic links (Git blobs with mode 120000) are synced: + * - 'real': recreate a real OS symlink on desktop; on mobile (no symlink API) + * fall back to syncing the link target's content as a normal file. + * - 'follow': always sync the target file's content as a normal file. + * - 'skip': ignore symlinks entirely. + */ +export type SymlinkHandling = 'real' | 'follow' | 'skip'; + export interface GitLabFilesPushSettings { serviceType: GitServiceType; gitlabToken: string; @@ -21,6 +30,7 @@ export interface GitLabFilesPushSettings { syncMetadata: Record; rootPath: string; vaultFolder: string; + symlinkHandling: SymlinkHandling; } export function getServiceName(settings: GitLabFilesPushSettings): string { @@ -38,7 +48,8 @@ export const DEFAULT_SETTINGS: GitLabFilesPushSettings = { rootPath: "", branch: 'main', syncMetadata: {}, - vaultFolder: '' + vaultFolder: '', + symlinkHandling: 'real' } export class GitLabSyncSettingTab extends PluginSettingTab { @@ -110,6 +121,19 @@ export class GitLabSyncSettingTab extends PluginSettingTab { void this.plugin.saveSettings(); })); + new Setting(containerEl) + .setName('Symbolic links') + .setDesc('How to sync symlinks: "real" recreates the link on desktop and falls back to the target content on mobile, "follow" always syncs the target content, and "skip" ignores symlinks.') + .addDropdown(dropdown => dropdown + .addOption('real', 'Real symlink (recommended)') + .addOption('follow', 'Follow (sync target content)') + .addOption('skip', 'Skip') + .setValue(this.plugin.settings.symlinkHandling) + .onChange((value: string) => { + this.plugin.settings.symlinkHandling = value as SymlinkHandling; + void this.plugin.saveSettings(); + })); + new Setting(containerEl) .setName('Test connection') .setDesc(`Verify your ${getServiceName(this.plugin.settings)} settings`) diff --git a/src/ui/SyncStatusView.ts b/src/ui/SyncStatusView.ts index dd50de2..3311423 100644 --- a/src/ui/SyncStatusView.ts +++ b/src/ui/SyncStatusView.ts @@ -303,19 +303,21 @@ export class SyncStatusView extends ItemView { private async discoverFiles() { const allFiles = this.app.vault.getFiles(); let local = this.plugin.filterFilesByVaultFolder(allFiles); - const remoteFullPaths = await this.plugin.gitService.listFiles(this.plugin.settings.branch); + const remoteEntries = await this.plugin.gitService.listFilesDetailed(this.plugin.settings.branch); await this.plugin.gitignoreManager.loadGitignores(); - + // Map remote paths to vault paths const remoteMap = new Map(); // vaultPath -> remoteFullPath - for (const remotePath of remoteFullPaths) { - const normalized = this.getNormalizedRemotePath(remotePath); + const skipSymlinks = this.plugin.settings.symlinkHandling === 'skip'; + for (const entry of remoteEntries) { + if (entry.symlink && skipSymlinks) continue; // Symlink handling: skip + const normalized = this.getNormalizedRemotePath(entry.path); if (normalized === null) continue; // Not under rootPath - + const vaultPath = this.plugin.getVaultPath(normalized); if (!this.plugin.gitignoreManager.isIgnored(normalized)) { - remoteMap.set(vaultPath, remotePath); + remoteMap.set(vaultPath, entry.path); } } diff --git a/tests/logic/sync-manager-mapping.test.ts b/tests/logic/sync-manager-mapping.test.ts index f69faa5..9df5fd8 100644 --- a/tests/logic/sync-manager-mapping.test.ts +++ b/tests/logic/sync-manager-mapping.test.ts @@ -48,6 +48,7 @@ const mockSettings: GitLabFilesPushSettings = { rootPath: 'notes', vaultFolder: 'Work', syncMetadata: {}, + symlinkHandling: 'real', }; describe('SyncManager Mapping', () => { diff --git a/tests/logic/sync-manager.test.ts b/tests/logic/sync-manager.test.ts index 4eda9cb..3a24a41 100644 --- a/tests/logic/sync-manager.test.ts +++ b/tests/logic/sync-manager.test.ts @@ -54,7 +54,8 @@ const mockSettings: GitLabFilesPushSettings = { branch: 'main', rootPath: '', syncMetadata: {}, - vaultFolder: '' + vaultFolder: '', + symlinkHandling: 'real' }; describe('SyncManager', () => { diff --git a/tests/services/github-service.test.ts b/tests/services/github-service.test.ts index 427f8da..8fc1c44 100644 --- a/tests/services/github-service.test.ts +++ b/tests/services/github-service.test.ts @@ -116,6 +116,18 @@ describe('GitHubService', () => { expect(await service.listFiles('main')).toEqual(['vault/file1.md']); }); + it('listFilesDetailed flags symlinks (mode 120000)', async () => { + mockRequest({ status: 200, json: { tree: [ + { path: 'real.md', type: 'blob', mode: '100644' }, + { path: 'link.md', type: 'blob', mode: '120000' }, + { path: 'dir', type: 'tree', mode: '040000' }, + ] } }); + expect(await service.listFilesDetailed('main')).toEqual([ + { path: 'real.md', symlink: false }, + { path: 'link.md', symlink: true }, + ]); + }); + it('should not match sibling paths with same prefix as rootPath', async () => { service.updateConfig(token, owner, repo, 'src/content'); mockRequest({ status: 200, json: { tree: [ diff --git a/tests/services/gitlab-service.test.ts b/tests/services/gitlab-service.test.ts index 9738008..9c5d90c 100644 --- a/tests/services/gitlab-service.test.ts +++ b/tests/services/gitlab-service.test.ts @@ -87,6 +87,17 @@ describe('GitLabService', () => { expect(await service.listFiles('main')).toEqual(['vault/file1.md']); }); + it('listFilesDetailed flags symlinks (mode 120000)', async () => { + mockRequest({ status: 200, json: [ + { path: 'real.md', type: 'blob', mode: '100644' }, + { path: 'link.md', type: 'blob', mode: '120000' }, + ] }); + expect(await service.listFilesDetailed('main')).toEqual([ + { path: 'real.md', symlink: false }, + { path: 'link.md', symlink: true }, + ]); + }); + it('should not match sibling paths with same prefix as rootPath', async () => { service.updateConfig(baseUrl, token, projectId, 'src/content'); mockRequest({ status: 200, json: [ From 9bcaed65f434a4c1596b5403f66df41a9887243b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 05:14:35 +0000 Subject: [PATCH 18/29] feat(sync): real symbolic link support (GitHub) with configurable handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds end-to-end symlink syncing driven by the "Symbolic links" setting (real / follow / skip; default real), building on the earlier detection. - Pull: on desktop with "real", a remote symlink is recreated as a real OS link via Node fs (utils/symlink.ts, guarded by Platform/FileSystemAdapter); otherwise the target path is written as content. - Push: GitHubService.pushSymlink commits a real symlink blob (mode 120000) through the Git Data API (blob -> tree -> commit -> ref). getFile now reports isSymlink/symlinkTarget. - Config 防呆: only GitHub offers "real"; on GitLab/Gitea (no API to create symlinks) "real" resolves to "skip" via getEffectiveSymlinkHandling. - Safety: a "follow" push never overwrites a detected remote symlink with a regular file; it is skipped with a notice. - Docs: docs/symlink-handling.md plus a README settings note. Lint is satisfied without disables (Electron global require, minimal Node type shims). Adds tests for getFile detection, the pushSymlink Git Data sequence, and the remote-symlink push guard. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DwioG4CNKUBuKiZdowLFWe --- README.md | 1 + docs/symlink-handling.md | 89 ++++++++++++++++++++++ src/logic/sync-manager.ts | 103 +++++++++++++++++++------ src/services/git-service-base.ts | 2 + src/services/git-service-interface.ts | 10 +++ src/services/github-service.ts | 45 ++++++++++- src/settings.ts | 40 +++++++--- src/ui/SyncStatusView.ts | 4 +- src/utils/symlink.ts | 105 ++++++++++++++++++++++++++ tests/logic/sync-manager.test.ts | 12 +++ tests/services/github-service.test.ts | 43 +++++++++++ 11 files changed, 419 insertions(+), 35 deletions(-) create mode 100644 docs/symlink-handling.md create mode 100644 src/utils/symlink.ts diff --git a/README.md b/README.md index 7bfb843..b666228 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ Go to **Settings** > **Git File Sync** and select **GitLab**, **GitHub**, or **G - **Branch**: Specify the target branch (default: `main`). - **Root Path**: Prefix for files in the repository (e.g., `notes/`). - **Vault Folder**: Limit sync to a specific folder in your vault. +- **Symbolic links**: Choose how symlinks are synced — *real* (recreate the link, GitHub only), *follow* (sync the target's content), or *skip*. See [Symbolic link handling](docs/symlink-handling.md). --- diff --git a/docs/symlink-handling.md b/docs/symlink-handling.md new file mode 100644 index 0000000..d56d56d --- /dev/null +++ b/docs/symlink-handling.md @@ -0,0 +1,89 @@ +# Symbolic Link Handling + +This document describes how Git File Sync syncs **symbolic links** (symlinks) +between your vault and the remote repository. + +## Background + +In Git, a symbolic link is not a normal file — it is stored as a *blob with file +mode `120000`* whose **content is the link's target path** (for example +`../shared/note.md`). Because of this, symlinks need special treatment: + +- They cannot be fetched/created like normal files through every provider's API. +- Obsidian exposes no symlink API, and **mobile platforms (iOS/Android) cannot + create OS-level symlinks at all**. + +To keep behavior predictable, Git File Sync makes symlink handling an explicit, +configurable choice. + +## The setting + +**Settings → Git File Sync → Symbolic links** + +| Mode | What it does | +| :--- | :--- | +| **Real symlink** (default) | Recreates a real OS symlink on **desktop** when pulling, and pushes local symlinks back as real Git symlinks (mode `120000`). Falls back to content on platforms/providers that can't do this (see below). | +| **Follow** | Treats a symlink as the file it points to: syncs the **target's content** as a normal file. Never creates links. | +| **Skip** | Ignores symlinks entirely — they are not listed, pulled, or pushed. | + +The default is **Real symlink**. + +## Provider support + +Creating a symlink on the remote requires writing a `120000` blob, which is only +possible with a provider that exposes the full **Git Data API**. + +| Provider | Real symlink | Notes | +| :--- | :---: | :--- | +| **GitHub** | ✅ | Full support via the Git Data API (blob → tree → commit → ref). | +| **GitLab** | ❌ | The Commits API can't set the symlink mode. "Real" is treated as **Skip**. | +| **Gitea** | ❌ | No write access to git data endpoints. "Real" is treated as **Skip**. | + +**Config safeguard (防呆):** the "Real symlink" option is only offered in the +settings dropdown when the active provider is GitHub. On other providers a saved +value of `real` is automatically treated as `skip`, so a symlink is never +silently turned into an ordinary file. + +## Platform behavior + +| | Desktop | Mobile (iOS/Android) | +| :--- | :--- | :--- | +| **Real symlink (GitHub)** | Creates/reads real OS symlinks via Node's `fs`. | No symlink API — falls back: on pull, writes the target *path* as the file content. | + +## What happens per direction + +### Pull (remote → vault) + +- **Real** + GitHub + desktop: a remote symlink is recreated as a real OS link + pointing at its target. +- **Real** on mobile (or if the link can't be created): the link target path is + written into the file as its content, so it still round-trips. +- **Follow**: the target's content is written as a normal file. (For a link whose + target is a normal in-repo file, GitHub's API already returns that content.) +- **Skip**: remote symlinks are excluded from the sync list. + +### Push (vault → remote) + +- **Real** + GitHub: a local symlink is committed as a real symlink blob + (mode `120000`) using the Git Data API. +- **Skip**: local symlinks are not pushed. +- **Follow**: the content the link points to is pushed as a normal file. + +#### Safety: Follow never destroys a remote symlink + +A `follow` push reads *through* a local symlink and would otherwise overwrite the +remote with a regular file. To avoid silently converting a remote symlink into an +ordinary file, **if the remote path is detected as a symlink, the push is skipped +with a notice**. Use **Real symlink** (GitHub) if you actually want to manage the +link. + +> Note: remote-symlink detection on push relies on the provider reporting the +> symlink type, which currently only GitHub does. On GitLab/Gitea a `follow` push +> cannot detect a remote symlink and may convert it to a regular file. + +## Error handling + +Earlier versions surfaced confusing errors when a symlinked `.gitignore` (or other +symlink) returned `404` during a refresh. Expected `404`s are now logged at debug +level instead of as errors, and symlinks are detected up front, so a normal +refresh/pull no longer shows spurious "Git Service Request Failed (404)" messages. diff --git a/src/logic/sync-manager.ts b/src/logic/sync-manager.ts index 60061e7..35fb9f8 100644 --- a/src/logic/sync-manager.ts +++ b/src/logic/sync-manager.ts @@ -1,9 +1,10 @@ import { TFile, App, Notice } from 'obsidian'; import { GitServiceInterface } from '../services/git-service-interface'; -import { GitLabFilesPushSettings, getServiceName } from '../settings'; +import { GitLabFilesPushSettings, getServiceName, getEffectiveSymlinkHandling } from '../settings'; import { SyncConflictModal } from '../ui/SyncConflictModal'; import { logger } from '../utils/logger'; import { isBinaryPath, contentsEqual } from '../utils/path'; +import { readLocalSymlinkTarget, createLocalSymlink } from '../utils/symlink'; export class SyncManager { private readonly app: App; @@ -54,8 +55,15 @@ export class SyncManager { return; } - const content = await this.getFileContent(fileOrPath); try { + // Symbolic link handling: real → push as a symlink (GitHub), skip → ignore. + const symlinkTarget = readLocalSymlinkTarget(this.app, path); + if (symlinkTarget !== null && await this.handleSymlinkPush({ path, name }, symlinkTarget)) { + return; + } + + const content = await this.getFileContent(fileOrPath); + // Check if this is a renamed file if (!isString && fileOrPath instanceof TFile) { const renamedFrom = this.detectRename(fileOrPath); @@ -67,7 +75,14 @@ export class SyncManager { // Conflict detection & equality check const remote = await this.gitService.getFile(repoPath, this.settings.branch); - + + // "follow" must not silently convert a remote symlink into a regular + // file. If the remote is a symlink, leave it untouched. + if (remote.isSymlink) { + new Notice(`${name} is a symlink on the remote; not overwriting (use "real" symlink mode to manage links).`); + return; + } + if (remote.sha && this.contentsEqual(content, remote.content)) { await this.updateMetadata(path, remote.sha); new Notice(`${name} is already up to date.`); @@ -84,7 +99,7 @@ export class SyncManager { if (choice === 'local') { await this.performPush({ path, name }, content, remote.sha); } else { - await this.performPull(fileRep, remote.content, remote.sha); + await this.performPull(fileRep, remote.content, remote.sha, false, this.symlinkPullTarget(remote)); } } catch (e) { this.handleError(`Failed to resolve conflict for ${name}`, e); @@ -170,10 +185,37 @@ export class SyncManager { } if (newSha) await this.updateMetadata(file.path, newSha); - + if (!silent) new Notice(`Pushed ${file.name} to ${this.serviceName}`); } + /** + * Handles pushing a local symbolic link per the configured behavior. + * Returns true if the link was handled (pushed as a symlink, or intentionally + * skipped); returns false to let the caller fall through to a normal content + * push ("follow", which reads through the link). + */ + private async handleSymlinkPush(file: {path: string, name: string}, target: string, silent = false): Promise { + const mode = getEffectiveSymlinkHandling(this.settings); + if (mode === 'skip') { + if (!silent) new Notice(`Skipped symlink ${file.name}.`); + return true; + } + if (mode === 'real' && this.gitService.pushSymlink) { + const repoPath = this.getNormalizedPath(file.path); + const result = await this.gitService.pushSymlink(repoPath, target, this.settings.branch, `Update ${file.name} from Obsidian`); + if (result.sha) await this.updateMetadata(file.path, result.sha); + if (!silent) new Notice(`Pushed symlink ${file.name} to ${this.serviceName}`); + return true; + } + return false; + } + + /** The symlink target to recreate on pull, or undefined when the remote isn't a symlink. */ + private symlinkPullTarget(remote: { isSymlink?: boolean; symlinkTarget?: string }): string | undefined { + return remote.isSymlink ? remote.symlinkTarget ?? '' : undefined; + } + async pullFile(fileOrPath: TFile | string) { const { path, name, isString } = this.getFileInfo(fileOrPath); const repoPath = this.getNormalizedPath(path); @@ -205,7 +247,7 @@ export class SyncManager { if (choice === 'local') { await this.performPush(fileRep, localContent || '', remote.sha); } else { - await this.performPull(fileRep, remote.content, remote.sha); + await this.performPull(fileRep, remote.content, remote.sha, false, this.symlinkPullTarget(remote)); } } catch (e) { this.handleError(`Failed to resolve conflict for ${name}`, e); @@ -230,29 +272,38 @@ export class SyncManager { return isBinaryPath(path); } - private async performPull(file: TFile | {path: string, name: string}, remoteContent: string | ArrayBuffer, remoteSha: string, silent = false) { + private async performPull(file: TFile | {path: string, name: string}, remoteContent: string | ArrayBuffer, remoteSha: string, silent = false, symlinkTarget?: string) { await this.ensureParentDirs(file.path); + if (symlinkTarget !== undefined) { + // Remote blob is a symbolic link. Recreate a real OS link when the + // setting is "real" and the platform supports it… + if (getEffectiveSymlinkHandling(this.settings) === 'real' && createLocalSymlink(this.app, file.path, symlinkTarget)) { + await this.updateMetadata(file.path, remoteSha); + if (!silent) new Notice(`Pulled symlink ${file.name} from ${this.serviceName}`); + return; + } + // …otherwise record where it pointed by writing the target as content. + remoteContent = symlinkTarget; + } + + await this.writePulledContent(file, remoteContent); + await this.updateMetadata(file.path, remoteSha); + if (!silent) new Notice(`Pulled ${file.name} from ${this.serviceName}`); + } + + private async writePulledContent(file: TFile | {path: string, name: string}, remoteContent: string | ArrayBuffer): Promise { if (typeof remoteContent !== 'string') { - // remoteContent is ArrayBuffer if (file instanceof TFile) { await this.app.vault.modifyBinary(file, remoteContent); } else { await this.app.vault.adapter.writeBinary(file.path, remoteContent); } + } else if (file instanceof TFile) { + await this.app.vault.modify(file, remoteContent); } else { - // remoteContent is string - if (file instanceof TFile) { - await this.app.vault.modify(file, remoteContent); - } else { - await this.app.vault.adapter.write(file.path, remoteContent); - } + await this.app.vault.adapter.write(file.path, remoteContent); } - - // Update metadata - await this.updateMetadata(file.path, remoteSha); - - if (!silent) new Notice(`Pulled ${file.name} from ${this.serviceName}`); } private async ensureParentDirs(filePath: string): Promise { @@ -372,6 +423,13 @@ export class SyncManager { private async processSingleBatchPush(fileOrPath: TFile | string, path: string, name: string, isString: boolean): Promise { if (!await this.checkFileExists(path, isString)) throw new Error('File no longer exists'); + + // Symbolic link handling: real → push as a symlink (GitHub), skip → ignore. + const symlinkTarget = readLocalSymlinkTarget(this.app, path); + if (symlinkTarget !== null && await this.handleSymlinkPush({ path, name }, symlinkTarget, true)) { + return getEffectiveSymlinkHandling(this.settings) !== 'skip'; + } + const content = await this.getFileContent(fileOrPath); const repoPath = this.getNormalizedPath(path); @@ -385,7 +443,10 @@ export class SyncManager { } const remote = await this.gitService.getFile(repoPath, this.settings.branch); - + + // Don't convert a remote symlink into a regular file. + if (remote.isSymlink) return false; + // Skip if already in sync if (remote.sha && this.contentsEqual(content, remote.content)) { await this.updateMetadata(path, remote.sha); @@ -411,7 +472,7 @@ export class SyncManager { } const fileRep = typeof fileOrPath === 'string' ? { path, name } : fileOrPath; - await this.performPull(fileRep, remote.content, remote.sha, true); + await this.performPull(fileRep, remote.content, remote.sha, true, this.symlinkPullTarget(remote)); return true; } } diff --git a/src/services/git-service-base.ts b/src/services/git-service-base.ts index 62722ce..e90aac7 100644 --- a/src/services/git-service-base.ts +++ b/src/services/git-service-base.ts @@ -5,6 +5,8 @@ import { GitTreeEntry } from './git-service-interface'; export interface GitFile { content: string | ArrayBuffer; sha: string; + isSymlink?: boolean; + symlinkTarget?: string; } // Git file mode for a symbolic link. Symlinks are stored as blobs whose content diff --git a/src/services/git-service-interface.ts b/src/services/git-service-interface.ts index e4ca517..1b6d388 100644 --- a/src/services/git-service-interface.ts +++ b/src/services/git-service-interface.ts @@ -1,6 +1,10 @@ export interface GitFile { content: string | ArrayBuffer; sha: string; + /** True when the remote blob is a symbolic link (mode 120000). */ + isSymlink?: boolean; + /** The link target path, when isSymlink is true and it could be determined. */ + symlinkTarget?: string; } /** A file entry from the repository tree, with whether it is a symbolic link. */ @@ -17,6 +21,12 @@ export interface GitServiceInterface { listFiles(branch: string, useFilter?: boolean): Promise; /** Like listFiles but also reports which entries are symbolic links (mode 120000). */ listFilesDetailed(branch: string, useFilter?: boolean): Promise; + /** + * Push a file as a symbolic link (Git blob mode 120000) whose content is the + * target path. Optional: only providers with a full Git Data API (GitHub) + * implement it; callers must fall back to pushFile when it's absent. + */ + pushSymlink?(path: string, target: string, branch: string, commitMessage: string): Promise<{ path: string, sha?: string }>; deleteFile(path: string, branch: string, commitMessage: string): Promise; getRepoGitignores(branch: string): Promise; } diff --git a/src/services/github-service.ts b/src/services/github-service.ts index 416408a..c3f0a5d 100644 --- a/src/services/github-service.ts +++ b/src/services/github-service.ts @@ -26,8 +26,15 @@ export class GitHubService extends BaseGitService implements GitServiceInterface try { const url = `${this.getApiUrl(path)}?ref=${branch}`; const response = await this.safeRequest(url, 'GET'); - const data = this.parseJson(response); - + const data = this.parseJson(response); + + // An unresolved symlink is returned as type 'symlink' with the literal + // target. (Links whose target is a normal in-repo file are followed by + // the Contents API and come back as ordinary file content.) + if (data.type === 'symlink') { + return { content: '', sha: data.sha, isSymlink: true, symlinkTarget: data.target }; + } + return { content: this.decodeContent(data.content, path), sha: data.sha @@ -54,6 +61,40 @@ export class GitHubService extends BaseGitService implements GitServiceInterface return { path: data.content.path, sha: data.content.sha }; } + async pushSymlink(path: string, target: string, branch: string, message: string): Promise<{ path: string, sha?: string }> { + // The Contents API can only create regular (100644) files, so symlinks + // (mode 120000) must be committed through the lower-level Git Data API: + // blob -> tree (with the symlink mode) -> commit -> move the branch ref. + const fullPath = this.getFullPath(path); + const base = `https://api.github.com/repos/${this.owner}/${this.repo}`; + + const refResp = await this.safeRequest(`${base}/git/ref/heads/${branch}`, 'GET'); + const latestCommitSha = this.parseJson<{ object: { sha: string } }>(refResp).object.sha; + + const commitResp = await this.safeRequest(`${base}/git/commits/${latestCommitSha}`, 'GET'); + const baseTreeSha = this.parseJson<{ tree: { sha: string } }>(commitResp).tree.sha; + + const blobResp = await this.safeRequest(`${base}/git/blobs`, 'POST', { content: target, encoding: 'utf-8' }); + const blobSha = this.parseJson<{ sha: string }>(blobResp).sha; + + const treeResp = await this.safeRequest(`${base}/git/trees`, 'POST', { + base_tree: baseTreeSha, + tree: [{ path: fullPath, mode: GIT_SYMLINK_MODE, type: 'blob', sha: blobSha }], + }); + const newTreeSha = this.parseJson<{ sha: string }>(treeResp).sha; + + const newCommitResp = await this.safeRequest(`${base}/git/commits`, 'POST', { + message, + tree: newTreeSha, + parents: [latestCommitSha], + }); + const newCommitSha = this.parseJson<{ sha: string }>(newCommitResp).sha; + + await this.safeRequest(`${base}/git/refs/heads/${branch}`, 'PATCH', { sha: newCommitSha }); + + return { path: fullPath, sha: blobSha }; + } + async listFilesDetailed(branch: string, useFilter = true): Promise { const url = `https://api.github.com/repos/${this.owner}/${this.repo}/git/trees/${branch}?recursive=1`; const response = await this.safeRequest(url, 'GET'); diff --git a/src/settings.ts b/src/settings.ts index 0fdcae1..d0f3233 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -43,6 +43,19 @@ export function getServiceName(settings: GitLabFilesPushSettings): string { return 'GitHub'; } +/** + * Resolves the symlink behavior that actually applies. Only GitHub can create or + * push real symlinks (it has the Git Data API); on other providers "real" is not + * possible, so it is treated as "skip" to avoid silently turning links into + * ordinary files. + */ +export function getEffectiveSymlinkHandling(settings: GitLabFilesPushSettings): SymlinkHandling { + if (settings.symlinkHandling === 'real' && settings.serviceType !== 'github') { + return 'skip'; + } + return settings.symlinkHandling; +} + export const DEFAULT_SETTINGS: GitLabFilesPushSettings = { serviceType: 'gitlab', gitlabToken: '', @@ -134,18 +147,25 @@ export class GitLabSyncSettingTab extends PluginSettingTab { void this.plugin.saveSettings(); })); + // "Real symlink" needs the Git Data API, which only GitHub offers. For + // other providers, offer follow/skip only so the option can't mislead. + const supportsRealSymlink = this.plugin.settings.serviceType === 'github'; new Setting(containerEl) .setName('Symbolic links') - .setDesc('How to sync symlinks: "real" recreates the link on desktop and falls back to the target content on mobile, "follow" always syncs the target content, and "skip" ignores symlinks.') - .addDropdown(dropdown => dropdown - .addOption('real', 'Real symlink (recommended)') - .addOption('follow', 'Follow (sync target content)') - .addOption('skip', 'Skip') - .setValue(this.plugin.settings.symlinkHandling) - .onChange((value: string) => { - this.plugin.settings.symlinkHandling = value as SymlinkHandling; - void this.plugin.saveSettings(); - })); + .setDesc(supportsRealSymlink + ? 'How to sync symlinks: "real" recreates the link on desktop and falls back to the target content on mobile, "follow" always syncs the target content, and "skip" ignores symlinks.' + : 'How to sync symlinks: "follow" syncs the target content as a normal file, "skip" ignores symlinks. Real symlinks require GitHub.') + .addDropdown(dropdown => { + if (supportsRealSymlink) dropdown.addOption('real', 'Real symlink (recommended)'); + dropdown + .addOption('follow', 'Follow (sync target content)') + .addOption('skip', 'Skip') + .setValue(getEffectiveSymlinkHandling(this.plugin.settings)) + .onChange((value: string) => { + this.plugin.settings.symlinkHandling = value as SymlinkHandling; + void this.plugin.saveSettings(); + }); + }); new Setting(containerEl) .setName('Test connection') diff --git a/src/ui/SyncStatusView.ts b/src/ui/SyncStatusView.ts index 3311423..bd04c81 100644 --- a/src/ui/SyncStatusView.ts +++ b/src/ui/SyncStatusView.ts @@ -1,6 +1,6 @@ import { ItemView, WorkspaceLeaf, TFile, Notice, Platform, setIcon, setTooltip } from 'obsidian'; import GitLabFilesPush from '../main'; -import { getServiceName } from '../settings'; +import { getServiceName, getEffectiveSymlinkHandling } from '../settings'; import { ConfirmModal } from './ConfirmModal'; import { logger } from '../utils/logger'; import { type FileStatus, type FilterValue } from './types'; @@ -309,7 +309,7 @@ export class SyncStatusView extends ItemView { // Map remote paths to vault paths const remoteMap = new Map(); // vaultPath -> remoteFullPath - const skipSymlinks = this.plugin.settings.symlinkHandling === 'skip'; + const skipSymlinks = getEffectiveSymlinkHandling(this.plugin.settings) === 'skip'; for (const entry of remoteEntries) { if (entry.symlink && skipSymlinks) continue; // Symlink handling: skip const normalized = this.getNormalizedRemotePath(entry.path); diff --git a/src/utils/symlink.ts b/src/utils/symlink.ts new file mode 100644 index 0000000..a436fe5 --- /dev/null +++ b/src/utils/symlink.ts @@ -0,0 +1,105 @@ +import { App, FileSystemAdapter, Platform } from 'obsidian'; +import { logger } from './logger'; + +/** + * Desktop-only helpers for real OS symbolic links. + * + * Obsidian exposes no symlink API, so on desktop (Electron) we reach for Node's + * `fs` via the global CommonJS `require`. None of this is available on mobile, + * so every entry point is guarded by `canUseRealSymlinks()` and callers must + * fall back to content-based syncing when it returns false. + */ + +// Minimal shapes of the Node APIs we use, so we don't statically import the +// builtins (they don't exist in the mobile bundle). +interface NodeFs { + lstatSync(p: string): { isSymbolicLink(): boolean }; + readlinkSync(p: string): string; + existsSync(p: string): boolean; + mkdirSync(p: string, opts: { recursive: boolean }): void; + rmSync(p: string, opts: { force: boolean }): void; + symlinkSync(target: string, p: string): void; +} + +interface NodePath { + join(...parts: string[]): string; + dirname(p: string): string; +} + +type NodeRequire = (id: string) => unknown; + +export function canUseRealSymlinks(app: App): boolean { + return typeof Platform !== 'undefined' && Platform.isDesktopApp + && typeof FileSystemAdapter === 'function' && app.vault.adapter instanceof FileSystemAdapter; +} + +// Electron exposes a CommonJS `require` on the global object on desktop; it is +// absent on mobile. Resolving it dynamically avoids a static node import. +function nodeModules(): { fs: NodeFs; path: NodePath } | null { + const req = (globalThis as unknown as { require?: NodeRequire }).require; + if (typeof req !== 'function') return null; + try { + return { fs: req('fs') as NodeFs, path: req('path') as NodePath }; + } catch { + return null; + } +} + +function absolutePath(app: App, vaultPath: string, path: NodePath): string | null { + const adapter = app.vault.adapter; + if (!(adapter instanceof FileSystemAdapter)) return null; + return path.join(adapter.getBasePath(), vaultPath); +} + +/** + * Returns the raw target of a local symlink, or null if the path is not a + * symlink (or real symlinks aren't supported on this platform). + */ +export function readLocalSymlinkTarget(app: App, vaultPath: string): string | null { + if (!canUseRealSymlinks(app)) return null; + const mods = nodeModules(); + if (!mods) return null; + try { + const abs = absolutePath(app, vaultPath, mods.path); + if (!abs) return null; + if (!mods.fs.lstatSync(abs).isSymbolicLink()) return null; + return mods.fs.readlinkSync(abs); + } catch (e) { + logger.warn(`Failed to read local symlink ${vaultPath}`, e); + return null; + } +} + +/** + * Creates (or replaces) a real OS symlink at vaultPath pointing to target. + * Returns true on success, false if it couldn't be created (caller should then + * fall back to writing the target content as a normal file). + */ +export function createLocalSymlink(app: App, vaultPath: string, target: string): boolean { + if (!canUseRealSymlinks(app)) return false; + const mods = nodeModules(); + if (!mods) return false; + try { + const abs = absolutePath(app, vaultPath, mods.path); + if (!abs) return false; + mods.fs.mkdirSync(mods.path.dirname(abs), { recursive: true }); + // Replace whatever is there (a stale file or an outdated link). existsSync + // follows links, so check lstat too in case of a dangling link. + if (mods.fs.existsSync(abs) || isSymlink(mods.fs, abs)) { + mods.fs.rmSync(abs, { force: true }); + } + mods.fs.symlinkSync(target, abs); + return true; + } catch (e) { + logger.warn(`Failed to create local symlink ${vaultPath} -> ${target}`, e); + return false; + } +} + +function isSymlink(fs: NodeFs, abs: string): boolean { + try { + return fs.lstatSync(abs).isSymbolicLink(); + } catch { + return false; + } +} diff --git a/tests/logic/sync-manager.test.ts b/tests/logic/sync-manager.test.ts index 58fd657..16f8e9c 100644 --- a/tests/logic/sync-manager.test.ts +++ b/tests/logic/sync-manager.test.ts @@ -113,6 +113,18 @@ describe('SyncManager', () => { ); }); + it('does not overwrite a remote symlink on push (follow mode safety)', async () => { + const mockFile = Object.assign(new TFile(), { path: 'link.md', name: 'link.md' }); + vi.spyOn(mockApp.vault, 'read').mockResolvedValue('local content'); + vi.spyOn(mockGitLab, 'getFile').mockResolvedValue({ content: '', sha: 'link-sha', isSymlink: true, symlinkTarget: '../x.md' }); + const pushSpy = vi.spyOn(mockGitLab, 'pushFile').mockResolvedValue({ path: 'link.md', sha: 'new' }); + + await manager.pushFile(mockFile); + + // The remote symlink must be left untouched. + expect(pushSpy).not.toHaveBeenCalled(); + }); + it('should detect conflict when remote SHA differs from last synced SHA', async () => { const mockFile = Object.assign(new TFile(), { path: 'test.md', name: 'test.md' }); diff --git a/tests/services/github-service.test.ts b/tests/services/github-service.test.ts index 8fc1c44..33ed214 100644 --- a/tests/services/github-service.test.ts +++ b/tests/services/github-service.test.ts @@ -55,6 +55,49 @@ describe('GitHubService', () => { }); }); + describe('getFile symlink detection', () => { + it('flags a symlink response and returns its target', async () => { + mockRequest({ status: 200, json: { type: 'symlink', target: '../shared/note.md', sha: 'link-sha' } }); + const result = await service.getFile('link.md', 'main'); + expect(result).toEqual({ content: '', sha: 'link-sha', isSymlink: true, symlinkTarget: '../shared/note.md' }); + }); + + it('treats a normal file response as non-symlink', async () => { + mockRequest({ status: 200, json: { content: btoa('hello'), sha: 'sha', type: 'file' } }); + const result = await service.getFile('note.md', 'main'); + expect(result.isSymlink).toBeUndefined(); + expect(result.content).toBe('hello'); + }); + }); + + describe('pushSymlink (Git Data API)', () => { + it('creates a blob, tree (mode 120000), commit, and moves the ref', async () => { + vi.mocked(requestUrl) + .mockResolvedValueOnce({ status: 200, json: { object: { sha: 'commit1' } } } as unknown as RequestUrlResponse) // get ref + .mockResolvedValueOnce({ status: 200, json: { tree: { sha: 'tree1' } } } as unknown as RequestUrlResponse) // get commit + .mockResolvedValueOnce({ status: 201, json: { sha: 'blob1' } } as unknown as RequestUrlResponse) // create blob + .mockResolvedValueOnce({ status: 201, json: { sha: 'tree2' } } as unknown as RequestUrlResponse) // create tree + .mockResolvedValueOnce({ status: 201, json: { sha: 'commit2' } } as unknown as RequestUrlResponse) // create commit + .mockResolvedValueOnce({ status: 200, json: {} } as unknown as RequestUrlResponse); // update ref + + const result = await service.pushSymlink('link.md', '../target.md', 'main', 'add link'); + + expect(result).toEqual({ path: 'link.md', sha: 'blob1' }); + const calls = vi.mocked(requestUrl).mock.calls.map(c => c[0] as RequestUrlParam); + expect(calls).toHaveLength(6); + // blob carries the target as utf-8 + const blobBody = JSON.parse(calls[2]?.body as string) as { content: string; encoding: string }; + expect(blobBody).toEqual({ content: '../target.md', encoding: 'utf-8' }); + // tree entry uses symlink mode 120000 + const treeBody = JSON.parse(calls[3]?.body as string) as { base_tree: string; tree: Array<{ path: string; mode: string; type: string; sha: string }> }; + expect(treeBody.base_tree).toBe('tree1'); + expect(treeBody.tree[0]).toEqual({ path: 'link.md', mode: '120000', type: 'blob', sha: 'blob1' }); + // ref update points at the new commit + expect(calls[5]?.method).toBe('PATCH'); + expect(JSON.parse(calls[5]?.body as string)).toEqual({ sha: 'commit2' }); + }); + }); + describe('pushFile', () => { it('should push new file correctly (no sha provided)', async () => { vi.mocked(requestUrl).mockResolvedValueOnce({ From 1e21061aa84f49b708326cc6ea7046c12579d2d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 05:36:47 +0000 Subject: [PATCH 19/29] perf(ui): parallelize refresh status checks and throttle re-renders Refresh checked files one at a time (a sequential network request per file) and re-rendered the whole view after every file, so a large vault was slow. Run the per-file checks with a bounded concurrency pool (8) and re-render on a ~150ms throttle plus a final render. Behavior and results are unchanged; wall time drops roughly in line with the concurrency. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01DwioG4CNKUBuKiZdowLFWe --- src/ui/SyncStatusView.ts | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/src/ui/SyncStatusView.ts b/src/ui/SyncStatusView.ts index bd04c81..e522a39 100644 --- a/src/ui/SyncStatusView.ts +++ b/src/ui/SyncStatusView.ts @@ -424,15 +424,38 @@ export class SyncStatusView extends ItemView { }); } + // Checks run with bounded concurrency (each file is an independent network + // request) and the view is re-rendered on a throttle rather than once per + // file, so a large vault refreshes far faster. + private static readonly STATUS_CHECK_CONCURRENCY = 8; + private static readonly RENDER_THROTTLE_MS = 150; + private async performStatusCheck(filesToCheck: Array): Promise { const total = filesToCheck.length; this.refreshProgress = { current: 0, total }; - for (let i = 0; i < total; i++) { - const file = filesToCheck[i]; - if (file) await this.refreshFileStatus(file); - this.refreshProgress.current = i + 1; - this.renderView(); - } + + let next = 0; + let lastRender = 0; + const maybeRender = (force = false): void => { + const now = Date.now(); + if (force || now - lastRender >= SyncStatusView.RENDER_THROTTLE_MS) { + lastRender = now; + this.renderView(); + } + }; + + const worker = async (): Promise => { + while (next < total) { + const file = filesToCheck[next++]; + if (file) await this.refreshFileStatus(file); + this.refreshProgress.current++; + maybeRender(); + } + }; + + const workerCount = Math.min(SyncStatusView.STATUS_CHECK_CONCURRENCY, total); + await Promise.all(Array.from({ length: workerCount }, () => worker())); + maybeRender(true); } private async refreshFileStatus(fileOrPath: TFile | string): Promise { From 2f6859a2f1cfb01fcce04f5dcd9a94df5989ffd3 Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Sun, 5 Jul 2026 04:57:47 +0000 Subject: [PATCH 20/29] fix(sync): clearer branch-not-found errors and connection test - listFilesDetailed rethrows 404 branch-resolution failures with a message naming the branch, instead of a bare "Git Service Error (404)" - testConnection now also checks the configured branch exists and reports repoOk/branchOk separately so the settings UI can warn when the repo is reachable but the branch is missing - fixes settings.ts silently reporting "connection successful" even when testConnection's result was never checked Co-Authored-By: Claude Sonnet 5 --- src/services/git-service-base.ts | 27 +++++++++++++++++++++++++- src/services/git-service-interface.ts | 5 ++++- src/services/gitea-service.ts | 25 +++++++++++++++++------- src/services/github-service.ts | 24 +++++++++++++++++------ src/services/gitlab-service.ts | 27 +++++++++++++++++++------- src/settings.ts | 14 +++++++++++-- tests/services/gitea-service.test.ts | 20 ++++++++++++++++--- tests/services/github-service.test.ts | 13 +++++++++++++ tests/services/gitlab-service.test.ts | 13 +++++++++++++ tests/services/service-test-helpers.ts | 10 ++++++---- 10 files changed, 147 insertions(+), 31 deletions(-) diff --git a/src/services/git-service-base.ts b/src/services/git-service-base.ts index e90aac7..022d4ca 100644 --- a/src/services/git-service-base.ts +++ b/src/services/git-service-base.ts @@ -43,6 +43,15 @@ export interface GitLabTreeItem { mode?: string; } +export interface ConnectionTestResult { + /** Whether the repository/project itself was reachable with the given credentials. */ + repoOk: boolean; + /** Whether the configured branch was found. Only meaningful when repoOk is true. */ + branchOk: boolean; + /** Populated when repoOk is false, describing the repo-level failure. */ + error?: string; +} + export abstract class BaseGitService { protected token: string = ''; protected rootPath: string = ''; @@ -197,6 +206,22 @@ export abstract class BaseGitService { throw e; } + /** + * Rethrows a branch-resolution failure (e.g. the "resolve branch to a commit" + * request in listFilesDetailed) with a message that names the branch, since a + * bare "404" gives no clue that the configured Branch setting is the problem. + */ + protected branchNotFoundError(e: unknown, branch: string): Error { + if (e instanceof Error && e.message.includes('404')) { + return new Error( + `Branch "${branch}" was not found in the repository. Check the Branch setting, ` + + 'or confirm the repository actually has a branch with this name (its default branch ' + + 'might be "master" instead of "main", or the repository may have no commits yet).' + ); + } + return e instanceof Error ? e : new Error(String(e)); + } + async getRepoGitignores(branch: string): Promise { try { const allFiles = await this.listFiles(branch, false); // Fetch ALL files to find gitignores @@ -215,5 +240,5 @@ export abstract class BaseGitService { abstract pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string): Promise<{ path: string, sha?: string }>; abstract listFilesDetailed(branch: string, useFilter?: boolean): Promise; abstract deleteFile(path: string, branch: string, message: string): Promise; - abstract testConnection(): Promise; + abstract testConnection(branch: string): Promise; } diff --git a/src/services/git-service-interface.ts b/src/services/git-service-interface.ts index 1b6d388..2ff6ac3 100644 --- a/src/services/git-service-interface.ts +++ b/src/services/git-service-interface.ts @@ -1,3 +1,5 @@ +import { ConnectionTestResult } from './git-service-base'; + export interface GitFile { content: string | ArrayBuffer; sha: string; @@ -17,7 +19,8 @@ export interface GitServiceInterface { updateConfig(...args: unknown[]): void; getFile(path: string, branch: string): Promise; pushFile(path: string, content: string | ArrayBuffer, branch: string, commitMessage: string, existingSha?: string): Promise<{ path: string, sha?: string }>; - testConnection(): Promise; + /** Checks the repository is reachable and the given branch exists. */ + testConnection(branch: string): Promise; listFiles(branch: string, useFilter?: boolean): Promise; /** Like listFiles but also reports which entries are symbolic links (mode 120000). */ listFilesDetailed(branch: string, useFilter?: boolean): Promise; diff --git a/src/services/gitea-service.ts b/src/services/gitea-service.ts index c5ffc8e..3ef8404 100644 --- a/src/services/gitea-service.ts +++ b/src/services/gitea-service.ts @@ -1,5 +1,5 @@ import { GitServiceInterface, GitTreeEntry } from './git-service-interface'; -import { BaseGitService, GitFile, GitHubContentResponse, GitHubTreeResponse, GIT_SYMLINK_MODE } from './git-service-base'; +import { BaseGitService, ConnectionTestResult, GitFile, GitHubContentResponse, GitHubTreeResponse, GIT_SYMLINK_MODE } from './git-service-base'; import { logger } from '../utils/logger'; export class GiteaService extends BaseGitService implements GitServiceInterface { @@ -59,9 +59,13 @@ export class GiteaService extends BaseGitService implements GitServiceInterface // Resolve branch name to commit SHA first for compatibility with all Gitea versions, // since the git/trees endpoint requires a SHA (not a ref name) on older instances. const branchUrl = `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}/branches/${branch}`; - const branchResponse = await this.safeRequest(branchUrl, 'GET'); - const branchData = this.parseJson<{ commit: { id: string } }>(branchResponse); - const commitSha = branchData.commit.id; + let commitSha: string; + try { + const branchResponse = await this.safeRequest(branchUrl, 'GET'); + commitSha = this.parseJson<{ commit: { id: string } }>(branchResponse).commit.id; + } catch (e) { + throw this.branchNotFoundError(e, branch); + } const treeUrl = `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}/git/trees/${commitSha}?recursive=1`; const treeResponse = await this.safeRequest(treeUrl, 'GET'); @@ -96,13 +100,20 @@ export class GiteaService extends BaseGitService implements GitServiceInterface await this.safeRequest(url, 'DELETE', body); } - async testConnection(): Promise { + async testConnection(branch: string): Promise { try { const url = `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}`; await this.safeRequest(url, 'GET'); - return true; + } catch (e) { + return { repoOk: false, branchOk: false, error: e instanceof Error ? e.message : String(e) }; + } + + try { + const branchUrl = `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}/branches/${branch}`; + await this.safeRequest(branchUrl, 'GET', undefined, undefined, true); + return { repoOk: true, branchOk: true }; } catch { - return false; + return { repoOk: true, branchOk: false }; } } } diff --git a/src/services/github-service.ts b/src/services/github-service.ts index c3f0a5d..a320e16 100644 --- a/src/services/github-service.ts +++ b/src/services/github-service.ts @@ -1,5 +1,5 @@ import { GitServiceInterface, GitTreeEntry } from './git-service-interface'; -import { BaseGitService, GitFile, GitHubContentResponse, GitHubTreeResponse, GIT_SYMLINK_MODE } from './git-service-base'; +import { BaseGitService, ConnectionTestResult, GitFile, GitHubContentResponse, GitHubTreeResponse, GIT_SYMLINK_MODE } from './git-service-base'; import { logger } from '../utils/logger'; export class GitHubService extends BaseGitService implements GitServiceInterface { @@ -97,8 +97,13 @@ export class GitHubService extends BaseGitService implements GitServiceInterface async listFilesDetailed(branch: string, useFilter = true): Promise { const url = `https://api.github.com/repos/${this.owner}/${this.repo}/git/trees/${branch}?recursive=1`; - const response = await this.safeRequest(url, 'GET'); - const data = this.parseJson(response); + let data: GitHubTreeResponse; + try { + const response = await this.safeRequest(url, 'GET'); + data = this.parseJson(response); + } catch (e) { + throw this.branchNotFoundError(e, branch); + } if (data.truncated) { logger.warn('GitHub tree result is truncated. Some files might not be shown.'); @@ -129,13 +134,20 @@ export class GitHubService extends BaseGitService implements GitServiceInterface await this.safeRequest(url, 'DELETE', body); } - async testConnection(): Promise { + async testConnection(branch: string): Promise { try { const url = `https://api.github.com/repos/${this.owner}/${this.repo}`; await this.safeRequest(url, 'GET'); - return true; + } catch (e) { + return { repoOk: false, branchOk: false, error: e instanceof Error ? e.message : String(e) }; + } + + try { + const branchUrl = `https://api.github.com/repos/${this.owner}/${this.repo}/branches/${branch}`; + await this.safeRequest(branchUrl, 'GET', undefined, undefined, true); + return { repoOk: true, branchOk: true }; } catch { - return false; + return { repoOk: true, branchOk: false }; } } diff --git a/src/services/gitlab-service.ts b/src/services/gitlab-service.ts index 1fbf001..8c5a900 100644 --- a/src/services/gitlab-service.ts +++ b/src/services/gitlab-service.ts @@ -1,5 +1,5 @@ import { GitServiceInterface, GitTreeEntry } from './git-service-interface'; -import { BaseGitService, GitFile, GitLabFileResponse, GitLabTreeItem, GIT_SYMLINK_MODE } from './git-service-base'; +import { BaseGitService, ConnectionTestResult, GitFile, GitLabFileResponse, GitLabTreeItem, GIT_SYMLINK_MODE } from './git-service-base'; export class GitLabService extends BaseGitService implements GitServiceInterface { private baseUrl: string = 'https://gitlab.com'; @@ -63,8 +63,13 @@ export class GitLabService extends BaseGitService implements GitServiceInterface while (true) { const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/tree?ref=${branch}&recursive=true&per_page=${perPage}&page=${page}`; - const response = await this.safeRequest(url, 'GET'); - const data = this.parseJson(response); + let data: GitLabTreeItem[]; + try { + const response = await this.safeRequest(url, 'GET'); + data = this.parseJson(response); + } catch (e) { + throw this.branchNotFoundError(e, branch); + } if (!data || data.length === 0) break; @@ -100,14 +105,22 @@ export class GitLabService extends BaseGitService implements GitServiceInterface await this.safeRequest(url, 'DELETE', body); } - async testConnection(): Promise { + async testConnection(branch: string): Promise { + const encodedProjectId = encodeURIComponent(this.projectId); try { - const encodedProjectId = encodeURIComponent(this.projectId); const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}`; await this.safeRequest(url, 'GET'); - return true; + } catch (e) { + return { repoOk: false, branchOk: false, error: e instanceof Error ? e.message : String(e) }; + } + + try { + const encodedBranch = encodeURIComponent(branch); + const branchUrl = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/branches/${encodedBranch}`; + await this.safeRequest(branchUrl, 'GET', undefined, undefined, true); + return { repoOk: true, branchOk: true }; } catch { - return false; + return { repoOk: true, branchOk: false }; } } diff --git a/src/settings.ts b/src/settings.ts index d0f3233..a1f4662 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -174,8 +174,18 @@ export class GitLabSyncSettingTab extends PluginSettingTab { .setButtonText('Test connection') .onClick(async () => { try { - await this.plugin.gitService.testConnection(); - new Notice(`${getServiceName(this.plugin.settings)} connection successful!`); + const result = await this.plugin.gitService.testConnection(this.plugin.settings.branch); + if (!result.repoOk) { + new Notice(`Connection failed: ${result.error ?? 'could not reach the repository'}`); + } else if (!result.branchOk) { + new Notice( + `Connected, but branch "${this.plugin.settings.branch}" was not found. ` + + 'Check the Branch setting, or confirm the repository has a branch with this name.', + 8000 + ); + } else { + new Notice(`${getServiceName(this.plugin.settings)} connection successful!`); + } } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e); new Notice(`Connection failed: ${message}`); diff --git a/tests/services/gitea-service.test.ts b/tests/services/gitea-service.test.ts index c8cdae7..4ce0367 100644 --- a/tests/services/gitea-service.test.ts +++ b/tests/services/gitea-service.test.ts @@ -179,6 +179,11 @@ describe('GiteaService', () => { expect(result).toEqual(['file1.md']); warnSpy.mockRestore(); }); + + it('should throw a message naming the branch when the branch is not found', async () => { + mockRequest({ status: 404, json: { message: 'branch does not exist' }, text: 'branch does not exist' }); + await expect(service.listFiles('missing-branch')).rejects.toThrow(/Branch "missing-branch" was not found/); + }); }); describe('deleteFile', () => { @@ -216,9 +221,18 @@ describe('GiteaService', () => { it('should call the correct repo API URL', async () => { mockRequest({ status: 200, json: {} }); - await service.testConnection(); - const call = getLastRequestCall(); - expect(call.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}`); + await service.testConnection('main'); + const calls = vi.mocked(requestUrl).mock.calls; + const firstCall = calls[0]?.[0] as RequestUrlParam; + expect(firstCall.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}`); + }); + + it('should report branchOk: false when the branch is not found', async () => { + vi.mocked(requestUrl) + .mockResolvedValueOnce({ status: 200, json: {} } as unknown as RequestUrlResponse) + .mockResolvedValueOnce({ status: 404, json: { message: 'not found' }, text: 'not found' } as unknown as RequestUrlResponse); + const result = await service.testConnection('missing-branch'); + expect(result).toEqual({ repoOk: true, branchOk: false }); }); }); diff --git a/tests/services/github-service.test.ts b/tests/services/github-service.test.ts index 33ed214..78248e1 100644 --- a/tests/services/github-service.test.ts +++ b/tests/services/github-service.test.ts @@ -191,6 +191,11 @@ describe('GitHubService', () => { expect(result).toEqual(['file1.md', 'file2.md']); warnSpy.mockRestore(); }); + + it('should throw a message naming the branch when the branch is not found', async () => { + mockRequest({ status: 404, json: { message: 'Not Found' }, text: 'Not Found' }); + await expect(service.listFiles('missing-branch')).rejects.toThrow(/Branch "missing-branch" was not found/); + }); }); describe('deleteFile', () => { @@ -211,6 +216,14 @@ describe('GitHubService', () => { describe('testConnection', () => { sharedTestConnection(() => service); + + it('should report branchOk: false when the branch is not found', async () => { + vi.mocked(requestUrl) + .mockResolvedValueOnce({ status: 200, json: {} } as unknown as RequestUrlResponse) + .mockResolvedValueOnce({ status: 404, json: { message: 'Not Found' }, text: 'Not Found' } as unknown as RequestUrlResponse); + const result = await service.testConnection('missing-branch'); + expect(result).toEqual({ repoOk: true, branchOk: false }); + }); }); describe('getRepoGitignores', () => { diff --git a/tests/services/gitlab-service.test.ts b/tests/services/gitlab-service.test.ts index 9c5d90c..55abb9f 100644 --- a/tests/services/gitlab-service.test.ts +++ b/tests/services/gitlab-service.test.ts @@ -143,6 +143,11 @@ describe('GitLabService', () => { expect(result).toHaveLength(142); expect(vi.mocked(requestUrl)).toHaveBeenCalledTimes(2); }); + + it('should throw a message naming the branch when the branch is not found', async () => { + mockRequest({ status: 404, json: { message: '404 Branch Not Found' }, text: '404 Branch Not Found' }); + await expect(service.listFiles('missing-branch')).rejects.toThrow(/Branch "missing-branch" was not found/); + }); }); describe('deleteFile', () => { @@ -157,6 +162,14 @@ describe('GitLabService', () => { describe('testConnection', () => { sharedTestConnection(() => service); + + it('should report branchOk: false when the branch is not found', async () => { + vi.mocked(requestUrl) + .mockResolvedValueOnce({ status: 200, json: {} } as unknown as RequestUrlResponse) + .mockResolvedValueOnce({ status: 404, json: { message: 'Branch Not Found' }, text: 'Branch Not Found' } as unknown as RequestUrlResponse); + const result = await service.testConnection('missing-branch'); + expect(result).toEqual({ repoOk: true, branchOk: false }); + }); }); describe('getRepoGitignores', () => { diff --git a/tests/services/service-test-helpers.ts b/tests/services/service-test-helpers.ts index 5570f4a..b0d9d1e 100644 --- a/tests/services/service-test-helpers.ts +++ b/tests/services/service-test-helpers.ts @@ -14,14 +14,16 @@ export function mockRequest(response: Partial): void { } export function sharedTestConnection(getService: () => GitServiceInterface): void { - it('should return true on successful connection', async () => { + it('should report repoOk and branchOk on successful connection', async () => { mockRequest({ status: 200, json: {} }); - expect(await getService().testConnection()).toBe(true); + expect(await getService().testConnection('main')).toEqual({ repoOk: true, branchOk: true }); }); - it('should return false on failed connection', async () => { + it('should report repoOk: false on failed connection', async () => { mockRequest({ status: 401, json: { message: 'Unauthorized' }, text: 'Unauthorized' }); - expect(await getService().testConnection()).toBe(false); + const result = await getService().testConnection('main'); + expect(result.repoOk).toBe(false); + expect(result.branchOk).toBe(false); }); } From 1364b94f0a4063c84649c278990ed7116f95196a Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Sun, 5 Jul 2026 05:05:54 +0000 Subject: [PATCH 21/29] fix(sync): stop batch push/pull from silently overwriting conflicts Single-file push/pull already detected when both local and remote changed since the last sync and prompted via SyncConflictModal, but "Push all"/"Pull all"/multi-select batch actions skipped that check and force-overwrote. Batch operations now skip conflicting files and report a conflicts count so nothing is silently clobbered. Also correct delete-confirmation copy: it claimed local deletes are recoverable ("moved to trash") unconditionally, but the actual destination depends on the vault's "Deleted files" setting (which can be permanent). Wording now defers to that setting instead of asserting recoverability. --- src/logic/sync-manager.ts | 65 +++++++++++++++++--------- src/ui/SyncStatusView.ts | 17 +++++-- tests/logic/sync-manager-batch.test.ts | 56 ++++++++++++++++++++++ 3 files changed, 112 insertions(+), 26 deletions(-) diff --git a/src/logic/sync-manager.ts b/src/logic/sync-manager.ts index 35fb9f8..dc0b949 100644 --- a/src/logic/sync-manager.ts +++ b/src/logic/sync-manager.ts @@ -6,6 +6,9 @@ import { logger } from '../utils/logger'; import { isBinaryPath, contentsEqual } from '../utils/path'; import { readLocalSymlinkTarget, createLocalSymlink } from '../utils/symlink'; +/** Result of syncing one file within a batch push/pull. */ +type BatchOutcome = 'done' | 'unchanged' | 'conflict'; + export class SyncManager { private readonly app: App; private gitService: GitServiceInterface; @@ -331,11 +334,11 @@ export class SyncManager { new Notice(`${message}: ${detail}`); } - async pushAllFiles(files: (TFile | string)[], onProgress?: (current: number, total: number, fileName: string) => void): Promise<{ success: number; failed: number; errors: Array<{ file: string; error: string }> }> { + async pushAllFiles(files: (TFile | string)[], onProgress?: (current: number, total: number, fileName: string) => void): Promise<{ success: number; failed: number; conflicts: number; errors: Array<{ file: string; error: string }> }> { return this.processBatch(files, 'push', onProgress); } - async pullAllFiles(files: (TFile | string)[], onProgress?: (current: number, total: number, fileName: string) => void): Promise<{ success: number; failed: number; errors: Array<{ file: string; error: string }> }> { + async pullAllFiles(files: (TFile | string)[], onProgress?: (current: number, total: number, fileName: string) => void): Promise<{ success: number; failed: number; conflicts: number; errors: Array<{ file: string; error: string }> }> { return this.processBatch(files, 'pull', onProgress); } @@ -343,8 +346,8 @@ export class SyncManager { files: (TFile | string)[], op: 'push' | 'pull', onProgress?: (current: number, total: number, fileName: string) => void - ): Promise<{ success: number; failed: number; errors: Array<{ file: string; error: string }> }> { - const results = { success: 0, failed: 0, errors: [] as Array<{ file: string; error: string }> }; + ): Promise<{ success: number; failed: number; conflicts: number; errors: Array<{ file: string; error: string }> }> { + const results = { success: 0, failed: 0, conflicts: 0, errors: [] as Array<{ file: string; error: string }> }; for (let i = 0; i < files.length; i++) { const fileOrPath = files[i]; @@ -354,13 +357,12 @@ export class SyncManager { onProgress?.(i + 1, files.length, name); try { - let performed = false; - if (op === 'push') { - performed = await this.processSingleBatchPush(fileOrPath, path, name, isString); - } else { - performed = await this.processSingleBatchPull(fileOrPath, path, name, isString); - } - if (performed) results.success++; + const outcome = op === 'push' + ? await this.processSingleBatchPush(fileOrPath, path, name, isString) + : await this.processSingleBatchPull(fileOrPath, path, name, isString); + + if (outcome === 'done') results.success++; + else if (outcome === 'conflict') results.conflicts++; } catch (e) { logger.error(`Failed to ${op} ${path}:`, e); results.failed++; @@ -369,16 +371,19 @@ export class SyncManager { } await this.saveSettings(); - this.notifyBatchResult(op, results.success, results.failed); + this.notifyBatchResult(op, results.success, results.failed, results.conflicts); return results; } - private notifyBatchResult(op: 'push' | 'pull', success: number, failed: number): void { + private notifyBatchResult(op: 'push' | 'pull', success: number, failed: number, conflicts: number): void { const opName = op === 'push' ? 'Pushed' : 'Pulled'; if (success > 0) { new Notice(`${opName} ${success} file(s) to ${this.serviceName}`); } + if (conflicts > 0) { + new Notice(`Skipped ${conflicts} file(s) with conflicting changes on both sides. Push or pull each one individually to resolve.`, 8000); + } if (failed > 0) { new Notice(`Failed to ${op} ${failed} file(s). Check console for details.`); } @@ -421,13 +426,13 @@ export class SyncManager { } } - private async processSingleBatchPush(fileOrPath: TFile | string, path: string, name: string, isString: boolean): Promise { + private async processSingleBatchPush(fileOrPath: TFile | string, path: string, name: string, isString: boolean): Promise { if (!await this.checkFileExists(path, isString)) throw new Error('File no longer exists'); // Symbolic link handling: real → push as a symlink (GitHub), skip → ignore. const symlinkTarget = readLocalSymlinkTarget(this.app, path); if (symlinkTarget !== null && await this.handleSymlinkPush({ path, name }, symlinkTarget, true)) { - return getEffectiveSymlinkHandling(this.settings) !== 'skip'; + return getEffectiveSymlinkHandling(this.settings) !== 'skip' ? 'done' : 'unchanged'; } const content = await this.getFileContent(fileOrPath); @@ -438,26 +443,36 @@ export class SyncManager { const renamedFrom = this.detectRename(fileOrPath); if (renamedFrom) { await this.handleRename(fileOrPath, renamedFrom, content); - return true; + return 'done'; } } const remote = await this.gitService.getFile(repoPath, this.settings.branch); // Don't convert a remote symlink into a regular file. - if (remote.isSymlink) return false; + if (remote.isSymlink) return 'unchanged'; // Skip if already in sync if (remote.sha && this.contentsEqual(content, remote.content)) { await this.updateMetadata(path, remote.sha); - return false; + return 'unchanged'; + } + + // Same conflict check as the single-file flow: if the remote has moved on + // from what we last synced, overwriting it here would silently discard + // whatever changed on the remote. Skip it instead of force-pushing so the + // batch action can't quietly clobber changes the way a single push would + // stop and ask about via SyncConflictModal. + const lastSynced = this.settings.syncMetadata[path]; + if (remote.sha && lastSynced && remote.sha !== lastSynced.lastSyncedSha) { + return 'conflict'; } await this.performPush({ path, name }, content, remote.sha || undefined, true); - return true; + return 'done'; } - private async processSingleBatchPull(fileOrPath: TFile | string, path: string, name: string, isString: boolean): Promise { + private async processSingleBatchPull(fileOrPath: TFile | string, path: string, name: string, isString: boolean): Promise { const repoPath = this.getNormalizedPath(path); const remote = await this.gitService.getFile(repoPath, this.settings.branch); if (!remote.sha) throw new Error('File not found in remote'); @@ -467,12 +482,18 @@ export class SyncManager { const localContent = await this.getFileContent(fileOrPath); if (this.contentsEqual(localContent, remote.content)) { await this.updateMetadata(path, remote.sha); - return false; + return 'unchanged'; + } + + // Same conflict check as the single-file flow (see processSingleBatchPush). + const lastSynced = this.settings.syncMetadata[path]; + if (lastSynced && remote.sha !== lastSynced.lastSyncedSha) { + return 'conflict'; } } const fileRep = typeof fileOrPath === 'string' ? { path, name } : fileOrPath; await this.performPull(fileRep, remote.content, remote.sha, true, this.symlinkPullTarget(remote)); - return true; + return 'done'; } } diff --git a/src/ui/SyncStatusView.ts b/src/ui/SyncStatusView.ts index e522a39..44f260e 100644 --- a/src/ui/SyncStatusView.ts +++ b/src/ui/SyncStatusView.ts @@ -225,7 +225,7 @@ export class SyncStatusView extends ItemView { // ── Single-file operations ────────────────────────────────────── private async handleLocalDelete(fileStatus: FileStatus): Promise { - const confirmed = await this.showConfirmDialog(`Delete local file "${fileStatus.path}"?`); + const confirmed = await this.showConfirmDialog(`Delete local file "${fileStatus.path}"? Handled per your vault's "Deleted files" setting.`); if (!confirmed) return; try { if (fileStatus.file) { @@ -631,10 +631,19 @@ export class SyncStatusView extends ItemView { } private async confirmDeletion(localCount: number, remoteCount: number): Promise { + // Local deletes go through Obsidian's own trash handling, whose actual + // destination (vault .trash/, OS trash, or permanent) depends on the + // user's "Deleted files" setting — not something this plugin can read. + // So local wording defers to that setting rather than promising + // recoverability; remote deletes are unconditionally permanent. let msg = ''; - if (localCount > 0 && remoteCount > 0) msg = `Delete ${localCount} local + ${remoteCount} remote file(s)? Cannot be undone.`; - else if (localCount > 0) msg = `Delete ${localCount} local file(s)? Cannot be undone.`; - else msg = `Delete ${remoteCount} remote file(s)? Cannot be undone.`; + if (localCount > 0 && remoteCount > 0) { + msg = `Delete ${localCount} local file(s) (per your vault's trash setting) and ${remoteCount} remote file(s) (cannot be undone)?`; + } else if (localCount > 0) { + msg = `Delete ${localCount} local file(s)? They'll be handled per your vault's "Deleted files" setting.`; + } else { + msg = `Delete ${remoteCount} remote file(s)? This cannot be undone.`; + } return this.showConfirmDialog(msg); } diff --git a/tests/logic/sync-manager-batch.test.ts b/tests/logic/sync-manager-batch.test.ts index 8bbec82..dd7b3f0 100644 --- a/tests/logic/sync-manager-batch.test.ts +++ b/tests/logic/sync-manager-batch.test.ts @@ -151,6 +151,62 @@ describe('SyncManager Batch Operations', () => { }); }); + describe('batch conflict detection', () => { + it('should skip (not overwrite) a push when the remote has moved on since last sync', async () => { + const path = 'conflicted.md'; + mockSettings.syncMetadata = { + [path]: { lastSyncedSha: 'sha-at-last-sync', lastSyncedAt: 0, lastKnownPath: path } + }; + const adapter = mockApp.vault.adapter as Mocked; + + vi.mocked(adapter.exists).mockResolvedValue(true); + vi.mocked(adapter.read).mockResolvedValue('local edit'); + // Remote sha differs from what we last synced, and content differs too. + vi.mocked(mockGitService.getFile).mockResolvedValue({ content: 'remote edit', sha: 'sha-changed-on-remote' }); + + const results = await manager.pushAllFiles([path]); + + expect(results.success).toBe(0); + expect(results.conflicts).toBe(1); + expect(results.failed).toBe(0); + expect(mockGitService.pushFile).not.toHaveBeenCalled(); + }); + + it('should skip (not overwrite) a pull when the local file has diverged since last sync', async () => { + const path = 'conflicted.md'; + mockSettings.syncMetadata = { + [path]: { lastSyncedSha: 'sha-at-last-sync', lastSyncedAt: 0, lastKnownPath: path } + }; + const adapter = mockApp.vault.adapter as Mocked; + + vi.mocked(adapter.exists).mockResolvedValue(true); + vi.mocked(adapter.read).mockResolvedValue('local edit'); + vi.mocked(mockGitService.getFile).mockResolvedValue({ content: 'remote edit', sha: 'sha-changed-on-remote' }); + + const results = await manager.pullAllFiles([path]); + + expect(results.success).toBe(0); + expect(results.conflicts).toBe(1); + expect(results.failed).toBe(0); + expect(mockApp.vault.adapter.write).not.toHaveBeenCalled(); + }); + + it('should still push normally when there is no prior sync metadata (not a conflict)', async () => { + const path = 'new-file.md'; + const adapter = mockApp.vault.adapter as Mocked; + + vi.mocked(adapter.exists).mockResolvedValue(true); + vi.mocked(adapter.read).mockResolvedValue('local content'); + vi.mocked(mockGitService.getFile).mockResolvedValue({ content: 'remote content', sha: 'some-sha' }); + vi.mocked(mockGitService.pushFile).mockResolvedValue({ path, sha: 'new-sha' }); + + const results = await manager.pushAllFiles([path]); + + expect(results.success).toBe(1); + expect(results.conflicts).toBe(0); + }); + }); + describe('batch push with rename detection', () => { it('should detect and handle rename during batch push', async () => { const oldPath = 'old.md'; From acebafd94a434e574686ad61878ce66f8173d1c1 Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Sun, 5 Jul 2026 05:13:52 +0000 Subject: [PATCH 22/29] fix(ui): keep ribbon/command labels in sync with configured Git service The push ribbon icon's tooltip and the "Push/Pull current file" command names embedded the configured service name (e.g. "Push to GitHub") but were only set once at plugin load. Switching service in Settings afterward left them stale until Obsidian was reloaded. - Ribbon tooltip is now re-applied via setTooltip() on every saveSettings(), so it always reflects the current service. - Command names have no rename API in Obsidian, so they're now generic ("Push current file" / "Pull current file"), matching the wording already used by "Push all files" / "Pull all files". --- src/main.ts | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/main.ts b/src/main.ts index 7c1ad7b..944f65f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,4 @@ -import { Plugin, TFile, MarkdownView, Notice, Platform } from 'obsidian'; +import { Plugin, TFile, MarkdownView, Notice, Platform, setTooltip } from 'obsidian'; import { DEFAULT_SETTINGS, GitLabFilesPushSettings, GitLabSyncSettingTab, getServiceName } from "./settings"; import { GitLabService } from './services/gitlab-service'; import { GitHubService } from './services/github-service'; @@ -15,6 +15,7 @@ export default class GitLabFilesPush extends Plugin { gitService: GitServiceInterface; sync: SyncManager; gitignoreManager: GitignoreManager; + private pushRibbonEl: HTMLElement; async onload() { await this.loadSettings(); @@ -41,7 +42,7 @@ export default class GitLabFilesPush extends Plugin { this.gitignoreManager = new GitignoreManager(this.app, this.gitService, this.settings.branch, this.settings.rootPath, this.settings.vaultFolder); this.sync = new SyncManager(this.app, this.gitService, this.settings, this.saveSettings.bind(this)); - this.addRibbonIcon('upload-cloud', Platform.isMobile ? `Push` : `Push to ${this.serviceName}`, async () => { + this.pushRibbonEl = this.addRibbonIcon('upload-cloud', this.pushRibbonLabel(), async () => { const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); if (activeView && activeView.file instanceof TFile) { await this.sync.pushFile(activeView.file); @@ -50,9 +51,13 @@ export default class GitLabFilesPush extends Plugin { } }); + // Command names are set once at registration and Obsidian has no API to + // rename them later, so they stay generic rather than embedding the + // configured service — otherwise switching service in Settings would + // leave a stale name in the Command Palette until Obsidian reloads. this.addCommand({ id: 'push-current-file', - name: `Push current file to ${this.serviceName}`, + name: 'Push current file', callback: async () => { const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); if (activeView && activeView.file instanceof TFile) { @@ -63,7 +68,7 @@ export default class GitLabFilesPush extends Plugin { this.addCommand({ id: 'pull-current-file', - name: `Pull current file from ${this.serviceName}`, + name: 'Pull current file', callback: async () => { const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); if (activeView && activeView.file instanceof TFile) { @@ -110,6 +115,17 @@ export default class GitLabFilesPush extends Plugin { return getServiceName(this.settings); } + private pushRibbonLabel(): string { + return Platform.isMobile ? 'Push' : `Push to ${this.serviceName}`; + } + + // The ribbon icon's tooltip is set once when addRibbonIcon runs, so it goes + // stale if the user switches Git service afterwards without reloading the + // plugin. Re-apply it whenever settings are saved to keep it in sync. + private updateRibbonTooltip(): void { + if (this.pushRibbonEl) setTooltip(this.pushRibbonEl, this.pushRibbonLabel()); + } + async activateSyncStatusView(): Promise { const { workspace } = this.app; @@ -284,5 +300,6 @@ export default class GitLabFilesPush extends Plugin { async saveSettings() { await this.saveData(this.settings); this.initializeGitService(); + this.updateRibbonTooltip(); } } From d11ca94073d7a36bac673ceb76c2d1815eebcd0a Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Sun, 5 Jul 2026 05:14:28 +0000 Subject: [PATCH 23/29] fix(lint): resolve Obsidian plugin linter warnings - drop unnecessary type assertion in SyncStatusView tab rendering - use window.setTimeout instead of global setTimeout - use window instead of globalThis when resolving Electron's require docs: list supported Git providers at the top of README --- README.md | 5 +++++ src/ui/SyncStatusView.ts | 4 ++-- src/utils/symlink.ts | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b666228..5df4741 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,11 @@ [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg?style=flat-square)](https://conventionalcommits.org) [![License](https://img.shields.io/github/license/firstsun-dev/git-files-sync?style=flat-square)](LICENSE) +**Supports:** +GitHub +GitLab +Gitea + **Git File Sync** is a powerful Obsidian plugin that enables seamless synchronization of individual notes with GitLab, GitHub, or self-hosted Gitea repositories. Unlike full-vault sync solutions, it gives you granular control over what gets pushed and pulled, making it perfect for shared projects, selective backups, and cross-platform workflows. [繁體中文使用說明](USAGE_zh.md) diff --git a/src/ui/SyncStatusView.ts b/src/ui/SyncStatusView.ts index 44f260e..1ad6367 100644 --- a/src/ui/SyncStatusView.ts +++ b/src/ui/SyncStatusView.ts @@ -143,7 +143,7 @@ export class SyncStatusView extends ItemView { }); // Share the status icon set with the file list so tabs never drift. if (tab.value !== 'all') { - setIcon(btn.createSpan(), statusMeta(tab.value as FileStatus['status']).icon); + setIcon(btn.createSpan(), statusMeta(tab.value).icon); } btn.createSpan({ cls: 'ssv-tab-label', text: ` ${tab.label}` }); const count = counts[tab.value]; @@ -252,7 +252,7 @@ export class SyncStatusView extends ItemView { await this.plugin.sync.pullFile(fileStatus.file || fileStatus.path); } - await new Promise(r => setTimeout(r, 500)); + await new Promise(r => window.setTimeout(r, 500)); await this.refreshFileStatus(fileStatus.file || fileStatus.path); this.renderView(); } catch (e) { diff --git a/src/utils/symlink.ts b/src/utils/symlink.ts index a436fe5..5b4de51 100644 --- a/src/utils/symlink.ts +++ b/src/utils/symlink.ts @@ -36,7 +36,7 @@ export function canUseRealSymlinks(app: App): boolean { // Electron exposes a CommonJS `require` on the global object on desktop; it is // absent on mobile. Resolving it dynamically avoids a static node import. function nodeModules(): { fs: NodeFs; path: NodePath } | null { - const req = (globalThis as unknown as { require?: NodeRequire }).require; + const req = (window as unknown as { require?: NodeRequire }).require; if (typeof req !== 'function') return null; try { return { fs: req('fs') as NodeFs, path: req('path') as NodePath }; From 1111308a2d56d52f7663a361940dc47868e1443f Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Sun, 5 Jul 2026 05:15:47 +0000 Subject: [PATCH 24/29] chore(skills): install clean-code, design-taste-frontend, frontend-design skills Adds skill files pulled from firstsun-dev/skills and their lockfile entries, used during this session's UX review and fixes. --- .agents/skills/clean-code/SKILL.md | 94 ++ .agents/skills/design-taste-frontend/SKILL.md | 1206 +++++++++++++++++ .agents/skills/frontend-design/LICENSE.txt | 177 +++ .agents/skills/frontend-design/SKILL.md | 55 + skills-lock.json | 18 + 5 files changed, 1550 insertions(+) create mode 100644 .agents/skills/clean-code/SKILL.md create mode 100644 .agents/skills/design-taste-frontend/SKILL.md create mode 100644 .agents/skills/frontend-design/LICENSE.txt create mode 100644 .agents/skills/frontend-design/SKILL.md diff --git a/.agents/skills/clean-code/SKILL.md b/.agents/skills/clean-code/SKILL.md new file mode 100644 index 0000000..ea6e121 --- /dev/null +++ b/.agents/skills/clean-code/SKILL.md @@ -0,0 +1,94 @@ +--- +name: clean-code +description: Apply Robert C. Martin's Clean Code principles (naming, functions, comments, formatting, error handling, tests, classes, code smells). Use when writing new code, reviewing pull requests, refactoring legacy code, or aligning on team coding standards. +--- + +# Clean Code Skill + +## 🧠 Core Philosophy +> "Code is clean if it can be read, and enhanced by a developer other than its original author." — Grady Booch + +## When to Use +Use this skill when: +- **Writing new code**: To ensure high quality from the start. +- **Reviewing Pull Requests**: To provide constructive, principle-based feedback. +- **Refactoring legacy code**: To identify and remove code smells. +- **Improving team standards**: To align on industry-standard best practices. + +## 1. Meaningful Names +- **Use Intention-Revealing Names**: `elapsedTimeInDays` instead of `d`. +- **Avoid Disinformation**: Don't use `accountList` if it's actually a `Map`. +- **Make Meaningful Distinctions**: Avoid `ProductData` vs `ProductInfo`. +- **Use Pronounceable/Searchable Names**: Avoid `genymdhms`. +- **Class Names**: Use nouns (`Customer`, `WikiPage`). Avoid `Manager`, `Data`. +- **Method Names**: Use verbs (`postPayment`, `deletePage`). + +## 2. Functions +- **Small!**: Functions should be shorter than you think. +- **Do One Thing**: A function should do only one thing, and do it well. +- **One Level of Abstraction**: Don't mix high-level business logic with low-level details (like regex). +- **Descriptive Names**: `isPasswordValid` is better than `check`. +- **Arguments**: 0 is ideal, 1-2 is okay, 3+ requires a very strong justification. +- **No Side Effects**: Functions shouldn't secretly change global state. + +## 3. Comments +- **Don't Comment Bad Code—Rewrite It**: Most comments are a sign of failure to express ourselves in code. +- **Explain Yourself in Code**: + ```python + # Check if employee is eligible for full benefits + if employee.flags & HOURLY and employee.age > 65: + ``` + vs + ```python + if employee.isEligibleForFullBenefits(): + ``` +- **Good Comments**: Legal, Informative (regex intent), Clarification (external libraries), TODOs. +- **Bad Comments**: Mumbling, Redundant, Misleading, Mandated, Noise, Position Markers. + +## 4. Formatting +- **The Newspaper Metaphor**: High-level concepts at the top, details at the bottom. +- **Vertical Density**: Related lines should be close to each other. +- **Distance**: Variables should be declared near their usage. +- **Indentation**: Essential for structural readability. + +## 5. Objects and Data Structures +- **Data Abstraction**: Hide the implementation behind interfaces. +- **The Law of Demeter**: A module should not know about the innards of the objects it manipulates. Avoid `a.getB().getC().doSomething()`. +- **Data Transfer Objects (DTO)**: Classes with public variables and no functions. + +## 6. Error Handling +- **Use Exceptions instead of Return Codes**: Keeps logic clean. +- **Write Try-Catch-Finally First**: Defines the scope of the operation. +- **Don't Return Null**: It forces the caller to check for null every time. +- **Don't Pass Null**: Leads to `NullPointerException`. + +## 7. Unit Tests +- **The Three Laws of TDD**: + 1. Don't write production code until you have a failing unit test. + 2. Don't write more of a unit test than is sufficient to fail. + 3. Don't write more production code than is sufficient to pass the failing test. +- **F.I.R.S.T. Principles**: Fast, Independent, Repeatable, Self-Validating, Timely. + +## 8. Classes +- **Small!**: Classes should have a single responsibility (SRP). +- **The Stepdown Rule**: We want the code to read like a top-down narrative. + +## 9. Smells and Heuristics +- **Rigidity**: Hard to change. +- **Fragility**: Breaks in many places. +- **Immobility**: Hard to reuse. +- **Viscosity**: Hard to do the right thing. +- **Needless Complexity/Repetition**. + +## 🛠️ Implementation Checklist +- [ ] Is this function smaller than 20 lines? +- [ ] Does this function do exactly one thing? +- [ ] Are all names searchable and intention-revealing? +- [ ] Have I avoided comments by making the code clearer? +- [ ] Am I passing too many arguments? +- [ ] Is there a failing test for this change? + +## Limitations +- Use this skill only when the task clearly matches the scope described above. +- Do not treat the output as a substitute for environment-specific validation, testing, or expert review. +- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing. diff --git a/.agents/skills/design-taste-frontend/SKILL.md b/.agents/skills/design-taste-frontend/SKILL.md new file mode 100644 index 0000000..b72132f --- /dev/null +++ b/.agents/skills/design-taste-frontend/SKILL.md @@ -0,0 +1,1206 @@ +--- +name: design-taste-frontend +description: Anti-slop frontend skill for landing pages, portfolios, and redesigns. The agent reads the brief, infers the right design direction, and ships interfaces that do not look templated. Real design systems when applicable, audit-first on redesigns, strict pre-flight check. +--- + +# tasteskill: Anti-Slop Frontend Skill + +> Landing pages, portfolios, and redesigns. Not dashboards, not data tables, not multi-step product UI. +> Every rule below is **contextual**. None of it fires automatically. First read the brief, then pull only what fits. + +--- + +## 0. BRIEF INFERENCE (Read the Room Before Anything Else) + +Before touching code or tweaking dials, **infer what the user actually wants**. Most LLM design output is bad because the model jumps to a default aesthetic instead of reading the room. + +### 0.A Read these signals first +1. **Page kind** - landing (SaaS / consumer / agency / event), portfolio (dev / designer / creative studio), redesign (preserve vs overhaul), editorial / blog. +2. **Vibe words** the user used - "minimalist", "calm", "Linear-style", "Awwwards", "brutalist", "premium consumer", "Apple-y", "playful", "serious B2B", "editorial", "agency-y", "glassy", "dark tech". +3. **Reference signals** - URLs they linked, screenshots they pasted, products they named, brands they're competing with. +4. **Audience** - B2B procurement panel vs. design-conscious consumer vs. recruiter scanning a portfolio. The audience picks the aesthetic, not your taste. +5. **Brand assets that already exist** - logo, color, type, photography. For redesigns, these are starting material, not optional input (see Section 11). +6. **Quiet constraints** - accessibility-first audiences, public-sector, regulated industries, trust-first commerce, kids' products. These constraints OVERRIDE aesthetic preference. + +### 0.B Output a one-line "Design Read" before generating +Before any code, state in one line: **"Reading this as: \ for \, with a \ language, leaning toward \."** + +Example reads: +- *"Reading this as: B2B SaaS landing for technical buyers, with a Linear-style minimalist language, leaning toward Tailwind utilities + Geist + restrained motion."* +- *"Reading this as: solo designer portfolio for hiring managers, with an editorial / kinetic-type language, leaning toward native CSS + scroll-driven animation + custom typography."* +- *"Reading this as: redesign of a public-sector service site, with a trust-first language, leaning toward GOV.UK Frontend or USWDS."* + +### 0.C If the brief is ambiguous, ask one question, do not guess +Ask exactly **one** clarifying question - never a multi-question dump - and only when the design read genuinely diverges. Example: *"Should this feel closer to Linear-clean or Awwwards-experimental?"* + +If you can confidently infer from context, **do not ask**. Just declare the design read and proceed. + +### 0.D Anti-Default Discipline +Do not default to: AI-purple gradients, centered hero over dark mesh, three equal feature cards, generic glassmorphism on everything, infinite-loop micro-animations everywhere, Inter + slate-900. These are the LLM defaults. Reach past them deliberately based on the design read. + +--- + +## 1. THE THREE DIALS (Core Configuration) + +After the design read, set three dials. Every layout, motion, and density decision below is gated by these. + +* **`DESIGN_VARIANCE: 8`** - 1 = Perfect Symmetry, 10 = Artsy Chaos +* **`MOTION_INTENSITY: 6`** - 1 = Static, 10 = Cinematic / Physics +* **`VISUAL_DENSITY: 4`** - 1 = Art Gallery / Airy, 10 = Cockpit / Packed Data + +**Baseline:** `8 / 6 / 4`. Use these unless the design read overrides them. Do not ask the user to edit this file - overrides happen conversationally. + +### 1.A Dial Inference (design read → dial values) +| Signal | VARIANCE | MOTION | DENSITY | +|---|---|---|---| +| "minimalist / clean / calm / editorial / Linear-style" | 5-6 | 3-4 | 2-3 | +| "premium consumer / Apple-y / luxury / brand" | 7-8 | 5-7 | 3-4 | +| "playful / wild / Dribbble / Awwwards / experimental / agency" | 9-10 | 8-10 | 3-4 | +| "landing page / portfolio / marketing site (default)" | 7-9 | 6-8 | 3-5 | +| "trust-first / public-sector / regulated / accessibility-critical" | 3-4 | 2-3 | 4-5 | +| "redesign - preserve" | match existing | +1 | match existing | +| "redesign - overhaul" | +2 | +2 | match existing | + +### 1.B Use-Case Presets +| Use case | VARIANCE | MOTION | DENSITY | +|---|---|---|---| +| Landing (SaaS, mainstream) | 7 | 6 | 4 | +| Landing (Agency / creative) | 9 | 8 | 3 | +| Landing (Premium consumer) | 7 | 6 | 3 | +| Portfolio (Designer / studio) | 8 | 7 | 3 | +| Portfolio (Developer) | 6 | 5 | 4 | +| Editorial / Blog | 6 | 4 | 3 | +| Public-sector service | 3 | 2 | 5 | +| Redesign - preserve | match | match+1 | match | +| Redesign - overhaul | +2 | +2 | match | + +### 1.C How the Dials Drive Output +Use these (or user-overridden values) as global variables. Cross-references throughout this document refer to these exact variable names - never invent aliases like `LAYOUT_VARIANCE` or `ANIM_LEVEL`. + +--- + +## 2. BRIEF → DESIGN SYSTEM MAP + +Once you have the design read (Section 0) and dials (Section 1), pick the right foundation. Do not invent CSS for things that have an official package. Do not pretend an aesthetic trend is an official system. + +### 2.A When to reach for a real design system (use official packages) +| Brief reads as… | Reach for | Why | +|---|---|---| +| Microsoft / enterprise SaaS / dashboards | `@fluentui/react-components` or `@fluentui/web-components` | Official Fluent UI, Microsoft tokens, accessibility done | +| Google-ish UI, Material-flavored product | `@material/web` + Material 3 tokens | Official, theme-able via Material Theming | +| IBM-style B2B / enterprise analytics | `@carbon/react` + `@carbon/styles` | Official Carbon, mature data-density patterns | +| Shopify app surfaces | `polaris.js` web components / Polaris React | Required for Shopify admin UI | +| Atlassian / Jira-style product | `@atlaskit/*` + `@atlaskit/tokens` | Official Atlassian DS | +| GitHub-style devtool / community page | `@primer/css` or `@primer/react-brand` | Official Primer; Brand variant for marketing | +| Public-sector UK service | `govuk-frontend` | Legally / regulatorily expected | +| US public-sector / trust-first | `uswds` | Same | +| Fast local-business / agency MVP | Bootstrap 5.3 | Boring, fast, works | +| Modern accessible React foundation | `@radix-ui/themes` | Primitives + polished theme | +| Modern SaaS where you own the components | shadcn/ui (`npx shadcn@latest add ...`) | You own the code, easy to customise; never ship default state | +| Tailwind-based modern SaaS / AI marketing | Tailwind v4 utilities + `dark:` variant | Default for indie + small team builds | + +**Honesty rule:** if the brief reads as one of the systems above, install and use the **official** package. Do not recreate its CSS by hand. Do not import a system's tokens but then override 90% of them. + +**One system per project.** Do not mix Fluent React with Carbon in the same tree. Do not import shadcn/ui components into a Material 3 app. + +### 2.B When the brief is an aesthetic, not a system +For these directions, there is **no single official package**. Build with native CSS + Tailwind + a maintained component library. Be honest in code comments about what is borrowed inspiration vs. official material. + +| Aesthetic | Honest implementation | +|---|---| +| Glassmorphism / "frosted glass" | `backdrop-filter`, layered borders, highlight overlays. Provide solid-fill fallback for `prefers-reduced-transparency`. | +| Bento (Apple-style tile grids) | CSS Grid with mixed cell sizes. No single library owns this. | +| Brutalism | Native CSS, monospace, raw borders. No library. | +| Editorial / magazine | Serif type, asymmetric grid, generous whitespace. No library. | +| Dark tech / hacker | Mono + accent neon, terminal motifs. No library. | +| Aurora / mesh gradients | SVG or layered radial gradients. No library. | +| Kinetic typography | Native CSS animations, scroll-driven animations, GSAP for hijacks. No library. | +| **Apple Liquid Glass** | Apple documents this for Apple platforms only. **There is no official `liquid-glass.css`.** Web implementations are approximations using `backdrop-filter` + layered borders + highlights. Label clearly as approximation. | + +--- + +## 3. DEFAULT ARCHITECTURE & CONVENTIONS + +Unless the design read picks a real design system (Section 2.A), these are the defaults: + +### 3.A Stack +* **Framework:** React or Next.js. Default to Server Components (RSC). + * **RSC SAFETY:** Global state works ONLY in Client Components. In Next.js, wrap providers in a `"use client"` component. + * **INTERACTIVITY ISOLATION:** Any component using Motion, scroll listeners, or pointer physics MUST be an isolated leaf with `'use client'` at the top. Server Components render static layouts only. +* **Styling:** **Tailwind v4** (default). Tailwind v3 only if the existing project demands it. + * For v4: do NOT use `tailwindcss` plugin in `postcss.config.js`. Use `@tailwindcss/postcss` or the Vite plugin. +* **Animation:** **Motion** (the library formerly known as Framer Motion). Import from `motion/react` (`import { motion } from "motion/react"`). The `framer-motion` package still works as a legacy alias - prefer `motion/react` in new code. +* **Fonts:** Always use `next/font` (Next.js) or self-host with `@font-face` + `font-display: swap`. Never link Google Fonts via `` in production. + +### 3.B State +* Local `useState` / `useReducer` for isolated UI. +* Global state ONLY for deep prop-drilling avoidance - Zustand, Jotai, or React context. +* **NEVER** use `useState` to track continuous values driven by user input (mouse position, scroll progress, pointer physics, magnetic hover). Use Motion's `useMotionValue` / `useTransform` / `useScroll`. `useState` re-renders the React tree on every change and collapses on mobile. + +### 3.C Icons +* **Allowed libraries (priority order):** `@phosphor-icons/react`, `hugeicons-react`, `@radix-ui/react-icons`, `@tabler/icons-react`. +* **Discouraged:** `lucide-react`. Acceptable only when the user explicitly asks for it or the project already depends on it. +* **NEVER hand-roll SVG icons.** If a glyph is missing, install a second library or compose from primitives - do not draw icon paths from scratch. +* **One family per project.** Do not mix Phosphor with Lucide in the same component tree. +* **Standardize `strokeWidth` globally** (e.g. `1.5` or `2.0`). + +### 3.D Emoji Policy +Discouraged by default in code, markup, and visible text. Replace symbols with icon-library glyphs. **Override:** allow emojis only when the user explicitly asks for a playful / chat-style / social-native vibe - and even then use them sparingly with intent. + +### 3.E Responsiveness & Layout Mechanics +* Standardize breakpoints (`sm 640`, `md 768`, `lg 1024`, `xl 1280`, `2xl 1536`). +* Contain page layouts using `max-w-[1400px] mx-auto` or `max-w-7xl`. +* **Viewport Stability:** NEVER use `h-screen` for full-height Hero sections. ALWAYS use `min-h-[100dvh]` to prevent layout jumping on mobile (iOS Safari address bar). +* **Grid over Flex-Math:** NEVER use complex flexbox percentage math (`w-[calc(33%-1rem)]`). ALWAYS use CSS Grid (`grid grid-cols-1 md:grid-cols-3 gap-6`). + +### 3.F Dependency Verification (mandatory) +Before importing ANY 3rd-party library, check `package.json`. If the package is missing, output the install command first. **Never** assume a library exists. + +--- + +## 4. DESIGN ENGINEERING DIRECTIVES (Bias Correction) + +LLMs default to clichés. Override these defaults proactively. Each rule has a context-aware override path. + +### 4.1 Typography +* **Display / Headlines:** Default `text-4xl md:text-6xl tracking-tighter leading-none`. +* **Body / Paragraphs:** Default `text-base text-gray-600 leading-relaxed max-w-[65ch]`. +* **Sans font choice:** + * **Discouraged as default:** `Inter`. Pick `Geist`, `Outfit`, `Cabinet Grotesk`, `Satoshi`, or a brand-appropriate serif first. + * **Override:** Inter is acceptable when the user explicitly asks for a neutral / standard / Linear-style feel, or when the brief is a public-sector / accessibility-first site. +* **Pairings to know:** `Geist` + `Geist Mono`, `Satoshi` + `JetBrains Mono`, `Cabinet Grotesk` + `Inter Tight`, `GT America` + `IBM Plex Mono`. + +* **SERIF DISCIPLINE (VERY DISCOURAGED AS DEFAULT):** + * Serif is **very discouraged as the default font for any project.** "It feels creative / premium / editorial" is NOT a reason to reach for serif. The agent's default mental model that "creative brief = serif" is the single most-tested AI tell in production rounds. + * **Serif is only acceptable when ONE of these is explicitly true:** + - The brand brief literally names a serif font, OR + - The aesthetic family is genuinely editorial / luxury / publication / manuscript / heritage / vintage AND you can articulate why this specific serif fits this specific brand + * For everything else (creative agency, design studio, modern brand, premium consumer, portfolio, lifestyle), **default sans-serif display** (Geist Display, ABC Diatype, Söhne Breit, Cabinet Grotesk Display, Migra Sans, GT Walsheim, Inter Display, PP Neue Montreal). Sans display fonts are not "boring" — they are the default for the same reason black is the default in fashion. + * **EMPHASIS RULE (related):** When you want to emphasize a word within a headline (the kinetic "and `spatial` design" type move), use **italic or bold of the SAME font**. Do NOT inject a random serif word into a sans headline (or vice versa) just to add visual interest. Mixed-family emphasis is amateur. Italic/bold emphasis in the same family is the right move. + * **Specifically BANNED as defaults:** `Fraunces` and `Instrument_Serif` (the two LLM-favorite display serifs). + * **If a serif is justified** (rare, per the above), rotate from this pool, do NOT reuse the same serif across consecutive projects: PP Editorial New, GT Sectra Display, Cardinal Grotesque, Reckless Neue, Tiempos Headline, Recoleta, Cormorant Garamond, Playfair Display, EB Garamond, IvyPresto, Migra, Editorial Old, Saol Display, Söhne Breit Kursiv, Domaine Display, Canela, Schnyder, Tobias, NB Architekt, ITC Galliard. + +* **ITALIC DESCENDER CLEARANCE (mandatory):** When italic is used in display type and the word contains a descender letter (`y g j p q`), `leading-[1]` or `leading-none` will clip the descender. Use `leading-[1.1]` minimum and add `pb-1` or `mb-1` reserve on the wrapping element. Audit every italic word in display headlines before shipping. + +### 4.2 Color Calibration +* Max 1 accent color. Saturation < 80% by default. +* **THE LILA RULE:** The "AI Purple / Blue glow" aesthetic is discouraged as a default. No automatic purple button glows, no random neon gradients. Use neutral bases (Zinc / Slate / Stone) with high-contrast singular accents (Emerald, Electric Blue, Deep Rose, Burnt Orange, etc.). +* **Override:** if the brand or brief explicitly asks for purple / violet / lila, embrace it. But execute with intent: consistent palette, harmonised neutrals, restrained gradients. Not generic AI gradient slop. +* **One palette per project.** Do not fluctuate between warm and cool grays within the same project. +* **COLOR CONSISTENCY LOCK (mandatory):** Once an accent color is chosen for a page, it is used on the WHOLE page. A warm-grey site does not suddenly get a blue CTA in section 7. A rose-accented site does not get a teal status badge in the footer. Pick one accent, lock it, audit every component before shipping. + +* **PREMIUM-CONSUMER PALETTE BAN (mandatory, second-most-recurring AI-tell):** + * For premium-consumer briefs (cookware, wellness, artisan, luxury, heritage craft, DTC home goods, etc.) the LLM default is **warm beige/cream + brass/clay/oxblood/ochre + espresso/ink dark text**. Concretely banned hex families as default backgrounds and accents: + - Backgrounds: `#f5f1ea`, `#f7f5f1`, `#fbf8f1`, `#efeae0`, `#ece6db`, `#faf7f1`, `#e8dfcb` (all "warm paper / cream / chalk / bone") + - Accents: `#b08947`, `#b6553a`, `#9a2436`, `#9c6e2a`, `#bc7c3a`, `#7d5621` (all "brass / clay / oxblood / ochre") + - Text: `#1a1714`, `#1a1814`, `#1b1814` (all "espresso / warm near-black") + * This palette is BANNED as the default reach for premium-consumer briefs. Every premium-consumer site you have ever shipped uses this exact palette. The brand becomes invisible. + * **Default alternatives (rotate, do not reuse):** + - **Cold Luxury:** silver-grey + chrome + smoke (think Tesla, Apple Watch Hermes-without-the-leather) + - **Forest:** deep green + bone + amber accent (think Filson, Patagonia premium) + - **Black and Tan:** true off-black + warm tan, sharp contrast, no beige + - **Cobalt + Cream:** saturated blue against a single neutral, no brass + - **Terracotta + Slate:** warm rust against cool grey, no brass + - **Olive + Brick + Paper:** muted olive plus brick-red accent + - **Pure monochrome + single saturated pop:** off-white + off-black + one bright accent (electric blue, emerald, hot pink, etc.) + * **Palette-rotation rule:** if the previous premium-consumer project you generated used the beige+brass family, this one MUST use a different family. Do not ship the same warm-craft palette twice in a row. + * **Override:** the beige+brass+espresso palette is acceptable ONLY when the brand brief explicitly names those colors, or when the brand identity is genuinely vintage / artisan / warm-craft AND you can articulate why this specific palette fits this specific brand. Default-reaching for it because "this is a cookware brief" is banned. + +### 4.3 Layout Diversification +* **ANTI-CENTER BIAS:** Centered Hero / H1 sections are avoided when `DESIGN_VARIANCE > 4`. Force "Split Screen" (50/50), "Left-aligned content / right-aligned asset", "Asymmetric white-space", or scroll-pinned structures. +* **Override:** centered hero is OK for editorial / manifesto / launch-announcement briefs where the message itself is the design. + +### 4.4 Materiality, Shadows, Cards +* Use cards ONLY when elevation communicates real hierarchy. Otherwise group with `border-t`, `divide-y`, or negative space. +* When a shadow is used, tint it to the background hue. No pure-black drop shadows on light backgrounds. +* For `VISUAL_DENSITY > 7`: generic card containers are banned. Data metrics breathe in plain layout. +* **SHAPE CONSISTENCY LOCK (mandatory):** Pick ONE corner-radius scale for the page and stick to it. Options: all-sharp (radius 0), all-soft (radius 12-16px), all-pill (full radius for interactive). Mixed systems are allowed only when there is a documented rule (e.g. "buttons are full-pill, cards are 16px, inputs are 8px") and that rule is followed everywhere. Round buttons in a square layout, or square cards on a pill-button page, is broken design. + +### 4.5 Interactive UI States +LLMs default to "static successful state only." Always implement full cycles: +* **Loading:** Skeletal loaders matching the final layout's shape. Avoid generic circular spinners. +* **Empty States:** Beautifully composed; indicate how to populate. +* **Error States:** Clear, inline (forms), or contextual (toasts only for transient). +* **Tactile Feedback:** On `:active`, use `-translate-y-[1px]` or `scale-[0.98]` to simulate a physical push. +* **BUTTON CONTRAST CHECK (mandatory, a11y):** Before shipping any button, verify the button text is readable against the button background. White button + white text, `bg-white` CTA with `text-white` label, transparent button against the page background with no border → all banned. Audit every CTA: contrast ratio WCAG AA min (4.5:1 for body, 3:1 for large text 18px+). Same rule applies to ghost buttons over photographic backgrounds (use a backdrop, scrim, or stroke). +* **CTA BUTTON WRAP BAN (mandatory):** Button text MUST fit on one line at desktop. If a label like "VIEW SELECTED WORK" wraps to 2 or 3 lines, the button is broken. Fix by EITHER shortening the label (3 words max for primary CTAs, ideally 1-2) OR widening the button (do not artificially constrain `max-width` on CTAs). Wrapped CTAs at desktop are a Pre-Flight Fail. +* **NO DUPLICATE CTA INTENT (mandatory):** Two CTAs with the same intent on one page is a Pre-Flight Fail. Examples of same intent: "Get in touch" + "Contact us" + "Let's talk" + "Start a project" + "Start something" + "Reach out" = all "contact" intent → pick ONE label and use it everywhere on the page (nav, hero, footer). Same for "Try free" + "Get started" + "Sign up free" (all "signup" intent) and "View work" + "See selected work" + "Browse projects" (all "portfolio" intent). One label per intent. +* **FORM CONTRAST CHECK (mandatory, a11y):** Form inputs, placeholder text, focus rings, helper text, and error text all pass WCAG AA contrast against the section background. Light placeholders on a near-white form, white form on white page section, form labels grayer than 4.5:1 contrast → all banned. Audit every form before shipping. + +### 4.6 Data & Form Patterns +* Label ABOVE input. Helper text optional but present in markup. Error text BELOW input. Standard `gap-2` for input blocks. +* No placeholder-as-label. Ever. + +### 4.7 Layout Discipline (Hard Rules. Failing any of these is shipping broken work) + +* **Hero MUST fit in the initial viewport.** Headline max 2 lines on desktop, subtext max **20 words** AND max 3-4 lines, CTAs visible without scroll. If the copy is too long: reduce font scale OR cut copy. If you cannot describe the value-prop in 20 words of subtext, the value-prop is unclear, not the rule too tight. Never let the hero overflow and force scroll to find the CTA. +* **Hero font-scale discipline.** Plan font size and image size *together*. If the hero asset is large and the headline is more than 6 words, do not start at `text-7xl/text-8xl`. Default sensible range: `text-4xl md:text-5xl lg:text-6xl` for most heroes; `text-6xl md:text-7xl` only when the headline is 3-5 words. A 4-line hero headline is always a font-size error, never a copy-length error. +* **HERO TOP PADDING CAP (mandatory):** Hero top padding max `pt-24` (≈6rem) at desktop. More than that means the hero content floats halfway down the viewport and reads as a layout bug, not as intentional space. If your hero needs more breathing room, increase font scale or asset size, not top padding. +* **HERO STACK DISCIPLINE (max 4 text elements).** The hero is a single moment, not a feature list. Allowed text elements, max 4 in total: + 1. Eyebrow (small uppercase label) OR brand strip OR neither - pick zero or one + 2. Headline (max 2 lines, see above) + 3. Subtext (max 20 words, max 4 lines) + 4. CTAs (1 primary + max 1 secondary) + - **BANNED in the hero:** tiny tagline below CTAs ("Works with GitHub, GitLab, and self-hosted Git"), trust micro-strip ("Used by engineering teams at..."), pricing teaser ("Free for solo, $10/user for teams"), feature bullet list, social-proof avatar row. All of those move to dedicated sections directly below the hero. + - If you have an eyebrow AND a tagline below CTAs in the same hero, drop the tagline. If you have a brand strip AND a tagline, drop the tagline. One small text element per hero, max. +* **"Used by" / "Trusted by" logo wall belongs UNDER the hero, never inside it.** The hero is for the value prop and primary CTA. The logo wall is a separate section directly below. Do not stuff trust logos into the same flex row as the hero copy. +* **Navigation MUST render on a single line on desktop.** If items don't fit at `lg` (1024px), condense labels, drop secondary items, or move to a hamburger. A two-line nav at desktop is broken design. +* **Navigation height cap: 80px max desktop, default 64-72px.** No huge "agency" nav bars that eat 15% of the viewport. +* **Bento grids MUST have rhythm, not one-sided repetition.** Do not stack 6 left-image / right-text rows. Vary the composition: alternate full-width feature rows, asymmetric tile sizes, vertical breaks. +* **BENTO CELL COUNT RULE (mandatory):** A bento grid has EXACTLY as many cells as you have content for. 3 items → 3 cells (1+2 split, or 2+1, or asymmetric trio). 5 items → 5 cells (2+3, 3+2, hero+4, etc.). If your grid has an empty cell in the middle or at the end, you planned wrong. Re-shape the grid; do not paste a blank tile. +* **Section-Layout-Repetition Ban.** Once you use a layout family for a section (e.g., 3-column-image-cards, full-width-quote, split-text-image), that family can appear at most ONCE on the page. "Selected commissions" must not look like "What we do." A landing page with 8 sections must use at least 4 different layout families. +* **ZIGZAG ALTERNATION CAP (mandatory).** Alternating "left-image + right-text" then "left-text + right-image" zigzag layout = banal. Max 2 sections in a row with this image+text-split pattern. The 3rd consecutive image+text split is a Pre-Flight Fail. Break the pattern with a full-width section, a vertical-stack section, a bento grid, a marquee, or a different layout family. +* **EYEBROW RESTRAINT (mandatory, the #1 violated rule in production tests).** An "eyebrow" is the small uppercase wide-tracking label sitting above a section headline (e.g. `FOUR COLORWAYS`, `SELECTED WORK`, `THE HARDWARE`, `Git-native task management`). Typical CSS signature: `text-[11px] uppercase tracking-[0.18em]`, `font-mono text-[10.5px] uppercase tracking-[0.22em]`. Every AI-built site puts an eyebrow above EVERY section header, producing the same templated rhythm. Hard rule: + - **Maximum 1 eyebrow per 3 sections.** Hero counts as 1. So a page with 9 sections may use at most 3 eyebrows total. + - If section A has an eyebrow, the next 2 sections cannot have one. + - **Pre-Flight Check is mechanical:** count instances of `uppercase tracking` (or similar small-caps mono labels above headlines) across all section components. If count > ceil(sectionCount / 3), the output fails. + - **What to do instead of an eyebrow:** drop it entirely. The headline alone is enough. If you need to categorize a section, the section's location on the page already categorizes it; no label needed. +* **SPLIT-HEADER BAN (mandatory).** The pattern "left big headline + right small explainer paragraph" as a section header (left col-span-7/8, right col-span-4/5 with a small body paragraph floating in the right column) is **banned as default**. Sections should have ONE focused message. If you genuinely need both a headline and an explainer paragraph, stack them vertically (headline on top, body below, max-width 65ch). Reach for the split-header pattern only when there is a real compositional reason (e.g., the right column carries a visual or interactive element, not just filler text). +* **Bento Background Diversity (mandatory).** Bento and feature-grid sections cannot be 6 white-on-white cards with text inside. At least 2-3 cells in any multi-cell grid need real visual variation: a real image, a brand-appropriate gradient (not AI-purple), a pattern, a tinted background. A cream-on-cream bento with only typography inside reads as boring AI default, even when the rest of the page is good. +* **Mobile collapse must be explicit per section.** For every multi-column layout, declare the `< 768px` fallback in the same component. No "it'll work, Tailwind handles it" assumptions. + +### 4.8 Image & Visual Asset Strategy + +Landing pages and portfolios are **visual products**. Text-only pages with fake-screenshot divs are slop. + +**Priority order for visual assets:** +1. **Image-generation tool first.** If ANY image-gen tool is available in the environment (`generate_image`, MCP image tool, IDE-integrated gen, OpenAI image tools, etc.) you MUST use it to create section-specific assets: hero photography, product shots, texture backgrounds, mood images. Generate at the right aspect ratio for the section. Do not skip this step because hand-rolled CSS feels faster. +2. **Real web images second.** When no gen tool is available, use real photography sources. Acceptable defaults: + * `https://picsum.photos/seed/{descriptive-seed}/{w}/{h}` for placeholder photography (seed should describe the section, e.g. `marrow-cookware-kitchen`) + * Actual stock or brand URLs when the brief provides them + * Open-license sources (Unsplash via direct URL, Pexels) if explicitly allowed +3. **Last resort: tell the user.** If neither is possible, do NOT fill the page with hand-rolled SVG illustrations or div-based "fake screenshots." Instead, leave clearly-labeled placeholder slots (``) and at the end of the response say: *"This page needs real images at: \[list of placements\]. Please generate or provide them."* + +**Even minimalist sites need real images.** A pure-text page is not minimalism. It is incomplete work. Even an editorial Linear-style site needs at least 2-3 real images (hero, one product/lifestyle shot, one supporting image). Generate B&W minimalist photography if the brief is restrained; do not skip images entirely because the dial is low. + +**Real company logos for social proof.** When the brief calls for a "Trusted by / Used by / Customers" logo wall, do NOT default to plain text wordmarks (`Acme Co` styled in a row). Use real SVG logos: +* **Source: Simple Icons** (`https://cdn.simpleicons.org/{slug}/ffffff` for any color, or `simple-icons` npm package). Covers most known brands. +* **Alternative: devicon** for tech-stack logos (`@svgr/cli` or CDN). +* **Make-up the brand name? Then make-up an SVG mark too.** Generate a simple monogram (one letter in a circle, two-letter ligature, abstract glyph) rendered as an inline `` matching the page style. Plain text wordmarks for invented brand names look generic. +* **Always** ensure logos render in both light and dark mode (white-on-dark, black-on-light, or single-color theme variable). +* **LOGO-ONLY rule (mandatory):** logo wall = logos and nothing else. Do NOT print industry / category labels below each logo (no `Vercel` + `hosting` underneath, no `Stripe` + `payments`, no `Cloudflare` + `infra`). The logo is the credibility, the label adds nothing the user does not already know. Optional: brand name as alt-text for screen readers, optional link to the brand's site. That is it. + +**Hand-rolled illustrations:** +* SVG icons from libraries: fine (see Section 3.C). +* Hand-rolled decorative SVGs (custom illustrations, logos, marks): **strongly discouraged**, never as default. Acceptable only when: + - The brief explicitly calls for it ("draw me an SVG logo") + - It's a single, simple geometric mark (a square, a circle, a wordmark in display type) + - You're confident in the output quality + +**Div-based fake screenshots are banned.** A "hand-built product preview" rendered with `
` rectangles, fake task lists, fake dashboards, fake terminal windows is a Tell. If you need to show a product: +* Use a real screenshot URL if one exists +* Generate one via image tool +* Use a real component preview (an actual mini-version of the UI inside the page) +* Or skip the preview entirely and use editorial photography + +**Hero needs a real visual.** Text + gradient blob is not a hero - it's a placeholder. + +### 4.9 Content Density + +Landing pages live on the **first impression**, not the full read. Cut ruthlessly. + +* **Default content shape per section:** short headline (≤ 8 words) + short sub-paragraph (≤ 25 words) + one visual asset OR one CTA. Anything more must be justified by the section's job. +* **No data-dump sections.** A 20-row publication table, a 30-row award list, a giant pricing matrix on a marketing page = wrong layout. Use: + - Top 3-5 highlights + "View full list" link + - Marquee / carousel for breadth + - Different page entirely if the data is the product +* **Long lists need a different UI component, not a longer list.** Default `
    ` with bullets / `divide-y` rows is the lazy choice. If you have > 5 items, reach for one of these instead: + - 2-column split with grouped items + - Card grid with image + label per item + - Tabs / accordion if items are categorisable + - Horizontal scroll-snap pills + - Carousel for breadth-heavy lists (testimonials, logos, capabilities) + - Marquee for "lots-of-things-that-don't-need-individual-attention" + A spec sheet with 10 rows + a hairline under every row is the WORST default. Either group rows into 2-3 chunks with sparse dividers, or move to a card-per-spec layout. +* **Spec sheets specifically (the Marrow-cookware pattern).** A long product specification table with `border-b` on every row is the AI default for cookware / hardware / apparel / artisan-goods briefs. Banned. Concrete alternatives: + - **2-col card grid:** each spec gets its own card with the spec name, the value (large display number), and a one-line "why it matters" body. Cards arranged 2-col on desktop, 1-col mobile. + - **Scroll-snap horizontal pills:** each spec is a pill, user can flick through. + - **Grouped chunks:** group 10 specs into 3 logical clusters (e.g. "Materials", "Cooking", "Warranty"), each cluster gets ONE soft divider and a cluster heading. + - **Featured-vs-rest:** 3-4 hero specs visualised as large display tiles, the rest collapsed under a "View full specifications" disclosure. + +* **COPY SELF-AUDIT (mandatory before ship):** Before declaring any task done, re-read every visible string on the page (headlines, subheads, eyebrows, button labels, body copy, captions, alt text, footer text, error messages). Flag any string that is: + - **Grammatically broken** ("free on its past", "two plans but one is honest", "to put it on the table" out of context) + - **Has unclear referents** ("we plan to stay that way" without prior context) + - **Sounds like AI hallucination** (cute-but-wrong wordplay, forced metaphors that don't track, "elegant nothing" phrases) + - **Reads like an LLM trying to sound thoughtful** (passive-aggressive humility, fake-craftsman labels, mock-poetic micro-meta) + Rewrite every flagged string. If unsure whether a string makes sense, replace it with a plain functional sentence. AI-generated cute copy is worse than boring copy. +* **Fake-precise numbers are flagged.** Numbers like `92%`, `4.1×`, `48k`, `5.8 mm`, `13.4 lb` either: + - Come from real data (brief, brand guidelines, public metrics) - fine + - Are explicitly labeled as mock (``, "example", "sample data") - fine + - Are AI-invented spec aesthetics - banned. Don't fake engineering precision the brand doesn't claim. +* **One copy register per page.** Don't mix technical mono ("47 tasks · 0.6 ctx-switches/day"), editorial prose, and marketing punch in the same composition unless the brand voice explicitly calls for it. + +### 4.10 Quotes & Testimonials + +* **Max 3 lines** of quote body. Never 6. If the original quote is longer → cut it. A landing-page quote is a snippet, not the full review. +* For very small font sizes (e.g. footer-style testimonials), the line cap can stretch slightly. Spirit: "fits in a glance." +* **No em-dashes inside the quote text** as design flourish (long pauses, kinetic em-dashes, em-dash-bullets). See Section 9.G - em-dash is completely banned. +* Attribution: name + role + (optionally) company. Never name only ("- Sarah"). +* Quote marks: use real typographic quotes ( " " ) or none at all. Not straight ASCII ( " ). + +### 4.11 Page Theme Lock (Light / Dark Mode Consistency) + +The page has ONE theme. Sections do not invert. + +* If the page is dark mode, ALL sections are dark mode. No light-mode-warm-paper section sandwiched between dark sections (or vice versa). The user must not feel they walked into a different website mid-scroll. +* The exception: if the brief explicitly calls for a "Color Block Story" or "Theme Switch on Scroll" device AND that is a deliberate composition (one full theme switch with a strong transition, not random alternation), it is allowed once per page. +* Default behaviour: pick light, dark, or auto (`prefers-color-scheme`) at the page level and lock it. Section-level background tints within the same theme family are fine (`bg-zinc-950` next to `bg-zinc-900`); flipping to `bg-amber-50` in the middle of a `bg-zinc-950` page is broken. +* When using a design system with built-in theming (Radix Themes, shadcn/ui with ``), set the theme ONCE in `layout.tsx` or the page root. Do not let individual sections override. + +--- + +## 5. CONTEXT-AWARE PROACTIVITY + +These are tools, not defaults. Use them when the design read calls for them. **None of these fire automatically.** + +* **Liquid Glass / Glassmorphism:** Appropriate for premium consumer, Apple-adjacent, luxury brand, or media-overlay vibes. Inappropriate for dashboards, public-sector, or "boring B2B." When used, go beyond `backdrop-blur`: add a 1px inner border (`border-white/10`) and a subtle inner shadow (`shadow-[inset_0_1px_0_rgba(255,255,255,0.1)]`) for physical edge refraction. Provide a solid-fill fallback under `prefers-reduced-transparency`. +* **Magnetic Micro-physics:** Use when `MOTION_INTENSITY > 5` AND the brief reads premium / playful / agency. Implement EXCLUSIVELY with Motion's `useMotionValue` / `useTransform` outside the React render cycle. Never `useState`. See Section 3.B. +* **Perpetual Micro-Interactions** (Pulse, Typewriter, Float, Shimmer, Carousel): Use when `MOTION_INTENSITY > 5` AND the section actively benefits from motion (status indicators, live feeds, AI-feel). **Not every card needs an infinite loop.** If a section is informational, leave it still. Apply Spring Physics (`type: "spring", stiffness: 100, damping: 20`) - no linear easing. +* **"Motion claimed, motion shown."** If `MOTION_INTENSITY > 4`, the page must actually move: entry transitions on hero, scroll-reveal on key sections, hover physics on CTAs, at minimum. A static page that claims `MOTION_INTENSITY: 7` is broken. Conversely, if you cannot ship working motion in the available scope, drop the dial to 3 and ship a clean static page. Never half-build motion that breaks (cut-off ScrollTriggers, jumpy enters, missing cleanups). +* **MOTION MUST BE MOTIVATED (mandatory).** Before adding any animation, ask: "what does this animation communicate?" Valid answers: hierarchy (drawing attention to the right thing), storytelling (revealing content in sequence that matches a narrative), feedback (acknowledging a user action), state transition (showing something changed). Invalid answer: "it looked cool". GSAP everywhere because GSAP is available is amateur. Each ScrollTrigger, each marquee, each pinned section needs a reason. If you cannot articulate the reason in one sentence, drop the animation. +* **MARQUEE MAX-ONE-PER-PAGE (mandatory).** Horizontal scrolling text marquees ("logos endlessly scrolling", "manifesto scrolling sideways", "kinetic word strip") are appropriate at most ONCE per page. Two or more marquees on the same page reads as lazy filler. Pick the one section where the marquee actually serves the content; the others get a different layout. +* **GSAP Sticky-Stack Pattern (when scroll-stack is used).** A "card stack on scroll" must be a REAL sticky-stack, not a sequential reveal list. See Section 5.A below for the canonical code skeleton. Common failure: trigger fires halfway through scroll instead of pinning at viewport top. Fix: `start: "top top"` not `start: "top center"` or `"top 80%"`. +* **GSAP Horizontal-Pan Pattern (when horizontal scroll-hijack is used).** See Section 5.B below for the canonical skeleton. Common failure: animation starts before the section is pinned, so the user sees half a slide. Same fix: `start: "top top"`, pin the wrapper, scrub the inner track. + +### 5.A Sticky-Stack - Canonical Skeleton + +```tsx +"use client"; +import { useRef, useEffect } from "react"; +import { gsap } from "gsap"; +import { ScrollTrigger } from "gsap/ScrollTrigger"; +import { useReducedMotion } from "motion/react"; + +gsap.registerPlugin(ScrollTrigger); + +export function StickyStack({ cards }: { cards: React.ReactNode[] }) { + const ref = useRef(null); + const reduce = useReducedMotion(); + + useEffect(() => { + if (reduce || !ref.current) return; + const ctx = gsap.context(() => { + const cardEls = gsap.utils.toArray(".stack-card"); + cardEls.forEach((card, i) => { + if (i === cardEls.length - 1) return; + ScrollTrigger.create({ + trigger: card, + start: "top top", // pin at viewport top + endTrigger: cardEls[cardEls.length - 1], + end: "top top", + pin: true, + pinSpacing: false, + }); + gsap.to(card, { + scale: 0.92, + opacity: 0.55, + ease: "none", + scrollTrigger: { + trigger: cardEls[i + 1], + start: "top bottom", + end: "top top", + scrub: true, + }, + }); + }); + }, ref); + return () => ctx.revert(); + }, [reduce]); + + return ( +
    + {cards.map((card, i) => ( +
    + {card} +
    + ))} +
    + ); +} +``` + +Critical points: `start: "top top"`, `pin: true`, every card except the last is pinned, the scale/opacity transform is driven by the NEXT card's scroll trigger (so previous card shrinks as next one arrives). + +### 5.B Horizontal-Pan - Canonical Skeleton + +```tsx +"use client"; +import { useRef, useEffect } from "react"; +import { gsap } from "gsap"; +import { ScrollTrigger } from "gsap/ScrollTrigger"; +import { useReducedMotion } from "motion/react"; + +gsap.registerPlugin(ScrollTrigger); + +export function HorizontalPan({ children }: { children: React.ReactNode }) { + const wrap = useRef(null); + const track = useRef(null); + const reduce = useReducedMotion(); + + useEffect(() => { + if (reduce || !wrap.current || !track.current) return; + const ctx = gsap.context(() => { + const distance = track.current!.scrollWidth - window.innerWidth; + gsap.to(track.current, { + x: -distance, + ease: "none", + scrollTrigger: { + trigger: wrap.current, + start: "top top", // pin starts when section top hits viewport top + end: () => `+=${distance}`, // scroll distance = track width minus viewport + pin: true, + scrub: 1, + invalidateOnRefresh: true, + }, + }); + }, wrap); + return () => ctx.revert(); + }, [reduce]); + + return ( +
    +
    + {children} +
    +
    + ); +} +``` + +Critical points: `start: "top top"`, `pin: true`, `end: "+=${distance}"` (scroll length = horizontal travel needed), `scrub: 1`. The wrapper is pinned, the inner track slides horizontally as the user scrolls vertically. + +### 5.C Scroll-Reveal Stagger - Canonical Skeleton (lighter alternative) + +For simple "items appear as they enter viewport" (no pinning), prefer Motion's `whileInView` over GSAP - lighter, no ScrollTrigger needed: + +```tsx +"use client"; +import { motion, useReducedMotion } from "motion/react"; + +export function RevealStagger({ items }: { items: string[] }) { + const reduce = useReducedMotion(); + return ( +
      + {items.map((item, i) => ( + + {item} + + ))} +
    + ); +} +``` + +Use this for: feature lists, testimonial grids, logo walls, anything that just needs "enter on scroll." Save GSAP for actual pin/scrub work. + +### 5.D Forbidden Animation Patterns + +* **`window.addEventListener("scroll", ...)`** is banned. It runs on every scroll frame, jank-prone, no batching. Use Motion's `useScroll()`, GSAP's `ScrollTrigger`, IntersectionObserver, or CSS `scroll-driven animations` (`animation-timeline: view()`). +* **Custom scroll progress calculations using `window.scrollY`** in React state. Same reason. Re-renders on every frame. +* **`requestAnimationFrame` loops that touch React state.** Use motion values (`useMotionValue` + `useTransform`) instead. +* **Layout Transitions:** Use Motion's `layout` and `layoutId` props for visible state changes (re-ordering lists, expanding modals, shared elements between routes). Do not wrap static content in `layout` props "for safety" - it costs measurement work. +* **Staggered Orchestration:** Use `staggerChildren` (Motion) or CSS cascade (`animation-delay: calc(var(--index) * 100ms)`) for reveal moments where sequence matters. For `staggerChildren`, parent (`variants`) and children MUST share the same Client Component tree. + +--- + +## 6. PERFORMANCE & ACCESSIBILITY GUARDRAILS + +### 6.A Hardware Acceleration +* Animate ONLY `transform` and `opacity`. Never animate `top`, `left`, `width`, `height`. +* Use `will-change: transform` sparingly - only on elements that will actually animate. + +### 6.B Reduced Motion (mandatory) +* **Any motion above `MOTION_INTENSITY > 3` MUST honor `prefers-reduced-motion`.** This is non-negotiable. +* In Motion: wrap with `useReducedMotion()` and degrade to static. +* In CSS: gate animations behind `@media (prefers-reduced-motion: no-preference)` or provide an override block under `@media (prefers-reduced-motion: reduce)` that disables. +* Infinite loops, parallax, scroll-hijack, and magnetic physics MUST collapse to static / instant under reduced motion. + +### 6.C Dark Mode (mandatory for any consumer-facing page) +* Design for **both modes from the start**. Never ship light-only or dark-only without explicit user instruction. +* Use Tailwind `dark:` variant OR CSS variables for tokens. Pick one strategy per project. +* **Do not prescribe specific dark-mode colors here.** The brief decides. Maintain visual hierarchy, brand identity, and WCAG AA contrast (AAA for body) across both modes. +* Respect `prefers-color-scheme: dark`. Default to system preference unless the brand insists on one mode. + +### 6.D Core Web Vitals Targets +* **LCP** < 2.5s. Hero image must be `next/image priority` or preloaded. +* **INP** < 200ms. Heavy work off main thread. +* **CLS** < 0.1. Reserve space for images, fonts, embeds. +* Run Lighthouse before declaring a page done. + +### 6.E DOM Cost +* Apply grain / noise filters EXCLUSIVELY to fixed, `pointer-events-none` pseudo-elements (e.g., `fixed inset-0 z-[60] pointer-events-none`). NEVER on scrolling containers - continuous GPU repaints destroy mobile FPS. +* Be aware of bundle size. Motion is not tiny. Three.js is large. Lazy-load anything that's not above-the-fold. + +### 6.F Z-Index Restraint +NEVER spam arbitrary `z-50` or `z-10`. Use z-index strictly for systemic layer contexts (sticky navbars, modals, overlays, grain). Document the z-index scale in a project constants file. + +--- + +## 7. DIAL DEFINITIONS (Technical Reference) + +### DESIGN_VARIANCE (Level 1-10) +* **1-3 (Predictable):** Symmetrical CSS Grid (12-col, equal fr-units), equal paddings, centered alignment. +* **4-7 (Offset):** `margin-top: -2rem` overlaps, varied image aspect ratios (4:3 next to 16:9), left-aligned headers over center-aligned data. +* **8-10 (Asymmetric):** Masonry layouts, CSS Grid with fractional units (`grid-template-columns: 2fr 1fr 1fr`), massive empty zones (`padding-left: 20vw`). +* **MOBILE OVERRIDE:** For levels 4-10, asymmetric layouts above `md:` MUST collapse to strict single-column (`w-full`, `px-4`, `py-8`) on viewports `< 768px`. + +### MOTION_INTENSITY (Level 1-10) +* **1-3 (Static):** No automatic animations. CSS `:hover` and `:active` states only. `prefers-reduced-motion` is the default mode anyway. +* **4-7 (Fluid CSS):** `transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1)`. `animation-delay` cascades for load-ins. Focus on `transform` and `opacity`. +* **8-10 (Advanced Choreography):** Complex scroll-triggered reveals, parallax, scroll-driven animation (CSS `animation-timeline` or GSAP ScrollTrigger). Use Motion hooks. **NEVER use `window.addEventListener('scroll')`** - it is a hard ban, not a "prefer-not." See Section 5.D for the allowed alternatives. + +### VISUAL_DENSITY (Level 1-10) +* **1-3 (Art Gallery):** Lots of white space. Huge section gaps (`py-32` to `py-48`). Expensive, clean. +* **4-7 (Daily App):** Standard web app spacing (`py-16` to `py-24`). +* **8-10 (Cockpit):** Tight paddings. No card boxes; 1px lines separate data. Mandatory: `font-mono` for all numbers. + +--- + +## 8. DARK MODE PROTOCOL + +Dual-mode by default. Never assume light-only unless the brief is print-emulating editorial. + +### 8.A Token Strategy (pick one, stick to it) +* **Tailwind `dark:` variant** (default for utility-first projects): every color utility paired with its dark variant (`bg-white dark:bg-zinc-950`, `text-gray-900 dark:text-gray-100`). +* **CSS variables** (for shadcn/ui, Radix Themes, or component libraries with theming): define semantic tokens (`--surface`, `--surface-elevated`, `--text-primary`, `--accent`) and swap values under `[data-theme="dark"]` or `@media (prefers-color-scheme: dark)`. + +### 8.B Do Not Prescribe Specific Colors Here +The brief and brand decide. This skill enforces only: +* **Contrast** - WCAG AA minimum for body text, AAA target for hero copy. +* **Hierarchy parity** - visual hierarchy that works in light must work in dark. If a CTA pops in light, it pops in dark. +* **Brand fidelity** - primary brand color stays recognisable. Don't desaturate the brand into a dark mode. +* **No pure `#000000` and no pure `#ffffff`** - use off-black (zinc-950, near-black warm gray) and off-white. Pure values kill depth. + +### 8.C Default Mode +Respect `prefers-color-scheme` unless the brand insists. Add a manual toggle if either mode would lose key brand expression. + +### 8.D Test in Both Modes Before Finishing +Open the page in both modes during development. Do not ship a page you've only seen in one mode. + +--- + +## 9. AI TELLS (Forbidden Patterns) + +Avoid these signatures unless the brief explicitly asks for them. + +### 9.A Visual & CSS +* **NO neon / outer glows** by default. Use inner borders or subtle tinted shadows. +* **NO pure black (`#000000`).** Off-black, zinc-950, or charcoal. +* **NO oversaturated accents.** Desaturate to blend with neutrals. +* **NO excessive gradient text** for large headers. +* **NO custom mouse cursors.** Outdated, accessibility-hostile, perf-hostile. + +### 9.B Typography +* **AVOID Inter as default.** See Section 4.1. Override path exists. +* **NO oversized H1s** that just scream. Control hierarchy with weight + color, not raw scale. +* **Serif constraints:** Serif for editorial / luxury / publication. Not for dashboards. + +### 9.C Layout & Spacing +* **Mathematically perfect** padding and margins. No floating elements with awkward gaps. +* **NO 3-column equal feature cards.** The generic "three identical cards horizontally" feature row is banned. Use 2-column zig-zag, asymmetric grid, scroll-pinned, or horizontal-scroll alternative. + +### 9.D Content & Data ("Jane Doe" Effect) +* **NO generic names.** "John Doe", "Sarah Chan", "Jack Su" → use creative, realistic, locale-appropriate names. +* **NO generic avatars.** No SVG "egg" or Lucide user icons → use believable photo placeholders or specific styling. +* **NO fake-perfect numbers.** Avoid `99.99%`, `50%`, `1234567`. Use organic, messy data (`47.2%`, `+1 (312) 847-1928`). +* **NO startup-slop brand names.** "Acme", "Nexus", "SmartFlow", "Cloudly" → invent contextual, premium names that sound real. +* **NO filler verbs.** "Elevate", "Seamless", "Unleash", "Next-Gen", "Revolutionize" → concrete verbs only. + +### 9.E External Resources & Components +* **NO hand-rolled SVG icons.** Use Phosphor / HugeIcons / Radix / Tabler. Lucide on explicit request only. +* **Hand-rolled decorative SVGs strongly discouraged** as default (see Section 4.8). +* **NO div-based fake screenshots.** Never build a fake product UI out of `
    ` rectangles to simulate a screenshot. Use real images, generated images, or skip the preview. +* **NO broken Unsplash links.** Use `https://picsum.photos/seed/{descriptive-string}/{w}/{h}`, or generated photo placeholders, or actual assets. +* **shadcn/ui customization:** Allowed, but NEVER in default state. Customize radii, colors, shadows, typography to the project aesthetic. +* **Production-Ready Cleanliness:** Code visually clean, memorable, meticulously refined. + +### 9.F Production-Test Tells (banned outright) + +These patterns came out of real LLM-generated landing-page tests. They are the signatures the model defaults to when it tries to "look designed." Treat them as hard bans unless the brief explicitly calls for one. + +**Hero & top-of-page** +* **NO version labels in the hero.** `V0.6`, `v2.0`, `BETA`, `INVITE-ONLY PREVIEW`, `EARLY ACCESS`, `ALPHA` - banned as default eyebrows. Only acceptable when the brief is explicitly about a product launch / preview status. +* **NO "Brand · No. 01"-style sub-eyebrows.** "Marrow · No. 01 · The 6-quart" type micro-meta lines. Skip them. + +**Section numbering & micro-labels** +* **NO section-number eyebrows.** `00 / INDEX`, `001 · Capabilities`, `002 · Featured commission`, `06 · how it works`, `05 · The honest table` - banned. Eyebrows should name the topic in plain language, not enumerate. +* **NO `01 / 4`-style pagination on images or bento tiles.** If the user can count, they don't need the label. +* **NO `Scroll · 001 Capabilities`-style scroll cues.** A simple arrow or "Scroll" is enough; no section-number prefix. +* **NO "Index of Work, 2018 - 2026"-style range labels** as eyebrows. Just say what the section is. + +**Separators & dots** +* **The middle-dot (`·`) is rationed.** Maximum 1 per line in metadata strips. Do NOT use it as the default separator for everything ("foo · bar · baz · qux · quux"). If you need a separator family, prefer line breaks, hairlines, or columns. +* **NO decorative colored status dots on every list/nav/badge.** A colored dot before "ONE Q4 SLOT OPEN" or before every nav link, or every task row - banned by default. Acceptable only when the dot conveys actual semantic state (a server status, an availability flag) and is used sparingly. + +**Em-dashes & typography flourishes** +* **NO em-dash (`—`) as a design element OR anywhere else.** See Section 9.G below for the complete, non-negotiable ban. The em-dash character is forbidden in headlines, eyebrows, pills, body copy, quotes, attribution, captions, button text, and alt text. Use the regular hyphen (`-`). +* **NO `
    `-broken-and-italicized headlines** as a default "design move." "for thirty\*years.*" type splits. Headlines should read naturally first, get clever only when the brief demands it. +* **NO vertical rotated text** ("INDEX OF WORK, 2018 - 2026" rotated 90°). Agency-portfolio cliché. Use it only when the brief is explicitly agency / Awwwards / experimental AND it serves a real composition purpose. +* **NO crosshair / hairline grid lines as decoration.** Vertical and horizontal lines drawn just to make the page "feel designed" - banned. Use them only when they organize real content. + +**Fake product previews** +* **NO div-based fake product UI in the hero** (fake task list, fake terminal, fake dashboard built from styled divs). It is the #1 LLM-design Tell. Use a real screenshot, a generated image, a real component preview, or none at all. +* **NO fake version footers** ("v0.6.2-rc.1", "last sync 4s ago · main") inside fake screenshots. Adds nothing, screams AI. + +**Marketing-copy Tells** +* **NO "Quietly in use at" / "Quietly trusted by"** social-proof headers. Use natural language: "Trusted by", "Used at", "Customers include", or skip the heading entirely if the logos speak. +* **NO "From the field" / "Field notes" / "Currently on the bench" / "On our desks" / "Loose plates" style poetic labels** on quote, blog, or sidebar sections. Reads as performative-craftsman. Use plain functional labels ("Testimonials", "Latest writing", "Now working on") or skip the label. +* **NO "We respect the French ones"-style** mock-humble industry-references in body copy. Cute and AI-y. +* **NO weather / locale strips** ("LIS 14:23 · 18°C") in headers/footers unless the brief is explicitly about a place / time-zone-distributed studio. +* **NO micro-meta-sentences under eyebrows.** Sentences like *"Each of these is a feature we ship today, not a roadmap promise. The list will stay short on purpose."* sitting under a section heading are clutter. Eyebrow + Headline + Body is enough. +* **NO generic step labels.** "Stage 1 / Stage 2 / Stage 3", "Step 1 / Step 2 / Step 3", "Phase 01 / Phase 02 / Phase 03", "Pass One / Pass Two / Pass Three". Banned. The actual step content is the label. If you must show progression, use the verb-noun directly ("Install", "Configure", "Ship") not "Stage 1: Install". + +**Pills, labels and version stamps** +* **NO pills/labels/tags overlaid on images.** No `` overlays on photos with tags like `Brand · 02`, `PLATE · BRAND`, `Field notes - journal`. Either let the image speak alone, or add a caption directly below (outside the image). +* **NO photo-credit captions as decoration.** Strings like `Field study no. 12 · Ines Caetano`, `Plate 03 · House archive`, `Frame XII · 35mm` under stock/picsum images are pretentious. Photo credit is allowed ONLY when there is a real photographer being credited for a real photo (with permission). Otherwise: skip the caption or use a one-line functional caption ("The 6-quart, in Sage."). +* **NO version footers on marketing pages.** Footer strings like `v1.4.2`, `Build 0048`, `last sync 4s ago · main` are CLI / devtool fixtures, not landing-page content. Banned on marketing/landing/portfolio pages. +* **NO "Reservation 412 of 800"-style live-stock counters** as decoration. Only if the brief is explicitly a limited-run waitlist with real data. + +**Decoration text strips** +* **NO decoration text strip at hero bottom.** Patterns like `BRAND. MOTION. SPATIAL.`, `TYPE / FORM / MOTION`, `DESIGN · BUILD · SHIP`, `ESTD. 2018 · LISBON · BRAND. MOTION. SPATIAL.` as a small mono-caps strip across the bottom of the hero are an agency-portfolio cliché. Banned by default. Only acceptable when the strip carries real, navigable links (sticky bottom nav) or real status info (cookie banner, build info on a docs site). +* **NO floating top-right sub-text in section headings.** Pattern: section has a giant left-aligned headline; in the top-right corner of the same section header there is a small explainer paragraph floating with no clear alignment to anything else. That floater is the Tell. Either put the sub-text directly under the headline, or build a clean 2-column header (left: headline, right: aligned body), but not a tiny corner paragraph. + +**Lists, dividers and scoring** +* **NO `border-t` + `border-b` on every row of a long list / spec table.** Pick one (bottom-border between rows OR top-border above the group) and use it sparsely. A 10-row spec table with hairlines under each row is the laziest layout - see Section 4.9 for alternative UI components. +* **NO scoring/progress bars with filled background tracks** as comparison visuals. If you need to show "X out of Y" comparisons, prefer a number + small icon, or a tiny inline bar WITHOUT a background track. Big filled `bg-zinc-200` tracks with a partial fill on top are dashboard-UI clutter on a landing page. + +**Locale, time, scroll cues** +* **Locale / city-name / time / weather strips are banned for 99% of briefs.** "Lisbon, working with founders" in the hero, "1200-690 Lisbon, Portugal" in the footer, "Lisbon 14:23 · 18°C" in the nav. These are agency-portfolio decoration tells. Allowed ONLY when: the brief explicitly describes a globally-distributed studio with timezone-relevant work, OR a travel-focused brand, OR a real-world physical venue. A single contact-address mention in the footer is fine; an atmospheric locale strip is not. +* **Scroll cues are banned.** `Scroll`, `↓ scroll`, `Scroll to explore`, `Scroll to walk through it`, animated mouse-wheel icons. If the user has not scrolled yet, they are looking at the hero. They know what scroll is. The bottom of the viewport does not need a label. +* **ZERO decorative status dots by default.** A coloured dot before nav items, before list rows, before badges, before status labels is a Tell. Only acceptable when conveying real semantic state (a live indicator on actual server status, a live availability flag) and limited to one per page section. + +### 9.G EM-DASH BAN (the single most-violated Tell) + +**Em-dash (`—`) is COMPLETELY banned.** It is the LLM's signature stylistic crutch and it is the #1 visual Tell in production tests. There is no "limited use" allowance, no "natural language frequency" allowance, no "in body copy is fine" allowance. None. + +* **Banned in headlines.** Use a period or a comma. +* **Banned in eyebrows / labels / pills / button text / image captions / nav items.** Replace with line breaks, columns, or hairlines. +* **Banned in body copy.** Restructure the sentence: two sentences with a period, OR a comma, OR parentheses, OR a colon. +* **Banned in quote attribution.** Use a normal hyphen with spaces (` - `) or a line break + smaller-weight name. +* **Banned in en-dash form too (`–`) when used as a separator.** Date ranges (`2018-2026`) use a hyphen. Number ranges (`€40-80k`) use a hyphen. + +The ONLY permitted dash characters on the page are: +* Regular hyphen `-` (for compound words, ranges, line dividers in markup) +* Minus sign in math (`-5°C`) + +If your output contains a single `—` or `–` anywhere visible to the user, the output fails the Pre-Flight Check and must be rewritten. + +This rule is non-negotiable. The agent has historically ignored em-dash limits when phrased as "use sparingly." The phrasing here is binary: zero em-dashes. + +--- + +## 10. REFERENCE VOCABULARY (Pattern Names the Agent Should Know) + +This is a vocabulary, not a library. The agent should KNOW these pattern names to communicate about them, design with them in mind, and reach for them when the design read calls for them. **Implementations and code sketches live in the Block Library (Section 12), which is populated iteratively.** + +### Hero Paradigms +* **Asymmetric Split Hero** - Text on one side, asset on the other, generous white space. +* **Editorial Manifesto Hero** - Large type, no asset, almost-poster. +* **Video / Media Mask Hero** - Type cut out as mask over video background. +* **Kinetic-Type Hero** - Animated typography as the primary visual. +* **Curtain-Reveal Hero** - Hero parts on scroll like a curtain. +* **Scroll-Pinned Hero** - Hero stays pinned while content scrolls behind. + +### Navigation & Menus +* **Mac OS Dock Magnification** - Edge nav, icons scale fluidly on hover. +* **Magnetic Button** - Pulls toward cursor. +* **Gooey Menu** - Sub-items detach like viscous liquid. +* **Dynamic Island** - Morphing pill for status / alerts. +* **Contextual Radial Menu** - Circular menu expanding at click point. +* **Floating Speed Dial** - FAB springing into curved secondary actions. +* **Mega Menu Reveal** - Full-screen dropdown, stagger-fade content. + +### Layout & Grids +* **Bento Grid** - Asymmetric tile grouping (Apple Control Center). +* **Masonry Layout** - Staggered grid, no fixed row height. +* **Chroma Grid** - Borders / tiles with subtle animating gradients. +* **Split-Screen Scroll** - Two halves sliding in opposite directions. +* **Sticky-Stack Sections** - Sections that pin and stack on scroll. + +### Cards & Containers +* **Parallax Tilt Card** - 3D tilt tracking mouse coordinates. +* **Spotlight Border Card** - Borders illuminate under cursor. +* **Glassmorphism Panel** - Frosted glass with inner refraction. +* **Holographic Foil Card** - Iridescent rainbow shift on hover. +* **Tinder Swipe Stack** - Physical card stack, swipe-away. +* **Morphing Modal** - Button expands into its own dialog. + +### Scroll Animations +* **Sticky Scroll Stack** - Cards stick and physically stack. +* **Horizontal Scroll Hijack** - Vertical scroll → horizontal pan. +* **Locomotive / Sequence Scroll** - Video / 3D sequence tied to scrollbar. +* **Zoom Parallax** - Central background image zooming on scroll. +* **Scroll Progress Path** - SVG line drawing along scroll. +* **Liquid Swipe Transition** - Page transition like viscous liquid. + +### Galleries & Media +* **Dome Gallery** - 3D panoramic gallery. +* **Coverflow Carousel** - 3D carousel with angled edges. +* **Drag-to-Pan Grid** - Boundless draggable canvas. +* **Accordion Image Slider** - Narrow strips expanding on hover. +* **Hover Image Trail** - Mouse leaves popping image trail. +* **Glitch Effect Image** - RGB-channel shift on hover. + +### Typography & Text +* **Kinetic Marquee** - Endless text bands reversing on scroll. +* **Text Mask Reveal** - Massive type as transparent window to video. +* **Text Scramble Effect** - Matrix-style decoding on load / hover. +* **Circular Text Path** - Text curving along spinning circle. +* **Gradient Stroke Animation** - Outlined text with running gradient. +* **Kinetic Typography Grid** - Letters dodging the cursor. + +### Micro-Interactions & Effects +* **Particle Explosion Button** - CTA shatters into particles on success. +* **Liquid Pull-to-Refresh** - Reload indicator like detaching droplets. +* **Skeleton Shimmer** - Shifting light reflection across placeholders. +* **Directional Hover-Aware Button** - Fill enters from cursor's exact side. +* **Ripple Click Effect** - Wave from click coordinates. +* **Animated SVG Line Drawing** - Vectors drawing themselves in real time. +* **Mesh Gradient Background** - Organic lava-lamp blobs. +* **Lens Blur Depth** - Background UI blurred to focus foreground action. + +### Animation Library Choice +* **Motion (`motion/react`)** - default for UI / Bento / state-change motion. +* **GSAP + ScrollTrigger** - for full-page scrolltelling and scroll hijacks. Isolate in dedicated leaf components with `useEffect` cleanup. +* **Three.js / WebGL** - for canvas backgrounds and 3D scenes. Same isolation rule. +* **NEVER mix GSAP / Three.js with Motion in the same component tree.** They fight over the same frames. + +--- + +## 11. REDESIGN PROTOCOL + +This skill handles **greenfield builds AND redesigns**. Misclassifying the mode is the single biggest source of bad redesign output. + +### 11.A Detect the Mode (first action) +* **Greenfield** - no existing site, or full overhaul approved. Dial baseline from Section 1. +* **Redesign - Preserve** - modernise without breaking the brand. Audit first, extract brand tokens, evolve gradually. +* **Redesign - Overhaul** - new visual language on top of existing content. Treat as greenfield for visuals; preserve content and IA. + +If ambiguous, ask **once**: *"Should this redesign preserve the existing brand, or are we starting visually from scratch?"* + +### 11.B Audit Before Touching +Document the current state before proposing changes: +* **Brand tokens** - primary / accent colors, type stack, logo treatment, radii. +* **Information architecture** - page tree, primary nav, key conversion paths. +* **Content blocks** - what exists, what's doing work, what's filler. +* **Patterns to preserve** - signature interactions, recognisable hero, copy voice. +* **Patterns to retire** - AI-slop tells, broken layouts, dead links, generic stock imagery, perf traps. +* **Dial reading of the existing site** - infer current `DESIGN_VARIANCE` / `MOTION_INTENSITY` / `VISUAL_DENSITY`. That's your starting point, not the baseline. +* **SEO baseline** - current ranking pages, meta titles, structured data, OG cards. **SEO migration is the #1 redesign risk.** + +### 11.C Preservation Rules +* **Do not change information architecture** unless asked. Keep page slugs, anchor IDs, primary nav labels stable for SEO and muscle memory. +* **Extract brand colors before applying Section 4.2.** A brand that is already purple stays purple - apply the LILA RULE's override. +* **Preserve copy voice** unless asked for a rewrite. Visual modernisation ≠ content rewrite. +* **Honor existing accessibility wins.** Do not regress focus states, alt text, keyboard nav, contrast. +* **Respect existing analytics events.** Do not rename buttons, form fields, section IDs that downstream tracking depends on. + +### 11.D Modernisation Levers (priority order) +Apply in order - stop when the brief is satisfied: +1. **Typography refresh** - biggest visual lift per unit of risk. +2. **Spacing & rhythm** - increase section padding, fix vertical rhythm. +3. **Color recalibration** - desaturate, unify neutrals, keep brand accent. +4. **Motion layer** - add `MOTION_INTENSITY`-appropriate micro-interactions to existing components. +5. **Hero & key-section recomposition** - restructure top-of-funnel using Section 10 vocabulary. +6. **Full block replacement** - only when the existing block is unsalvageable. + +### 11.E Decision Tree: Targeted Evolution vs Full Redesign +* IA, content, and SEO sound → **targeted evolution** (Levers 1-4). ~70% of value at ~40% of risk. +* Visual debt is structural (broken IA, no design system, broken mobile) → **full redesign** with strict content preservation. +* Brand itself is changing → **greenfield**. + +### 11.F What Never Changes Silently +Never modify without explicit user approval: +* URL structure / route slugs. +* Primary nav labels. +* Form field names or order (breaks analytics + autofill). +* Brand logo or wordmark. +* Existing legal / consent / cookie copy. + +--- + +## 12. THE BLOCK LIBRARY (Contract - Implementations Land Here Iteratively) + +The Reference Vocabulary (Section 10) names patterns. The Block Library implements them with real props, real motion specs, and real code sketches. + +**Status:** schema defined here. Blocks will be added iteratively. Do not freelance new blocks without following this schema. + +### 12.A File Location +``` +skills/taste-skill/blocks/ + hero/ + asymmetric-split.md + editorial-manifesto.md + kinetic-type.md + ... + feature/ + bento-grid.md + sticky-scroll-stack.md + zig-zag.md + ... + social-proof/ + pricing/ + cta/ + footer/ + navigation/ + portfolio/ + transition/ +``` + +### 12.B Required Frontmatter +```yaml +--- +name: asymmetric-split-hero +category: hero +dial_compatibility: + variance: [6, 10] + motion: [3, 10] + density: [2, 5] +when_to_use: "Landing pages with one strong asset and one strong message. Default hero for SaaS, agency, premium consumer." +not_for: "Editorial / manifesto launches where the message IS the design." +stack: ["react", "next", "tailwind", "motion"] +--- +``` + +### 12.C Required Body Sections +1. **Visual sketch** - short ASCII or description of the layout. +2. **Props API** - the component's interface. +3. **Code sketch** - minimal working implementation (Server Component default, Client island for motion). +4. **Mobile fallback** - explicit collapse rules for `< 768px`. +5. **Motion variants** - one variant per `MOTION_INTENSITY` band (1-3, 4-7, 8-10). Reduced-motion fallback explicit. +6. **Dark-mode notes** - token strategy specific to this block. +7. **Anti-patterns** - common ways this block goes wrong. +8. **References** - links to real examples in production. + +### 12.D Block-Library Discipline +* One block per file. No multi-block files. +* Every block must work standalone (drop it into a page, it renders). +* Every block must pass the Pre-Flight Check (Section 14). +* Blocks that depend on a design system from Section 2.A live under `blocks//--.md` (e.g. `feature/bento-grid--material.md`). + +--- + +## 13. OUT OF SCOPE + +This skill is NOT for: +* Dashboards / dense product UI / admin panels (use Fluent, Carbon, Atlassian, or Polaris from Section 2.A). +* Data tables (use TanStack Table or AG Grid). +* Multi-step forms / wizards (use Form-specific patterns; this skill won't make them better). +* Code editors (use Monaco / CodeMirror with their official skinning). +* Native mobile (use Apple HIG / Material directly). +* Realtime collab UIs (presence, cursors, OT-aware - different problem class). + +If the brief is one of the above, **say so explicitly**, point to the right tool, and only apply this skill's marketing-page / about-page / landing-page parts to the surfaces where they apply. + +--- + +## 14. FINAL PRE-FLIGHT CHECK + +Run this matrix before outputting code. This is the last filter. + +**THIS IS NOT OPTIONAL. Run every box. If any box fails, the output is not done.** + +- [ ] **Brief inference** declared (Section 0.B one-liner)? +- [ ] **Dial values** explicit and reasoned from the brief, not silently using baseline? +- [ ] **Design system** chosen from Section 2 if applicable, or aesthetic labeled honestly? +- [ ] **Redesign mode** detected and audit performed (if applicable, Section 11)? +- [ ] **ZERO em-dashes (`—`) anywhere on the page.** Headlines, eyebrows, pills, body, quotes, attribution, captions, buttons, alt text. Zero. (Section 9.G - non-negotiable.) +- [ ] **Page Theme Lock**: ONE theme (light, dark, or auto) for the whole page. No section flips to inverted mode mid-page (Section 4.11)? +- [ ] **Color Consistency Lock**: one accent color used identically across all sections (Section 4.2)? +- [ ] **Shape Consistency Lock**: one corner-radius system applied consistently (Section 4.4)? +- [ ] **Button Contrast Check**: every CTA text is readable against its background (no white-on-white, WCAG AA 4.5:1)? +- [ ] **CTA Button Wrap**: no CTA label wraps to 2+ lines at desktop? +- [ ] **Form Contrast Check**: form inputs, placeholders, focus rings, labels all pass WCAG AA against the section background? +- [ ] **Serif discipline**: if a serif is used, it is NOT Fraunces or Instrument_Serif (or it is, with explicit brand justification)? Different serif from your previous project? +- [ ] **Premium-consumer palette check**: if the brief is premium-consumer (cookware / wellness / artisan / luxury), the palette is NOT the AI-default beige+brass+oxblood+espresso family? Different family from your previous premium-consumer project? +- [ ] **Italic descender clearance**: every italic word with `y g j p q` has `leading-[1.1]` min + `pb-1` reserve? +- [ ] **Hero fits the viewport**: headline ≤ 2 lines, subtext ≤ 20 words AND ≤ 4 lines, CTA visible without scroll, font scale planned around image? +- [ ] **Hero top padding**: max `pt-24` at desktop, hero content does not float halfway down the viewport? +- [ ] **Hero stack discipline**: max 4 text elements in hero (eyebrow OR brand strip, headline, subtext, CTAs)? No tiny tagline below CTAs, no trust micro-strip in hero? +- [ ] **EYEBROW COUNT (mechanical)**: count instances of `uppercase tracking` micro-labels above section headlines across all components. Count ≤ ceil(sectionCount / 3)? Hero counts as 1. +- [ ] **Split-Header Ban**: no "left big headline + right small explainer paragraph" pattern as a section header (vertical stack instead)? +- [ ] **Zigzag Alternation Cap**: no 3+ consecutive sections with the same image+text-split layout? +- [ ] **No Duplicate CTA Intent**: no two CTAs with the same intent ("Get in touch" + "Let's talk" both on page = Fail)? +- [ ] **Logo wall = logo only**: no industry / category labels printed below logos? +- [ ] **Bento Background Diversity**: at least 2-3 bento cells have real visual variation (image, gradient, pattern), not all white-on-white text cards? +- [ ] **"Used by / Trusted by" logo wall** lives UNDER the hero, not inside it, uses REAL SVG logos (Simple Icons / devicon) or generated SVG marks, NOT plain text wordmarks? +- [ ] **Copy Self-Audit**: every visible string re-read, no grammatically-broken or AI-hallucinated phrases ("free on its past" type) shipped? +- [ ] **Motion motivated**: every animation can be justified in one sentence (hierarchy / storytelling / feedback / state transition), no GSAP-for-show? +- [ ] **Marquee max-one-per-page**: no two horizontal marquees on the same page? +- [ ] **Navigation on ONE line** at desktop, height ≤ 80px? +- [ ] **Section-Layout-Repetition** check: no two sections share the same layout family (at least 4 different families across 8 sections)? +- [ ] **Bento has rhythm AND exact cell count** (N items → N cells, no empty cells in middle or at end)? +- [ ] **Long lists use the right UI component** (not default `
      ` with `divide-y` for > 5 items - see Section 4.9 alternatives)? +- [ ] **Real images used** (gen-tool first, then Picsum-seed, then explicit placeholder slots) - NO div-based fake screenshots, NO hand-rolled decorative SVGs, NO pure-text minimalism? +- [ ] **No pills/labels overlaid on images** (no `Plate · Brand`, no `Field notes - journal`)? +- [ ] **No photo-credit captions as decoration** (`Field study no. 12 · Ines Caetano`)? +- [ ] **No version footers** (`v1.4.2`, `Build 0048`) on marketing pages? +- [ ] **No micro-meta-sentences** under eyebrows ("Each of these is a feature we ship today...")? +- [ ] **No decoration text strip at hero bottom** (`BRAND. MOTION. SPATIAL.`)? +- [ ] **No floating top-right sub-text** in section headings? +- [ ] **No scoring/progress bars with filled background tracks** as comparison visuals? +- [ ] **No locale / city-name / time / weather strips** unless brief is genuinely globally-distributed or place-focused? +- [ ] **No scroll cues** (`Scroll`, `↓ scroll`, `Scroll to explore`)? +- [ ] **No version labels in hero** (V0.6, BETA, INVITE-ONLY) unless the brief is a launch? +- [ ] **No section-numbering eyebrows** (`00 / INDEX`, `001 · Capabilities`, `06 · how it works`)? +- [ ] **No decorative dots** (zero by default, only for real semantic state)? +- [ ] **No `border-t` + `border-b` on every row** of long lists / spec tables? +- [ ] **Content density** sane: no 20-row data tables, no fake-precise specs without justification, ≤ 25-word sub-paragraphs by default? +- [ ] **Quotes ≤ 3 lines** of body, attribution clean (no em-dash)? +- [ ] **Motion claimed = motion shown**: if `MOTION_INTENSITY > 4`, page actually animates, not just claimed? +- [ ] **GSAP sticky-stack / horizontal-pan** implemented per Section 5.A / 5.B canonical skeleton (`start: "top top"`, `pin: true`, correct scrub)? +- [ ] **No `window.addEventListener('scroll')`** - using Motion `useScroll()` / ScrollTrigger / IntersectionObserver / CSS scroll-driven animations only? +- [ ] **Reduced motion** wrapped for everything `MOTION_INTENSITY > 3`? +- [ ] **Dark mode** tokens defined and tested in both modes? +- [ ] **Mobile collapse** explicit (`w-full`, `px-4`, `max-w-7xl mx-auto`) for high-variance layouts? +- [ ] **Viewport stability**: `min-h-[100dvh]`, never `h-screen`? +- [ ] **`useEffect` animations** have strict cleanup functions? +- [ ] **Empty / loading / error** states provided? +- [ ] **Cards omitted** in favor of spacing where possible? +- [ ] **Icons** from an allowed library only (Phosphor / HugeIcons / Radix / Tabler), no hand-rolled SVG paths? +- [ ] **Motion** isolated in client-leaf components with `'use client'` at the top, memoized? +- [ ] **No AI Tells** from Section 9 (Inter as default, AI-purple, three-equal cards, Jane Doe, Acme, "Quietly in use at")? +- [ ] **Core Web Vitals** plausibly hit (LCP < 2.5s, INP < 200ms, CLS < 0.1)? +- [ ] **One design system** per project (no Material + shadcn mixed)? + +If a single checkbox cannot be honestly ticked, the page is not done. Fix it before delivering. + +--- + +# APPENDICES - Real Source-Backed Reference Material + +The sections below are vendored reference content. They give the agent real install commands, real canonical doc links, and real working starter snippets for each design system named in Section 2. Use them to ground decisions in production reality, not training-data fiction. + +## Appendix A - Install Commands per Design System + +```bash +# Material Web (Material 3) +npm install @material/web + +# Fluent UI React (v9) +npm install @fluentui/react-components + +# Fluent UI Web Components (framework-free) +npm install @fluentui/web-components @fluentui/tokens + +# IBM Carbon +npm install @carbon/react @carbon/styles + +# Radix Themes +npm install @radix-ui/themes + +# shadcn/ui (open code, owned components) +npx shadcn@latest init +npx shadcn@latest add button card badge separator input + +# Primer CSS (GitHub product/devtool UI) +npm install --save @primer/css + +# Primer Brand (GitHub marketing UI) +npm install @primer/react-brand + +# GOV.UK Frontend +npm install govuk-frontend + +# USWDS (US Web Design System) +npm install uswds + +# Atlassian Design System (Atlaskit) +yarn add @atlaskit/css-reset @atlaskit/tokens @atlaskit/button @atlaskit/badge @atlaskit/section-message @atlaskit/card + +# Bootstrap 5.3 +npm install bootstrap + +# Shopify Polaris Web Components (Shopify apps only) +# Add this to your app HTML head: +# +# +``` + +## Appendix B - Canonical Sources (read these before reinventing) + +### Material Web +- https://github.com/material-components/material-web +- https://material-web.dev/theming/material-theming/ +- https://m3.material.io/develop/web + +### Fluent UI +- https://fluent2.microsoft.design/get-started/develop +- https://fluent2.microsoft.design/components/web/react/ +- https://github.com/microsoft/fluentui +- https://learn.microsoft.com/en-us/fluent-ui/web-components/ + +### Carbon +- https://carbondesignsystem.com/ +- https://github.com/carbon-design-system/carbon +- https://carbondesignsystem.com/developing/react-tutorial/overview/ +- https://carbondesignsystem.com/developing/web-components-tutorial/overview/ + +### Shopify Polaris +- https://shopify.dev/docs/api/app-home/web-components +- https://github.com/Shopify/polaris-react +- https://polaris-react.shopify.com/components + +### Atlassian +- https://atlassian.design/get-started/develop +- https://atlassian.design/components/button/examples +- https://atlaskit.atlassian.com/packages/design-system/button/example/disabled +- https://atlassian.design/tokens/design-tokens + +### Primer +- https://primer.style/ +- https://github.com/primer/css +- https://github.com/primer/brand + +### GOV.UK +- https://design-system.service.gov.uk/components/button/ +- https://design-system.service.gov.uk/styles/layout/ +- https://github.com/alphagov/govuk-frontend + +### USWDS +- https://designsystem.digital.gov/documentation/developers/ +- https://designsystem.digital.gov/components/button/ +- https://designsystem.digital.gov/components/card/ +- https://github.com/uswds/uswds + +### Bootstrap +- https://getbootstrap.com/docs/5.3/layout/grid/ +- https://getbootstrap.com/docs/5.3/components/card/ + +### Tailwind +- https://tailwindcss.com/docs/dark-mode +- https://tailwindcss.com/blog/tailwindcss-v4 + +### Radix +- https://www.radix-ui.com/themes/docs/components/theme +- https://www.radix-ui.com/themes/docs/components/card +- https://github.com/radix-ui/themes + +### shadcn/ui +- https://ui.shadcn.com/docs +- https://ui.shadcn.com/docs/components/card +- https://github.com/shadcn-ui/ui + +### Native CSS / W3C standards +- https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/backdrop-filter +- https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-color-scheme +- https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-reduced-motion +- https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Grid_layout +- https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Scroll-driven_animations +- https://drafts.csswg.org/scroll-animations-1/ + +### Apple Liquid Glass (Apple platforms only) +- https://developer.apple.com/design/human-interface-guidelines/materials +- https://developer.apple.com/documentation/TechnologyOverviews/liquid-glass +- https://developer.apple.com/documentation/TechnologyOverviews/adopting-liquid-glass +- https://developer.apple.com/documentation/SwiftUI/Material + +--- + +## Appendix C - Apple Liquid Glass: Honest Web Approximation + +Do **not** treat random CSS snippets as official Apple Liquid Glass. + +### What is official +Apple documents Liquid Glass inside Apple's Human Interface Guidelines and Developer Documentation for **Apple platforms**. It is a dynamic material used across Apple platform UI. Apple's native implementation belongs to Apple platform APIs and system components, **not a public web CSS package**. + +Relevant official docs: +- Apple Human Interface Guidelines → Materials +- Apple Developer Documentation → Liquid Glass +- Apple Developer Documentation → Adopting Liquid Glass +- SwiftUI → Material + +### What is NOT official +There is no `liquid-glass.css` from Apple for normal websites. + +A web approximation can use: +- `backdrop-filter` +- transparent backgrounds +- layered borders +- highlight overlays +- gradients +- motion +- strong contrast fallbacks + +But that is **web glassmorphism / frosted-glass approximation**, not official Apple Liquid Glass. Label it as such in comments. + +### Safer web approximation skeleton + +```css +.liquid-glass-web-approx { + position: relative; + isolation: isolate; + overflow: hidden; + border-radius: 999px; + border: 1px solid rgb(255 255 255 / .32); + background: + linear-gradient(135deg, rgb(255 255 255 / .30), rgb(255 255 255 / .08)), + rgb(255 255 255 / .12); + backdrop-filter: blur(24px) saturate(180%) contrast(1.05); + -webkit-backdrop-filter: blur(24px) saturate(180%) contrast(1.05); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / .48), + inset 0 -1px 0 rgb(255 255 255 / .12), + 0 18px 60px rgb(0 0 0 / .18); +} + +.liquid-glass-web-approx::before { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + border-radius: inherit; + background: + radial-gradient(circle at 20% 0%, rgb(255 255 255 / .55), transparent 34%), + linear-gradient(90deg, rgb(255 255 255 / .18), transparent 42%, rgb(255 255 255 / .14)); + pointer-events: none; +} + +.liquid-glass-web-approx::after { + content: ""; + position: absolute; + inset: 1px; + border-radius: inherit; + border: 1px solid rgb(255 255 255 / .14); + pointer-events: none; +} + +@media (prefers-color-scheme: dark) { + .liquid-glass-web-approx { + border-color: rgb(255 255 255 / .18); + background: + linear-gradient(135deg, rgb(255 255 255 / .16), rgb(255 255 255 / .04)), + rgb(15 23 42 / .42); + box-shadow: + inset 0 1px 0 rgb(255 255 255 / .22), + 0 18px 60px rgb(0 0 0 / .42); + } +} + +@media (prefers-reduced-transparency: reduce) { + .liquid-glass-web-approx { + background: rgb(255 255 255 / .96); + backdrop-filter: none; + -webkit-backdrop-filter: none; + } +} +``` + +**Important:** `prefers-reduced-transparency` has uneven browser support; test it. Always provide enough contrast even without blur. + +--- + +**End of appendices.** Install commands above are reality anchors. The Apple Liquid Glass skeleton is a labeled approximation, not an Apple-issued package. For canonical docs per design system, consult the system's official docs (links in Section 2 plus Appendix B). diff --git a/.agents/skills/frontend-design/LICENSE.txt b/.agents/skills/frontend-design/LICENSE.txt new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/.agents/skills/frontend-design/LICENSE.txt @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/.agents/skills/frontend-design/SKILL.md b/.agents/skills/frontend-design/SKILL.md new file mode 100644 index 0000000..decdff4 --- /dev/null +++ b/.agents/skills/frontend-design/SKILL.md @@ -0,0 +1,55 @@ +--- +name: frontend-design +description: Guidance for distinctive, intentional visual design when building new UI or reshaping an existing one. Helps with aesthetic direction, typography, and making choices that don't read as templated defaults. +license: Complete terms in LICENSE.txt +--- + +# Frontend Design + +Approach this as the design lead at a small studio known for giving every client a visual identity that could not be mistaken for anyone else's. This client has already rejected proposals that felt templated, and is paying for a distinctive point of view: make deliberate, opinionated choices about palette, typography, and layout that are specific to this brief, and take one real aesthetic risk you can justify. + +## Ground it in the subject + +If the brief does not pin down what the product or subject is, pin it yourself before designing: name one concrete subject, its audience, and the page's single job, and state your choice. If there's any information in your memory about the human's preferences, context about what they're building, or designs you've made before – use that as a hint. The subject's own world, its materials, instruments, artifacts, and vernacular, is where distinctive choices come from. Build with the brief's real content and subject matter throughout. + +## Design principles + +For web designs, the hero is a thesis. Open with the most characteristic thing in the subject's world, in whatever form makes sense for it: a headline, an image, an animation, a live demo, an interactive moment. Be deliberate with your choice: a big number with a small label, supporting stats, and a gradient accent is the template answer, only use if that's truly the best option. + +Typography carries the personality of the page. Pair the display and body faces deliberately, not the same families you would reach for on any other project, and set a clear type scale with intentional weights, widths, and spacing. Make the type treatment itself a memorable part of the design, not a neutral delivery vehicle for the content. + +Structure is information. Structural devices, numbering, eyebrows, dividers, labels, should encode something true about the content, not decorate it. Many generic designs use numbered markers (01 / 02 / 03), but that's only appropriate if the content actually is a sequence - like a real process or a typed timeline where order carries information the reader needs. Question if choices like numbered markers actually make sense before incorporating them. + +Leverage motion deliberately. Think about where and if animation can serve the subject: a page-load sequence, a scroll-triggered reveal, hover micro-interactions, ambient atmosphere. An orchestrated moment usually lands harder than scattered effects; choose what the direction calls for. However, sometimes less is more, and extra animation contributes to the feeling that the design is AI-generated. + +Match complexity to the vision. Maximalist directions need elaborate execution; minimal directions need precision in spacing, type, and detail. Elegance is executing the chosen vision well. + +Consider written content carefully. Often a design brief may not contain real content, and it's up to you to come up with copy. Copy can make a design feel as templated as the design itself. See the below section on writing for more guidance. + +## Process: brainstorm, explore, plan, critique, build, critique again + +For calibration: AI-generated design right now clusters around three looks: (1) a warm cream background (near #F4F1EA) with a high-contrast serif display and a terracotta accent; (2) a near-black background with a single bright acid-green or vermilion accent; (3) a broadsheet-style layout with hairline rules, zero border-radius, and dense newspaper-like columns. All three are legitimate for some briefs, but they are defaults rather than choices, and they appear regardless of subject. Where the brief pins down a visual direction, follow it exactly — the brief's own words always win, including when it asks for one of these looks. Where it leaves an axis free, don't spend that freedom on one of these defaults. Just like a human designer who's hired, there's often a careful balance between doing what you're good at and taking each project as a chance to experiment and learn. + +Work in two passes. First, brainstorm a short design plan based on the human's design brief: create a compact token system with color, type, layout, and signature. Color: describe the palette as 4–6 named hex values. Type: the typefaces for 2+ roles (a characterful display face that's used with restraint, a complementary body face, and a utility face for captions or data if needed). Layout: a layout concept, using one-sentence prose descriptions and ASCII wireframes to ideate and compare. Signature: the single unique element this page will be remembered by that embodies the brief in an appropriate way. + +Then review that plan against the brief before building: if any part of it reads like the generic default you would produce for any similar page (work through a similar prompt to see if you arrive somewhere similar) rather than a choice made for this specific brief — revise that part, say what you changed and why. Only after you've confirmed the relative uniqueness of your design plan should you start to write the code, following the revised plan exactly and deriving every color and type decision from it. + +When writing the code, be careful of structuring your CSS selector specificities. It's easy to generate CSS classes that cancel each other out (especially with a type-based selector like .section and a element-based selector like .cta). This can happen often with paddings/margins between sections. + +Try to do a lot of this planning and iteration in your thinking, and only show ideas to the user when you have higher confidence it'll delight them. + +## Restraint and self-critique + +Spend your boldness in one place. Let the signature element be the one memorable thing, keep everything around it quiet and disciplined, and cut any decoration that does not serve the brief. Not taking a risk can be a risk itself! Build to a quality floor without announcing it: responsive down to mobile, visible keyboard focus, reduced motion respected. Critique your own work as you build, taking screenshots if your environment supports it – a picture is worth 1000 tokens. Consider Chanel's advice: before leaving the house, take a look in the mirror and remove one accessory. Human creators have memory and always try to do something new, so if you have a space to quickly jot down notes about what you've tried, it can help you in future passes. + +## More on writing in design + +Words appear in a design for one reason: to make it easier to understand, and therefore easier to use. They are design material, not decoration. Bring the same intentionality to copy that you would bring to spacing and color. Before writing anything, ask what the design needs to say, and how it can best be said to help the person navigate the experience. + +Write from the end user's side of the screen. Name things by what people control and recognize, never by how the system is built. A person manages notifications, not webhook config. Describe what something does in plain terms rather than selling it. Being specific is always better than being clever. + +Use active voice as default. A control should say exactly what happens when it's used: "Save changes," not "Submit." An action keeps the same name through the whole flow, so the button that says "Publish" produces a toast that says "Published." The vocabulary of an interface is the signposting for someone navigating the product. Cohesion and consistency are how people learn their way around. + +Treat failure and emptiness as moments for direction, not mood. Explain what went wrong and how to fix it, in the interface's voice rather than a person's. Errors don't apologize, and they are never vague about what happened. An empty screen is an invitation to act. + +Keep the register conversational and tuned: plain verbs, sentence case, no filler, with tone matched to the brand and the audience. Let each element do exactly one job. A label labels, an example demonstrates, and nothing quietly does double duty. diff --git a/skills-lock.json b/skills-lock.json index fe6e1ed..573290b 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,6 +1,24 @@ { "version": 1, "skills": { + "clean-code": { + "source": "firstsun-dev/skills", + "sourceType": "github", + "skillPath": "external/develop/code-quality/clean-code/SKILL.md", + "computedHash": "3f3c09033d11ce887e64d99c3639faf011355cb73c5d6891da05efe8004956f2" + }, + "design-taste-frontend": { + "source": "firstsun-dev/skills", + "sourceType": "github", + "skillPath": "external/develop/frontend/design-taste-frontend/SKILL.md", + "computedHash": "6d838b246d0e35d0b53f4f23f98ba7a1dd561937e64f7d0c7553b0928e376c3e" + }, + "frontend-design": { + "source": "firstsun-dev/skills", + "sourceType": "github", + "skillPath": "external/develop/frontend/frontend-design/SKILL.md", + "computedHash": "4eabc66183767153e404b39d1b839b1c37f2d82d86f0a0d7e880a579d8d62336" + }, "obsidian-development": { "source": "firstsun-dev/skills", "sourceType": "github", From 235d9e09766db0e3426e98b78385f6ab4d5f1c54 Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Sun, 5 Jul 2026 05:19:46 +0000 Subject: [PATCH 25/29] fix(settings): mask personal access token fields Token fields for GitLab, GitHub, and Gitea were plain text inputs, so tokens were visible in plaintext during screen shares, recordings, or on shared machines. They're now password-type inputs with a toggle (eye icon) to reveal them when the user needs to verify what they pasted. --- src/settings.ts | 95 +++++++++++++++++++++++++++++++------------------ 1 file changed, 61 insertions(+), 34 deletions(-) diff --git a/src/settings.ts b/src/settings.ts index a1f4662..689e5a6 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,4 +1,4 @@ -import {App, PluginSettingTab, Setting, Notice} from 'obsidian'; +import {App, PluginSettingTab, Setting, Notice, TextComponent} from 'obsidian'; import GitLabFilesPush from "./main"; export interface SyncMetadata { @@ -193,18 +193,45 @@ export class GitLabSyncSettingTab extends PluginSettingTab { })); } - private displayGitLabSettings(containerEl: HTMLElement): void { + // Token fields are masked like a password input (with a toggle to reveal + // them) since they're secrets that shouldn't sit in plaintext on screen + // during screen shares, recordings, or shared machines. + private addTokenSetting(containerEl: HTMLElement, name: string, desc: string, getValue: () => string, onChange: (value: string) => void): void { + let textComponent: TextComponent; new Setting(containerEl) - .setName('GitLab personal access token') - .setDesc('Create a token in GitLab user settings > access tokens with "API" scope') - .addText(text => text - .setPlaceholder('Enter your token') - .setValue(this.plugin.settings.gitlabToken) - .onChange((value) => { - this.plugin.settings.gitlabToken = value; - void this.plugin.saveSettings(); - this.plugin.initializeGitService(); - })); + .setName(name) + .setDesc(desc) + .addText(text => { + textComponent = text; + text.inputEl.type = 'password'; + text.setPlaceholder('Enter your token') + .setValue(getValue()) + .onChange(onChange); + }) + .addExtraButton(btn => { + btn.setIcon('eye') + .setTooltip('Show token') + .onClick(() => { + const revealing = textComponent.inputEl.type === 'password'; + textComponent.inputEl.type = revealing ? 'text' : 'password'; + btn.setIcon(revealing ? 'eye-off' : 'eye'); + btn.setTooltip(revealing ? 'Hide token' : 'Show token'); + }); + }); + } + + private displayGitLabSettings(containerEl: HTMLElement): void { + this.addTokenSetting( + containerEl, + 'GitLab personal access token', + 'Create a token in GitLab user settings > access tokens with "API" scope', + () => this.plugin.settings.gitlabToken, + (value) => { + this.plugin.settings.gitlabToken = value; + void this.plugin.saveSettings(); + this.plugin.initializeGitService(); + } + ); new Setting(containerEl) .setName('GitLab base URL') @@ -232,17 +259,17 @@ export class GitLabSyncSettingTab extends PluginSettingTab { } private displayGiteaSettings(containerEl: HTMLElement): void { - new Setting(containerEl) - .setName('Gitea personal access token') - .setDesc('Create a token in user settings > applications > access tokens') - .addText(text => text - .setPlaceholder('Enter your token') - .setValue(this.plugin.settings.giteaToken) - .onChange((value) => { - this.plugin.settings.giteaToken = value; - void this.plugin.saveSettings(); - this.plugin.initializeGitService(); - })); + this.addTokenSetting( + containerEl, + 'Gitea personal access token', + 'Create a token in user settings > applications > access tokens', + () => this.plugin.settings.giteaToken, + (value) => { + this.plugin.settings.giteaToken = value; + void this.plugin.saveSettings(); + this.plugin.initializeGitService(); + } + ); new Setting(containerEl) .setName('Gitea base URL') @@ -282,17 +309,17 @@ export class GitLabSyncSettingTab extends PluginSettingTab { } private displayGitHubSettings(containerEl: HTMLElement): void { - new Setting(containerEl) - .setName('GitHub personal access token') - .setDesc('Create a token in GitHub settings > developer settings > personal access tokens with "repo" scope') - .addText(text => text - .setPlaceholder('Enter your token') - .setValue(this.plugin.settings.githubToken) - .onChange((value) => { - this.plugin.settings.githubToken = value; - void this.plugin.saveSettings(); - this.plugin.initializeGitService(); - })); + this.addTokenSetting( + containerEl, + 'GitHub personal access token', + 'Create a token in GitHub settings > developer settings > personal access tokens with "repo" scope', + () => this.plugin.settings.githubToken, + (value) => { + this.plugin.settings.githubToken = value; + void this.plugin.saveSettings(); + this.plugin.initializeGitService(); + } + ); new Setting(containerEl) .setName('Repository owner') From 5c64b96c084430cffefc6fab47fb98b411a18c55 Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Sun, 5 Jul 2026 05:28:53 +0000 Subject: [PATCH 26/29] fix(deprecations): migrate off deprecated Obsidian APIs - Bump obsidian dependency to ^1.13.1 - settings.ts: add getSettingDefinitions() alongside display() fallback for Obsidian < 1.13.0 (minAppVersion) - SyncConflictModal.ts: setWarning() -> setDestructive() --- package-lock.json | 8 ++++---- package.json | 2 +- src/settings.ts | 27 ++++++++++++++++++++++++--- src/ui/SyncConflictModal.ts | 2 +- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index f69825f..a4bfe2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "ignore": "^7.0.5", - "obsidian": "latest" + "obsidian": "^1.13.1" }, "devDependencies": { "@eslint/js": "9.30.1", @@ -10146,9 +10146,9 @@ } }, "node_modules/obsidian": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.12.3.tgz", - "integrity": "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.13.1.tgz", + "integrity": "sha512-qtTEA2pmhJzhuhJqzbBFRYhpIOqvW+krDYjtFynv66KbxBbumHBlsJfWw3I4jtnK/6fZwbQhCrmmDdRwXmX56w==", "license": "MIT", "dependencies": { "@types/codemirror": "5.60.8", diff --git a/package.json b/package.json index 16e65e2..34a91c6 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,6 @@ }, "dependencies": { "ignore": "^7.0.5", - "obsidian": "latest" + "obsidian": "^1.13.1" } } diff --git a/src/settings.ts b/src/settings.ts index 689e5a6..5947bd5 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,4 +1,4 @@ -import {App, PluginSettingTab, Setting, Notice, TextComponent} from 'obsidian'; +import {App, PluginSettingTab, Setting, Notice, TextComponent, SettingDefinitionItem} from 'obsidian'; import GitLabFilesPush from "./main"; export interface SyncMetadata { @@ -83,9 +83,30 @@ export class GitLabSyncSettingTab extends PluginSettingTab { this.plugin = plugin; } + // Kept as a fallback for Obsidian < 1.13.0 (this plugin's minAppVersion), + // which don't know about getSettingDefinitions() and always call display(). display(): void { - const {containerEl} = this; + this.renderSettings(this.containerEl); + } + getSettingDefinitions(): SettingDefinitionItem[] { + return [{ + name: '', + render: (_setting, group) => { + this.renderSettings(group.listEl); + } + }]; + } + + private refresh(): void { + if (typeof this.update === 'function') { + this.update(); + } else { + this.renderSettings(this.containerEl); + } + } + + private renderSettings(containerEl: HTMLElement): void { containerEl.empty(); new Setting(containerEl) @@ -100,7 +121,7 @@ export class GitLabSyncSettingTab extends PluginSettingTab { this.plugin.settings.serviceType = value as GitServiceType; void this.plugin.saveSettings(); this.plugin.initializeGitService(); - this.display(); + this.refresh(); })); new Setting(containerEl).setName('').setHeading(); diff --git a/src/ui/SyncConflictModal.ts b/src/ui/SyncConflictModal.ts index dd89656..655de05 100644 --- a/src/ui/SyncConflictModal.ts +++ b/src/ui/SyncConflictModal.ts @@ -55,7 +55,7 @@ export class SyncConflictModal extends Modal { .addButton(btn => btn .setButtonText('Keep remote') .setTooltip('Overwrite local with remote content') - .setWarning() + .setDestructive() .onClick(() => { this.onChoose('remote'); this.close(); From 06953e1b12e4b4380658b443ce505a6a14fe1b4b Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Sun, 5 Jul 2026 05:44:54 +0000 Subject: [PATCH 27/29] fix(sync): stop false-positive rename detection and 422 on rename push detectRename only checked whether a tracked old path's file was missing from the vault, with no content verification. Any orphaned syncMetadata entry (e.g. left behind by a local delete, which never cleared it) would cause the next unrelated push to be misclassified as a rename from that stale path, using create-only semantics that 422'd if the target path already existed remotely. - detectRename now confirms identity by checking that the remote content at the candidate old path still matches what's being pushed. - handleRename looks up the existing remote file at the new path and sends its sha, so pushing onto an existing remote path updates instead of failing with "file already exists". - Local deletes (single and batch) now clear syncMetadata for the deleted path so it can't become an orphaned rename source later. Co-Authored-By: Claude Sonnet 5 --- src/logic/sync-manager.ts | 39 ++++++++--- src/ui/SyncStatusView.ts | 2 + tests/logic/sync-manager-batch.test.ts | 62 +++++++++++++++- tests/logic/sync-manager.test.ts | 97 +++++++++++++++++++++++++- 4 files changed, 186 insertions(+), 14 deletions(-) diff --git a/src/logic/sync-manager.ts b/src/logic/sync-manager.ts index dc0b949..ab5cf27 100644 --- a/src/logic/sync-manager.ts +++ b/src/logic/sync-manager.ts @@ -35,6 +35,13 @@ export class SyncManager { await this.saveSettings(); } + /** Drop sync metadata for a path that's been deleted, so it can't be mistaken for a rename source later. */ + public async clearMetadata(path: string): Promise { + if (!(path in this.settings.syncMetadata)) return; + delete this.settings.syncMetadata[path]; + await this.saveSettings(); + } + private getNormalizedPath(path: string): string { if (!this.settings.vaultFolder) return path; const folderPath = this.settings.vaultFolder + '/'; @@ -69,7 +76,7 @@ export class SyncManager { // Check if this is a renamed file if (!isString && fileOrPath instanceof TFile) { - const renamedFrom = this.detectRename(fileOrPath); + const renamedFrom = await this.detectRename(fileOrPath, content); if (renamedFrom) { await this.handleRename(fileOrPath, renamedFrom, content); return; @@ -118,19 +125,24 @@ export class SyncManager { } } - private detectRename(file: TFile): string | null { - // Check if there's a metadata entry with the same SHA but different path + /** + * A missing local file at a tracked path is only weak evidence of a rename — + * any orphaned metadata entry (e.g. from a local delete) matches it too. Only + * report a rename once the remote content at the old path still matches the + * content being pushed now, confirming it's really the same file that moved. + */ + private async detectRename(file: TFile, content: string | ArrayBuffer): Promise { const metadataEntries = Object.keys(this.settings.syncMetadata); for (const oldPath of metadataEntries) { const metadata = this.settings.syncMetadata[oldPath]; if (!metadata) continue; + if (oldPath === file.path || metadata.lastKnownPath !== oldPath) continue; + if (this.app.vault.getFileByPath(oldPath)) continue; - if (oldPath !== file.path && metadata.lastKnownPath === oldPath) { - // Check if the old file no longer exists - if (!this.app.vault.getFileByPath(oldPath)) { - // This might be a rename - return oldPath; - } + const oldRepoPath = this.getNormalizedPath(oldPath); + const remoteAtOldPath = await this.gitService.getFile(oldRepoPath, this.settings.branch); + if (remoteAtOldPath.sha && this.contentsEqual(content, remoteAtOldPath.content)) { + return oldPath; } } return null; @@ -141,13 +153,18 @@ export class SyncManager { const repoPath = this.getNormalizedPath(file.path); const oldRepoPath = this.getNormalizedPath(oldPath); + // The new path may already exist on the remote (e.g. a prior push, or a + // stale rename match); if so we must send its sha or the API rejects the + // request as a duplicate create. + const existingAtNewPath = await this.gitService.getFile(repoPath, this.settings.branch); + // Push the file to the new location const result = await this.gitService.pushFile( repoPath, content, this.settings.branch, `Rename ${oldRepoPath} to ${repoPath}`, - undefined + existingAtNewPath.sha ); // Update metadata @@ -440,7 +457,7 @@ export class SyncManager { // Rename detection if (!isString && fileOrPath instanceof TFile) { - const renamedFrom = this.detectRename(fileOrPath); + const renamedFrom = await this.detectRename(fileOrPath, content); if (renamedFrom) { await this.handleRename(fileOrPath, renamedFrom, content); return 'done'; diff --git a/src/ui/SyncStatusView.ts b/src/ui/SyncStatusView.ts index 1ad6367..b47ca9c 100644 --- a/src/ui/SyncStatusView.ts +++ b/src/ui/SyncStatusView.ts @@ -233,6 +233,7 @@ export class SyncStatusView extends ItemView { } else { await this.app.vault.adapter.remove(fileStatus.path); } + await this.plugin.sync.clearMetadata(fileStatus.path); new Notice(`Deleted ${fileStatus.path}`); this.fileStatuses.delete(fileStatus.path); this.renderView(); @@ -655,6 +656,7 @@ export class SyncStatusView extends ItemView { try { if (s.file) await this.app.fileManager.trashFile(s.file); else await this.app.vault.adapter.remove(s.path); + await this.plugin.sync.clearMetadata(s.path); this.fileStatuses.delete(s.path); this.selectedFiles.delete(s.path); } catch { errors.push(s.path); } diff --git a/tests/logic/sync-manager-batch.test.ts b/tests/logic/sync-manager-batch.test.ts index dd7b3f0..6c7faca 100644 --- a/tests/logic/sync-manager-batch.test.ts +++ b/tests/logic/sync-manager-batch.test.ts @@ -220,14 +220,72 @@ describe('SyncManager Batch Operations', () => { vi.mocked(mockApp.vault.read).mockResolvedValue('content'); vi.mocked(mockApp.vault.adapter.exists as ReturnType).mockResolvedValue(true); vi.mocked(mockGitService.pushFile).mockResolvedValue({ path: newPath, sha: 'new-sha' }); - vi.mocked(mockGitService.getFile).mockResolvedValue({ content: 'content', sha: 'new-sha' }); + vi.mocked(mockGitService.getFile).mockImplementation(async (path) => { + // Remote still has the old path with matching content: confirms a real rename. + if (path === oldPath) return { content: 'content', sha: 'sha' }; + // New path does not exist on the remote yet. + return { content: '', sha: '' }; + }); const results = await manager.pushAllFiles([mockFile]); expect(results.success).toBe(1); expect(mockGitService.pushFile).toHaveBeenCalledWith( - newPath, 'content', 'main', `Rename ${oldPath} to ${newPath}`, undefined + newPath, 'content', 'main', `Rename ${oldPath} to ${newPath}`, '' ); }); + + it('should send existing sha when rename target already exists remotely (avoids 422)', async () => { + const oldPath = 'old.md'; + const newPath = 'new.md'; + const mockFile = Object.assign(new TFile(), { path: newPath, name: 'new.md' }); + mockSettings.syncMetadata = { + [oldPath]: { lastSyncedSha: 'sha', lastSyncedAt: 0, lastKnownPath: oldPath } + }; + + vi.mocked(mockApp.vault.getFileByPath).mockImplementation(p => p === oldPath ? null : mockFile); + vi.mocked(mockApp.vault.read).mockResolvedValue('content'); + vi.mocked(mockApp.vault.adapter.exists as ReturnType).mockResolvedValue(true); + vi.mocked(mockGitService.pushFile).mockResolvedValue({ path: newPath, sha: 'new-sha' }); + vi.mocked(mockGitService.getFile).mockImplementation(async (path) => { + // Remote still has the old path with matching content: confirms a real rename. + if (path === oldPath) return { content: 'content', sha: 'sha' }; + // A file already exists on the remote at the new path. + return { content: 'old remote content', sha: 'remote-existing-sha' }; + }); + + const results = await manager.pushAllFiles([mockFile]); + + expect(results.success).toBe(1); + expect(mockGitService.pushFile).toHaveBeenCalledWith( + newPath, 'content', 'main', `Rename ${oldPath} to ${newPath}`, 'remote-existing-sha' + ); + }); + + it('does not misclassify an unrelated push as a rename just because an orphaned metadata entry exists', async () => { + const orphanedPath = 'deleted-unrelated-note.md'; + const pushedPath = 'unrelated.md'; + const mockFile = Object.assign(new TFile(), { path: pushedPath, name: 'unrelated.md' }); + mockSettings.syncMetadata = { + [orphanedPath]: { lastSyncedSha: 'orphaned-sha', lastSyncedAt: 0, lastKnownPath: orphanedPath } + }; + + vi.mocked(mockApp.vault.getFileByPath).mockImplementation(p => p === orphanedPath ? null : mockFile); + vi.mocked(mockApp.vault.read).mockResolvedValue('unrelated content'); + vi.mocked(mockApp.vault.adapter.exists as ReturnType).mockResolvedValue(true); + vi.mocked(mockGitService.pushFile).mockResolvedValue({ path: pushedPath, sha: 'new-sha' }); + vi.mocked(mockGitService.getFile).mockImplementation(async (path) => { + if (path === orphanedPath) return { content: 'totally different content', sha: 'orphaned-sha' }; + return { content: 'old content', sha: 'remote-sha' }; + }); + + const results = await manager.pushAllFiles([mockFile]); + + expect(results.success).toBe(1); + expect(mockGitService.pushFile).toHaveBeenCalledWith( + pushedPath, 'unrelated content', 'main', `Update ${mockFile.name} from Obsidian`, 'remote-sha' + ); + expect(mockSettings.syncMetadata[orphanedPath]).toBeDefined(); + }); }); }); diff --git a/tests/logic/sync-manager.test.ts b/tests/logic/sync-manager.test.ts index 16f8e9c..649e66c 100644 --- a/tests/logic/sync-manager.test.ts +++ b/tests/logic/sync-manager.test.ts @@ -308,6 +308,12 @@ describe('SyncManager', () => { }); vi.spyOn(mockApp.vault, 'read').mockResolvedValue('content'); + vi.spyOn(mockGitLab, 'getFile').mockImplementation(async (path) => { + // Remote still has the old path with the same content: confirms a real rename. + if (path === oldPath) return { content: 'content', sha: 'old-sha' }; + // New path does not exist on the remote yet. + return { content: '', sha: '' }; + }); vi.spyOn(mockGitLab, 'pushFile').mockResolvedValue({ path: newPath, sha: 'new-sha' }); await manager.pushFile(mockFile); @@ -317,11 +323,95 @@ describe('SyncManager', () => { 'content', 'main', `Rename ${oldPath} to ${newPath}`, - undefined + '' ); expect(mockSettings.syncMetadata[oldPath]).toBeUndefined(); expect(mockSettings.syncMetadata[newPath]?.lastSyncedSha).toBe('new-sha'); }); + + it('should send the existing sha when the renamed-to path already exists remotely (avoids 422 "file already exists")', async () => { + const oldPath = 'old.md'; + const newPath = 'new.md'; + const mockFile = Object.assign(new TFile(), { path: newPath, name: 'new.md' }); + + mockSettings.syncMetadata[oldPath] = { + lastSyncedSha: 'old-sha', + lastSyncedAt: Date.now(), + lastKnownPath: oldPath + }; + + vi.spyOn(mockApp.vault, 'getFileByPath').mockImplementation((path) => { + if (path === oldPath) return null; + if (path === newPath) return mockFile; + return null; + }); + + vi.spyOn(mockApp.vault, 'read').mockResolvedValue('content'); + vi.spyOn(mockGitLab, 'getFile').mockImplementation(async (path) => { + // Remote still has the old path with matching content: confirms a real rename. + if (path === oldPath) return { content: 'content', sha: 'old-sha' }; + // A file already exists on the remote at the new path (e.g. from a prior push). + return { content: 'old remote content', sha: 'remote-existing-sha' }; + }); + vi.spyOn(mockGitLab, 'pushFile').mockResolvedValue({ path: newPath, sha: 'new-sha' }); + + await manager.pushFile(mockFile); + + expect(mockGitLab.pushFile).toHaveBeenCalledWith( + newPath, + 'content', + 'main', + `Rename ${oldPath} to ${newPath}`, + 'remote-existing-sha' + ); + expect(mockSettings.syncMetadata[oldPath]).toBeUndefined(); + expect(mockSettings.syncMetadata[newPath]?.lastSyncedSha).toBe('new-sha'); + }); + + it('does not misclassify an unrelated push as a rename just because an orphaned metadata entry exists', async () => { + // Regression test: a local delete that never cleared its syncMetadata entry + // used to make detectRename treat ANY later, unrelated push as "renamed from" + // that orphaned path -- because it only checked "does the old path's file no + // longer exist in the vault", without verifying the content actually matches. + const orphanedPath = 'deleted-unrelated-note.md'; + const pushedPath = 'shinyi-muyu-tutorial.md'; + const mockFile = Object.assign(new TFile(), { path: pushedPath, name: 'shinyi-muyu-tutorial.md' }); + + mockSettings.syncMetadata[orphanedPath] = { + lastSyncedSha: 'orphaned-sha', + lastSyncedAt: Date.now(), + lastKnownPath: orphanedPath + }; + + // The orphaned file is gone from the vault (it was deleted, not renamed). + vi.spyOn(mockApp.vault, 'getFileByPath').mockImplementation((path) => { + if (path === orphanedPath) return null; + if (path === pushedPath) return mockFile; + return null; + }); + + vi.spyOn(mockApp.vault, 'read').mockResolvedValue('unrelated content'); + vi.spyOn(mockGitLab, 'getFile').mockImplementation(async (path) => { + // The orphaned path's remote content is unrelated to what's being pushed now. + if (path === orphanedPath) return { content: 'totally different content', sha: 'orphaned-sha' }; + // Normal push target: already exists remotely with older content. + return { content: 'old content', sha: 'remote-sha' }; + }); + vi.spyOn(mockGitLab, 'pushFile').mockResolvedValue({ path: pushedPath, sha: 'new-sha' }); + + await manager.pushFile(mockFile); + + // Must be treated as a normal update, not a rename from the orphaned path. + expect(mockGitLab.pushFile).toHaveBeenCalledWith( + pushedPath, + 'unrelated content', + 'main', + `Update ${mockFile.name} from Obsidian`, + 'remote-sha' + ); + // The orphaned entry must be left alone -- it wasn't the source of this push. + expect(mockSettings.syncMetadata[orphanedPath]).toBeDefined(); + }); }); describe('Error Handling', () => { @@ -346,6 +436,11 @@ describe('SyncManager', () => { vi.spyOn(mockApp.vault, 'getFileByPath').mockImplementation(p => p === oldPath ? null : mockFile); vi.spyOn(mockApp.vault, 'read').mockResolvedValue('c'); + vi.spyOn(mockGitLab, 'getFile').mockImplementation(async (path) => { + // Remote still has the old path with matching content: confirms a real rename. + if (path === oldPath) return { content: 'c', sha: 's' }; + return { content: '', sha: '' }; + }); vi.spyOn(mockGitLab, 'pushFile').mockRejectedValue(new Error('Rename failed')); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); From e89f6bad705b5807af39546cafc2c6900866da24 Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Sun, 5 Jul 2026 05:50:00 +0000 Subject: [PATCH 28/29] chore(release): bump minAppVersion to 1.13.0 and version to 1.2.0 Obsidian APIs used (getSettingDefinitions, PluginSettingTab.update, setDestructive) all require 1.13.0+, but manifest.json still declared 1.12.7. Bump minAppVersion to match and cut a new release version. Co-Authored-By: Claude Sonnet 5 --- manifest.json | 4 ++-- package.json | 2 +- versions.json | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/manifest.json b/manifest.json index 6de16eb..5ffa4a2 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "id": "git-file-sync", "name": "Git File Sync", - "version": "1.1.2", - "minAppVersion": "1.12.7", + "version": "1.2.0", + "minAppVersion": "1.13.0", "description": "Selectively sync individual notes with GitLab or GitHub. Push, pull, diff, and resolve conflicts — file by file, on mobile and desktop.", "author": "ClaudiaFang", "authorUrl": "https://github.com/ClaudiaFang", diff --git a/package.json b/package.json index 34a91c6..1cd919f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "git-file-sync", - "version": "1.1.2", + "version": "1.2.0", "description": "Selectively sync individual notes with GitLab or GitHub. Push, pull, diff, and resolve conflicts — file by file, on mobile and desktop.", "main": "main.js", "type": "module", diff --git a/versions.json b/versions.json index ea6286f..66971d8 100644 --- a/versions.json +++ b/versions.json @@ -4,5 +4,6 @@ "1.0.5": "1.12.7", "1.0.6": "1.12.7", "1.1.1": "1.12.7", - "1.1.2": "1.12.7" + "1.1.2": "1.12.7", + "1.2.0": "1.13.0" } \ No newline at end of file From a47cfcb708d20d852018ea4de4bfd8eb250cfd6a Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Sun, 5 Jul 2026 05:56:13 +0000 Subject: [PATCH 29/29] fix(deps): resolve Dependabot security alerts in dev dependencies Bump semantic-release, vitest, jsdom, and related tooling to latest patch versions, and add npm overrides for transitive packages bundled deep inside the npm CLI (sigstore, tar, ip-address) and the eslint toolchain (js-yaml, undici via @actions/http-client) that npm install alone could not reach. brace-expansion needed no override: npm's default resolver already picks the highest version satisfying each consumer's own semver range once tar/sigstore/etc. are unpinned, so a nested override there only forced an incompatible version onto minimatch@3.1.5 and broke lockfile consistency (`npm ci` failed with EUSAGE). All flagged packages were dev-only; none ship in the built plugin. npm audit now reports 0 vulnerabilities, and `npm ci` + build/test/lint pass from a clean install. --- package-lock.json | 2703 ++++++++++++++++++++------------------------- package.json | 24 +- 2 files changed, 1210 insertions(+), 1517 deletions(-) diff --git a/package-lock.json b/package-lock.json index a4bfe2e..90629d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "git-file-sync", - "version": "1.1.2", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "git-file-sync", - "version": "1.1.2", + "version": "1.2.0", "license": "MIT", "dependencies": { "ignore": "^7.0.5", @@ -18,11 +18,11 @@ "@semantic-release/changelog": "^6.0.3", "@semantic-release/exec": "^7.1.0", "@semantic-release/git": "^10.0.1", - "@semantic-release/github": "^12.0.6", + "@semantic-release/github": "^12.0.9", "@types/jsdom": "^28.0.3", "@types/node": "^24.0.0", - "@vitest/coverage-v8": "^4.1.5", - "@vitest/ui": "^4.1.2", + "@vitest/coverage-v8": "^4.1.9", + "@vitest/ui": "^4.1.9", "conventional-changelog-conventionalcommits": "^9.3.1", "esbuild": "0.28.1", "eslint-plugin-obsidianmd": "0.1.9", @@ -30,12 +30,12 @@ "globals": "14.0.0", "husky": "^9.1.7", "jiti": "2.6.1", - "jsdom": "^29.0.1", - "semantic-release": "^25.0.3", + "jsdom": "^29.1.1", + "semantic-release": "^25.0.5", "tslib": "2.4.0", "typescript": "^5.8.3", "typescript-eslint": "8.35.1", - "vitest": "^4.1.2" + "vitest": "^4.1.9" } }, "node_modules/@actions/core": { @@ -71,9 +71,9 @@ } }, "node_modules/@actions/http-client/node_modules/undici": { - "version": "6.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", - "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", + "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", "dev": true, "license": "MIT", "engines": { @@ -88,51 +88,64 @@ "license": "MIT" }, "node_modules/@asamuzakjp/css-color": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.1.tgz", - "integrity": "sha512-iGWN8E45Ws0XWx3D44Q1t6vX2LqhCKcwfmwBYCDsFrYFS6m4q/Ks61L2veETaLv+ckDC6+dTETJoaAAb7VjLiw==", + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", "dev": true, + "license": "MIT", "dependencies": { - "@csstools/css-calc": "^3.1.1", - "@csstools/css-color-parser": "^4.0.2", + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.2.7" + "@csstools/css-tokenizer": "^4.0.0" }, "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz", - "integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", "dev": true, + "license": "MIT", "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.7" + "is-potential-custom-element-name": "^1.0.1" }, "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/@asamuzakjp/nwsapi": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -140,10 +153,17 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/code-frame/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -151,9 +171,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -161,13 +181,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -177,14 +197,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -205,6 +225,7 @@ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", "dev": true, + "license": "MIT", "dependencies": { "css-tree": "^3.0.0" }, @@ -247,9 +268,9 @@ } }, "node_modules/@csstools/color-helpers": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", - "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", "dev": true, "funding": [ { @@ -261,14 +282,15 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "engines": { "node": ">=20.19.0" } }, "node_modules/@csstools/css-calc": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", - "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", "dev": true, "funding": [ { @@ -280,6 +302,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "engines": { "node": ">=20.19.0" }, @@ -289,9 +312,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", - "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", "dev": true, "funding": [ { @@ -303,9 +326,10 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.1.1" + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" }, "engines": { "node": ">=20.19.0" @@ -330,6 +354,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "engines": { "node": ">=20.19.0" }, @@ -338,9 +363,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", - "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", "dev": true, "funding": [ { @@ -352,6 +377,7 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT-0", "peerDependencies": { "css-tree": "^3.2.1" }, @@ -376,26 +402,27 @@ "url": "https://opencollective.com/csstools" } ], + "license": "MIT", "engines": { "node": ">=20.19.0" } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, @@ -404,9 +431,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -857,9 +884,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -899,15 +926,15 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^3.1.5" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -940,20 +967,20 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", "dependencies": { - "ajv": "^6.12.4", + "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, "engines": { @@ -1027,10 +1054,11 @@ } }, "node_modules/@exodus/bytes": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", - "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", "dev": true, + "license": "MIT", "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, @@ -1044,29 +1072,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -1086,6 +1128,7 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/momoa/-/momoa-3.3.10.tgz", "integrity": "sha512-KWiFQpSAqEIyrTXko3hFNLeQvSK8zXlJQzhhxsyVn58WFRYXST99b3Nqnu+ttOtjds2Pl2grUHGpe2NzhPynuQ==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=18" } @@ -1133,9 +1176,9 @@ } }, "node_modules/@marijn/find-cluster-break": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", - "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", + "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", "license": "MIT", "peer": true }, @@ -1178,14 +1221,14 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -1351,16 +1394,16 @@ } }, "node_modules/@octokit/request": { - "version": "10.0.8", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.8.tgz", - "integrity": "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==", + "version": "10.0.11", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.11.tgz", + "integrity": "sha512-+s7HUxjfFqOMS9VlIwDffq0MikjSAK0gSpG73W+meAvVAvX4MBrHYTK5Bj3Uot55qFT4gzUtfzE4mGWY4Br8/Q==", "dev": true, "license": "MIT", "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", - "fast-content-type-parse": "^3.0.0", + "content-type": "^2.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" }, @@ -1392,9 +1435,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", - "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", "dev": true, "license": "MIT", "funding": { @@ -1445,9 +1488,9 @@ "license": "ISC" }, "node_modules/@pnpm/npm-conf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.2.tgz", - "integrity": "sha512-h104Kh26rR8tm+a3Qkc5S4VLYint3FE48as7+/5oCEcKR2idC/pF1G6AhIXKI+eHPJa/3J9i5z0Al47IeGHPkA==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-3.0.3.tgz", + "integrity": "sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA==", "dev": true, "license": "MIT", "dependencies": { @@ -1463,12 +1506,13 @@ "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", "cpu": [ "arm64" ], @@ -1483,9 +1527,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", "cpu": [ "arm64" ], @@ -1500,9 +1544,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", "cpu": [ "x64" ], @@ -1517,9 +1561,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", "cpu": [ "x64" ], @@ -1534,9 +1578,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", - "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", "cpu": [ "arm" ], @@ -1551,13 +1595,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1568,13 +1615,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1585,13 +1635,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1602,13 +1655,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1619,13 +1675,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1636,13 +1695,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1653,9 +1715,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", "cpu": [ "arm64" ], @@ -1670,9 +1732,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", - "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", "cpu": [ "wasm32" ], @@ -1680,18 +1742,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", "cpu": [ "arm64" ], @@ -1706,9 +1768,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", "cpu": [ "x64" ], @@ -1723,9 +1785,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", - "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -1826,173 +1888,6 @@ "node": ">=18" } }, - "node_modules/@semantic-release/exec/node_modules/execa": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", - "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^4.0.0", - "cross-spawn": "^7.0.6", - "figures": "^6.1.0", - "get-stream": "^9.0.0", - "human-signals": "^8.0.1", - "is-plain-obj": "^4.1.0", - "is-stream": "^4.0.1", - "npm-run-path": "^6.0.0", - "pretty-ms": "^9.2.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^4.0.0", - "yoctocolors": "^2.1.1" - }, - "engines": { - "node": "^18.19.0 || >=20.5.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/@semantic-release/exec/node_modules/get-stream": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", - "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sec-ant/readable-stream": "^0.4.1", - "is-stream": "^4.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@semantic-release/exec/node_modules/human-signals": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", - "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@semantic-release/exec/node_modules/is-stream": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@semantic-release/exec/node_modules/npm-run-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", - "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^4.0.0", - "unicorn-magic": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@semantic-release/exec/node_modules/parse-json": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", - "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.26.2", - "index-to-position": "^1.1.0", - "type-fest": "^4.39.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@semantic-release/exec/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@semantic-release/exec/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@semantic-release/exec/node_modules/strip-final-newline": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", - "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@semantic-release/exec/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@semantic-release/exec/node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@semantic-release/git": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/@semantic-release/git/-/git-10.0.1.tgz", @@ -2016,10 +1911,100 @@ "semantic-release": ">=18.0.0" } }, + "node_modules/@semantic-release/git/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/@semantic-release/git/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/git/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/@semantic-release/git/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@semantic-release/git/node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@semantic-release/git/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@semantic-release/git/node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/@semantic-release/github": { - "version": "12.0.6", - "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-12.0.6.tgz", - "integrity": "sha512-aYYFkwHW3c6YtHwQF0t0+lAjlU+87NFOZuH2CvWFD0Ylivc7MwhZMiHOJ0FMpIgPpCVib/VUAcOwvrW0KnxQtA==", + "version": "12.0.9", + "resolved": "https://registry.npmjs.org/@semantic-release/github/-/github-12.0.9.tgz", + "integrity": "sha512-ODIqb0V3QqndipryEEiaBxUQCFjvv7Oese5Dt4omMGa60YRNEW0Sx3K+zri0uac2Y6S9nOlMehciWIzvvRCTGQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2031,8 +2016,8 @@ "aggregate-error": "^5.0.0", "debug": "^4.3.4", "dir-glob": "^3.0.1", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", + "http-proxy-agent": "^9.0.0", + "https-proxy-agent": "^9.0.0", "issue-parser": "^7.0.0", "lodash-es": "^4.17.21", "mime": "^4.0.0", @@ -2203,60 +2188,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@semantic-release/npm/node_modules/execa": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", - "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^4.0.0", - "cross-spawn": "^7.0.6", - "figures": "^6.1.0", - "get-stream": "^9.0.0", - "human-signals": "^8.0.1", - "is-plain-obj": "^4.1.0", - "is-stream": "^4.0.1", - "npm-run-path": "^6.0.0", - "pretty-ms": "^9.2.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^4.0.0", - "yoctocolors": "^2.1.1" - }, - "engines": { - "node": "^18.19.0 || >=20.5.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/@semantic-release/npm/node_modules/get-stream": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", - "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sec-ant/readable-stream": "^0.4.1", - "is-stream": "^4.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@semantic-release/npm/node_modules/human-signals": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", - "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, "node_modules/@semantic-release/npm/node_modules/indent-string": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", @@ -2270,105 +2201,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@semantic-release/npm/node_modules/is-stream": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@semantic-release/npm/node_modules/npm-run-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", - "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^4.0.0", - "unicorn-magic": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@semantic-release/npm/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@semantic-release/npm/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@semantic-release/npm/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@semantic-release/npm/node_modules/strip-final-newline": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", - "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@semantic-release/npm/node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@semantic-release/release-notes-generator": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.1.0.tgz", - "integrity": "sha512-CcyDRk7xq+ON/20YNR+1I/jP7BYKICr1uKd1HHpROSnnTdGqOTburi4jcRiTYz0cpfhxSloQO3cGhnoot7IEkA==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@semantic-release/release-notes-generator/-/release-notes-generator-14.1.1.tgz", + "integrity": "sha512-Pbd2e2XRMUD0OxehHpgd5/YghsE76cddkRHSoDvKLK+OCy4Ewxn49rWR631MEUU01lgwF/uyVXvbnVuu6+Z6VA==", "dev": true, "license": "MIT", "dependencies": { @@ -2377,9 +2213,7 @@ "conventional-commits-filter": "^5.0.0", "conventional-commits-parser": "^6.0.0", "debug": "^4.0.0", - "get-stream": "^7.0.0", "import-from-esm": "^2.0.0", - "into-stream": "^7.0.0", "lodash-es": "^4.17.21", "read-package-up": "^11.0.0" }, @@ -2390,19 +2224,6 @@ "semantic-release": ">=20.1.0" } }, - "node_modules/@semantic-release/release-notes-generator/node_modules/get-stream": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-7.0.1.tgz", - "integrity": "sha512-3M8C1EOFN6r8AMUhwUAACIoXZJEOufDU5+0gFFN5uNs6XYOralD2Pqkl7m046va6x77FwposWXbAhPPIOus7mQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@semantic-release/release-notes-generator/node_modules/hosted-git-info": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", @@ -2438,24 +2259,6 @@ "node": "^16.14.0 || >=18.0.0" } }, - "node_modules/@semantic-release/release-notes-generator/node_modules/parse-json": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", - "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.26.2", - "index-to-position": "^1.1.0", - "type-fest": "^4.39.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@semantic-release/release-notes-generator/node_modules/read-package-up": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-11.0.0.tgz", @@ -2494,32 +2297,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@semantic-release/release-notes-generator/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@semantic-release/release-notes-generator/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@semantic-release/release-notes-generator/node_modules/unicorn-magic": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz", @@ -2580,9 +2357,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -2629,9 +2406,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, "node_modules/@types/jsdom": { @@ -2647,13 +2424,6 @@ "undici-types": "^7.21.0" } }, - "node_modules/@types/jsdom/node_modules/undici-types": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.25.0.tgz", - "integrity": "sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2669,19 +2439,19 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.12.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", - "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/node/node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, @@ -2888,9 +2658,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { @@ -2913,19 +2683,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/utils": { "version": "8.35.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.1.tgz", @@ -2969,14 +2726,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", - "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.9", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -2990,8 +2747,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.5", - "vitest": "4.1.5" + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -3000,16 +2757,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", - "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -3018,13 +2775,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", - "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.5", + "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3045,9 +2802,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", - "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", "dev": true, "license": "MIT", "dependencies": { @@ -3058,13 +2815,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", - "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.9", "pathe": "^2.0.3" }, "funding": { @@ -3072,14 +2829,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", - "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3088,9 +2845,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", - "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", "dev": true, "license": "MIT", "funding": { @@ -3098,13 +2855,13 @@ } }, "node_modules/@vitest/ui": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.5.tgz", - "integrity": "sha512-3Z9HNFiV0IF1fk0JPiK+7kE1GcaIPefQQIBYur6PM5yFIq6agys3uqP/0t966e1wXfmjbRCHDe7qW236Xjwnag==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.9.tgz", + "integrity": "sha512-U/cRvtqfEPj27FI1n9cyUvi4vXXdcLhjJiI+InYKdk8hP4VrS6RXOjGL7rfFaeBc37iRKANsR6eEzIoC7lmgBQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.9", "fflate": "^0.8.2", "flatted": "^3.4.2", "pathe": "^2.0.3", @@ -3116,17 +2873,17 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "vitest": "4.1.5" + "vitest": "4.1.9" } }, "node_modules/@vitest/utils": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", - "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", + "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -3135,10 +2892,11 @@ } }, "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -3157,13 +2915,13 @@ } }, "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 14" + "node": ">= 20" } }, "node_modules/aggregate-error": { @@ -3181,9 +2939,9 @@ } }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -3441,9 +3199,9 @@ } }, "node_modules/ast-v8-to-istanbul": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", - "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", "dev": true, "license": "MIT", "dependencies": { @@ -3452,13 +3210,6 @@ "js-tokens": "^10.0.0" } }, - "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" - }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -3504,6 +3255,7 @@ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", "dev": true, + "license": "MIT", "dependencies": { "require-from-string": "^2.0.2" } @@ -3516,9 +3268,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "dev": true, "license": "MIT", "dependencies": { @@ -3563,15 +3315,15 @@ } }, "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" }, "engines": { @@ -3729,9 +3481,9 @@ } }, "node_modules/cli-highlight/node_modules/yargs": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", - "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", + "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", "dev": true, "license": "MIT", "dependencies": { @@ -3878,6 +3630,20 @@ "proto-list": "~1.2.1" } }, + "node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/conventional-changelog-angular": { "version": "8.3.1", "resolved": "https://registry.npmjs.org/conventional-changelog-angular/-/conventional-changelog-angular-8.3.1.tgz", @@ -3924,19 +3690,6 @@ "node": ">=18" } }, - "node_modules/conventional-changelog-writer/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/conventional-commits-filter": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-5.0.0.tgz", @@ -3992,9 +3745,9 @@ "license": "MIT" }, "node_modules/cosmiconfig": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", - "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", "dev": true, "license": "MIT", "dependencies": { @@ -4018,10 +3771,29 @@ } } }, + "node_modules/cosmiconfig/node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/crelt": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", - "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", "license": "MIT", "peer": true }, @@ -4074,6 +3846,7 @@ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, + "license": "MIT", "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" @@ -4087,6 +3860,7 @@ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", "dev": true, + "license": "MIT", "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" @@ -4171,7 +3945,8 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/deep-extend": { "version": "0.6.0", @@ -4315,9 +4090,9 @@ "license": "MIT" }, "node_modules/empathic": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", - "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.1.tgz", + "integrity": "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==", "dev": true, "license": "MIT", "engines": { @@ -4325,26 +4100,27 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.18.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", - "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "version": "5.24.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.1.tgz", + "integrity": "sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" } }, "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", "dev": true, + "license": "BSD-2-Clause", "engines": { - "node": ">=0.12" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" @@ -4482,19 +4258,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/env-ci/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/env-ci/node_modules/strip-final-newline": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", @@ -4542,9 +4305,9 @@ } }, "node_modules/es-abstract": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", - "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", "dev": true, "license": "MIT", "dependencies": { @@ -4610,6 +4373,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -4631,44 +4413,44 @@ } }, "node_modules/es-iterator-helpers": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", - "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.3.tgz", + "integrity": "sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", + "es-abstract": "^1.24.2", "es-errors": "^1.3.0", - "es-set-tostringtag": "^2.0.3", + "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.6", + "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", - "iterator.prototype": "^1.1.4", - "safe-array-concat": "^1.1.3" + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" } }, "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "dev": true, "license": "MIT", "dependencies": { @@ -4708,15 +4490,18 @@ } }, "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", "dev": true, "license": "MIT", "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" }, "engines": { "node": ">= 0.4" @@ -4791,25 +4576,25 @@ } }, "node_modules/eslint": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", - "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", + "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.1", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", + "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", @@ -4828,7 +4613,7 @@ "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -4866,29 +4651,16 @@ "eslint": ">=6.0.0" } }, - "node_modules/eslint-compat-utils/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", "dev": true, "license": "MIT", "dependencies": { "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" } }, "node_modules/eslint-import-resolver-node/node_modules/debug": { @@ -4902,9 +4674,9 @@ } }, "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz", + "integrity": "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==", "dev": true, "license": "MIT", "dependencies": { @@ -4941,19 +4713,6 @@ "semver": "^7.6.3" } }, - "node_modules/eslint-plugin-depend/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-plugin-es-x": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/eslint-plugin-es-x/-/eslint-plugin-es-x-7.8.0.tgz", @@ -5020,6 +4779,16 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/eslint-plugin-json-schema-validator": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/eslint-plugin-json-schema-validator/-/eslint-plugin-json-schema-validator-5.1.0.tgz", @@ -5050,9 +4819,9 @@ } }, "node_modules/eslint-plugin-json-schema-validator/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { @@ -5067,9 +4836,9 @@ } }, "node_modules/eslint-plugin-json-schema-validator/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { @@ -5126,9 +4895,9 @@ } }, "node_modules/eslint-plugin-n/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", "dependencies": { @@ -5174,19 +4943,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/eslint-plugin-n/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-plugin-obsidianmd": { "version": "0.1.9", "resolved": "https://registry.npmjs.org/eslint-plugin-obsidianmd/-/eslint-plugin-obsidianmd-0.1.9.tgz", @@ -5259,6 +5015,13 @@ "node": ">=14.17" } }, + "node_modules/eslint-plugin-obsidianmd/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint-plugin-react": { "version": "7.37.3", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.3.tgz", @@ -5292,22 +5055,14 @@ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, - "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.5", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", - "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, + "license": "ISC", "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "semver": "bin/semver.js" } }, "node_modules/eslint-plugin-security": { @@ -5321,9 +5076,9 @@ } }, "node_modules/eslint-plugin-sonarjs": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-4.0.3.tgz", - "integrity": "sha512-5drkJKLC9qQddIiaATV0e8+ygbUc7b0Ti6VB7M2d3jmKNh3X0RaiIJYTs3dr9xnlhlrxo+/s1FoO3Jgv6O/c7g==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-4.1.0.tgz", + "integrity": "sha512-rh+FlVz0yfd2RNIb6WqSkuGh0addX/Qi5scwQ5FphXDFrM6fZKcxP1+attJ78yUKcyYfiu6MTaISPpAFPzqRJw==", "dev": true, "license": "LGPL-3.0-only", "dependencies": { @@ -5331,14 +5086,15 @@ "builtin-modules": "^3.3.0", "bytes": "^3.1.2", "functional-red-black-tree": "^1.0.1", - "globals": "^17.4.0", + "globals": "^17.6.0", "jsx-ast-utils-x": "^0.1.0", "lodash.merge": "^4.6.2", "minimatch": "^10.2.5", "scslre": "^0.3.0", - "semver": "^7.7.4", + "semver": "^7.8.4", "ts-api-utils": "^2.5.0", - "typescript": ">=5" + "typescript": ">=5", + "yaml": "^2.9.0" }, "peerDependencies": { "eslint": "^8.0.0 || ^9.0.0 || ^10.0.0" @@ -5355,9 +5111,9 @@ } }, "node_modules/eslint-plugin-sonarjs/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { @@ -5368,9 +5124,9 @@ } }, "node_modules/eslint-plugin-sonarjs/node_modules/globals": { - "version": "17.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", - "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==", + "version": "17.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", "dev": true, "license": "MIT", "engines": { @@ -5396,19 +5152,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/eslint-plugin-sonarjs/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -5440,9 +5183,9 @@ } }, "node_modules/eslint/node_modules/@eslint/js": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", - "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", "engines": { @@ -5481,9 +5224,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -5537,56 +5280,42 @@ } }, "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", "dev": true, "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.6", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.1", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.2.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.1.1" }, "engines": { - "node": ">=10" + "node": "^18.19.0 || >=20.5.0" }, "funding": { "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, "license": "Apache-2.0", "engines": { "node": ">=12.0.0" } }, - "node_modules/fast-content-type-parse": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", - "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5639,9 +5368,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", "dev": true, "funding": [ { @@ -5656,37 +5385,21 @@ "license": "BSD-3-Clause" }, "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", - "dev": true + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" }, "node_modules/figures": { "version": "6.1.0", @@ -5795,7 +5508,8 @@ "version": "3.4.2", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/for-each": { "version": "0.3.5", @@ -5813,21 +5527,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/from2": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", - "integrity": "sha512-OMcX/4IC/uqEPVgGeyfN22LJk6AZrMkRZHxcHBMBvHScDGgwTm2GT2Wkgtocyd3JfZffjj2kYUDXXII0Fk9W0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.1", - "readable-stream": "^2.0.0" - } - }, "node_modules/fs-extra": { - "version": "11.3.4", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", - "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", + "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", "dev": true, "license": "MIT", "dependencies": { @@ -5878,18 +5581,21 @@ } }, "node_modules/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -5936,9 +5642,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "dev": true, "license": "MIT", "engines": { @@ -5988,13 +5694,17 @@ } }, "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", "dev": true, "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -6019,9 +5729,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", - "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", "dev": true, "license": "MIT", "dependencies": { @@ -6220,9 +5930,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -6256,9 +5966,9 @@ } }, "node_modules/hosted-git-info": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", - "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", "dev": true, "license": "ISC", "dependencies": { @@ -6273,6 +5983,7 @@ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", "dev": true, + "license": "MIT", "dependencies": { "@exodus/bytes": "^1.6.0" }, @@ -6288,41 +5999,43 @@ "license": "MIT" }, "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", "dev": true, "license": "MIT", "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" }, "engines": { - "node": ">= 14" + "node": ">= 20" } }, "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", "dev": true, "license": "MIT", "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" }, "engines": { - "node": ">= 14" + "node": ">= 20" } }, "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", "dev": true, "license": "Apache-2.0", "engines": { - "node": ">=10.17.0" + "node": ">=18.18.0" } }, "node_modules/husky": { @@ -6330,6 +6043,7 @@ "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", "dev": true, + "license": "MIT", "bin": { "husky": "bin.js" }, @@ -6453,23 +6167,6 @@ "node": ">= 0.4" } }, - "node_modules/into-stream": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/into-stream/-/into-stream-7.0.0.tgz", - "integrity": "sha512-2dYz766i9HprMBasCMvHMuazJ7u4WzhJwo5kb3iPSiW/iRYV6uPari3zHoqZlnuaR7V1bEiNMxikhp37rdBXbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "from2": "^2.3.0", - "p-is-promise": "^3.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -6562,13 +6259,13 @@ } }, "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "hasown": "^2.0.3" }, "engines": { "node": ">= 0.4" @@ -6612,6 +6309,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -6761,7 +6474,8 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/is-regex": { "version": "1.2.1", @@ -6812,13 +6526,13 @@ } }, "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -6949,9 +6663,9 @@ "license": "ISC" }, "node_modules/issue-parser": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.1.tgz", - "integrity": "sha512-3YZcUUR2Wt1WsapF+S/WiA2WmlW0cWAoPccMqne7AxEBhCdFeTPjfv/Axb8V2gyCgY3nRw+ksZ3xSUX+R47iAg==", + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/issue-parser/-/issue-parser-7.0.2.tgz", + "integrity": "sha512-7atWPjhGEIX3JEtMrOYd8TKzboYlq+5sNbdl9POiLYOI14G5HZiQbZP0Xj5EZdrufQVXfJlpTV0hys0CuxwxZw==", "dev": true, "license": "MIT", "dependencies": { @@ -7043,17 +6757,27 @@ } }, "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", "dev": true, "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -7063,27 +6787,28 @@ } }, "node_modules/jsdom": { - "version": "29.0.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", - "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", "dev": true, + "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^5.0.1", - "@asamuzakjp/dom-selector": "^7.0.3", + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.7", - "parse5": "^8.0.0", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", - "undici": "^7.24.5", + "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", @@ -7134,9 +6859,9 @@ } }, "node_modules/json-schema-migrate/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { @@ -7192,9 +6917,9 @@ } }, "node_modules/jsonc-eslint-parser": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/jsonc-eslint-parser/-/jsonc-eslint-parser-2.4.1.tgz", - "integrity": "sha512-uuPNLJkKN8NXAlZlQ6kmUF9qO+T6Kyd7oV4+/7yy8Jz6+MZNyhPq8EdLpdfnPVzUC8qSf1b4j1azKaGnFsjmsw==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jsonc-eslint-parser/-/jsonc-eslint-parser-2.4.2.tgz", + "integrity": "sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA==", "dev": true, "license": "MIT", "dependencies": { @@ -7241,19 +6966,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/jsonc-eslint-parser/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/jsonfile": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", @@ -7460,6 +7172,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7481,6 +7196,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7502,6 +7220,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7523,6 +7244,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7700,11 +7424,19 @@ "loose-envify": "cli.js" } }, - "node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "node_modules/loose-envify/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } @@ -7720,13 +7452,13 @@ } }, "node_modules/magicast": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", - "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", + "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } @@ -7749,19 +7481,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/make-asynchronous/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -7778,19 +7497,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/make-dir/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/marked": { "version": "15.0.12", "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", @@ -7853,7 +7559,8 @@ "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true + "dev": true, + "license": "CC0-1.0" }, "node_modules/meow": { "version": "13.2.0", @@ -7899,19 +7606,6 @@ "node": ">=8.6" } }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/mime": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz", @@ -7962,9 +7656,9 @@ } }, "node_modules/module-replacements": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/module-replacements/-/module-replacements-2.10.1.tgz", - "integrity": "sha512-qkKuLpMHDqRSM676OPL7HUpCiiP3NSxgf8NNR1ga2h/iJLNKTsOSjMEwrcT85DMSti2vmOqxknOVBGWj6H6etQ==", + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/module-replacements/-/module-replacements-2.11.0.tgz", + "integrity": "sha512-j5sNQm3VCpQQ7nTqGeOZtoJtV3uKERgCBm9QRhmGRiXiqkf7iRFOkfxdJRZWLkqYY8PNf4cDQF/WfXUYLENrRA==", "dev": true, "license": "MIT" }, @@ -7982,6 +7676,7 @@ "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" } @@ -8006,9 +7701,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "dev": true, "funding": [ { @@ -8061,6 +7756,35 @@ "node": ">=18" } }, + "node_modules/node-exports-info": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", + "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/normalize-package-data": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", @@ -8076,23 +7800,10 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/normalize-package-data/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/normalize-url": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-9.0.0.tgz", - "integrity": "sha512-z9nC87iaZXXySbWWtTHfCFJyFvKaUAW6lODhikG7ILSbVgmwuFjUqkgnheHvAUcGedO29e2QGBRXMUD64aurqQ==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-9.0.1.tgz", + "integrity": "sha512-ARftfC5HdUNu9jJeL8pHj8debUIHA2b91FizCoMzY4lG6dDX13jdvTK0TBe24IBDRf2HvJSzzwEPvmbkQWHRSg==", "dev": true, "license": "MIT", "engines": { @@ -8103,9 +7814,9 @@ } }, "node_modules/npm": { - "version": "11.13.0", - "resolved": "https://registry.npmjs.org/npm/-/npm-11.13.0.tgz", - "integrity": "sha512-cRmhaghDWA1lFgl3Ug4/VxDJdPBK/U+tNtnrl9kXunFqhWw1x4xL5txkNn7qzPuVfvXOmXyjHpMwsuk2uisbkg==", + "version": "11.18.0", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.18.0.tgz", + "integrity": "sha512-T67M4L5wNm0cZ7EBLErcEkY1SmzEW/WJ+SADBzsFUY1UdAPfFHXFQtZ6SEXiK0+vzXysCvAsepbMaBTwnrAD+w==", "bundleDependencies": [ "@isaacs/string-locale-compare", "@npmcli/arborist", @@ -8184,8 +7895,8 @@ ], "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^9.4.3", - "@npmcli/config": "^10.8.1", + "@npmcli/arborist": "^9.9.0", + "@npmcli/config": "^10.12.0", "@npmcli/fs": "^5.0.0", "@npmcli/map-workspaces": "^5.0.3", "@npmcli/metavuln-calculator": "^9.0.3", @@ -8203,46 +7914,46 @@ "fs-minipass": "^3.0.3", "glob": "^13.0.6", "graceful-fs": "^4.2.11", - "hosted-git-info": "^9.0.2", + "hosted-git-info": "^9.0.3", "ini": "^6.0.0", "init-package-json": "^8.2.5", "is-cidr": "^6.0.4", "json-parse-even-better-errors": "^5.0.0", "libnpmaccess": "^10.0.3", - "libnpmdiff": "^8.1.6", - "libnpmexec": "^10.2.6", - "libnpmfund": "^7.0.20", + "libnpmdiff": "^8.1.11", + "libnpmexec": "^10.3.1", + "libnpmfund": "^7.0.25", "libnpmorg": "^8.0.1", - "libnpmpack": "^9.1.6", - "libnpmpublish": "^11.1.3", + "libnpmpack": "^9.1.11", + "libnpmpublish": "^11.2.0", "libnpmsearch": "^9.0.1", "libnpmteam": "^8.0.2", - "libnpmversion": "^8.0.3", - "make-fetch-happen": "^15.0.5", + "libnpmversion": "^8.0.4", + "make-fetch-happen": "^15.0.6", "minimatch": "^10.2.5", "minipass": "^7.1.3", "minipass-pipeline": "^1.2.4", "ms": "^2.1.2", - "node-gyp": "^12.3.0", + "node-gyp": "^12.4.0", "nopt": "^9.0.0", "npm-audit-report": "^7.0.0", "npm-install-checks": "^8.0.0", "npm-package-arg": "^13.0.2", "npm-pick-manifest": "^11.0.3", - "npm-profile": "^12.0.1", + "npm-profile": "^12.0.2", "npm-registry-fetch": "^19.1.1", "npm-user-validate": "^4.0.0", "p-map": "^7.0.4", - "pacote": "^21.5.0", + "pacote": "^21.5.1", "parse-conflict-json": "^5.0.1", "proc-log": "^6.1.0", "qrcode-terminal": "^0.12.0", "read": "^5.0.1", - "semver": "^7.7.4", + "semver": "^7.8.5", "spdx-expression-parse": "^4.0.0", "ssri": "^13.0.1", "supports-color": "^10.2.2", - "tar": "^7.5.13", + "tar": "^7.5.19", "text-table": "~0.2.0", "tiny-relative-date": "^2.0.2", "treeverse": "^3.0.0", @@ -8258,16 +7969,33 @@ } }, "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", "dev": true, "license": "MIT", "dependencies": { - "path-key": "^3.0.0" + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/npm/node_modules/@gar/promise-retry": { @@ -8298,7 +8026,7 @@ "license": "ISC" }, "node_modules/npm/node_modules/@npmcli/agent": { - "version": "4.0.0", + "version": "4.0.2", "dev": true, "inBundle": true, "license": "ISC", @@ -8314,7 +8042,7 @@ } }, "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "9.4.3", + "version": "9.9.0", "dev": true, "inBundle": true, "license": "ISC", @@ -8362,7 +8090,7 @@ } }, "node_modules/npm/node_modules/@npmcli/config": { - "version": "10.8.1", + "version": "10.12.0", "dev": true, "inBundle": true, "license": "ISC", @@ -8556,7 +8284,7 @@ } }, "node_modules/npm/node_modules/@sigstore/core": { - "version": "3.2.0", + "version": "3.2.1", "dev": true, "inBundle": true, "license": "Apache-2.0", @@ -8604,13 +8332,13 @@ } }, "node_modules/npm/node_modules/@sigstore/verify": { - "version": "3.1.0", + "version": "3.1.1", "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.1.0", + "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0" }, "engines": { @@ -8679,7 +8407,7 @@ } }, "node_modules/npm/node_modules/bin-links": { - "version": "6.0.0", + "version": "6.0.2", "dev": true, "inBundle": true, "license": "ISC", @@ -8707,7 +8435,7 @@ } }, "node_modules/npm/node_modules/brace-expansion": { - "version": "5.0.5", + "version": "5.0.7", "dev": true, "inBundle": true, "license": "MIT", @@ -8776,7 +8504,7 @@ } }, "node_modules/npm/node_modules/cidr-regex": { - "version": "5.0.4", + "version": "5.0.5", "dev": true, "inBundle": true, "license": "BSD-2-Clause", @@ -8900,7 +8628,7 @@ "license": "ISC" }, "node_modules/npm/node_modules/hosted-git-info": { - "version": "9.0.2", + "version": "9.0.3", "dev": true, "inBundle": true, "license": "ISC", @@ -8999,7 +8727,7 @@ } }, "node_modules/npm/node_modules/ip-address": { - "version": "10.1.0", + "version": "10.2.0", "dev": true, "inBundle": true, "license": "MIT", @@ -9081,12 +8809,12 @@ } }, "node_modules/npm/node_modules/libnpmdiff": { - "version": "8.1.6", + "version": "8.1.11", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.4.3", + "@npmcli/arborist": "^9.9.0", "@npmcli/installed-package-contents": "^4.0.0", "binary-extensions": "^3.0.0", "diff": "^8.0.2", @@ -9100,13 +8828,13 @@ } }, "node_modules/npm/node_modules/libnpmexec": { - "version": "10.2.6", + "version": "10.3.1", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { "@gar/promise-retry": "^1.0.0", - "@npmcli/arborist": "^9.4.3", + "@npmcli/arborist": "^9.9.0", "@npmcli/package-json": "^7.0.0", "@npmcli/run-script": "^10.0.0", "ci-info": "^4.0.0", @@ -9123,12 +8851,12 @@ } }, "node_modules/npm/node_modules/libnpmfund": { - "version": "7.0.20", + "version": "7.0.25", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.4.3" + "@npmcli/arborist": "^9.9.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -9148,12 +8876,12 @@ } }, "node_modules/npm/node_modules/libnpmpack": { - "version": "9.1.6", + "version": "9.1.11", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.4.3", + "@npmcli/arborist": "^9.9.0", "@npmcli/run-script": "^10.0.0", "npm-package-arg": "^13.0.0", "pacote": "^21.0.2" @@ -9163,7 +8891,7 @@ } }, "node_modules/npm/node_modules/libnpmpublish": { - "version": "11.1.3", + "version": "11.2.0", "dev": true, "inBundle": true, "license": "ISC", @@ -9207,7 +8935,7 @@ } }, "node_modules/npm/node_modules/libnpmversion": { - "version": "8.0.3", + "version": "8.0.4", "dev": true, "inBundle": true, "license": "ISC", @@ -9223,7 +8951,7 @@ } }, "node_modules/npm/node_modules/lru-cache": { - "version": "11.3.5", + "version": "11.5.1", "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", @@ -9232,7 +8960,7 @@ } }, "node_modules/npm/node_modules/make-fetch-happen": { - "version": "15.0.5", + "version": "15.0.6", "dev": true, "inBundle": true, "license": "ISC", @@ -9398,7 +9126,7 @@ } }, "node_modules/npm/node_modules/node-gyp": { - "version": "12.3.0", + "version": "12.4.0", "dev": true, "inBundle": true, "license": "MIT", @@ -9522,13 +9250,13 @@ } }, "node_modules/npm/node_modules/npm-profile": { - "version": "12.0.1", + "version": "12.0.2", "dev": true, "inBundle": true, "license": "ISC", "dependencies": { "npm-registry-fetch": "^19.0.0", - "proc-log": "^6.0.0" + "proc-log": "^6.1.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -9575,7 +9303,7 @@ } }, "node_modules/npm/node_modules/pacote": { - "version": "21.5.0", + "version": "21.5.1", "dev": true, "inBundle": true, "license": "ISC", @@ -9636,7 +9364,7 @@ } }, "node_modules/npm/node_modules/postcss-selector-parser": { - "version": "7.1.1", + "version": "7.1.4", "dev": true, "inBundle": true, "license": "MIT", @@ -9733,7 +9461,7 @@ "optional": true }, "node_modules/npm/node_modules/semver": { - "version": "7.7.4", + "version": "7.8.5", "dev": true, "inBundle": true, "license": "ISC", @@ -9757,17 +9485,17 @@ } }, "node_modules/npm/node_modules/sigstore": { - "version": "4.1.0", + "version": "4.1.1", "dev": true, "inBundle": true, "license": "Apache-2.0", "dependencies": { "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.1.0", + "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0", - "@sigstore/sign": "^4.1.0", - "@sigstore/tuf": "^4.0.1", - "@sigstore/verify": "^3.1.0" + "@sigstore/sign": "^4.1.1", + "@sigstore/tuf": "^4.0.2", + "@sigstore/verify": "^3.1.1" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -9784,12 +9512,12 @@ } }, "node_modules/npm/node_modules/socks": { - "version": "2.8.7", + "version": "2.8.9", "dev": true, "inBundle": true, "license": "MIT", "dependencies": { - "ip-address": "^10.0.1", + "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" }, "engines": { @@ -9858,7 +9586,7 @@ } }, "node_modules/npm/node_modules/tar": { - "version": "7.5.13", + "version": "7.5.19", "dev": true, "inBundle": true, "license": "BlueOak-1.0.0", @@ -9886,7 +9614,7 @@ "license": "MIT" }, "node_modules/npm/node_modules/tinyglobby": { - "version": "0.2.16", + "version": "0.2.17", "dev": true, "inBundle": true, "license": "MIT", @@ -9954,7 +9682,7 @@ } }, "node_modules/npm/node_modules/undici": { - "version": "6.25.0", + "version": "6.27.0", "dev": true, "inBundle": true, "license": "MIT", @@ -10160,15 +9888,18 @@ } }, "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" ], - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } }, "node_modules/onetime": { "version": "5.1.2", @@ -10267,16 +9998,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-is-promise": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-3.0.0.tgz", - "integrity": "sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -10310,9 +10031,9 @@ } }, "node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.5.tgz", + "integrity": "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA==", "dev": true, "license": "MIT", "engines": { @@ -10369,19 +10090,18 @@ } }, "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" }, "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -10401,12 +10121,13 @@ } }, "node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", "dev": true, + "license": "MIT", "dependencies": { - "entities": "^6.0.0" + "entities": "^8.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" @@ -10470,7 +10191,8 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", @@ -10480,13 +10202,13 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=8.6" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -10590,9 +10312,9 @@ } }, "node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "dev": true, "funding": [ { @@ -10610,7 +10332,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -10670,6 +10392,24 @@ "dev": true, "license": "ISC" }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -10752,6 +10492,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/read-package-up/node_modules/type-fest": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/read-pkg": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-10.1.0.tgz", @@ -10772,32 +10528,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-pkg/node_modules/parse-json": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", - "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "node_modules/read-pkg/node_modules/type-fest": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", "dev": true, - "license": "MIT", + "license": "(MIT OR CC0-1.0)", "dependencies": { - "@babel/code-frame": "^7.26.2", - "index-to-position": "^1.1.0", - "type-fest": "^4.39.1" + "tagged-tag": "^1.0.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/read-pkg/node_modules/parse-json/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "node_modules/read-pkg/node_modules/unicorn-magic": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", + "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", "dev": true, - "license": "(MIT OR CC0-1.0)", + "license": "MIT", "engines": { - "node": ">=16" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -10826,13 +10580,6 @@ "dev": true, "license": "MIT" }, - "node_modules/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT" - }, "node_modules/refa": { "version": "0.12.1", "resolved": "https://registry.npmjs.org/refa/-/refa-0.12.1.tgz", @@ -10948,13 +10695,16 @@ } }, "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", "dev": true, "license": "MIT", "dependencies": { - "is-core-module": "^2.16.1", + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -11010,14 +10760,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", - "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.127.0", - "@rolldown/pluginutils": "1.0.0-rc.17" + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -11026,21 +10776,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-x64": "1.0.0-rc.17", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" } }, "node_modules/run-parallel": { @@ -11068,15 +10818,15 @@ } }, "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" }, @@ -11088,24 +10838,10 @@ } }, "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], "license": "MIT" }, "node_modules/safe-push-apply": { @@ -11158,6 +10894,7 @@ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "dev": true, + "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" }, @@ -11181,9 +10918,9 @@ } }, "node_modules/semantic-release": { - "version": "25.0.3", - "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.3.tgz", - "integrity": "sha512-WRgl5GcypwramYX4HV+eQGzUbD7UUbljVmS+5G1uMwX/wLgYuJAxGeerXJDMO2xshng4+FXqCgyB5QfClV6WjA==", + "version": "25.0.5", + "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-25.0.5.tgz", + "integrity": "sha512-mn61SUJwtM8ThrWn2WmgLVpwVJeG/hPSupua1psdMoufmwRIPyvRLkRkL0JDXkP67OntlLWUYnBnfVc8EDO3/g==", "dev": true, "license": "MIT", "dependencies": { @@ -11279,60 +11016,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/execa": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", - "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "node_modules/semantic-release/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", "dev": true, "license": "MIT", - "dependencies": { - "@sindresorhus/merge-streams": "^4.0.0", - "cross-spawn": "^7.0.6", - "figures": "^6.1.0", - "get-stream": "^9.0.0", - "human-signals": "^8.0.1", - "is-plain-obj": "^4.1.0", - "is-stream": "^4.0.1", - "npm-run-path": "^6.0.0", - "pretty-ms": "^9.2.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^4.0.0", - "yoctocolors": "^2.1.1" - }, "engines": { - "node": "^18.19.0 || >=20.5.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/semantic-release/node_modules/execa/node_modules/get-stream": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", - "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sec-ant/readable-stream": "^0.4.1", - "is-stream": "^4.0.1" - }, - "engines": { - "node": ">=18" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/human-signals": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", - "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, "node_modules/semantic-release/node_modules/indent-string": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", @@ -11346,36 +11042,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/is-stream": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/semantic-release/node_modules/npm-run-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", - "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^4.0.0", - "unicorn-magic": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/semantic-release/node_modules/p-reduce": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/p-reduce/-/p-reduce-3.0.0.tgz", @@ -11389,19 +11055,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/semantic-release/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/semantic-release/node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -11412,10 +11065,10 @@ "node": ">=8" } }, - "node_modules/semantic-release/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -11425,55 +11078,6 @@ "node": ">=10" } }, - "node_modules/semantic-release/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/semantic-release/node_modules/strip-final-newline": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", - "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/semantic-release/node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/semver-regex": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-4.0.5.tgz", @@ -11560,15 +11164,15 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -11580,14 +11184,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -11639,15 +11243,22 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/signale": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/signale/-/signale-1.4.0.tgz", @@ -11759,6 +11370,7 @@ "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", "dev": true, + "license": "MIT", "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", @@ -11796,6 +11408,7 @@ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -11857,12 +11470,13 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/std-env": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", - "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", "dev": true, "license": "MIT" }, @@ -11901,13 +11515,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/string_decoder/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT" - }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -11963,19 +11570,20 @@ } }, "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -11985,16 +11593,16 @@ } }, "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" + "es-object-atoms": "^1.1.2" }, "engines": { "node": ">= 0.4" @@ -12055,13 +11663,16 @@ } }, "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/strip-json-comments": { @@ -12149,7 +11760,8 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/synckit": { "version": "0.9.3", @@ -12189,9 +11801,9 @@ } }, "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", "engines": { @@ -12311,12 +11923,13 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", - "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "dev": true, "license": "MIT", "engines": { @@ -12324,9 +11937,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -12340,32 +11953,66 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tinyrainbow": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.0.0" } }, "node_modules/tldts": { - "version": "7.0.27", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", - "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.6.tgz", + "integrity": "sha512-rbP0Gyx8b3Ae9yO//CU2wbSnQNoQ66m1nJdSbSHmnwKwzkkz/u8mERYU8T2rmlmy+bJvRNn84yNCW8gYqox44Q==", "dev": true, + "license": "MIT", "dependencies": { - "tldts-core": "^7.0.27" + "tldts-core": "^7.4.6" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.27", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", - "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", - "dev": true + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.6.tgz", + "integrity": "sha512-TkQNGJIhlEphpHCjKodMTSe23egUZr/g+flI2qkLgiJ/maAzSgXypSLRTNH3nCmqgayEmtcJBiLcfODSAr1xoA==", + "dev": true, + "license": "MIT" }, "node_modules/to-regex-range": { "version": "5.0.1", @@ -12414,6 +12061,7 @@ "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -12423,6 +12071,7 @@ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "tldts": "^7.0.5" }, @@ -12435,6 +12084,7 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", "dev": true, + "license": "MIT", "dependencies": { "punycode": "^2.3.1" }, @@ -12525,16 +12175,13 @@ } }, "node_modules/type-fest": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", - "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "dev": true, "license": "(MIT OR CC0-1.0)", - "dependencies": { - "tagged-tag": "^1.0.0" - }, "engines": { - "node": ">=20" + "node": ">=16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -12598,18 +12245,18 @@ } }, "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" }, "engines": { "node": ">= 0.4" @@ -12689,18 +12336,19 @@ } }, "node_modules/undici": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", - "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, + "license": "MIT", "engines": { "node": ">=20.18.1" } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.28.0.tgz", + "integrity": "sha512-LJAfY+2w6HGeT8d8J1wNQsUGUEGio6NWWpwdwurQe4f6oojzCFuGLizl1KSve4irsTxyLly1QhEeE6iapdaIvQ==", "dev": true, "license": "MIT" }, @@ -12715,13 +12363,13 @@ } }, "node_modules/unicorn-magic": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", - "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", "dev": true, "license": "MIT", "engines": { - "node": ">=20" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -12799,17 +12447,17 @@ } }, "node_modules/vite": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", - "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.10", - "rolldown": "1.0.0-rc.17", - "tinyglobby": "^0.2.16" + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -12825,7 +12473,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -12876,20 +12524,33 @@ } } }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vitest": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", - "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.5", - "@vitest/mocker": "4.1.5", - "@vitest/pretty-format": "4.1.5", - "@vitest/runner": "4.1.5", - "@vitest/snapshot": "4.1.5", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -12917,12 +12578,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.5", - "@vitest/browser-preview": "4.1.5", - "@vitest/browser-webdriverio": "4.1.5", - "@vitest/coverage-istanbul": "4.1.5", - "@vitest/coverage-v8": "4.1.5", - "@vitest/ui": "4.1.5", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -12966,6 +12627,19 @@ } } }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -12978,6 +12652,7 @@ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, + "license": "MIT", "dependencies": { "xml-name-validator": "^5.0.0" }, @@ -12997,6 +12672,7 @@ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", "dev": true, + "license": "BSD-2-Clause", "engines": { "node": ">=20" } @@ -13006,6 +12682,7 @@ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", "dev": true, + "license": "MIT", "engines": { "node": ">=20" } @@ -13015,6 +12692,7 @@ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", "dev": true, + "license": "MIT", "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", @@ -13108,14 +12786,14 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", + "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", @@ -13134,6 +12812,7 @@ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, + "license": "MIT", "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" @@ -13239,6 +12918,7 @@ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=18" } @@ -13247,7 +12927,8 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/xtend": { "version": "4.0.2", @@ -13270,9 +12951,9 @@ } }, "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "dev": true, "license": "ISC", "bin": { @@ -13286,9 +12967,9 @@ } }, "node_modules/yaml-eslint-parser": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/yaml-eslint-parser/-/yaml-eslint-parser-1.3.0.tgz", - "integrity": "sha512-E/+VitOorXSLiAqtTd7Yqax0/pAS3xaYMP+AUUJGOK1OZG3rhcj9fcJOM5HJ2VrP1FrStVCWr1muTfQCdj4tAA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/yaml-eslint-parser/-/yaml-eslint-parser-1.3.2.tgz", + "integrity": "sha512-odxVsHAkZYYglR30aPYRY4nUGJnoJ2y1ww2HDvZALo0BDETv9kWbi16J52eHs+PWRNmF4ub6nZqfVOeesOvntg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 1cd919f..de29e90 100644 --- a/package.json +++ b/package.json @@ -27,11 +27,11 @@ "@semantic-release/changelog": "^6.0.3", "@semantic-release/exec": "^7.1.0", "@semantic-release/git": "^10.0.1", - "@semantic-release/github": "^12.0.6", + "@semantic-release/github": "^12.0.9", "@types/jsdom": "^28.0.3", "@types/node": "^24.0.0", - "@vitest/coverage-v8": "^4.1.5", - "@vitest/ui": "^4.1.2", + "@vitest/coverage-v8": "^4.1.9", + "@vitest/ui": "^4.1.9", "conventional-changelog-conventionalcommits": "^9.3.1", "esbuild": "0.28.1", "eslint-plugin-obsidianmd": "0.1.9", @@ -39,15 +39,27 @@ "globals": "14.0.0", "husky": "^9.1.7", "jiti": "2.6.1", - "jsdom": "^29.0.1", - "semantic-release": "^25.0.3", + "jsdom": "^29.1.1", + "semantic-release": "^25.0.5", "tslib": "2.4.0", "typescript": "^5.8.3", "typescript-eslint": "8.35.1", - "vitest": "^4.1.2" + "vitest": "^4.1.9" }, "dependencies": { "ignore": "^7.0.5", "obsidian": "^1.13.1" + }, + "overrides": { + "npm": "^11.18.0", + "@actions/http-client": { + "undici": "^6.27.0" + }, + "sigstore": "^4.1.1", + "@sigstore/core": "^3.2.1", + "@sigstore/verify": "^3.1.1", + "tar": "^7.5.19", + "ip-address": "^10.1.1", + "js-yaml": "^4.2.0" } }