diff --git a/.cursor/.gitignore b/.cursor/.gitignore new file mode 100644 index 000000000..8bf7cc27a --- /dev/null +++ b/.cursor/.gitignore @@ -0,0 +1 @@ +plans/ diff --git a/.cursor/commands/accessibility-audit.md b/.cursor/commands/accessibility-audit.md new file mode 100644 index 000000000..8d1f56ba4 --- /dev/null +++ b/.cursor/commands/accessibility-audit.md @@ -0,0 +1,41 @@ +# Accessibility Audit + +## Overview + +Perform comprehensive accessibility (a11y) audit of the current UI code to ensure compliance with WCAG guidelines and provide an inclusive user experience. + +## Steps + +1. **WCAG Compliance** + - Check conformance to WCAG 2.1 guidelines (A, AA, AAA levels) + - Verify proper semantic HTML structure + - Ensure keyboard navigation support + - Review color contrast and visual accessibility +2. **Screen Reader Support** + - Validate ARIA labels and descriptions + - Check heading hierarchy and structure + - Ensure form labels and error messages are accessible + - Review dynamic content announcements +3. **Interactive Elements** + - Verify focus management and visible focus indicators + - Check tab order and keyboard shortcuts + - Ensure interactive elements are properly sized + - Review modal and dialog accessibility +4. **Testing & Tools** + - Suggest automated accessibility testing tools + - Provide manual testing procedures + - Create accessibility test cases + - Recommend browser extensions and validators +5. **Remediation** + - Provide specific code fixes for each issue + - Include ARIA attributes and semantic improvements + - Suggest alternative approaches for complex interactions + - Create accessible component patterns + +## Accessibility Audit Checklist + +- [ ] WCAG compliance verified +- [ ] Screen reader support validated +- [ ] Interactive elements accessible +- [ ] Testing tools recommended +- [ ] Remediation code provided with examples diff --git a/.cursor/commands/address-github-pr-comments.md b/.cursor/commands/address-github-pr-comments.md new file mode 100644 index 000000000..9a522cd1a --- /dev/null +++ b/.cursor/commands/address-github-pr-comments.md @@ -0,0 +1,32 @@ +# Address GitHub PR Comments + +## Overview + +Process outstanding reviewer feedback, apply required fixes, and draft clear responses for each GitHub pull-request comment. + +## Steps + +1. **Sync and audit comments** + - Pull the latest branch changes + - Open the PR conversation view and read every unresolved comment + - Group comments by affected files or themes +2. **Plan resolutions** + - List the requested code edits for each thread + - Identify clarifications or additional context you must provide + - Note any dependencies or blockers before implementing changes +3. **Implement fixes** + - Apply targeted updates addressing one comment thread at a time + - Run relevant tests or linters after impactful changes + - Stage changes with commits that reference the addressed feedback +4. **Draft responses** + - Summarize the action taken or reasoning provided for each comment + - Link to commits or lines when clarification helps reviewers verify + - Highlight any remaining questions or follow-up needs + +## Response Checklist + +- [ ] All reviewer comments acknowledged +- [ ] Required code changes implemented and tested +- [ ] Clarifying explanations prepared for nuanced threads +- [ ] Follow-up items documented or escalated +- [ ] PR status updated for reviewers diff --git a/.cursor/commands/code-review.md b/.cursor/commands/code-review.md new file mode 100644 index 000000000..27f77deea --- /dev/null +++ b/.cursor/commands/code-review.md @@ -0,0 +1,54 @@ +# Code Review + +## Overview + +Perform a thorough code review that verifies functionality, maintainability, and security before approving a change. Focus on architecture, readability, performance implications, and provide actionable suggestions for improvement. + +## Steps + +1. **Understand the change** + - Read the PR description and related issues for context + - Identify the scope of files and features impacted + - Note any assumptions or questions to clarify with the author +2. **Validate functionality** + - Confirm the code delivers the intended behavior + - Exercise edge cases or guard conditions mentally or by running locally + - Check error handling paths and logging for clarity +3. **Assess quality** + - Ensure functions are focused, names are descriptive, and code is readable + - Watch for duplication, dead code, or missing tests + - Verify documentation and comments reflect the latest changes +4. **Review security and risk** + - Look for injection points, insecure defaults, or missing validation + - Confirm secrets or credentials are not exposed + - Evaluate performance or scalability impacts of the change + +## Review Checklist + +### Functionality + +- [ ] Intended behavior works and matches requirements +- [ ] Edge cases handled gracefully +- [ ] Error handling is appropriate and informative + +### Code Quality + +- [ ] Code structure is clear and maintainable +- [ ] No unnecessary duplication or dead code +- [ ] Tests/documentation updated as needed + +### Security & Safety + +- [ ] No obvious security vulnerabilities introduced +- [ ] Inputs validated and outputs sanitized +- [ ] Sensitive data handled correctly + +## Additional Review Notes + +- Architecture and design decisions considered +- Performance bottlenecks or regressions assessed +- Coding standards and best practices followed +- Resource management, error handling, and logging reviewed +- Suggested alternatives, additional test cases, or documentation updates captured + +Provide constructive feedback with concrete examples and actionable guidance for the author. diff --git a/.cursor/commands/optimize-performance.md b/.cursor/commands/optimize-performance.md new file mode 100644 index 000000000..bd4566ef7 --- /dev/null +++ b/.cursor/commands/optimize-performance.md @@ -0,0 +1,35 @@ +# Optimize Performance + +## Overview + +Analyze the current code for performance bottlenecks and provide optimization recommendations, focusing on measurable improvements while maintaining code quality and readability. + +## Steps + +1. **Performance Analysis** + - Identify slow algorithms and inefficient data structures + - Find memory leaks and excessive allocations + - Detect unnecessary computations and redundant operations + - Analyze database queries and API calls +2. **Optimization Strategies** + - Suggest algorithm improvements and better data structures + - Recommend caching strategies where appropriate + - Propose lazy loading and pagination solutions + - Identify opportunities for parallel processing +3. **Implementation** + - Provide optimized code with explanations + - Include performance impact estimates + - Suggest profiling and monitoring approaches + - Consider trade-offs between performance and maintainability + +## Optimize Performance Checklist + +- [ ] Identified slow algorithms and inefficient data structures +- [ ] Found memory leaks and excessive allocations +- [ ] Detected unnecessary computations and redundant operations +- [ ] Analyzed database queries and API calls +- [ ] Suggested algorithm improvements and better data structures +- [ ] Recommended caching strategies where appropriate +- [ ] Provided optimized code with explanations +- [ ] Included performance impact estimates +- [ ] Considered trade-offs between performance and maintainability diff --git a/.cursor/commands/refactor-code.md b/.cursor/commands/refactor-code.md new file mode 100644 index 000000000..b44d3eb4b --- /dev/null +++ b/.cursor/commands/refactor-code.md @@ -0,0 +1,35 @@ +# Refactor Code + +## Overview + +Refactor the selected code to improve its quality while maintaining the same functionality, providing the refactored code with explanations of the improvements made. + +## Steps + +1. **Code Quality Improvements** + - Extract reusable functions or components + - Eliminate code duplication + - Improve variable and function naming + - Simplify complex logic and reduce nesting +2. **Performance Optimizations** + - Identify and fix performance bottlenecks + - Optimize algorithms and data structures + - Reduce unnecessary computations + - Improve memory usage +3. **Maintainability** + - Make the code more readable and self-documenting + - Add appropriate comments where needed + - Follow SOLID principles and design patterns + - Improve error handling and edge case coverage + +## Refactor Code Checklist + +- [ ] Extracted reusable functions or components +- [ ] Eliminated code duplication +- [ ] Improved variable and function naming +- [ ] Simplified complex logic and reduced nesting +- [ ] Identified and fixed performance bottlenecks +- [ ] Optimized algorithms and data structures +- [ ] Made code more readable and self-documenting +- [ ] Followed SOLID principles and design patterns +- [ ] Improved error handling and edge case coverage diff --git a/.cursor/commands/security-audit.md b/.cursor/commands/security-audit.md new file mode 100644 index 000000000..96f1e27cd --- /dev/null +++ b/.cursor/commands/security-audit.md @@ -0,0 +1,28 @@ +# Security Audit + +## Overview + +Comprehensive security review to identify and fix vulnerabilities in the codebase. + +## Steps + +1. **Dependency audit** + - Check for known vulnerabilities + - Update outdated packages + - Review third-party dependencies +2. **Code security review** + - Check for common vulnerabilities + - Review authentication/authorization + - Audit data handling practices +3. **Infrastructure security** + - Review environment variables + - Check access controls + - Audit network security + +## Security Checklist + +- [ ] Dependencies updated and secure +- [ ] No hardcoded secrets +- [ ] Input validation implemented +- [ ] Authentication secure +- [ ] Authorization properly configured diff --git a/.cursor/commands/write-unit-tests.md b/.cursor/commands/write-unit-tests.md new file mode 100644 index 000000000..7fc8dd6d8 --- /dev/null +++ b/.cursor/commands/write-unit-tests.md @@ -0,0 +1,42 @@ +# Write Unit Tests + +## Overview + +Create comprehensive unit tests for the current code and generate the test file with proper imports and setup according to the project's testing conventions. + +## Steps + +1. **Test Coverage** + - Test all public methods and functions + - Cover edge cases and error conditions + - Test both positive and negative scenarios + - Aim for high code coverage +2. **Test Structure** + - Use the project's testing framework conventions + - Write clear, descriptive test names + - Follow the Arrange-Act-Assert pattern + - Group related tests logically +3. **Test Cases to Include** + - Happy path scenarios + - Edge cases and boundary conditions + - Error handling and exception cases + - Mock external dependencies appropriately +4. **Test Quality** + - Make tests independent and isolated + - Ensure tests are deterministic and repeatable + - Keep tests simple and focused on one thing + - Add helpful assertion messages + +## Write Unit Tests Checklist + +- [ ] Tested all public methods and functions +- [ ] Covered edge cases and error conditions +- [ ] Tested both positive and negative scenarios +- [ ] Used the project's testing framework conventions +- [ ] Written clear, descriptive test names +- [ ] Followed the Arrange-Act-Assert pattern +- [ ] Included happy path scenarios +- [ ] Included edge cases and boundary conditions +- [ ] Mocked external dependencies appropriately +- [ ] Made tests independent and isolated +- [ ] Ensured tests are deterministic and repeatable diff --git a/.cursor/rules/core-development.mdc b/.cursor/rules/core-development.mdc new file mode 100644 index 000000000..a7cf0cb49 --- /dev/null +++ b/.cursor/rules/core-development.mdc @@ -0,0 +1,37 @@ +--- +description: Core development workflow and critical thinking rules +alwaysApply: true +--- + +# Core Development + +Read `AGENTS.md` at the repository root for full project standards, coding conventions, and architecture. + +## Critical Partner Mindset + +Do not affirm my statements or assume my conclusions are correct. Question assumptions, offer counterpoints, test reasoning. Prioritize truth over agreement. + +## Execution Sequence (always reply with "Applying rules X,Y,Z") + +1. SEARCH FIRST - Search the codebase until finding similar functionality or confirming none exists. Investigate deeply, be 100% sure before implementing. +2. REUSE FIRST - Check existing functions/patterns/structure. Extend before creating new. Strive to smallest possible code changes. +3. NO ASSUMPTIONS - Only use: files read, user messages, tool results. Missing info? Search then ask user. +4. CHALLENGE IDEAS - If you see flaws/risks/better approaches, say so directly. +5. BE HONEST - State what's needed/problematic, don't sugarcoat to please. +6. SELF-CHECK - Periodically self-check rule compliance during long conversations. +7. RULE REFRESH - Re-check this rules file every few messages to stay compliant. + +## Coding Standards + +- Plan before coding, explain reasoning for complex suggestions +- Follow the code comment guidance in `AGENTS.md` +- Keep imports alphabetically sorted +- Keep code SOLID but simple - separation of concerns without over-engineering +- Treat file length as a signal; split only for reuse, meaningful separation, or clearer testing +- Follow the testing requirements in `AGENTS.md`. Use the AAA pattern. +- Run `npm run lint` for lint check + +## Prohibited Actions + +- DO NOT WRITE DOCS UNLESS EXPLICITLY ASKED TO +- NEVER run `npm run watch` command - assume dev servers always running diff --git a/.cursor/rules/declarative-react.mdc b/.cursor/rules/declarative-react.mdc new file mode 100644 index 000000000..609e2f28f --- /dev/null +++ b/.cursor/rules/declarative-react.mdc @@ -0,0 +1,158 @@ +--- +description: Enforces declarative React coding patterns, avoiding imperative DOM manipulation and useEffect chains in favor of derived state and direct updates +globs: ["**/*.tsx", "**/*.jsx", "**/*.ts", "**/*.js"] +alwaysApply: false +--- + +# Declarative React + +Always write React code in a declarative style. + +## Core Principles + +1. Prefer **functional components** with JSX and hooks over class components. +2. **Never** manipulate the DOM directly (e.g., `document.querySelector`, `innerHTML`). Instead, let React's render cycle handle UI updates via **state** and **props**. +3. Use hooks (`useState`, `useReducer`, `useEffect`, `useMemo`, `useCallback`) responsibly: + - Avoid running imperative logic in `useEffect` that can be expressed through declarative data-flow. + - Derive data instead of duplicating state. +4. Separate components when doing so clarifies responsibility or enables reuse. Prefer conditional rendering (`{condition && }`) and array mapping to create lists. Reserve `condition ? : null` for numeric or string conditions that would otherwise render. +5. Keep side-effects isolated; only place effectful code inside `useEffect`, `useLayoutEffect`, or custom hooks. +6. Use **refs** sparingly and only for non-DOM data persistence, not for styling or visibility toggling. +7. Prefer **controlled components** for forms. Keep input values in React state. +8. Pass data downward via props; avoid event emitters or global variables for child-parent communication, unless using a proper state management solution. +9. Use unique `key` props for list items to maintain element identity. +10. Favor pure functions and immutable data patterns to make render output predictable. +11. **Avoid cascading state updates**: Never create chains where updating one state triggers a `useEffect` that updates another state. Instead, derive values using `useMemo` or calculate them during render. +12. **Prefer direct state updates over useEffect chains**: When you need to update multiple related state values, do it directly in the event handler rather than creating cascading useEffect dependencies. +13. **Keep hooks non-visual**: Custom hooks return state and actions, not rendered elements. +14. **Avoid trivial memoization**: Calculate inexpensive derived values during render instead of wrapping them in `useMemo` or `useCallback`. + +## Anti-Patterns to Avoid + +- Direct DOM manipulation (`document.getElementById`, `jQuery`, etc.) +- Calling imperative animations or style changes outside React's lifecycle +- Mutating state directly (e.g., `state.value = 1`). Always use the state setter returned by `useState` or a reducer action +- Fetching or performing expensive computations during render +- Using refs to bypass React for UI updates instead of relying on state + +## Derived State vs Cascading Updates + +**Avoid:** + +```jsx +const [users, setUsers] = useState([]); +const [filteredUsers, setFilteredUsers] = useState([]); +const [searchTerm, setSearchTerm] = useState(''); + +useEffect(() => { + const filtered = users.filter(user => + user.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + setFilteredUsers(filtered); +}, [users, searchTerm]); +``` + +**Prefer:** + +```jsx +const [users, setUsers] = useState([]); +const [searchTerm, setSearchTerm] = useState(''); + +const filteredUsers = useMemo(() => + users.filter(user => + user.name.toLowerCase().includes(searchTerm.toLowerCase()) + ), [users, searchTerm] +); +``` + +## Complex State Derivation + +For complex state transformations, declare what you want step by step: + +```jsx +function UserDashboard() { + const [users, setUsers] = useState([]); + const [selectedCategory, setSelectedCategory] = useState('all'); + const [sortOrder, setSortOrder] = useState('asc'); + + const categoryFiltered = useMemo(() => + selectedCategory === 'all' + ? users + : users.filter(user => user.category === selectedCategory) + , [users, selectedCategory]); + + const sortedAndFiltered = useMemo(() => + [...categoryFiltered].sort((a, b) => + sortOrder === 'asc' + ? a.name.localeCompare(b.name) + : b.name.localeCompare(a.name) + ) + , [categoryFiltered, sortOrder]); + + const groupedUsers = useMemo(() => + sortedAndFiltered.reduce((groups, user) => { + const key = user.department; + groups[key] = groups[key] || []; + groups[key].push(user); + return groups; + }, {}) + , [sortedAndFiltered]); + + return ( +
+ {/* Render UI based on derived state */} +
+ ); +} +``` + +## Imperative vs Declarative Examples + +**Avoid:** + +```js +componentDidMount() { + document.getElementById('sidebar').style.display = 'none'; +} +``` + +**Prefer:** + +```jsx +function Sidebar({ isOpen }) { + if (!isOpen) return null; + return ; +} +``` + +## Direct State Updates vs useEffect Chains + +**Avoid:** + +```jsx +function handleSubmit() { + setLoading(true); +} + +useEffect(() => { + if (loading) { + setError(null); + } +}, [loading]); + +useEffect(() => { + if (loading && !error) { + setSubmitted(false); + } +}, [loading, error]); +``` + +**Prefer:** + +```jsx +function handleSubmit() { + setLoading(true); + setError(null); + setSubmitted(false); +} +``` diff --git a/.cursor/rules/development-guidelines.mdc b/.cursor/rules/development-guidelines.mdc new file mode 100644 index 000000000..955cc8549 --- /dev/null +++ b/.cursor/rules/development-guidelines.mdc @@ -0,0 +1,53 @@ +--- +description: Planning, file organization, testing, typing, clean code, and code quality guidelines +alwaysApply: true +--- + +# Development Guidelines + +## Planning + +- Always plan the code before writing it +- Think about how the new code will: + - Fit into the existing codebase + - Interact with other parts of the codebase + - Handle errors and edge cases + - Be used by the frontend + - Be used by the users or by the developers + +## File Organization + +- Keep files focused on a single responsibility or closely related functionality +- Treat file length as a signal; split only for reuse, meaningful separation, or clearer testing +- Avoid single-use helper files and pass-through abstractions +- Use clear directory structure to organize related files + +## Testing Requirements + +- Follow the testing requirements in `AGENTS.md` +- Follow existing testing patterns and conventions in the codebase +- Ensure tests are deterministic and properly isolated + +## Typing + +- Always use proper TypeScript types for all variables, parameters, and return values +- Avoid using `any` type unless absolutely necessary +- Try finding existing types / defined package types, and re-use them or build on top of them instead of creating new ones +- For functions with 3+ parameters, use types for better maintainability + +## Clean Code + +- Write clean, readable, and maintainable code +- Keep functions small and focused +- Use descriptive and clear variable, function, and class names that explain their purpose +- Prefer self-documenting code with meaningful names over excessive comments +- Add comments only when needed to explain complex business logic or non-obvious decisions +- Avoid redundant or obvious comments that just restate what the code does + +## Code Quality + +- Don't Repeat Yourself (DRY): Identify and refactor duplicated code +- Single Responsibility Principle (SRP): Ensure each module/function has one responsibility +- Separation of Concerns: Ensure different concerns are handled in separate modules/components +- Meaningful Names: Verify that names are descriptive and adhere to conventions +- Parameter Handling: Avoid redundant parameter extraction; keep parameters close to the logic where they are used diff --git a/.cursor/rules/development-workflow.mdc b/.cursor/rules/development-workflow.mdc new file mode 100644 index 000000000..f2aa448fa --- /dev/null +++ b/.cursor/rules/development-workflow.mdc @@ -0,0 +1,54 @@ +--- +description: Development workflow guidelines for before, during, and after implementation +alwaysApply: true +--- + +# Development Workflow + +## Before Starting Work + +1. **Search First**: Use codebase_search to find existing functionality +2. **Understand Architecture**: Know if it's main repo or turbo-repo work +3. **Check Dependencies**: Understand package relationships +4. **Plan API Design**: Design interfaces before implementation +5. **Sync Target Branch**: Fetch the target branch and verify it hasn't changed the files you plan to modify since you branched. If it has, read the new version before proceeding +6. **Scope by Layer**: Plan separate PRs for independent layers (PHP vs JS/TS, backend vs frontend, features vs fixes). Smaller, layer-scoped PRs merge cleaner, review faster, and survive upstream changes + +## During Development + +1. **Follow Patterns**: Use existing patterns and conventions +2. **Write Tests**: Follow the testing requirements in `AGENTS.md` +3. **Type Safety**: Use TypeScript strictly +4. **Document APIs**: Document all public interfaces + +## After Implementation + +1. **Run Tests**: Run and verify the merge gate required by `AGENTS.md` before release integration +2. **Lint Code**: Fix any linting issues +3. **Check Dependencies**: Verify no circular dependencies +4. **Test Integration**: Verify with other packages + +## Performance Considerations + +### Bundle size + +- Monitor bundle size changes +- Use dynamic imports for large components +- Optimize images and assets +- Remove unused dependencies + +### Runtime performance + +- Profile React components +- Optimize re-renders +- Use proper memoization +- Monitor memory usage + +## Security Guidelines + +- Sanitize all user inputs +- Validate data types and ranges +- Use proper escaping +- Check user permissions +- Keep dependencies updated +- Audit for vulnerabilities diff --git a/.cursor/rules/general-code-style.mdc b/.cursor/rules/general-code-style.mdc new file mode 100644 index 000000000..8cbc1fdbe --- /dev/null +++ b/.cursor/rules/general-code-style.mdc @@ -0,0 +1,38 @@ +--- +description: Code style rules for magic numbers, error codes, and self-documenting code +alwaysApply: true +--- + +# Code Style + +## Avoid Magic Numbers + +- Do not use unexplained hardcoded values ("magic numbers") in code or tests +- Define such values as named constants or use existing constants to clarify their meaning + +## Consistent Error Codes and Status + +- When returning error codes and HTTP status, always be very specific to use the correct code, not only 200 and 500 + +## Prioritize Style and Developer Experience + +- Always pay attention for clarity, maintainability, and ease of understanding, even if the underlying logic does not change +- Code style and developer experience are important for long-term project health + +## Self-Documented Code + +- Avoid adding comments that can be a constant or a well-named function +- Prefer existing or local functions for small, stateless, single-use behavior; extract helpers when they provide reuse + or meaningful separation +- Only add comments to explain "why" when it is truly not understandable from the code itself +- Do NOT add comments that explain "what" the code does (the code should be self-explanatory) +- Examples of unnecessary comments to avoid: + - `// Loop through items` (obvious from for loop) + - `// Check if user exists` (obvious from if statement) + - `// Return the result` (obvious from return statement) + - JSDoc comments that just repeat the function name or parameter names +- Examples of valuable comments to keep: + - Business logic explanations that aren't obvious from code + - Workarounds for bugs in third-party libraries + - Performance optimizations that might look odd without context + - Complex algorithms where the "why" matters more than the "what" diff --git a/.cursor/rules/typescript-method-typing.mdc b/.cursor/rules/typescript-method-typing.mdc new file mode 100644 index 000000000..0451e9595 --- /dev/null +++ b/.cursor/rules/typescript-method-typing.mdc @@ -0,0 +1,38 @@ +--- +description: Use typed parameter objects for functions with 3 or more parameters +globs: ["**/*.ts", "**/*.tsx"] +alwaysApply: false +--- + +# Method Parameter Typing + +## Functions with 3+ Parameters + +When a function or method has 3 or more parameters, use a type for better maintainability and type safety. + +**Avoid:** + +```typescript +function processData(param1: string, param2: number, param3: boolean, param4: string) { + // implementation +} +``` + +**Prefer:** + +```typescript +type ProcessDataArgs = { + param1: string; + param2: number; + param3: boolean; + param4: string; +}; + +function processData(args: ProcessDataArgs) { + // implementation +} +``` + +## Auto-Suggestion Rule + +When you see a function with 3 or more parameters, suggest refactoring to use a type for the parameters. diff --git a/.cursor/rules/wordpress.mdc b/.cursor/rules/wordpress.mdc new file mode 100644 index 000000000..bc4708413 --- /dev/null +++ b/.cursor/rules/wordpress.mdc @@ -0,0 +1,73 @@ +--- +description: WordPress and PHP best practices, coding standards, and key conventions +globs: ["**/*.php"] +alwaysApply: false +--- + +# WordPress + +You are an expert in WordPress, PHP, and related web development technologies. + +## Core Principles + +- Provide precise, technical PHP and WordPress examples +- Adhere to PHP and WordPress best practices for consistency and readability +- Use OOP for state, lifecycle, contracts, or reusable behavior; prefer existing or local functions for small, stateless, + single-use behavior +- Focus on code reusability through iteration and modularization, avoiding duplication +- Use descriptive and meaningful function, variable, and file names +- Directory naming conventions: lowercase with hyphens (e.g., wp-content/themes/my-theme) +- Use WordPress hooks (actions and filters) for extending functionality + +## PHP/WordPress Coding Practices + +- Utilize features of PHP 7.4+ (e.g., typed properties, arrow functions) where applicable +- Follow WordPress PHP coding standards throughout the codebase +- Prefer `declare(strict_types=1);` in appropriate new or already-strict code; do not add it as unrelated cleanup in + existing integration files +- Leverage core WordPress functions and APIs wherever possible +- Maintain WordPress theme and plugin directory structure and naming conventions +- Implement robust error handling: + - Use WordPress's built-in debug logging (WP_DEBUG_LOG) + - Implement custom error handlers if necessary + - Apply try-catch blocks for controlled exception handling +- Always use WordPress's built-in functions for data validation and sanitization +- Ensure secure form handling by verifying nonces in submissions +- For database interactions: + - Use WordPress's `$wpdb` abstraction layer + - Apply `prepare()` statements for all dynamic queries to prevent SQL injection + - Use the `dbDelta()` function for schema changes only after custom storage is justified under `AGENTS.md` + +## Dependencies + +- Ensure compatibility with the repository's minimum supported WordPress and PHP versions +- Use Composer for dependency management in advanced plugins or themes + +## WordPress Best Practices + +- Use child themes for customizations to preserve update compatibility +- Never modify core WordPress files — extend using hooks (actions and filters) +- Organize theme-specific functions within `functions.php` +- Use WordPress's user roles and capabilities for managing permissions +- Apply the transients API for caching data and optimizing performance +- Implement background processing tasks using `wp_cron()` for long-running operations +- Write unit tests using WordPress's built-in `WP_UnitTestCase` framework +- Follow best practices for internationalization (i18n) by using WordPress localization functions +- Apply proper security practices such as nonce verification, input sanitization, and data escaping +- Manage scripts and styles by using `wp_enqueue_script()` and `wp_enqueue_style()` +- Use custom post types and taxonomies when necessary to extend WordPress functionality +- Store configuration data securely using WordPress's options API +- Implement pagination effectively with functions like `paginate_links()` + +## Key Conventions + +1. Follow WordPress's plugin API to extend functionality in a modular and scalable manner +2. Use WordPress's template hierarchy when developing themes to ensure flexibility +3. Apply WordPress's built-in functions for data sanitization and validation to secure user inputs +4. Implement WordPress's template tags and conditional tags in themes for dynamic content handling +5. For custom queries, use `$wpdb` or `WP_Query` for database interactions +6. Use WordPress's authentication and authorization mechanisms for secure access control +7. For AJAX requests, use `admin-ajax.php` or the WordPress REST API for handling backend requests +8. Always apply WordPress's hook system (actions and filters) for extensible and modular code +9. Implement database operations using transactional functions where needed +10. Schedule tasks using WordPress's WP_Cron API for automated workflows diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 000000000..7e617c2c7 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,3 @@ +#!/usr/bin/env sh + +npx --no-install lint-staged diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..ff61c8fe1 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,71 @@ +# Code Snippets — Copilot Instructions + +> **Project standards** live in `AGENTS.md` at the repository root. Read that file in full before performing +> any task. This file contains only GitHub Copilot-specific configuration. + +--- + +## MCP Tools — Context7 and Chrome DevTools + +When working on tasks that involve library documentation, API references, or browser debugging, use the available +MCP tools for better, up-to-date results. + +### Context7 (`mcp_context7_*`) + +Use Context7 to look up accurate, version-specific documentation for any library used in this project +(WordPress, React, TypeScript, CodeMirror, Playwright, etc.) rather than relying on training-data knowledge. + +**When to use:** + +- Looking up WordPress hook signatures, REST API schemas, or WP component props. +- Confirming TypeScript / React API details. +- Checking Playwright test API or assertion methods. +- Any time you are about to write code that depends on a third-party API you are not 100% certain about. + +**How to use:** + +1. Call `mcp_context7_resolve-library-id` with the library name to get its Context7 ID. +2. Call `mcp_context7_query-docs` with the resolved ID and a specific question. + +**If Context7 is not installed:** + +Ask the user: *"The Context7 MCP server is not available. Would you like to set it up? It gives me access to +up-to-date library docs. Install via: `npx -y @upstash/context7-mcp@latest` and add it to your MCP config."* + +### Chrome DevTools (`mcp_chrome-devtoo_*`) + +Use Chrome DevTools MCP for any task involving the browser UI: inspecting rendered admin pages, debugging +JavaScript errors, validating accessibility (contrast, ARIA), checking network requests, or running +performance traces. + +**When to use:** + +- Debugging a UI regression or layout issue in the WP admin. +- Verifying that a REST API call returns the expected payload. +- Checking console errors after a JS change. +- Validating accessibility of admin UI changes (colour contrast, keyboard focus, ARIA). +- Running a Lighthouse / performance trace on a frontend page. + +**How to use:** + +Use the `mcp_chrome-devtoo_*` family of tools — take a snapshot, navigate a page, inspect network requests, +evaluate scripts, or start a performance trace. + +**If Chrome DevTools MCP is not installed:** + +Ask the user: *"The Chrome DevTools MCP server is not available. Would you like to set it up? It lets me +inspect the live browser state. Install via the VS Code MCP extension or add `@modelcontextprotocol/server-chrome` +to your MCP config."* + +--- + +## Path-Specific Instructions + +More targeted rules live in `.github/instructions/` as `*.instructions.md` files: + +| File | Scope | +|---|---| +| `code-review.instructions.md` | Copilot Code Review (CCR) — review standards (excludes coding agent) | + +Add new `*.instructions.md` files here for language- or area-specific rules (e.g., `php.instructions.md`, +`react.instructions.md`). diff --git a/.github/instructions/code-review.instructions.md b/.github/instructions/code-review.instructions.md new file mode 100644 index 000000000..b18bf225e --- /dev/null +++ b/.github/instructions/code-review.instructions.md @@ -0,0 +1,166 @@ +--- +applyTo: "**/*" +excludeAgent: "coding-agent" +description: "Repository code-review standards for Copilot Code Review (CCR). Follow the severity model, security checks, WP conventions, i18n, accessibility, tests, and suggested remediation steps." +--- + +# Code Review Standards for Code Snippets + +## Purpose + +These instructions guide Copilot code review across all files in the Code Snippets WordPress plugin repository. +Use these rules when reviewing pull requests to produce specific, actionable feedback. + +Severity labels used in this file: +- **MUST** — Flag as a blocking issue; must be resolved before merge. +- **SHOULD** — Flag as a recommendation; resolve before merge unless a risk-aware rationale is provided. + +## Practical Reviewer Checks + +- Don't silently widen types or drop generics when refactoring. If typing gets weaker, require a clear reason. [MUST] +- In namespaced PHP, call WordPress globals with `\function_name()` and do not assume pluggable/core functions exist on very early execution paths. If a callback can run during early bootstrap, guard availability appropriately. [MUST] +- Ensure hook callbacks that respond to options/actions are tightly gated to the intended option/action. Be suspicious of inverted or overly broad conditionals that can be triggered by other plugins. [MUST] +- Avoid no-op abstractions (pass-through helpers, one-liner wrappers) unless they materially improve readability, reuse, or testability. [SHOULD] +- Inline single-use extractions that do not clarify intent (local functions/variables used once). [SHOULD] +- Prefer `undefined` for absent optional values in TypeScript/React unless `null` has explicit semantics in that API. [SHOULD] +- For conditional class names, prefer the repository `classnames.classnames` helper over manual array filtering and joining. [SHOULD] +- Prefer JSX for React markup. If a hook/util needs to render elements, suggest moving the markup into a `.tsx` component instead of using `createElement` in a `.ts` file. [SHOULD] +- For simple key-to-value parsing/transforms, prefer a literal object/record map over a loop + `switch` when it improves clarity. [SHOULD] +- Do not stack redundant `catch` blocks (e.g., `ParseError` plus `Throwable`) unless the handlers differ. [SHOULD] +- When a screen is React-driven, question heavy PHP view logic. If PHP is used due to WordPress admin primitives (e.g., Screen Options, non-REST file streaming), require a short rationale. [SHOULD] + +## Scope and Diff Hygiene + +- Flag PRs that mix behavior changes with refactors, renames, or formatting-only edits. Each change should be single-purpose. [MUST] +- Flag renamed identifiers, files, UI labels, or data keys that lack a concrete justification in the commit message or comments. [MUST] +- Look for indentation or formatting regressions in PHP, JS/TS, CSS, or Markdown. [MUST] +- Identify redundant changes that do not alter behavior (e.g., pointless `printf`/wrapper edits). [MUST] +- Check whether large changes could be split into smaller reviewable units (UI refactor vs logic vs data model). [SHOULD] +- Flag "drive-by" cleanup outside the area being changed. [SHOULD] + +## Correctness, Resilience, and Types + +- Verify all external/variable inputs (request parameters, shortcode content, option values, API responses) are validated before use. [MUST] +- Check that code does not assume array structure; verify the expected key/shape exists before parsing or indexing. [MUST] +- Flag type inconsistencies (e.g., storing an "int-like" state as a `string` when it is treated as an `int`). [MUST] +- Identify redundant checks and tautologies (e.g., checking a condition already guaranteed by casting or a documented union type). [MUST] +- Look for inverted `if` chains that reduce readability; prefer early returns and simpler control flow. [SHOULD] +- Check that conditionals use braces; flag unbraced single-line `if` statements. Prefer `switch` for multi-branch dispatch logic. [SHOULD] +- Flag `if/else` chains that could be simplified as "default then override" to reduce nesting (e.g., initialize `$primary_button` then adjust fields). [SHOULD] +- Flag mutation of list arrays via numeric offsets like `$buttons[0]`; prefer named local variables or associative keys. [SHOULD] +- Identify repeated passes over the same data that could be consolidated (e.g., multiple `array_filter` iterations). [SHOULD] + +## WordPress Conventions and Internal APIs + +- Verify user-facing behavior and labels follow WordPress conventions and terminology (e.g., "Trash" and "Undo" patterns for reversible deletion). [MUST] +- Flag hand-built admin page URLs; use platform/internal URL builders and constants instead. [MUST] +- Flag use of fragile constants for environment/variant checks; use established internal APIs when a canonical method exists. [MUST] +- Check for vendored assets that duplicate WordPress core functionality (e.g., dashicons, CodeMirror/linting scripts). Prefer core assets. [SHOULD] +- Verify changes do not silently degrade when other plugins load conflicting assets. [SHOULD] + +## Extensibility: Hooks, Filters, and Configuration + +- Flag changes to default behavior that lack a non-UI escape hatch (filter/hook) or explicit configuration surface, when the behavior may be preference-driven. [MUST] +- Flag hooks/actions added without a clear, long-term extension need. [MUST] +- Check whether filters are preferred over new UI options for niche workflows (unless discoverability is critical or support burden demands UI). [SHOULD] +- Flag "too-early" checks outside the runtime context they depend on; prefer evaluating conditions inside the actual hook callback. [SHOULD] + +## Security and Trust Boundaries + +- Flag dynamic execution patterns (e.g., `eval`, `create_function`) that increase security risk or trigger security-scanner false positives. Require a documented trust model if used. [MUST] +- Flag remote code or remotely sourced content executed without an explicit, robust trust model. [MUST] +- Verify escaping/sanitization is applied at the correct output boundary (attribute context, HTML context, JS context). Flag "random escaping" that breaks dependent scripts or UI. [MUST] +- Check that shared UI renderers (base classes/helpers/templates) escape/sanitize their own inputs rather than relying on callers returning pre-escaped strings. [MUST] +- Check every `target="_blank"` link for `rel="noopener noreferrer"`. Flag any missing instance unless a documented exception exists. [MUST] +- Verify all state-changing requests (AJAX endpoints, form submissions, REST/action handlers) require and verify a WordPress nonce server-side. [MUST] +- Verify capability checks are present alongside nonce checks for state-changing endpoints. [MUST] +- Check that all external and remote inputs are validated server-side; flag reliance on client-side validation alone. [MUST] +- Flag features that fetch or render remote content without a documented threat assessment (attack surface, trust model, mitigations). [MUST] +- Check whether output helpers could be refactored to own correct escaping, rather than scattering escaping at call sites. [SHOULD] +- Flag large opaque blobs (e.g., encoded payloads) shipped into contexts where security tools may flag them. [SHOULD] + +## Internationalization (i18n) + +- Verify correct translation functions are used for each string type and context (e.g., context-aware functions when the string is ambiguous or partial). [MUST] +- Flag HTML markup placed inside translated strings unless there is a strong, documented reason. [MUST] +- Flag access to translated labels before translation files are loaded. [MUST] +- Check for concatenation of translated fragments; prefer translating full sentences/phrases. [SHOULD] + +## UX and Accessibility + +- Flag removal of accessibility-relevant context that lacks an equivalent or better replacement affordance. [MUST] +- When `title` attributes are replaced with ARIA attributes, verify the change does not regress non-assistive UX (e.g., hover help) or reduce meaning for screen readers. [MUST] +- Verify user settings that disable or hide UI elements are respected (e.g., do not render upsell/promotions when the "hide" setting is enabled). [MUST] +- Check that dismissible notices actually persist dismissal for the users who can see them (capability checks, AJAX handlers, and nonces must align). [MUST] +- Check that the change follows WordPress admin interaction patterns (undo/trash flows, notices behavior, iconography). [SHOULD] +- Verify built-in icon sets and established admin styling conventions are used where applicable. [SHOULD] +- For UI changes: verify all interactive elements are keyboard-reachable and operable. [MUST] +- For UI changes: check semantic markup and ARIA usage for screen reader support. [MUST] +- For UI changes: verify text and UI elements meet WCAG AA color contrast ratios. [MUST] +- For UI changes: check for visible and logical focus order on interactive elements. [MUST] + +## Architecture and Code Organization + +- Flag new classes, namespaces, or files that lack a clear need, or redundant wrapper classes. [MUST] +- Flag complex commands or subsystems stuffed into unrelated classes; keep concerns separated. [MUST] +- Flag manual loading of class files that are already covered by Composer autoloading. [MUST] +- In `src/php/class-plugin.php`, verify Composer autoloading is used; flag `require_once` unless the file cannot be loaded via Composer. [MUST] +- Flag direct-access guards or runtime checks in individual class files; keep bootstrapping and gating at entry points. [SHOULD] +- Check that class names, file names, and directory naming are consistent. [SHOULD] +- Check whether complex logic could be extracted into small, named methods with single responsibilities. [SHOULD] +- Flag hook registration split into extra methods without clear value; prefer co-locating registration with construction/bootstrap. [SHOULD] + +## Dependencies, Assets, and Tooling + +- Flag dependencies that are not needed for the actual implementation. [MUST] +- Flag bundled assets that duplicate platform-provided equivalents. If bundling is unavoidable, note the maintenance obligation (updates, conflicts, compatibility). [MUST] +- Verify the repository's linting/formatting conventions are followed (e.g., logical CSS properties, consistent formatting). [SHOULD] +- Check for manual workarounds that could be replaced by framework-native patterns in React/TS. [SHOULD] + +## Persistence, Cleanup, and Uninstall + +- Verify any new persistent option/setting is accounted for in uninstall/cleanup paths. [MUST] +- Flag storage of empty/default data that could be safely removed to reduce configuration drift. [MUST] +- Verify installation/uninstallation behavior is coherent and repeatable (fresh installs vs reinstalls vs data-preserving removals). [MUST] +- Check whether WordPress API conveniences (e.g., `get_option` default values) could reduce conditional noise and edge cases. [SHOULD] +- For new DB schema changes or options: verify migration and rollback paths exist. [MUST] +- For new persistent options: verify an uninstall path removes them. [MUST] + +## JavaScript/React State and Async Behavior + +- Verify types referenced by exported functions and public APIs are also exported. Flag unexported internal shapes leaked through public APIs. [MUST] +- Check that the correct state primitive (`useState` vs `useRef`) is used based on rendering and lifecycle needs. Flag refs used as a state substitute. [MUST] +- Look for concurrent async calls and race conditions; flag state updates that can interleave unpredictably. [MUST] +- Check whether patterns degrade gracefully on partial failure (e.g., one failed async request should not fail the entire operation when partial results are acceptable). [SHOULD] +- Flag repeated state updates in loops; prefer a single update at the end. [SHOULD] + +## Tests, Compatibility, and Release Hygiene + +- Verify changes are compatible with the minimum supported WordPress/PHP versions (especially type-related behavior). [MUST] +- Flag automation/scripts that rely on brittle timing/waiting; they must be deterministic. [MUST] +- Verify changelog/readme formatting remains valid (Markdown and `readme.txt` heading/list spacing must not break rendering). [MUST] +- When the repo ships generated artifacts (Composer autoload/classmaps, built `dist` assets), verify they are regenerated and include newly added files/classes. [MUST] +- Check whether tests cover the minimum supported platform versions, not just "latest". [SHOULD] +- Flag unverified bug fixes; require additional diagnostics (logs, error output, minimal reproduction) rather than guessing. [SHOULD] +- Verify unit tests are included for logic changes, covering edge cases and error paths. [MUST] +- Verify integration tests are included when cross-cutting concerns (DB, REST, hooks) or subsystem interactions change. [MUST] +- Verify E2E tests are included for changes affecting critical user flows (snippet creation, execution, editor workflows); check for updated Playwright specs. [MUST] +- Check for tests asserting compatibility with minimum-supported WordPress/PHP versions when behavior differs by version. [SHOULD] + +## Operational Safety and Recovery + +- For changes affecting snippet execution: verify recovery paths (safe mode flows) are preserved. Flag changes that turn recoverable failures into unrecoverable lockouts. [MUST] +- Verify documented safe-mode activation mechanisms are not broken. [MUST] +- Flag reliance on "deactivate the plugin" as a recovery mechanism; the product must support safe recovery without forcing users to lose state. [MUST] +- For changes affecting snippet execution: check for a recovery and verification plan. [MUST] +- Check that proactive validation (syntax checks, duplicate identifier checks) does not produce false positives for legitimate WordPress patterns (e.g., pluggable functions). [SHOULD] +- Verify example snippet code (docs, help text, UI templates) uses anonymous functions and collision-resistant patterns. [SHOULD] + +## Backwards Compatibility and Deprecation + +- For any public API change (hooks/filters, public methods, REST endpoints, option names): verify the PR description documents compatibility impact and deprecation plan. [MUST] +- Verify deprecation shims with clear warnings and compatibility tests are provided when removing or changing public APIs. [MUST] +- Check for migration guidance and a compatibility matrix listing affected versions and suggested mitigations. [MUST] +- Verify automated tests exercise deprecated paths to ensure compatibility remains intact until removal. [MUST] +- Check for a code example showing how to migrate away from the deprecated API. [SHOULD] +- Check that user-facing deprecation messages are included where appropriate. [SHOULD] diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000..70029ba91 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,88 @@ +name: "(Lint): CSS, JS, PHP" + +on: + pull_request: + types: [opened, synchronize, reopened, labeled] + push: + branches: + - 'core' + - 'pro' + paths-ignore: + - '**.md' + - '**.txt' + - '.gitignore' + - 'docs/**' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: lint-${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + lint: + name: stylelint, eslint, phpcs + runs-on: ubuntu-22.04 + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .node-version + cache: 'npm' + + - name: Install npm dependencies + run: npm ci + + - name: Set up PHP + uses: codesnippetspro/setup-php@v2 + with: + php-version: '8.2' + + - name: Compute dependency hash + id: deps-hash + run: | + set -euo pipefail + tmpfile=$(mktemp) + if [ -f "src/composer.lock" ]; then + cat "src/composer.lock" >> "$tmpfile" + fi + if [ -s "$tmpfile" ]; then + deps_hash=$(shasum -a 1 "$tmpfile" | awk '{print $1}' | cut -c1-8) + else + deps_hash=$(echo "${GITHUB_SHA:-unknown}" | cut -c1-8) + fi + echo "deps_hash=$deps_hash" >> "$GITHUB_OUTPUT" + + - name: Get Composer cache + id: composer-cache + uses: actions/cache/restore@v4 + with: + path: src/vendor + key: ${{ runner.os }}-php-8.2-composer-${{ steps.deps-hash.outputs.deps_hash }} + restore-keys: | + ${{ runner.os }}-php-8.2-composer- + + - name: Install Composer dependencies + if: steps.composer-cache.outputs.cache-hit != 'true' + run: composer install -d src --no-progress --prefer-dist --optimize-autoloader + + - name: Save Composer cache + if: steps.composer-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: src/vendor + key: ${{ runner.os }}-php-8.2-composer-${{ steps.deps-hash.outputs.deps_hash }} + + - name: Run Stylelint + run: npm run lint:styles + + - name: Run ESLint + run: npm run lint:js + + - name: Run PHP CS + run: npm run lint:php diff --git a/.github/workflows/phpunit-test.yml b/.github/workflows/phpunit-test.yml new file mode 100644 index 000000000..d2789e82c --- /dev/null +++ b/.github/workflows/phpunit-test.yml @@ -0,0 +1,124 @@ +name: PHPUnit Test Runner + +on: + workflow_call: + inputs: + php-version: + required: false + type: string + default: '8.2' + description: 'PHP version to test against' + +jobs: + phpunit-test: + name: PHPUnit tests (PHP ${{ inputs.php-version }}) + runs-on: ubuntu-22.04 + env: + WP_CORE_DIR: /tmp/wordpress + WP_TESTS_DIR: /tmp/wordpress-tests-lib + + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: wordpress_test + ports: + - 3306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Set up PHP + uses: codesnippetspro/setup-php@v2 + with: + php-version: ${{ inputs.php-version }} + + - name: Compute dependency hash + id: deps-hash + run: | + set -euo pipefail + tmpfile=$(mktemp) + if [ -f "src/composer.lock" ]; then + cat "src/composer.lock" >> "$tmpfile" + fi + if [ -f "src/composer.json" ]; then + cat "src/composer.json" >> "$tmpfile" + fi + if [ -s "$tmpfile" ]; then + deps_hash=$(shasum -a 1 "$tmpfile" | awk '{print $1}' | cut -c1-8) + else + deps_hash=$(echo "${GITHUB_SHA:-unknown}" | cut -c1-8) + fi + echo "deps_hash=$deps_hash" >> "$GITHUB_OUTPUT" + + - name: Get Composer cache + id: composer-cache + uses: actions/cache/restore@v4 + with: + path: src/vendor + key: ${{ runner.os }}-php-${{ inputs.php-version }}-composer-${{ steps.deps-hash.outputs.deps_hash }} + restore-keys: | + ${{ runner.os }}-php-${{ inputs.php-version }}-composer- + + - name: Install Composer dependencies + if: steps.composer-cache.outputs.cache-hit != 'true' + run: composer install -d src --no-progress --prefer-dist --optimize-autoloader + + - name: Save Composer cache + if: steps.composer-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: src/vendor + key: ${{ runner.os }}-php-${{ inputs.php-version }}-composer-${{ steps.deps-hash.outputs.deps_hash }} + + - name: Resolve WordPress version metadata + id: wp-meta + run: | + set -euo pipefail + wp_version=$(curl -s http://api.wordpress.org/core/version-check/1.7/ | grep -o '"version":"[^"]*' | head -1 | sed 's/"version":"//') + if [ -z "$wp_version" ]; then + wp_version="latest-unknown" + fi + echo "wp_version=$wp_version" >> "$GITHUB_OUTPUT" + + - name: Get WordPress test suite cache + id: wp-tests-cache + uses: actions/cache/restore@v4 + with: + path: | + ${{ env.WP_CORE_DIR }} + ${{ env.WP_TESTS_DIR }} + key: ${{ runner.os }}-php-${{ inputs.php-version }}-wp-tests-${{ hashFiles('tests/install-wp-tests.sh') }}-wp-${{ steps.wp-meta.outputs.wp_version }} + + - name: Install WordPress test suite + run: | + bash scripts/install-wp-tests.sh wordpress_test root root 127.0.0.1:3306 latest true + + - name: Save WordPress test suite cache + if: steps.wp-tests-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ${{ env.WP_CORE_DIR }} + ${{ env.WP_TESTS_DIR }} + key: ${{ steps.wp-tests-cache.outputs.cache-primary-key }} + + - name: Run PHPUnit tests + run: | + set -euo pipefail + mkdir -p test-results/phpunit + cd src + vendor/bin/phpunit -c ../phpunit.xml --testdox --log-junit ../test-results/phpunit/phpunit-${{ inputs.php-version }}.xml 2>&1 | tee ../test-results/phpunit/phpunit-${{ inputs.php-version }}.log + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: phpunit-test-results-php-${{ inputs.php-version }} + path: | + src/.phpunit.result.cache + test-results/phpunit/ + if-no-files-found: ignore + retention-days: 2 diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml new file mode 100644 index 000000000..e6d550d27 --- /dev/null +++ b/.github/workflows/phpunit.yml @@ -0,0 +1,275 @@ +name: "(Test): PHPUnit" + +on: + pull_request: + types: [labeled, synchronize, opened, reopened] + push: + branches: + - 'core' + - 'pro' + - 'core-beta' + - 'pro-beta' + paths-ignore: + - '**.md' + - '**.txt' + - '.gitignore' + - 'docs/**' + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + actions: read + +concurrency: + group: phpunit-${{ github.event_name }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + phpunit: + if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-tests') + strategy: + fail-fast: false + matrix: + php-version: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] + uses: ./.github/workflows/phpunit-test.yml + with: + php-version: ${{ matrix.php-version }} + + test-result: + needs: [phpunit] + if: always() && needs.phpunit.result != 'skipped' + runs-on: ubuntu-22.04 + name: PHPUnit - Test Results Summary + permissions: + pull-requests: write + issues: write + steps: + - name: Test status summary + run: | + echo "PHPUnit matrix result: ${{ needs.phpunit.result }}" + + - name: Delete previous PR failure comment on success + if: github.event_name == 'pull_request' && needs.phpunit.result == 'success' + uses: actions/github-script@v7 + with: + script: | + const marker = ''; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + per_page: 100, + }); + + const existing = comments.filter(c => typeof c.body === 'string' && c.body.startsWith(marker)); + + for (const comment of existing) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: comment.id, + }); + } + + - name: Download PHPUnit artifacts + if: github.event_name == 'pull_request' && needs.phpunit.result == 'failure' + uses: actions/download-artifact@v5 + with: + pattern: phpunit-test-results-php-* + path: phpunit-artifacts + merge-multiple: true + + - name: Build distinct error summary + if: github.event_name == 'pull_request' && needs.phpunit.result == 'failure' + run: | + set -euo pipefail + python3 - <<'PY' + import glob + import os + import re + import xml.etree.ElementTree as ET + from collections import defaultdict + + artifacts_dir = 'phpunit-artifacts' + + def version_from_path(path: str) -> str: + base = os.path.basename(path) + m = re.search(r'phpunit-([0-9]+\.[0-9]+)\.xml$', base) + if m: + return m.group(1) + m = re.search(r'phpunit-([0-9]+\.[0-9]+)\.log$', base) + if m: + return m.group(1) + return 'unknown' + + def add_entry(grouped, key, version, message): + if not message.strip(): + return + grouped[key]['versions'].add(version) + # Keep the first representative message we see for this key. + if not grouped[key]['message']: + grouped[key]['message'] = message.strip() + + grouped = defaultdict(lambda: {'versions': set(), 'message': ''}) + versions_seen = set() + + # Prefer JUnit XML when present. + for xml_path in sorted(glob.glob(os.path.join(artifacts_dir, '**', '*.xml'), recursive=True)): + version = version_from_path(xml_path) + versions_seen.add(version) + try: + root = ET.parse(xml_path).getroot() + except Exception: + continue + + for testcase in root.iter('testcase'): + for tag in ('error', 'failure'): + for node in testcase.findall(tag): + etype = (node.attrib.get('type') or tag).strip() + msg = (node.attrib.get('message') or '').strip() + details = (node.text or '').strip() + combined = f"{etype}: {msg}".strip(': ') + if details: + combined = combined + "\n" + details + + testcase_id = ( + (testcase.attrib.get('classname') or '').strip() + + '::' + + (testcase.attrib.get('name') or '').strip() + ).strip(':') + + extracted = '' + if not msg and details: + for line in details.splitlines(): + line = line.strip() + if line.startswith('CI demo:'): + extracted = line + break + if not extracted: + extracted = details.splitlines()[0].strip() + + # Dedupe key: prefer explicit message; otherwise use extracted details + testcase id. + key_parts = [etype] + if msg: + key_parts.append(msg) + elif extracted: + key_parts.append(extracted) + if testcase_id: + key_parts.append(testcase_id) + key = "\n".join([p for p in key_parts if p]).strip() or (combined.splitlines()[0] if combined else 'unknown') + add_entry(grouped, key, version, combined) + + # Fallback: scan logs for fatals if XML missing. + fatal_re = re.compile(r'^(PHP\s+Fatal\s+error:.*|Fatal\s+error:.*)$', re.MULTILINE) + for log_path in sorted(glob.glob(os.path.join(artifacts_dir, '**', '*.log'), recursive=True)): + version = version_from_path(log_path) + versions_seen.add(version) + try: + log = open(log_path, 'r', encoding='utf-8', errors='replace').read() + except Exception: + continue + m = fatal_re.search(log) + if m: + msg = m.group(1).strip() + key = 'Fatal error\n' + msg + add_entry(grouped, key, version, msg) + + versions = sorted(v for v in versions_seen if v != 'unknown') + + def versions_label(affected): + affected = sorted(v for v in affected if v != 'unknown') + if versions and affected == versions: + return 'all' + return ', '.join(affected) if affected else 'unknown' + + items = sorted(grouped.items(), key=lambda kv: (-len(kv[1]['versions']), kv[0])) + blocks = [] + for idx, (key, info) in enumerate(items): + affected = versions_label(info['versions']) + message = info['message'] + block = ( + "-----\n" + f"Affected PHP version: `{affected}`\n" + "```php\n" + f"{message}\n" + "```" + ) + if idx == len(items) - 1: + block += "\n-----" + blocks.append(block) + + if blocks: + details = "\n\n".join(blocks) + else: + details = "No PHPUnit error details could be parsed from artifacts." + + md = "\n".join([ + "
", + "See all PHPUnit errors (click to expand)", + "", + details, + "", + "
", + "", + ]) + + with open('phpunit-errors.md', 'w', encoding='utf-8') as f: + f.write(md) + PY + + - name: Post PR comment on failure + if: github.event_name == 'pull_request' && needs.phpunit.result == 'failure' + uses: actions/github-script@v7 + with: + script: | + const marker = ''; + + const fs = require('fs'); + let details = ''; + try { + details = fs.readFileSync('phpunit-errors.md', 'utf8').trim(); + } catch (e) { + details = ''; + } + + const body = [ + marker, + '## PHPUnit Test Failure', + '', + `One or more PHP version targets failed in [this workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).`, + '', + details || '_No parsed error details found._', + '', + 'Please review the failing jobs and fix the issues before merging.', + ].join('\n'); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + per_page: 100, + }); + + const existing = comments.filter(c => typeof c.body === 'string' && c.body.startsWith(marker)); + + for (const comment of existing) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: comment.id, + }); + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + + - name: Check overall status + if: needs.phpunit.result != 'success' + run: exit 1 + diff --git a/.github/workflows/playwright-test.yml b/.github/workflows/playwright-test.yml index 59f5b4a91..a59a3d8f8 100644 --- a/.github/workflows/playwright-test.yml +++ b/.github/workflows/playwright-test.yml @@ -45,13 +45,20 @@ jobs: WORDPRESS_DEBUG: 1 WORDPRESS_CONFIG_EXTRA: | define( 'FS_METHOD', 'direct' ); + define( 'WP_DEBUG', true ); define( 'WP_DEBUG_LOG', true ); - define( 'WP_DEBUG_DISPLAY', false ); + define( 'WP_DEBUG_DISPLAY', true ); + define( 'WP_DISABLE_FATAL_ERROR_HANDLER', true ); define( 'SCRIPT_DEBUG', true ); define( 'WP_ENVIRONMENT_TYPE', 'local' ); + @ini_set( 'display_errors', '1' ); + @ini_set( 'display_startup_errors', '1' ); + @ini_set( 'log_errors', '1' ); + @ini_set( 'error_reporting', (string) E_ALL ); ports: - 8888:80 steps: + - name: Checkout source code uses: actions/checkout@v4 @@ -88,25 +95,29 @@ jobs: uses: actions/cache/restore@v4 with: path: | - node_modules src/vendor - key: ${{ runner.os }}-${{ inputs.test-mode }}-deps-${{ steps.deps-hash.outputs.deps_hash }}-${{ github.run_id }}-${{ github.job }} + node_modules + key: ${{ runner.os }}-${{ inputs.test-mode }}-deps-${{ steps.deps-hash.outputs.deps_hash }} restore-keys: | ${{ runner.os }}-${{ inputs.test-mode }}-deps-${{ steps.deps-hash.outputs.deps_hash }}- + ${{ runner.os }}-${{ inputs.test-mode }}-deps- - name: Install workflow dependencies - if: steps.deps-cache.outputs.cache-matched-key == '' - run: npm run prepare-environment:ci && npm run bundle + if: steps.deps-cache.outputs.cache-hit != 'true' + run: npm run prepare-environment:ci + + - name: Build plugin assets + run: npm run bundle - name: Save vendor and node_modules cache - if: steps.deps-cache.outputs.cache-matched-key == '' + if: steps.deps-cache.outputs.cache-hit != 'true' uses: actions/cache/save@v4 with: path: | src/vendor node_modules - key: ${{ steps.deps-cache.outputs.cache-primary-key }} - + key: ${{ steps.deps-cache.outputs.cache-primary-key }}-${{ github.run_id }}-${{ github.job }} + - name: Wait for WordPress to be reachable run: | for i in $(seq 1 60); do @@ -114,7 +125,7 @@ jobs: echo "WordPress is reachable." exit 0 fi - echo "Waiting for WordPress... ($i/60)" + echo "Waiting for WordPress… ($i/60)" sleep 2 done @@ -229,8 +240,32 @@ jobs: WP_E2E_WPCLI_URL: http://localhost:8888 WP_E2E_WP_CONTAINER: ${{ job.services.wordpress.id }} WP_E2E_MULTISITE_MODE: ${{ inputs.multisite }} - run: npm run test:playwright -- --project=${{ inputs.project-name }} - + run: | + set -euo pipefail + mkdir -p test-results/ci + suffix="${{ inputs.project-name }}${{ inputs.multisite && '-multisite' || '' }}" + : > "test-results/ci/playwright-${suffix}.log" + npm run test:playwright -- --project=${{ inputs.project-name }} 2>&1 | tee -a "test-results/ci/playwright-${suffix}.log" + + - name: Normalize Playwright report filenames + if: always() + run: | + set -euo pipefail + mkdir -p test-results/ci + suffix="${{ inputs.project-name }}${{ inputs.multisite && '-multisite' || '' }}" + + if [ -f test-results/results.xml ]; then + mv test-results/results.xml "test-results/ci/results-${suffix}.xml" + elif [ -f tests/playwright/test-results/results.xml ]; then + mv tests/playwright/test-results/results.xml "test-results/ci/results-${suffix}.xml" + fi + + if [ -f test-results/results.json ]; then + mv test-results/results.json "test-results/ci/results-${suffix}.json" + elif [ -f tests/playwright/test-results/results.json ]; then + mv tests/playwright/test-results/results.json "test-results/ci/results-${suffix}.json" + fi + - name: Print WordPress logs on failure if: failure() run: | @@ -241,7 +276,7 @@ jobs: - uses: actions/upload-artifact@v4 if: always() with: - name: playwright-test-results-${{ inputs.test-mode }} + name: playwright-test-results-${{ inputs.test-mode }}-${{ inputs.project-name }}${{ inputs.multisite && '-multisite' || '' }} path: test-results/ if-no-files-found: ignore retention-days: 2 diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 51f39bd4d..cf36bdff0 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -7,6 +7,8 @@ on: branches: - 'core' - 'pro' + - 'core-beta' + - 'pro-beta' paths-ignore: - '**.md' - '**.txt' @@ -15,8 +17,8 @@ on: workflow_dispatch: permissions: - contents: write - pull-requests: write + contents: read + pull-requests: read actions: read concurrency: @@ -45,12 +47,289 @@ jobs: if: always() && (needs.playwright-default.result != 'skipped' || needs.playwright-file-based-execution.result != 'skipped') runs-on: ubuntu-22.04 name: Playwright - Test Results Summary + permissions: + pull-requests: write + issues: write steps: - name: Test status summary run: | echo "Default Mode: ${{ needs.playwright-default.result }}" echo "File-based Execution: ${{ needs.playwright-file-based-execution.result }}" + - name: Delete previous PR failure comment on success + if: github.event_name == 'pull_request' && needs.playwright-default.result == 'success' && needs.playwright-file-based-execution.result == 'success' + uses: actions/github-script@v7 + with: + script: | + const marker = ''; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + per_page: 100, + }); + + const existing = comments.filter(c => typeof c.body === 'string' && c.body.startsWith(marker)); + + for (const comment of existing) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: comment.id, + }); + } + + - name: Download Playwright artifacts + if: github.event_name == 'pull_request' && (needs.playwright-default.result == 'failure' || needs.playwright-file-based-execution.result == 'failure') + uses: actions/download-artifact@v5 + with: + pattern: playwright-test-results-* + path: playwright-artifacts + merge-multiple: true + + - name: Build distinct error summary + if: github.event_name == 'pull_request' && (needs.playwright-default.result == 'failure' || needs.playwright-file-based-execution.result == 'failure') + run: | + set -euo pipefail + python3 - <<'PY' + import glob + import json + import os + import re + import xml.etree.ElementTree as ET + from collections import defaultdict + + artifacts_dir = 'playwright-artifacts' + + def target_from_path(path: str) -> str: + base = os.path.basename(path) + m = re.search(r'(?:results|playwright)-(.+)\.(?:xml|json|log)$', base) + if m: + return m.group(1) + return 'unknown' + + def add_entry(grouped, key, target, message): + if not message.strip(): + return + grouped[key]['targets'].add(target) + if not grouped[key]['message']: + grouped[key]['message'] = message.strip() + + grouped = defaultdict(lambda: {'targets': set(), 'message': ''}) + targets_seen = set() + + def safe_str(val) -> str: + if val is None: + return '' + if isinstance(val, str): + return val + try: + return str(val) + except Exception: + return '' + + def walk_suite(suite, title_path): + # Playwright JSON reporter structure: suites -> suites/specs. + for child in suite.get('suites', []) or []: + walk_suite(child, title_path + [safe_str(child.get('title'))]) + + for spec in suite.get('specs', []) or []: + spec_title = safe_str(spec.get('title')) + for test in spec.get('tests', []) or []: + test_title = safe_str(test.get('title')) + for result in test.get('results', []) or []: + status = safe_str(result.get('status')) + err = result.get('error') or {} + err_msg = safe_str(err.get('message')) + err_stack = safe_str(err.get('stack')) + + if status != 'failed' and not err_msg and not err_stack: + continue + + name = ' > '.join([p for p in (title_path + [spec_title, test_title]) if p]) + combined = name + if err_msg: + combined += "\n" + err_msg + if err_stack: + combined += "\n" + err_stack + yield combined.strip() + + # 1) Parse JSON reporter output (most reliable for Playwright failures). + for json_path in sorted(glob.glob(os.path.join(artifacts_dir, '**', '*.json'), recursive=True)): + target = target_from_path(json_path) + targets_seen.add(target) + try: + data = json.loads(open(json_path, 'r', encoding='utf-8', errors='replace').read()) + except Exception: + continue + + suites = data.get('suites', []) if isinstance(data, dict) else [] + if not suites: + continue + + for suite in suites: + for combined in walk_suite(suite, [safe_str(suite.get('title'))]): + # Dedupe key: first line of error message + test name line. + first = combined.splitlines()[0].strip() if combined else 'unknown' + key = first + add_entry(grouped, key, target, combined) + + # 2) Parse JUnit XML (secondary; may be missing on some runner failures). + for xml_path in sorted(glob.glob(os.path.join(artifacts_dir, '**', '*.xml'), recursive=True)): + target = target_from_path(xml_path) + targets_seen.add(target) + try: + root = ET.parse(xml_path).getroot() + except Exception: + continue + + for testcase in root.iter('testcase'): + for tag in ('error', 'failure'): + for node in testcase.findall(tag): + etype = (node.attrib.get('type') or tag).strip() + msg = (node.attrib.get('message') or '').strip() + details = (node.text or '').strip() + + combined = f"{etype}: {msg}".strip(': ') + if details: + combined = combined + "\n" + details + + testcase_id = ( + (testcase.attrib.get('classname') or '').strip() + + '::' + + (testcase.attrib.get('name') or '').strip() + ).strip(':') + + extracted = '' + if not msg and details: + extracted = details.splitlines()[0].strip() + + key_parts = [etype] + if msg: + key_parts.append(msg) + elif extracted: + key_parts.append(extracted) + if testcase_id: + key_parts.append(testcase_id) + key = "\n".join([p for p in key_parts if p]).strip() or (combined.splitlines()[0] if combined else 'unknown') + + add_entry(grouped, key, target, combined) + + # 3) Fallback: scan logs if JSON/XML were not usable. + if not grouped: + # Try to extract something meaningful from Playwright logs. + error_line_re = re.compile(r'^(Error:.*|\s+at\s+.*|expect\(.*\).*)$', re.MULTILINE) + for log_path in sorted(glob.glob(os.path.join(artifacts_dir, '**', '*.log'), recursive=True)): + target = target_from_path(log_path) + targets_seen.add(target) + try: + text = open(log_path, 'r', encoding='utf-8', errors='replace').read() + except Exception: + continue + + # Prefer a compact tail if we can't find explicit error lines. + lines = [ln.rstrip() for ln in text.splitlines()] + tail = "\n".join(lines[-80:]) + + matches = error_line_re.findall(text) + extracted = "\n".join(matches[:80]).strip() if matches else tail.strip() + if extracted: + key = extracted.splitlines()[0][:200] + add_entry(grouped, key, target, extracted) + + targets = sorted(t for t in targets_seen if t != 'unknown') + + def targets_label(affected): + affected = sorted(t for t in affected if t != 'unknown') + if targets and affected == targets: + return 'all' + return ', '.join(affected) if affected else 'unknown' + + items = sorted(grouped.items(), key=lambda kv: (-len(kv[1]['targets']), kv[0])) + blocks = [] + for idx, (key, info) in enumerate(items): + affected = targets_label(info['targets']) + message = info['message'] + block = ( + "-----\n" + f"Affected Playwright test: `{affected}`\n" + "```text\n" + f"{message}\n" + "```" + ) + if idx == len(items) - 1: + block += "\n-----" + blocks.append(block) + + if blocks: + details_md = "\n\n".join(blocks) + else: + details_md = "No Playwright error details could be parsed from artifacts." + + md = "\n".join([ + "
", + "See all Playwright errors (click to expand)", + "", + details_md, + "", + "
", + "", + ]) + + with open('playwright-errors.md', 'w', encoding='utf-8') as f: + f.write(md) + PY + + - name: Post PR comment on failure + if: github.event_name == 'pull_request' && (needs.playwright-default.result == 'failure' || needs.playwright-file-based-execution.result == 'failure') + uses: actions/github-script@v7 + with: + script: | + const marker = ''; + + const fs = require('fs'); + let details = ''; + try { + details = fs.readFileSync('playwright-errors.md', 'utf8').trim(); + } catch (e) { + details = ''; + } + + const body = [ + marker, + '## Playwright Test Failure', + '', + `One or more Playwright targets failed in [this workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).`, + '', + details || '_No parsed error details found._', + '', + 'Please review the failing jobs and fix the issues before merging.', + ].join('\n'); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + per_page: 100, + }); + + const existing = comments.filter(c => typeof c.body === 'string' && c.body.startsWith(marker)); + for (const comment of existing) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: comment.id, + }); + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + - name: Check overall status if: ${{ (needs.playwright-default.result != 'success' && needs.playwright-default.result != 'skipped') || (needs.playwright-file-based-execution.result != 'success' && needs.playwright-file-based-execution.result != 'skipped') }} run: exit 1 diff --git a/.gitignore b/.gitignore index 1c3b03cb0..453a38177 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,12 @@ node_modules/ npm-debug.log .sass-cache/ +# PHPUnit +.phpunit.result.cache +.wp-core +.wp-tests-lib +/coverage/ + # Playwright playwright-report/ test-results/ @@ -19,9 +25,18 @@ tests/e2e/.auth/ auth.json # Local files (ideally, should be in a global .gitignore) +/.env .idea/ Thumbs.db .DS_Store # storyman .story +.tmp +tmp +.continue +.codex +# Local environment overrides (license keys, secrets) +/.env +.superpowers/ +docs/superpowers/ diff --git a/.storyman.json b/.storyman.json deleted file mode 100644 index b246f1786..000000000 --- a/.storyman.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "jiraUrl": "https://codesnippets.atlassian.net/", - "defaultProject": "PD" -} \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index e2bcb26b1..000000000 --- a/.travis.yml +++ /dev/null @@ -1,59 +0,0 @@ -# Declare project language. -# @link http://about.travis-ci.org/docs/user/languages/php/ -language: php - -# Declare versions of PHP to use. Use one decimal max. -php: - - '7.2' - - '7.3' - - '7.4' - - '8.0' - - nightly - -services: - - mysql - -env: - global: - - WP_DEVELOP_DIR=/tmp/wordpress/ - matrix: - # Trunk - - WP_VERSION=master WP_MULTISITE=0 - - WP_VERSION=master WP_MULTISITE=1 - - # Latest stable version - - WP_VERSION=stable WP_MULTISITE=0 - - WP_VERSION=stable WP_MULTISITE=1 - - -# Prepare the system by installing prerequisites and dependencies. -# Failures in this section will result in build status 'errored'. -before_install: - - nvm install node - - composer self-update - -# Prepare your build for testing. -# Failures in this section will result in build status 'errored'. -before_script: - - pwd - - # Build plugin - - composer install - - npm install - - npm run build - - npm run package - - # set up WP install - - bash tests/install.sh $WP_VERSION wordpress_test root - -# Run test script commands -# Default is specific to project language. -# All commands must exit with code 0 on success. Anything else is considered failure. -script: - - # Search for PHP syntax errors. - - find . -maxdepth 1 \( -name '*.php' \) -exec php -lf {} \; - - find src/php/ \( -name '*.php' \) -exec php -lf {} \; - - # Run linters - - npm run lint diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dc3ada52..c9db77a9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,47 +1,71 @@ # Changelog -## [3.9.6] (2026-04-28) +## [3.10.0] (UPCOMING) -### Changed -* tweak: improve snippets rest api +### Added +* New admin interface for managing snippets, with a cleaner layout, faster interactions, and a more consistent experience across plugin screens. +* Card view for browsing snippets, with a view switcher on the snippets table and Community Cloud. +* Snippet preview modal for viewing snippet code from the snippets table without opening the editor. +* Automatic hiding of unrelated admin notices from other plugins on Code Snippets screens. +* Admin bar snippet drawer, based on the Deckerweb Snippets workflow, for quick access to snippets and Safe Mode from the WordPress admin bar. +* Snippet locking to help prevent accidental edits or deletion of important snippets. Props to https://github.com/mgiannopoulos24. +* Improved screen options on the main snippets table, including controls for visible columns and truncating long snippet names or descriptions. +* Bulk actions and bulk code download support in the redesigned snippets table. +* Featured snippets and improved browsing in Community Cloud. +* WordPress modern theme admin styling compatibility. +* Clearer accessibility labels, headings, tab markup, table checkboxes, sort buttons, copy buttons, and drag-and-drop upload controls. + +### Changed +* Redesigned the main snippets table with improved search, filtering, sorting, pagination, row actions, and bulk selection. +* Improved the snippet import and migration experience, including clearer file upload handling and third-party plugin migration flows. +* Improved snippet error handling so activation failures, validation errors, and stack traces are easier to understand. +* Improved Community Cloud search and filtering, including server-side filters, better result loading, and clearer empty states. +* Updated the welcome screen, toolbar, import screen, and cloud screens to match the new admin experience. +* Updated internal plugin architecture to a cleaner PSR-4 structure for better long-term maintainability. +* Improved accessibility across the snippets table, import screen, migration flow, Community Cloud, welcome screen, toolbar, dialogs, tooltips, and code editor. +* Improved colour contrast and reduced-motion support across admin screens. + +### Fixed +* Fixed REST API server error responses on missing snippets. +* Fixed redundant frontend logic, improving overall performance. +* Fixed Community Cloud search results and pagination to respect WordPress screen options. +* Fixed snippet saving and activation feedback to improve validation and runtime error display. +* Fixed downloaded Community Cloud snippets appearing as not downloaded after a page reload. +* Fixed network snippet lookups using the wrong database table on multisite. +* Fixed the inactive snippets count including trashed snippets. +* Fixed featured Community Cloud snippets failing to load with some cloud API responses. -### Removed -* remove redundant comments +## [3.9.6] (2026-04-28) ### Fixed -* site admin cannot toggle shared network snippets status +* Improved permissions handling with snippets REST API. +* Site admin cannot toggle shared network snippets status. ## [3.9.5] (2026-02-05) -### Added -* Confirmed WordPress 6.9 compatability - ### Fixed -* Improved nonce handling for cloud snippet download and update actions to for enhanced security +* Improved security when handling actions for downloading and updating cloud snippets. ## [3.9.4] (2026-01-14) ### Added -* New import functionality to migrate snippets from file uploads with drag-and-drop interface -* Support for importing snippets from other popular plugins (Header Footer Code Manager, Insert Headers and Footers, Insert PHP Code Snippet) -* Enhanced file based execution support with improved multisite mode compatibility - -### Changed -* Updated links to more recent documentation pages +* New import functionality to migrate snippets from file uploads with drag-and-drop interface. +* Support for importing snippets from other popular plugins (Header Footer Code Manager, Insert Headers and Footers, Insert PHP Code Snippet). +* Enhanced file based execution support with improved multisite mode compatibility. ### Fixed -* Fixed multisite capability checks in Plugin class -* Fixed snippet execution logic for multisite support by centralizing trashed snippet handling -* Fixed multisite snippet handling to ensure local snippets use correct table and filter out trashed snippets +* Fixed multisite capability checks in Plugin class. +* Fixed snippet execution logic for multisite support by centralizing trashed snippet handling. +* Fixed multisite snippet handling to ensure local snippets use correct table and filter out trashed snippets. ## [3.9.3] (2025-12-03) ### Added -* end-to-end tests to verify the toggle visual state in the snippets list page, improving UI verification and test reliability +* End-to-end tests to verify the toggle visual state in the snippets list page, improving UI verification and test reliability. ### Fixed -* Fix missing import of common/direction in src/css/manage.scss to restore correct styling and direction-aware layout -* Fix toggle activation check to ensure the correct transformation value is used when detecting active/inactive state +* Restored missing styles styling and direction-aware layout from Manage menu. +* Ensure correct transformation value is used when detecting state of activation toggle. ## [3.9.2] (2025-11-17) @@ -226,10 +250,10 @@ ### Changed * Updated CSS to use latest Sass features. -* Moved theme selector to just above editor preview on settings page (thanks to [brandonjp]). ([#206](https://github.com/codesnippetspro/code-snippets/issues/206)) +* Moved theme selector to just above editor preview on settings page (thanks to [brandonjp]). ([#206](https://github.com/codesnippetspro/code-snippets/issues/206)) * `[code_snippet]` shortcodes can now be nested within each other. ([#198](https://github.com/codesnippetspro/code-snippets/issues/198)) -### Fixed +### Fixed * Save buttons above editor did not follow usual validation process in Pro. (PRO) ([#197](https://github.com/codesnippetspro/code-snippets/issues/197)) * Minor inconsistencies in consistent UI elements between Core and Pro. * Tags input not allowing input. ([#211](https://github.com/codesnippetspro/code-snippets/issues/211)) @@ -253,13 +277,13 @@ * Action hook `code_snippets/admin/manage` now includes the currently viewed type. ### Fixed -* Memory issue from checking aggregate posts while loading front-end syntax highlighter. +* Memory issue from checking aggregate posts while loading front-end syntax highlighter. * Translation functions being called too early on upgrade, resulting in localisation loading errors. * Bug preventing the 'share on network' status of network snippets from correctly updating. * Incorrect logic controlling when to display 'Save Changes' or 'Save Changes and Activate' buttons. * Old notices persisting when switching between editing and creating snippets. -## 3.6.5.1 (2024-05-24) +## [3.6.5.1] (2024-05-24) * Redeployment of [v3.6.5](#365-2024-05-24) to overcome issue with initial build. @@ -387,11 +411,11 @@ ### Changed * Better compatibility with modern versions of PHP (7.0+). -* Converted Edit/Add New Snippet page to use React. - * Converted action buttons to asynchronously use REST API endpoints through AJAX. - * Load page components dynamically through React. - * Added action notice queue system. - * Replaced native alert dialog with proper React modal. +* Converted Edit/Add New Snippet page to use React: + - Converted action buttons to asynchronously use REST API endpoints through AJAX. + - Load page components dynamically through React. + - Added action notice queue system + - Replaced native alert dialog with proper React modal. * Catch snippet execution errors to prevent site from crashing. * Display recent snippet errors in admin dashboard instead. * Updated editor block to use new REST API endpoints. (PRO) @@ -575,9 +599,9 @@ ### Added * Added translations: - * Spanish by [Ibidem Group](https://www.ibidemgroup.com) - * Urdu by [Samuel Badree](https://mobilemall.pk/) - * Greek by [Toni Bishop from Jrop](https://www.jrop.com/) + - Spanish by [Ibidem Group](https://www.ibidemgroup.com) + - Urdu by [Samuel Badree](https://mobilemall.pk/) + - Greek by [Toni Bishop from Jrop](https://www.jrop.com/) * Support for `:class` syntax to the code validator. * PHP8 support to the code linter. * Color picker feature to the code editor. @@ -1318,12 +1342,12 @@ ### Changed * Updated CodeMirror to version 2.33. -* Updated the 'Manage Snippets' page to use the WP_List_Table class. - * Added 'Screen Options' tab to 'Manage Snippets' page. - * Added search capability to 'Manage Snippets' page. - * Added views to easily filter activated, deactivated and recently activated snippets. - * Added ID column to 'Manage Snippets' page. - * Added sortable name and ID column on 'Manage Snippets' page ([#](https://wordpress.org/support/topic/plugin-code-snippets-suggestion-sort-by-snippet-name)) +* Updated the 'Manage Snippets' page to use the WP_List_Table class: + - Added 'Screen Options' tab to 'Manage Snippets' page. + - Added search capability to 'Manage Snippets' page. + - Added views to easily filter activated, deactivated and recently activated snippets. + - Added ID column to 'Manage Snippets' page. + - Added sortable name and ID column on 'Manage Snippets' page ([#](https://wordpress.org/support/topic/plugin-code-snippets-suggestion-sort-by-snippet-name)) * Improved API. * Lengthened snippet name field to 64 characters. ([#](https://wordpress.org/support/topic/plugin-code-snippets-snippet-title-limited-to-36-characters)) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0b543a3b3..4949dde96 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,6 +69,44 @@ command: npm run watch ``` +## Pre-commit hooks (automatic linting & autofix) 🔧 + +We use a native Git hook and lint-staged to run linters only on the files being committed. The hook will: + +- Run the appropriate autofix for the changed files (PHP, JS/TS, CSS/SCSS). +- Automatically stage any files that were fixed so the fixes are included in the same commit. +- Block the commit only if non-fixable linter errors remain. + +Files → actions (configured in this repository): + +- `*.php` → `npm run lint:php:fix` (phpcbf) +- `*.{js,ts,jsx,tsx}` → `npm run lint:js:fix` (ESLint --fix) +- `*.{css,scss}` → `npm run lint:styles:fix` (Stylelint --fix) + +Setup + +1. Install node deps. The `prepare` script configures Git to use this repository's hooks: + +```shell +npm install +``` + +2. If you already have the repo checked out, activate the Git hooks (run once): + +```shell +npm run prepare +``` + +Usage notes + +- To bypass hooks in an emergency: `git commit --no-verify` (not recommended). +- To run the same checks locally on staged files: `npx lint-staged`. +- If fixes were applied by the hook they will be included automatically in the commit; the commit is only blocked when + a non-autofixable problem remains. + +If you need to change which linters run for a filetype, see `package.json` -> `lint-staged`. + + ## Managing Composer dependencies Code Snippets uses the [Imposter plugin](https://github.com/TypistTech/imposter) to namespace-prefix all vendor diff --git a/claude.md b/claude.md new file mode 100644 index 000000000..fa4318081 --- /dev/null +++ b/claude.md @@ -0,0 +1,7 @@ +# Code Snippets — Claude Instructions + +Project standards live in `AGENTS.md` at the repository root. Read that file in full before performing any task. + +This file exists to establish the Claude-specific configuration location. No additional Claude-specific +directives are defined at this time — follow `AGENTS.md` for all coding standards, architecture patterns, +security requirements, and workflow conventions. diff --git a/config/modules/postcss-color-hsl.d.ts b/config/modules/postcss-color-hsl.d.ts deleted file mode 100644 index fc76708c8..000000000 --- a/config/modules/postcss-color-hsl.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module 'postcss-color-hsl' { - import type { Plugin } from 'postcss' - export default function (): Plugin -} diff --git a/tests/playwright/playwright.config.ts b/config/playwright/playwright.config.ts similarity index 52% rename from tests/playwright/playwright.config.ts rename to config/playwright/playwright.config.ts index 10c2e67f5..68b6ac87d 100644 --- a/tests/playwright/playwright.config.ts +++ b/config/playwright/playwright.config.ts @@ -2,27 +2,44 @@ import { join } from 'path' import { defineConfig, devices } from '@playwright/test' -const RETRIES = 2 const WORKERS = 1 +const CI_RETRIES = 2 +const LOCAL_RETRIES = 1 + +const TEST_TIMEOUT_SECONDS = 60 +const ASSERT_TIMEOUT_SECONDS = 30 + +const MILLISECONDS_IN_SECOND = 1000 + +const baseTestsDir = join(__dirname, '..', '..', 'tests') +const storageState = join(baseTestsDir, 'e2e/.auth/user.json') + /** * @see https://playwright.dev/docs/test-configuration */ export default defineConfig({ - testDir: '../e2e', + testDir: join(baseTestsDir, 'e2e'), snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-{platform}{ext}', fullyParallel: true, forbidOnly: !!process.env.CI, - retries: process.env.CI ? RETRIES : 0, - workers: process.env.CI ? WORKERS : undefined, - reporter: [ - ['html'], - ['json', { outputFile: 'test-results/results.json' }], - ['junit', { outputFile: 'test-results/results.xml' }] - ], + retries: process.env.CI ? CI_RETRIES : LOCAL_RETRIES, + workers: WORKERS, + reporter: process.env.CI + ? [ + ['line'], + ['html'], + ['json', { outputFile: join(process.cwd(), 'test-results', 'results.json') }], + ['junit', { outputFile: join(process.cwd(), 'test-results', 'results.xml') }] + ] + : [ + ['html'], + ['json', { outputFile: join(process.cwd(), 'test-results', 'results.json') }], + ['junit', { outputFile: join(process.cwd(), 'test-results', 'results.xml') }] + ], use: { baseURL: 'http://localhost:8888', - trace: 'on-first-retry', + trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'retain-on-failure' }, @@ -32,13 +49,12 @@ export default defineConfig({ name: 'setup', testMatch: /auth\.setup\.ts/ }, - { name: 'flat-files-setup', testMatch: /flat-files\.setup\.ts/, use: { ...devices['Desktop Chrome'], - storageState: join(__dirname, '../e2e/.auth/user.json') + storageState }, dependencies: ['setup'] }, @@ -47,7 +63,7 @@ export default defineConfig({ name: 'chromium-db-snippets', use: { ...devices['Desktop Chrome'], - storageState: join(__dirname, '../e2e/.auth/user.json') + storageState }, dependencies: ['setup'], testIgnore: /.*\.setup\.ts/ @@ -57,17 +73,17 @@ export default defineConfig({ name: 'chromium-file-based-snippets', use: { ...devices['Desktop Chrome'], - storageState: join(__dirname, '../e2e/.auth/user.json') + storageState }, dependencies: ['setup', 'flat-files-setup'], testIgnore: /.*\.setup\.ts/ } ], - timeout: 30000, + timeout: TEST_TIMEOUT_SECONDS * MILLISECONDS_IN_SECOND, expect: { - timeout: 10000, + timeout: ASSERT_TIMEOUT_SECONDS * MILLISECONDS_IN_SECOND, toHaveScreenshot: { maxDiffPixels: 100 } } }) diff --git a/config/webpack/modules/codemirror-keymaps.d.ts b/config/webpack/modules/codemirror-keymaps.d.ts new file mode 100644 index 000000000..9f5e587c1 --- /dev/null +++ b/config/webpack/modules/codemirror-keymaps.d.ts @@ -0,0 +1,5 @@ +declare module 'codemirror/src/input/keymap' { + import type { KeyMap } from 'codemirror' + + export const getKeyMap: (keyMap: string) => KeyMap +} diff --git a/config/modules/postcss-hexrgba.d.ts b/config/webpack/modules/postcss-hexrgba.d.ts similarity index 100% rename from config/modules/postcss-hexrgba.d.ts rename to config/webpack/modules/postcss-hexrgba.d.ts diff --git a/config/webpack/postcss-hsl-legacy.ts b/config/webpack/postcss-hsl-legacy.ts new file mode 100644 index 000000000..07dc87d17 --- /dev/null +++ b/config/webpack/postcss-hsl-legacy.ts @@ -0,0 +1,208 @@ +interface HslMatch { + fn: 'hsl' | 'hsla' + h: string + s: string + l: string + alpha?: string +} + +interface DeclarationLike { + value?: string +} + +const GRAD_TO_DEG = 0.9 +const DEG_PER_TURN = 360 +const DEG_PER_PI = 180 +const PERCENT_DIVISOR = 100 +const ROUNDING_MULTIPLIER = 1000 + +const hslArgsRegex = new RegExp( + [ + String.raw`(?hsl)a?\s*\(\s*`, + String.raw`(?\d*\.?\d+(?:deg|grad|rad|turn)?)`, + String.raw`(?:\s+|(?:\s*,\s*))`, + String.raw`(?\d*\.?\d+%)`, + String.raw`(?:\s+|(?:\s*,\s*))`, + String.raw`(?\d*\.?\d+%)`, + String.raw`(?:\s*(?:\/|,)\s*(?\d*\.?\d+%?))?`, + String.raw`\s*\)` + ].join(''), + 'g' +) + +const hueWithUnitRegex = /^(?\d*\.?\d+)(?deg|grad|rad|turn)$/ + +const convertHueToDeg = (hue: string): string => { + const match = hueWithUnitRegex.exec(hue) + if (!match?.groups) { + return hue + } + + const value = Number(match.groups.value) + const unit = match.groups.unit + + const degrees = + 'deg' === unit + ? value + : 'grad' === unit + ? value * GRAD_TO_DEG + : 'rad' === unit + ? value * DEG_PER_PI / Math.PI + : value * DEG_PER_TURN + + return String(Math.round(degrees * ROUNDING_MULTIPLIER) / ROUNDING_MULTIPLIER) +} + +const normalizeAlpha = (alpha: string): string => { + if (alpha.includes('%')) { + const value = Number(alpha.slice(0, -1)) / PERCENT_DIVISOR + alpha = String(value) + } + + return alpha.replace(/^0\./, '.') +} + +const toLegacyHsl = (colorFn: string): HslMatch | null => { + hslArgsRegex.lastIndex = 0 + const match = hslArgsRegex.exec(colorFn) + if (!match?.groups) { + return null + } + + const alpha = match.groups.alpha + + return { + fn: alpha ? 'hsla' : 'hsl', + h: convertHueToDeg(match.groups.hue), + s: match.groups.s, + l: match.groups.l, + alpha: alpha ? normalizeAlpha(alpha) : undefined + } +} + +const isIdentChar = (char: string | undefined): boolean => Boolean(char && /[a-zA-Z0-9_-]/.test(char)) + +const isUnescapedQuote = (value: string, index: number, quote: '"' | "'"): boolean => + quote === value[index] && '\\' !== value[index - 1] + +const findFunctionEnd = (value: string, openParenIndex: number): number | null => { + let depth = 0 + let index = openParenIndex + + while (index < value.length) { + const char = value[index] + + if ('(' === char) { + depth += 1 + } else if (')' === char) { + depth -= 1 + if (0 === depth) { + return index + } + } + + index += 1 + } + + return null +} + +const legacyHslString = (fnText: string): string | null => { + const legacy = toLegacyHsl(fnText) + if (!legacy) { + return null + } + + if ('hsl' === legacy.fn) { + return `hsl(${legacy.h}, ${legacy.s}, ${legacy.l})` + } + + return `hsla(${legacy.h}, ${legacy.s}, ${legacy.l}, ${legacy.alpha})` +} + +const replaceHslAtIndex = ( + value: string, + index: number +): { nextIndex: number; text: string } | null => { + const isStart = value.startsWith('hsl', index) || value.startsWith('hsla', index) + if (!isStart || isIdentChar(value[index - 1])) { + return null + } + + const name = value.startsWith('hsla', index) ? 'hsla' : 'hsl' + let afterNameIndex = index + name.length + + while (afterNameIndex < value.length && /\s/.test(value[afterNameIndex])) { + afterNameIndex += 1 + } + + if ('(' !== value[afterNameIndex]) { + return null + } + + const endIndex = findFunctionEnd(value, afterNameIndex) + if (null === endIndex) { + return { nextIndex: value.length, text: value.slice(index) } + } + + const fnText = value.slice(index, endIndex + 1) + return { nextIndex: endIndex + 1, text: legacyHslString(fnText) ?? fnText } +} + +const transformHslFunctions = (value: string): string => { + let result = '' + let index = 0 + + let inSingle = false + let inDouble = false + + while (index < value.length) { + const char = value[index] + + if (!inDouble && isUnescapedQuote(value, index, "'")) { + inSingle = !inSingle + result += char + index += 1 + continue + } + + if (!inSingle && isUnescapedQuote(value, index, '"')) { + inDouble = !inDouble + result += char + index += 1 + continue + } + + if (inSingle || inDouble) { + result += char + index += 1 + continue + } + + const replacement = replaceHslAtIndex(value, index) + if (!replacement) { + result += char + index += 1 + continue + } + + result += replacement.text + index = replacement.nextIndex + } + + return result +} + +const postcssHslLegacy = () => ({ + postcssPlugin: 'postcss-hsl-legacy', + Declaration(decl: DeclarationLike) { + if (!decl.value || !decl.value.includes('hsl(') && !decl.value.includes('hsla(')) { + return + } + decl.value = transformHslFunctions(decl.value) + } +}) + +postcssHslLegacy.postcss = true + +export default postcssHslLegacy diff --git a/config/webpack-css.ts b/config/webpack/webpack-css.ts similarity index 98% rename from config/webpack-css.ts rename to config/webpack/webpack-css.ts index 828d7a023..1e4f94e54 100644 --- a/config/webpack-css.ts +++ b/config/webpack/webpack-css.ts @@ -3,10 +3,10 @@ import libsass from 'sass' import cssnano from 'cssnano' import autoprefixer from 'autoprefixer' import rgbaCompat from 'postcss-hexrgba' -import hslCompat from 'postcss-color-hsl' import MiniCssExtractPlugin from 'mini-css-extract-plugin' import RemoveEmptyScriptsPlugin from 'webpack-remove-empty-scripts' import { glob } from 'glob' +import hslCompat from './postcss-hsl-legacy' import type { Configuration, EntryObject } from 'webpack' import type { Config as PostCssConfig } from 'postcss-load-config' diff --git a/config/webpack-js.ts b/config/webpack/webpack-js.ts similarity index 64% rename from config/webpack-js.ts rename to config/webpack/webpack-js.ts index 3e2a19ce8..5c88861e8 100644 --- a/config/webpack-js.ts +++ b/config/webpack/webpack-js.ts @@ -2,11 +2,11 @@ import { join, resolve } from 'path' import { DefinePlugin } from 'webpack' import ESLintPlugin from 'eslint-webpack-plugin' import RemoveEmptyScriptsPlugin from 'webpack-remove-empty-scripts' -import { toCamelCase } from '../src/js/utils/text' -import { dependencies } from '../package.json' +import { toCamelCase } from '../../src/js/utils/text' +import { dependencies } from '../../package.json' import type { Configuration } from 'webpack' -const SOURCE_DIR = './src/js' +const SOURCE_DIR = './src/js/entries' const DEST_DIR = './src/dist' const babelConfig = { @@ -24,16 +24,18 @@ const babelConfig = { export const jsWebpackConfig: Configuration = { entry: { - edit: { import: `${SOURCE_DIR}/edit.tsx`, dependOn: 'editor' }, - editor: `${SOURCE_DIR}/editor.ts`, - import: `${SOURCE_DIR}/import.tsx`, - manage: `${SOURCE_DIR}/manage.ts`, - mce: `${SOURCE_DIR}/mce.ts`, - prism: `${SOURCE_DIR}/prism.ts`, - settings: { import: `${SOURCE_DIR}/settings.ts`, dependOn: 'editor' } + 'admin-bar': `${SOURCE_DIR}/admin-bar.ts`, + 'edit': { import: `${SOURCE_DIR}/edit.ts`, dependOn: 'editor' }, + 'editor': `${SOURCE_DIR}/editor.ts`, + 'import': `${SOURCE_DIR}/import.ts`, + 'manage': `${SOURCE_DIR}/manage.ts`, + 'mce': `${SOURCE_DIR}/mce.ts`, + 'prism': `${SOURCE_DIR}/prism.ts`, + 'settings': { import: `${SOURCE_DIR}/settings.ts`, dependOn: 'editor' }, + 'welcome': `${SOURCE_DIR}/welcome.ts` }, output: { - path: join(resolve(__dirname), '..', DEST_DIR), + path: join(resolve(__dirname), '..', '..', DEST_DIR), filename: '[name].js', clean: true }, @@ -41,6 +43,8 @@ export const jsWebpackConfig: Configuration = { externals: { 'react': 'React', 'react-dom': 'ReactDOM', + 'react-dom/client': 'ReactDOM', + 'react/jsx-runtime': 'ReactJSXRuntime', 'jquery': 'jQuery', 'tinymce': 'tinymce', 'codemirror': ['wp', 'CodeMirror'], @@ -54,7 +58,7 @@ export const jsWebpackConfig: Configuration = { ) }, resolve: { - modules: [resolve(__dirname, '..', 'node_modules')], + modules: [resolve(__dirname, '..', '..', 'node_modules')], extensions: ['.ts', '.tsx', '.js', '.jsx', '.json'] }, module: { diff --git a/eslint.config.mjs b/eslint.config.mjs index c66739dc1..387380142 100755 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -6,7 +6,9 @@ import eslintTs from 'typescript-eslint' import stylistic from '@stylistic/eslint-plugin' import reactHooks from 'eslint-plugin-react-hooks' import importPlugin from 'eslint-plugin-import' +import jsxA11yPlugin from 'eslint-plugin-jsx-a11y' import reactPlugin from 'eslint-plugin-react' +import svgPlugin from 'eslint-plugin-svg-jsx' import { FlatCompat } from '@eslint/eslintrc' const compat = new FlatCompat({ @@ -21,12 +23,17 @@ export default eslintTs.config( ...compat.extends('plugin:react-hooks/recommended'), reactPlugin.configs.flat.recommended, importPlugin.flatConfigs.recommended, + jsxA11yPlugin.flatConfigs.recommended, { plugins: { 'react-hooks': reactHooks }, rules: reactHooks.configs.recommended.rules, }, { - ignores: ['bundle/*', 'src/dist/*', 'src/vendor/*', 'svn/*', '*.config.mjs', '*.config.js'] + ignores: [ + 'bundle/*', 'src/dist/*', 'src/vendor/*', 'svn/*', + '*.config.mjs', '*.config.js', + '.*/*', 'tmp/*', 'playwright-report/*' + ] }, { languageOptions: { @@ -41,18 +48,8 @@ export default eslintTs.config( }, plugins: { '@stylistic': stylistic, - 'react': reactPlugin - }, - settings: { - 'react': { - version: 'detect' - }, - 'import/resolver': { - typescript: { - alwaysTryTypes: true, - project: './tsconfig.json', - } - } + 'react': reactPlugin, + 'svg-jsx': svgPlugin }, rules: { '@stylistic/array-bracket-newline': ['error', 'consistent'], @@ -63,9 +60,9 @@ export default eslintTs.config( '@stylistic/indent': ['error', 'tab', { SwitchCase: 1 }], '@stylistic/jsx-quotes': ['error', 'prefer-double'], '@stylistic/linebreak-style': ['error', 'unix'], - '@stylistic/max-len': ['warn', 140, { ignorePattern: 'd="(.*?)"|_[_xn]\\(|import .+ from .+' }], + '@stylistic/max-len': ['error', 140, { ignorePattern: 'd="(.*?)"|_[_xn]\\(|import .+ from .+' }], '@stylistic/multiline-ternary': 'off', - '@stylistic/no-extra-parens': ['error', 'all'], + '@stylistic/no-extra-parens': ['error', 'all', { ignoreJSX: 'all', returnAssign: true }], '@stylistic/no-mixed-spaces-and-tabs': ['error', 'smart-tabs'], '@stylistic/no-tabs': ['error', { allowIndentationTabs: true }], '@stylistic/object-property-newline': ['error', { allowAllPropertiesOnSameLine: true }], @@ -81,11 +78,11 @@ export default eslintTs.config( objectLiteralTypeAssertions: 'never' }], '@typescript-eslint/consistent-type-imports': 'error', - '@typescript-eslint/consistent-type-exports': 'error', '@typescript-eslint/no-confusing-void-expression': ['error', { ignoreArrowShorthand: true }], '@typescript-eslint/no-for-in-array': 'error', '@typescript-eslint/no-import-type-side-effects': 'error', '@typescript-eslint/no-inferrable-types': ['error', { ignoreProperties: true, ignoreParameters: false }], + '@typescript-eslint/no-magic-numbers': ['error', { ignore: [-1, 0, 1], ignoreEnums: true }], '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', @@ -117,14 +114,50 @@ export default eslintTs.config( }], 'max-lines-per-function': ['warn', { skipBlankLines: true, skipComments: true }], 'no-invalid-this': 'error', - 'no-magic-numbers': ['error', { ignore: [-1, 0, 1] }], 'no-plusplus': ['error', { allowForLoopAfterthoughts: true }], 'no-ternary': 'off', 'one-var': ['error', 'never'], 'prefer-named-capture-group': 'error', 'prefer-template': 'error', 'sort-imports': ['error', { ignoreDeclarationSort: true }], - 'yoda': ['error', 'always'] + 'yoda': ['error', 'always'], + // Accessibility rules. + 'jsx-a11y/alt-text': 'error', + 'jsx-a11y/anchor-has-content': 'error', + 'jsx-a11y/anchor-is-valid': 'error', + 'jsx-a11y/aria-props': 'error', + 'jsx-a11y/aria-proptypes': 'error', + 'jsx-a11y/aria-role': 'error', + 'jsx-a11y/aria-unsupported-elements': 'error', + 'jsx-a11y/click-events-have-key-events': 'error', + 'jsx-a11y/control-has-associated-label': ['warn', { ignoreElements: ['th', 'td'] }], + 'jsx-a11y/heading-has-content': 'error', + 'jsx-a11y/iframe-has-title': 'error', + 'jsx-a11y/img-redundant-alt': 'error', + 'jsx-a11y/interactive-supports-focus': 'error', + 'jsx-a11y/label-has-associated-control': 'error', + 'jsx-a11y/no-autofocus': 'error', + 'jsx-a11y/no-noninteractive-element-interactions': 'error', + 'jsx-a11y/no-noninteractive-tabindex': 'error', + 'jsx-a11y/no-redundant-roles': 'error', + 'jsx-a11y/no-static-element-interactions': 'error', + 'jsx-a11y/role-has-required-aria-props': 'error', + 'jsx-a11y/role-supports-aria-props': 'error', + 'jsx-a11y/tabindex-no-positive': 'error', + 'svg-jsx/camel-case-dash': 'error', + 'svg-jsx/camel-case-colon': 'error', + 'svg-jsx/no-style-string': 'error', + }, + settings: { + 'react': { + version: 'detect' + }, + 'import/resolver': { + typescript: { + alwaysTryTypes: true, + project: './tsconfig.json', + } + } } }, { @@ -137,8 +170,9 @@ export default eslintTs.config( } }, { - files: ['test/**', '**/*.test.*', '**/*.spec.*'], + files: ['tests/**', '**/*.test.*', '**/*.spec.*'], rules: { + '@typescript-eslint/no-magic-numbers': 'off', 'max-lines-per-function': 'off' } } diff --git a/package-lock.json b/package-lock.json index b21cce5be..63be2d3ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,22 @@ { "name": "code-snippets", - "version": "3.9.6", + "version": "3.10.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-snippets", - "version": "3.9.6", + "version": "3.10.0-beta.1", "license": "GPL-2.0-or-later", "dependencies": { "@codemirror/fold": "^0.19.4", "@wordpress/components": "^29.3.0", + "@wordpress/date": "^5.43.0", "@wordpress/dom-ready": "^4.17.0", "@wordpress/element": "^6.28.0", "@wordpress/i18n": "^5.17.0", "@wordpress/url": "^4.20.0", - "axios": "^1.7.9", + "axios": "^1.13.5", "classnames": "^2.5.1", "codemirror": "^5.29", "php-parser": "^3.2.2", @@ -25,7 +26,8 @@ "react-select": "^5.10.0" }, "devDependencies": { - "@eslint/eslintrc": "^3.2.0", + "@axe-core/playwright": "^4.11.2", + "@eslint/eslintrc": "^3.3.3", "@eslint/js": "^9.20.0", "@playwright/test": "^1.48.0", "@stylistic/eslint-plugin": "^3.1.0", @@ -53,14 +55,16 @@ "eslint": "^9.20.1", "eslint-import-resolver-typescript": "^3.7.0", "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.4", "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-svg-jsx": "^1.3.0", "eslint-webpack-plugin": "^4.2.0", - "glob": "^11.0.1", + "glob": "^11.1.0", "globals": "^15.14.0", + "lint-staged": "^15.5.2", "mini-css-extract-plugin": "^2.9.2", "postcss": "^8.5.2", - "postcss-color-hsl": "^2.0.0", "postcss-hexrgba": "^2.1.0", "postcss-load-config": "^6.0.1", "postcss-loader": "^8.1.1", @@ -127,6 +131,19 @@ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@axe-core/playwright": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.3.tgz", + "integrity": "sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.11.4" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.26.2", "license": "MIT", @@ -430,8 +447,6 @@ }, "node_modules/@babel/helpers": { "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz", - "integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==", "dev": true, "license": "MIT", "dependencies": { @@ -444,8 +459,6 @@ }, "node_modules/@babel/parser": { "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz", - "integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==", "license": "MIT", "dependencies": { "@babel/types": "^7.26.10" @@ -1702,19 +1715,16 @@ } }, "node_modules/@babel/runtime": { - "version": "7.25.7", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz", - "integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.26.2", @@ -1750,8 +1760,6 @@ }, "node_modules/@babel/types": { "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz", - "integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.25.9", @@ -1843,8 +1851,6 @@ }, "node_modules/@csstools/css-parser-algorithms": { "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.4.tgz", - "integrity": "sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==", "dev": true, "funding": [ { @@ -1866,8 +1872,6 @@ }, "node_modules/@csstools/css-tokenizer": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.3.tgz", - "integrity": "sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==", "dev": true, "funding": [ { @@ -1886,8 +1890,6 @@ }, "node_modules/@csstools/media-query-list-parser": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-4.0.2.tgz", - "integrity": "sha512-EUos465uvVvMJehckATTlNqGj4UJWkTmdWuDMjqvSUkjGpmOyFZBVwb4knxCm/k2GMTXY+c/5RkdndzFYWeX5A==", "dev": true, "funding": [ { @@ -1918,8 +1920,6 @@ }, "node_modules/@dual-bundle/import-meta-resolve": { "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", - "integrity": "sha512-+nxncfwHM5SgAtrVzgpzJOI1ol0PkumhVo469KCf9lUi21IGcY90G98VuHm9VRrUypmAzawAHO9bs6hqeADaVg==", "dev": true, "license": "MIT", "funding": { @@ -1927,10 +1927,43 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", - "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", - "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", @@ -1951,8 +1984,7 @@ }, "node_modules/@emotion/cache": { "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", - "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", @@ -1974,26 +2006,22 @@ }, "node_modules/@emotion/hash": { "version": "0.9.2", - "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", - "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==" + "license": "MIT" }, "node_modules/@emotion/is-prop-valid": { "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz", - "integrity": "sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==", + "license": "MIT", "dependencies": { "@emotion/memoize": "^0.9.0" } }, "node_modules/@emotion/memoize": { "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", - "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==" + "license": "MIT" }, "node_modules/@emotion/react": { "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", - "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2015,8 +2043,7 @@ }, "node_modules/@emotion/serialize": { "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", - "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", "dependencies": { "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", @@ -2027,13 +2054,11 @@ }, "node_modules/@emotion/sheet": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", - "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==" + "license": "MIT" }, "node_modules/@emotion/styled": { "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.0.tgz", - "integrity": "sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA==", + "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2054,43 +2079,46 @@ }, "node_modules/@emotion/unitless": { "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", - "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==" + "license": "MIT" }, "node_modules/@emotion/use-insertion-effect-with-fallbacks": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", - "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", "peerDependencies": { "react": ">=16.8.0" } }, "node_modules/@emotion/utils": { "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", - "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==" + "license": "MIT" }, "node_modules/@emotion/weak-memoize": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", - "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==" + "license": "MIT" }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", + "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": { - "eslint-visitor-keys": "^3.3.0" + "eslint-visitor-keys": "^3.4.3" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, + "funding": { + "url": "https://opencollective.com/eslint" + }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "node_modules/@eslint-community/regexpp": { - "version": "4.11.0", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -2098,11 +2126,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.19.2", + "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==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -2110,8 +2140,23 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@eslint/core": { - "version": "0.11.0", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2122,7 +2167,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.2.0", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2132,7 +2179,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" }, @@ -2145,6 +2192,8 @@ }, "node_modules/@eslint/eslintrc/node_modules/ajv": { "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", "dependencies": { @@ -2171,19 +2220,28 @@ }, "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, "license": "MIT" }, "node_modules/@eslint/js": { - "version": "9.20.0", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2191,28 +2249,19 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.2.5", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.10.0", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.10.0", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/@floating-ui/core": { "version": "1.5.0", "license": "MIT", @@ -2288,7 +2337,9 @@ } }, "node_modules/@humanwhocodes/retry": { - "version": "0.4.1", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2437,7 +2488,9 @@ } }, "node_modules/@jridgewell/source-map": { - "version": "0.3.6", + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "dev": true, "license": "MIT", "dependencies": { @@ -2459,8 +2512,6 @@ }, "node_modules/@keyv/serialize": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.0.3.tgz", - "integrity": "sha512-qnEovoOp5Np2JDGonIDL6Ayihw0RhnRh6vxPuHo4RDn1UOzwEo4AeIfpL6UGIrsceWrCMiVPgwRjbHu4vYFc3g==", "dev": true, "license": "MIT", "dependencies": { @@ -2469,18 +2520,16 @@ }, "node_modules/@kwsites/file-exists": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", - "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^4.1.1" } }, "node_modules/@kwsites/promise-deferred": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", - "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@lezer/common": { "version": "0.15.12", @@ -2493,6 +2542,19 @@ "@lezer/common": "^0.15.0" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "dev": true, @@ -2568,8 +2630,10 @@ "@parcel/watcher-win32-x64": "2.5.0" } }, - "node_modules/@parcel/watcher-darwin-arm64": { + "node_modules/@parcel/watcher-android-arm64": { "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.0.tgz", + "integrity": "sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==", "cpu": [ "arm64" ], @@ -2577,7 +2641,7 @@ "license": "MIT", "optional": true, "os": [ - "darwin" + "android" ], "engines": { "node": ">= 10.0.0" @@ -2587,154 +2651,386 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.0", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=14" - } - }, - "node_modules/@playwright/test": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz", - "integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==", - "dev": true, - "dependencies": { - "playwright": "1.55.0" - }, - "bin": { - "playwright": "cli.js" + "node": ">= 10.0.0" }, - "engines": { - "node": ">=18" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "dev": true, - "license": "MIT" - }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "dev": true, - "license": "MIT" - }, - "node_modules/@sindresorhus/is": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", - "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.0.tgz", + "integrity": "sha512-9rhlwd78saKf18fT869/poydQK8YqlU26TMiNg7AIu7eBp9adqbJZqmdFOsbZ5cnLp5XvRo9wcFmNHgHdWaGYA==", + "cpu": [ + "x64" + ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=10" + "node": ">= 10.0.0" }, "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@stylistic/eslint-plugin": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-3.1.0.tgz", - "integrity": "sha512-pA6VOrOqk0+S8toJYhQGv2MWpQQR0QpeUo9AhNkC49Y26nxBQ/nH1rta9bUU1rPw2fJ1zZEMV5oCX5AazT7J2g==", + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.0.tgz", + "integrity": "sha512-syvfhZzyM8kErg3VF0xpV8dixJ+RzbUaaGaeb7uDuz0D3FK97/mZ5AJQ3XNnDsXX7KkFNtyQyFrXZzQIcN49Tw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@typescript-eslint/utils": "^8.13.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", - "estraverse": "^5.3.0", - "picomatch": "^4.0.2" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">= 10.0.0" }, - "peerDependencies": { - "eslint": ">=8.40.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@stylistic/eslint-plugin/node_modules/eslint-visitor-keys": { - "version": "4.2.0", + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.0.tgz", + "integrity": "sha512-0VQY1K35DQET3dVYWpOaPFecqOT9dbuCfzjxoQyif1Wc574t3kOSkKevULddcR9znz1TcklCE7Ht6NIxjvTqLA==", + "cpu": [ + "arm" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">= 10.0.0" }, "funding": { - "url": "https://opencollective.com/eslint" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@stylistic/eslint-plugin/node_modules/picomatch": { - "version": "4.0.2", + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.0.tgz", + "integrity": "sha512-6uHywSIzz8+vi2lAzFeltnYbdHsDm3iIB57d4g5oaB9vKwjb6N6dRIgZMujw4nm5r6v9/BQH0noq6DzHrqr2pA==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" + "node": ">= 10.0.0" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@stylistic/stylelint-plugin": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@stylistic/stylelint-plugin/-/stylelint-plugin-3.1.2.tgz", - "integrity": "sha512-tylFJGMQo62alGazK74MNxFjMagYOHmBZiePZFOJK2n13JZta0uVkB3Bh5qodUmOLtRH+uxH297EibK14UKm8g==", + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.0.tgz", + "integrity": "sha512-BfNjXwZKxBy4WibDb/LDCriWSKLz+jJRL3cM/DllnHH5QUyoiUNEp3GmL80ZqxeumoADfCCP19+qiYiC8gUBjA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@csstools/css-parser-algorithms": "^3.0.1", - "@csstools/css-tokenizer": "^3.0.1", - "@csstools/media-query-list-parser": "^3.0.1", - "is-plain-object": "^5.0.0", - "postcss-selector-parser": "^6.1.2", - "postcss-value-parser": "^4.2.0", - "style-search": "^0.1.0", - "stylelint": "^16.8.2" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^18.12 || >=20.9" + "node": ">= 10.0.0" }, - "peerDependencies": { - "stylelint": "^16.8.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@stylistic/stylelint-plugin/node_modules/@csstools/media-query-list-parser": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-3.0.1.tgz", - "integrity": "sha512-HNo8gGD02kHmcbX6PvCoUuOQvn4szyB9ca63vZHKX5A81QytgDG4oxG4IaEfHTlEZSZ6MjPEMWIVU+zF2PZcgw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.0.tgz", + "integrity": "sha512-S1qARKOphxfiBEkwLUbHjCY9BWPdWnW9j7f7Hb2jPplu8UZ3nes7zpPOW9bkLbHRvWM0WDTsjdOTUgW0xLBN1Q==", + "cpu": [ + "arm64" ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18" + "node": ">= 10.0.0" }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.1", - "@csstools/css-tokenizer": "^3.0.1" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@szmarczak/http-timer": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", - "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.0.tgz", + "integrity": "sha512-d9AOkusyXARkFD66S6zlGXyzx5RvY+chTP9Jp0ypSTC9d4lzyRs9ovGf/80VCxjKddcUvnsGwCHWuF2EoPgWjw==", + "cpu": [ + "x64" + ], "dev": true, - "dependencies": { - "defer-to-connect": "^2.0.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10" + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/@tannin/compile": { + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.0.tgz", + "integrity": "sha512-iqOC+GoTDoFyk/VYSFHwjHhYrk8bljW6zOhPuhi5t9ulqiYq1togGJB5e3PwYVFFfeVgc6pbz3JdQyDoBszVaA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.0.tgz", + "integrity": "sha512-twtft1d+JRNkM5YbmexfcH/N4znDtjgysFaV9zvZmmJezQsKpkfLYJ+JFV3uygugK6AtIM2oADPkB2AdhBrNig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.0.tgz", + "integrity": "sha512-+rgpsNRKwo8A53elqbbHXdOMtY/tAtTzManTWShB5Kk54N8Q9mzNWV7tV+IbGueCbcj826MfWGU3mprWtuf1TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.0.tgz", + "integrity": "sha512-lPrxve92zEHdgeff3aiu4gDOIt4u7sJYha6wbdEZDCDUhtjTsOMiaJzG5lMY4GkWH8p0fMmO2Ppq5G5XXG+DQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@stylistic/eslint-plugin": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.13.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "estraverse": "^5.3.0", + "picomatch": "^4.0.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": ">=8.40.0" + } + }, + "node_modules/@stylistic/eslint-plugin/node_modules/picomatch": { + "version": "4.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@stylistic/stylelint-plugin": { + "version": "3.1.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-parser-algorithms": "^3.0.1", + "@csstools/css-tokenizer": "^3.0.1", + "@csstools/media-query-list-parser": "^3.0.1", + "is-plain-object": "^5.0.0", + "postcss": "^8.4.41", + "postcss-selector-parser": "^6.1.2", + "postcss-value-parser": "^4.2.0", + "style-search": "^0.1.0" + }, + "engines": { + "node": "^18.12 || >=20.9" + }, + "peerDependencies": { + "stylelint": "^16.8.0" + } + }, + "node_modules/@stylistic/stylelint-plugin/node_modules/@csstools/media-query-list-parser": { + "version": "3.0.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.1", + "@csstools/css-tokenizer": "^3.0.1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tannin/compile": { "version": "1.1.0", "license": "MIT", "dependencies": { @@ -2757,6 +3053,12 @@ "version": "1.1.0", "license": "MIT" }, + "node_modules/@tannin/sprintf": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@tannin/sprintf/-/sprintf-1.3.3.tgz", + "integrity": "sha512-RwARl+hFwhzy0tg9atWcchLFvoQiOh4rrP7uG2N5E4W80BPCUX0ElcUR9St43fxB9EfjsW2df9Qp+UsTbvQDjA==", + "license": "MIT" + }, "node_modules/@trysound/sax": { "version": "0.2.0", "dev": true, @@ -2790,6 +3092,17 @@ "dev": true, "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==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/archiver": { "version": "6.0.3", "dev": true, @@ -2800,9 +3113,8 @@ }, "node_modules/@types/cacheable-request": { "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", - "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", "dev": true, + "license": "MIT", "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", @@ -2837,7 +3149,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.6", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, @@ -2851,9 +3165,8 @@ }, "node_modules/@types/http-cache-semantics": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", - "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", @@ -2896,15 +3209,16 @@ }, "node_modules/@types/keyv": { "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", - "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*" } }, "node_modules/@types/mousetrap": { - "version": "1.6.14", + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/@types/mousetrap/-/mousetrap-1.6.15.tgz", + "integrity": "sha512-qL0hyIMNPow317QWW/63RvL1x5MVMV+Ru3NaY9f/CuEpCqrmb7WeuK2071ZY5hczOnm38qExWM2i2WtkXLSqFw==", "license": "MIT" }, "node_modules/@types/node": { @@ -2929,11 +3243,13 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.12", + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "license": "MIT", "dependencies": { "@types/prop-types": "*", - "csstype": "^3.0.2" + "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { @@ -2960,9 +3276,8 @@ }, "node_modules/@types/responselike": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", - "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*" } @@ -2990,8 +3305,6 @@ }, "node_modules/@types/web": { "version": "0.0.202", - "resolved": "https://registry.npmjs.org/@types/web/-/web-0.0.202.tgz", - "integrity": "sha512-2iO+wBir5OBnMlB9Z7aD/0SUZjR2mhiCLtPvGPboTqwBC4O3Yv6Vjwn5eMxGMXtRAm01OV9yUBi9C8pJa02TIA==", "dev": true, "license": "Apache-2.0" }, @@ -3008,20 +3321,144 @@ "dev": true, "license": "MIT" }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.24.0", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", + "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/type-utils": "8.56.0", + "@typescript-eslint/utils": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.56.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz", + "integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz", + "integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", + "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz", + "integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys/node_modules/@typescript-eslint/types": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz", + "integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz", + "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.24.0", - "@typescript-eslint/type-utils": "8.24.0", - "@typescript-eslint/utils": "8.24.0", - "@typescript-eslint/visitor-keys": "8.24.0", - "graphemer": "^1.4.0", - "ignore": "^5.3.1", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.0.1" + "@typescript-eslint/tsconfig-utils": "^8.56.0", + "@typescript-eslint/types": "^8.56.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3031,21 +3468,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/parser": { - "version": "8.24.0", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", + "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.24.0", - "@typescript-eslint/types": "8.24.0", - "@typescript-eslint/typescript-estree": "8.24.0", - "@typescript-eslint/visitor-keys": "8.24.0", - "debug": "^4.3.4" + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3053,37 +3487,69 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.24.0", + "node_modules/@typescript-eslint/scope-manager/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz", + "integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.24.0", - "@typescript-eslint/visitor-keys": "8.24.0" + "@typescript-eslint/types": "8.55.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/scope-manager/node_modules/@typescript-eslint/visitor-keys/node_modules/@typescript-eslint/types": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz", + "integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz", + "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==", + "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.24.0", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz", + "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.24.0", - "@typescript-eslint/utils": "8.24.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.0.1" + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/utils": "8.56.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3093,12 +3559,14 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.24.0", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", + "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", "dev": true, "license": "MIT", "engines": { @@ -3110,18 +3578,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.24.0", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz", + "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.24.0", - "@typescript-eslint/visitor-keys": "8.24.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.0.1" + "@typescript-eslint/project-service": "8.56.0", + "@typescript-eslint/tsconfig-utils": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3131,19 +3602,45 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", + "node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz", + "integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "@typescript-eslint/types": "8.55.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/visitor-keys/node_modules/@typescript-eslint/types": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz", + "integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, "license": "ISC", "dependencies": { @@ -3157,7 +3654,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.1", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -3168,14 +3667,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.24.0", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", + "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.24.0", - "@typescript-eslint/types": "8.24.0", - "@typescript-eslint/typescript-estree": "8.24.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3185,36 +3686,278 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.24.0", + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@typescript-eslint/types": "8.24.0", - "eslint-visitor-keys": "^4.2.0" + "@napi-rs/wasm-runtime": "^0.2.11" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" + "node": ">=14.0.0" } }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.0", + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@use-gesture/core": { "version": "10.3.1", @@ -3403,14 +4146,33 @@ } }, "node_modules/@wordpress/a11y": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/a11y/-/a11y-4.20.0.tgz", - "integrity": "sha512-hyFKC3D1o0Cvy1HeFgujsuW9gTrwVL4DVIfnQytG2+gMFaDyux4Qmzyg2e3k71BKlHn7J28Q3i0xNqC2k7ZoFw==", + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@wordpress/a11y/-/a11y-4.40.0.tgz", + "integrity": "sha512-WhBuBgJTvanbBMNeflgCvwQLOU9ToITdYSzOvWg0kzz1i/e138NlCxrVpcXGUc6MQulduKhOWOtjizSdotaQRA==", "license": "GPL-2.0-or-later", "dependencies": { - "@babel/runtime": "7.25.7", - "@wordpress/dom-ready": "^4.20.0", - "@wordpress/i18n": "^5.20.0" + "@wordpress/dom-ready": "^4.40.0", + "@wordpress/i18n": "^6.13.0" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, + "node_modules/@wordpress/a11y/node_modules/@wordpress/i18n": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-6.13.0.tgz", + "integrity": "sha512-Yx882uFxcg6QpB13fv8UhvM6k5NwMQGfNXKB9SVSNL/APvDWn2m/n4n+5GZYi+wOV+KJLojQZbdRpHWCnX/jFg==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@tannin/sprintf": "^1.3.2", + "@wordpress/hooks": "^4.40.0", + "gettext-parser": "^1.3.1", + "memize": "^2.1.0", + "tannin": "^1.2.0" + }, + "bin": { + "pot-to-php": "tools/pot-to-php.js" }, "engines": { "node": ">=18.12.0", @@ -3418,20 +4180,20 @@ } }, "node_modules/@wordpress/babel-preset-default": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/babel-preset-default/-/babel-preset-default-8.20.0.tgz", - "integrity": "sha512-UGfPuNFjN8RG1BsFc04jOHoJFi3ZINYo4nsmrrUx1PFSFD2qpttmV03dWFWfqSvLvrMlYPQPMkYyK5KS6THxVQ==", + "version": "8.40.0", + "resolved": "https://registry.npmjs.org/@wordpress/babel-preset-default/-/babel-preset-default-8.40.0.tgz", + "integrity": "sha512-UzSwDaxsMarnlfFUmEWW2qvkJy4JupW49uH0JztFobCamQ5QCL71M75zIspIXffiZVjQMBWruR7/+5QTJklewA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/core": "7.25.7", + "@babel/plugin-syntax-import-attributes": "7.26.0", "@babel/plugin-transform-react-jsx": "7.25.7", "@babel/plugin-transform-runtime": "7.25.7", "@babel/preset-env": "7.25.7", "@babel/preset-typescript": "7.25.7", - "@babel/runtime": "7.25.7", - "@wordpress/browserslist-config": "^6.20.0", - "@wordpress/warning": "^3.20.0", + "@wordpress/browserslist-config": "^6.40.0", + "@wordpress/warning": "^3.40.0", "browserslist": "^4.21.10", "core-js": "^3.31.0", "react": "^18.3.0" @@ -3442,9 +4204,9 @@ } }, "node_modules/@wordpress/browserslist-config": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/browserslist-config/-/browserslist-config-6.20.0.tgz", - "integrity": "sha512-n9Q1UN3QL4DuZLySZpbJoZbQvBTjMjRV5yaxnmQaEpOyqablX4GnYq39fwTY72hBN/c1b0oyOFcsbhsrx0wqzg==", + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/@wordpress/browserslist-config/-/browserslist-config-6.40.0.tgz", + "integrity": "sha512-aX44MD4Kcr4LZT1YWa3VkMUUJjNfAgt7UECs/qrNGM8tC3l4/2Z4zRkJfpK3AoaXusb1J8+r5ZXWJWhxYK1JMQ==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -3453,9 +4215,9 @@ } }, "node_modules/@wordpress/components": { - "version": "29.6.0", - "resolved": "https://registry.npmjs.org/@wordpress/components/-/components-29.6.0.tgz", - "integrity": "sha512-kk9GxGnoGBqHz0S4gT2UJHQBwudE1AgTPOc3v3k72kZkDaT88ZayBd/4/gHsa659zImgrwXZ6SjQ6Nczt80Bgg==", + "version": "29.12.0", + "resolved": "https://registry.npmjs.org/@wordpress/components/-/components-29.12.0.tgz", + "integrity": "sha512-jE96pUj84OZya54VusRdEIdTiLjbe2Qst3GbHZcQpA5GiSkPBmGjKWpO6FxR7kRDT4GMnZoVxgtV6xJk4IaNQw==", "license": "GPL-2.0-or-later", "dependencies": { "@ariakit/react": "^0.4.15", @@ -3470,23 +4232,23 @@ "@types/gradient-parser": "0.1.3", "@types/highlight-words-core": "1.2.1", "@use-gesture/react": "^10.3.1", - "@wordpress/a11y": "^4.20.0", - "@wordpress/compose": "^7.20.0", - "@wordpress/date": "^5.20.0", - "@wordpress/deprecated": "^4.20.0", - "@wordpress/dom": "^4.20.0", - "@wordpress/element": "^6.20.0", - "@wordpress/escape-html": "^3.20.0", - "@wordpress/hooks": "^4.20.0", - "@wordpress/html-entities": "^4.20.0", - "@wordpress/i18n": "^5.20.0", - "@wordpress/icons": "^10.20.0", - "@wordpress/is-shallow-equal": "^5.20.0", - "@wordpress/keycodes": "^4.20.0", - "@wordpress/primitives": "^4.20.0", - "@wordpress/private-apis": "^1.20.0", - "@wordpress/rich-text": "^7.20.0", - "@wordpress/warning": "^3.20.0", + "@wordpress/a11y": "^4.26.0", + "@wordpress/compose": "^7.26.0", + "@wordpress/date": "^5.26.0", + "@wordpress/deprecated": "^4.26.0", + "@wordpress/dom": "^4.26.0", + "@wordpress/element": "^6.26.0", + "@wordpress/escape-html": "^3.26.0", + "@wordpress/hooks": "^4.26.0", + "@wordpress/html-entities": "^4.26.0", + "@wordpress/i18n": "^5.26.0", + "@wordpress/icons": "^10.26.0", + "@wordpress/is-shallow-equal": "^5.26.0", + "@wordpress/keycodes": "^4.26.0", + "@wordpress/primitives": "^4.26.0", + "@wordpress/private-apis": "^1.26.0", + "@wordpress/rich-text": "^7.26.0", + "@wordpress/warning": "^3.26.0", "change-case": "^4.1.2", "clsx": "^2.1.1", "colord": "^2.7.0", @@ -3494,7 +4256,7 @@ "deepmerge": "^4.3.0", "fast-deep-equal": "^3.1.3", "framer-motion": "^11.1.9", - "gradient-parser": "^0.1.5", + "gradient-parser": "1.0.2", "highlight-words-core": "^1.2.2", "is-plain-object": "^5.0.0", "memize": "^2.1.0", @@ -3514,20 +4276,19 @@ } }, "node_modules/@wordpress/compose": { - "version": "7.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-7.20.0.tgz", - "integrity": "sha512-L84QUGXbXPdCAgNDNmmH+4tJuAl1MwH5an6CaQ+NaSXk4kM4xAc42znHo0n5LfsRmWxOPrtlGikxMXCaejvoyw==", + "version": "7.40.0", + "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-7.40.0.tgz", + "integrity": "sha512-u8LR5dxJd8KsiEv8eKG+aIgyRrp0lH0oOJy7cK9Jh721zc24TBu8vpxCADL7LbgmpPjQrjHh3LmPoCBtWL+FMg==", "license": "GPL-2.0-or-later", "dependencies": { - "@babel/runtime": "7.25.7", "@types/mousetrap": "^1.6.8", - "@wordpress/deprecated": "^4.20.0", - "@wordpress/dom": "^4.20.0", - "@wordpress/element": "^6.20.0", - "@wordpress/is-shallow-equal": "^5.20.0", - "@wordpress/keycodes": "^4.20.0", - "@wordpress/priority-queue": "^3.20.0", - "@wordpress/undo-manager": "^1.20.0", + "@wordpress/deprecated": "^4.40.0", + "@wordpress/dom": "^4.40.0", + "@wordpress/element": "^6.40.0", + "@wordpress/is-shallow-equal": "^5.40.0", + "@wordpress/keycodes": "^4.40.0", + "@wordpress/priority-queue": "^3.40.0", + "@wordpress/undo-manager": "^1.40.0", "change-case": "^4.1.2", "clipboard": "^2.0.11", "mousetrap": "^1.6.5", @@ -3542,19 +4303,18 @@ } }, "node_modules/@wordpress/data": { - "version": "10.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/data/-/data-10.20.0.tgz", - "integrity": "sha512-oj1Ci7mPZ2kbmI2cdqk7apfvd4nlWziPstlIZIKCb02rCEMqP8dC0lc/CDt8GVOXJ23iMhZgkfkvnFNaMXmBNQ==", + "version": "10.40.0", + "resolved": "https://registry.npmjs.org/@wordpress/data/-/data-10.40.0.tgz", + "integrity": "sha512-wwqkMc9iLteRO1zNxL/R3COWnijsdC5TIjenmd2JivReUmdA4ulAN3Tq7QiHkhwOV4jzZkuWW7DgR2ynxf55lw==", "license": "GPL-2.0-or-later", "dependencies": { - "@babel/runtime": "7.25.7", - "@wordpress/compose": "^7.20.0", - "@wordpress/deprecated": "^4.20.0", - "@wordpress/element": "^6.20.0", - "@wordpress/is-shallow-equal": "^5.20.0", - "@wordpress/priority-queue": "^3.20.0", - "@wordpress/private-apis": "^1.20.0", - "@wordpress/redux-routine": "^5.20.0", + "@wordpress/compose": "^7.40.0", + "@wordpress/deprecated": "^4.40.0", + "@wordpress/element": "^6.40.0", + "@wordpress/is-shallow-equal": "^5.40.0", + "@wordpress/priority-queue": "^3.40.0", + "@wordpress/private-apis": "^1.40.0", + "@wordpress/redux-routine": "^5.40.0", "deepmerge": "^4.3.0", "equivalent-key-map": "^0.2.2", "is-plain-object": "^5.0.0", @@ -3572,13 +4332,12 @@ } }, "node_modules/@wordpress/date": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/date/-/date-5.20.0.tgz", - "integrity": "sha512-V34zSLveuXTe8wvnIpUXroP7dP9FK1HzMmGNB5JtoPhrqJeNvP4fzju8RJwBGpU1sFaqO3w+EZoNdTV9k0hqxA==", + "version": "5.43.0", + "resolved": "https://registry.npmjs.org/@wordpress/date/-/date-5.43.0.tgz", + "integrity": "sha512-8DiFlE7YzP7F/P59Hr6h5fWJxJlvt6eZgU1C7huM9XhANh8Y3dZfepsySL6K7h1yE66SQDSq07cEefFQgJW31g==", "license": "GPL-2.0-or-later", "dependencies": { - "@babel/runtime": "7.25.7", - "@wordpress/deprecated": "^4.20.0", + "@wordpress/deprecated": "^4.43.0", "moment": "^2.29.4", "moment-timezone": "^0.5.40" }, @@ -3588,13 +4347,12 @@ } }, "node_modules/@wordpress/deprecated": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/deprecated/-/deprecated-4.20.0.tgz", - "integrity": "sha512-36JbtGUSQ49SM33fvfSAvN8ZGDqCxCPAj2PByAney4WhoVbznxGWnao8qKwWrNNG5xec1reQvXFxOsD7qab4rg==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@wordpress/deprecated/-/deprecated-4.43.0.tgz", + "integrity": "sha512-Pxn+nUmCVAaKBiZun2tEVweVdevMvWFWyCRqIqsAKdWCLsD8Uk6o27EwXc1u8BlO65VmK8D2zF9uWKGKfdZbCw==", "license": "GPL-2.0-or-later", "dependencies": { - "@babel/runtime": "7.25.7", - "@wordpress/hooks": "^4.20.0" + "@wordpress/hooks": "^4.43.0" }, "engines": { "node": ">=18.12.0", @@ -3602,13 +4360,12 @@ } }, "node_modules/@wordpress/dom": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/dom/-/dom-4.20.0.tgz", - "integrity": "sha512-uLYH7hKfJDUHkooAy0uoFJXMCkraTP3gdybblAJT9a/dqAOVcsMODH9gTGI99IoFhsvJwWo5Vk94/kgqeOdarA==", + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@wordpress/dom/-/dom-4.40.0.tgz", + "integrity": "sha512-JBF1sRjJMFgLn0pet0tmPzO1kNaa35/DwAAtG81zzjikctR1PzE3EK8o6ZGPtUY1sTa9l7aB1Lxfcum/eroyRg==", "license": "GPL-2.0-or-later", "dependencies": { - "@babel/runtime": "7.25.7", - "@wordpress/deprecated": "^4.20.0" + "@wordpress/deprecated": "^4.40.0" }, "engines": { "node": ">=18.12.0", @@ -3616,28 +4373,24 @@ } }, "node_modules/@wordpress/dom-ready": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/dom-ready/-/dom-ready-4.20.0.tgz", - "integrity": "sha512-FkdfoITfj1yBSUMn+IKIqpm7zwA4AbHPkYdCXNgP9w5BRBpoTqXMGgDbe8rt4aSWkSEiRChZ9rGmtG84LByRTA==", + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@wordpress/dom-ready/-/dom-ready-4.40.0.tgz", + "integrity": "sha512-mHVy4P6yc0XLmGgnccxptMKg83TwcbYKfYrQH8pTcIu43P24zONTd44eZFjkfz7c/b+RLJg1Kj+d5mKh1xqH1A==", "license": "GPL-2.0-or-later", - "dependencies": { - "@babel/runtime": "7.25.7" - }, "engines": { "node": ">=18.12.0", "npm": ">=8.19.2" } }, "node_modules/@wordpress/element": { - "version": "6.28.0", - "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-6.28.0.tgz", - "integrity": "sha512-FSojQxfsaDXwc11nMgc/OlIgq1BgjpNf9m2Smw1Z3GmVq8J4E6wAFpJuoUPwyjON4i1apiWl/bqQA84yT9C84g==", + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-6.40.0.tgz", + "integrity": "sha512-OhU8B2xEGg7c41rh/VRiJLOz6TnM/r5r8sraAg5ISc2bF7s2oAFqLwvlR0/U6ervyYwbK644osWZGQxFyL3huA==", "license": "GPL-2.0-or-later", "dependencies": { - "@babel/runtime": "7.25.7", - "@types/react": "^18.2.79", - "@types/react-dom": "^18.2.25", - "@wordpress/escape-html": "^3.28.0", + "@types/react": "^18.3.27", + "@types/react-dom": "^18.3.1", + "@wordpress/escape-html": "^3.40.0", "change-case": "^4.1.2", "is-plain-object": "^5.0.0", "react": "^18.3.0", @@ -3650,9 +4403,8 @@ }, "node_modules/@wordpress/env": { "version": "9.10.0", - "resolved": "https://registry.npmjs.org/@wordpress/env/-/env-9.10.0.tgz", - "integrity": "sha512-GqUg1XdrUXI3l5NhHhEZisrccW+VPqJSU5xO1IXybI6KOvmSecidxWEqlMj26vzu2P5aLCWZcx28QkrrY3jvdg==", "dev": true, + "license": "GPL-2.0-or-later", "dependencies": { "chalk": "^4.0.0", "copy-dir": "^1.3.0", @@ -3667,85 +4419,110 @@ "terminal-link": "^2.0.0", "yargs": "^17.3.0" }, - "bin": { - "wp-env": "bin/wp-env" + "bin": { + "wp-env": "bin/wp-env" + } + }, + "node_modules/@wordpress/env/node_modules/cliui": { + "version": "8.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" } }, - "node_modules/@wordpress/env/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "node_modules/@wordpress/env/node_modules/wrap-ansi": { + "version": "7.0.0", "dev": true, + "license": "MIT", "dependencies": { - "sprintf-js": "~1.0.2" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@wordpress/env/node_modules/y18n": { + "version": "5.0.8", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" } }, - "node_modules/@wordpress/env/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "node_modules/@wordpress/env/node_modules/yargs": { + "version": "17.7.2", "dev": true, + "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": ">=12" } }, - "node_modules/@wordpress/env/node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true + "node_modules/@wordpress/env/node_modules/yargs-parser": { + "version": "21.1.1", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } }, "node_modules/@wordpress/escape-html": { - "version": "3.28.0", - "resolved": "https://registry.npmjs.org/@wordpress/escape-html/-/escape-html-3.28.0.tgz", - "integrity": "sha512-LDcr26vX7OkcvHMjAFxg0vNmI7cP5lzLs+HbnwM1H9h0dsj3svIWXXFF/7lQl7sbI9+rjF0GkR1Fgd+DvF7zxw==", + "version": "3.40.0", + "resolved": "https://registry.npmjs.org/@wordpress/escape-html/-/escape-html-3.40.0.tgz", + "integrity": "sha512-DD6xWVbnw4fGGgO6DFDTJiLj52om0OG4cYHLz7ZhuipmOlEUGljPYOcrj8uxtlh5EFrqHCIPkOya+qQXUHUSBw==", "license": "GPL-2.0-or-later", - "dependencies": { - "@babel/runtime": "7.25.7" - }, "engines": { "node": ">=18.12.0", "npm": ">=8.19.2" } }, "node_modules/@wordpress/hooks": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.20.0.tgz", - "integrity": "sha512-nn6RbAER5EitMJVr+jpOg5HDIUEEOEv6jC/P1s5C0HvsOaldBeJ80A73Gsd/NFGlUqCc7o51uoZO36wGoPjIpg==", + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.43.0.tgz", + "integrity": "sha512-BY7GPjEwhOlgkavVak40E3RtA8Z9ehydqTZckRoesMRjXYfxKSzr1C1FT4wAPS5uXM1pNlWivfofMaJjVNQu5w==", "license": "GPL-2.0-or-later", - "dependencies": { - "@babel/runtime": "7.25.7" - }, "engines": { "node": ">=18.12.0", "npm": ">=8.19.2" } }, "node_modules/@wordpress/html-entities": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/html-entities/-/html-entities-4.20.0.tgz", - "integrity": "sha512-ZOQ9zsfs5p32K+uAEy2vbY7rnAG5KjMdXwOn4v2FPeXF6A6jWQudK/smV7nRB3ZMaSZnzQ54tiUXbuSpCmmGYA==", + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@wordpress/html-entities/-/html-entities-4.40.0.tgz", + "integrity": "sha512-bsJrwZk22On8gNhUd84yyWKt/nrNZtACNZpXmkpyue/oTlFqNenLfhqRkvTKJzjbLxrrcUPsXlskbPcS7mxwTQ==", "license": "GPL-2.0-or-later", - "dependencies": { - "@babel/runtime": "7.25.7" - }, "engines": { "node": ">=18.12.0", "npm": ">=8.19.2" } }, "node_modules/@wordpress/i18n": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-5.20.0.tgz", - "integrity": "sha512-JrgVe5QT+nDHFbujeD0lJifDpdgmOt1SSnEK631jIISjfGjriYwphoOEAzBGRh9S9ThqOOfW4mLOOeXPYmJR7w==", + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-5.26.0.tgz", + "integrity": "sha512-YHzaUWlCuN2ynl47qbsdMkTGtP52+E1giDOdWBgUaSexUYjbeFxKFUzRMB0Wuh1psL80+VzvJOH/mU440KAJnA==", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/hooks": "^4.20.0", + "@wordpress/hooks": "^4.26.0", "gettext-parser": "^1.3.1", "memize": "^2.1.0", "sprintf-js": "^1.1.1", @@ -3760,14 +4537,14 @@ } }, "node_modules/@wordpress/icons": { - "version": "10.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/icons/-/icons-10.20.0.tgz", - "integrity": "sha512-wGmmGDQoDKjmuGdC2I8C3JA9GlqVM9DK5FJZuUukHTh+Nz72W8CA30PzGKavxWOYd7cZ0B97VioE85aVwOAe3g==", + "version": "10.32.0", + "resolved": "https://registry.npmjs.org/@wordpress/icons/-/icons-10.32.0.tgz", + "integrity": "sha512-1WvJdT361X1LnetYBpBWUjAVXZzl+pBdIwHbYRAp8ej47EI/igPmNxmq81nFd40s8fer/9qtipielcqSI6H2rA==", "license": "GPL-2.0-or-later", "dependencies": { "@babel/runtime": "7.25.7", - "@wordpress/element": "^6.20.0", - "@wordpress/primitives": "^4.20.0" + "@wordpress/element": "^6.32.0", + "@wordpress/primitives": "^4.32.0" }, "engines": { "node": ">=18.12.0", @@ -3775,26 +4552,42 @@ } }, "node_modules/@wordpress/is-shallow-equal": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/is-shallow-equal/-/is-shallow-equal-5.20.0.tgz", - "integrity": "sha512-/m8P/6AQgZchMbeDhne5z8Wzde07mv8+l7qsYK6VhChEWonrYN7Sfig9uGPtWijkWwOkxYjWE6ggcJ5xn8KVlg==", + "version": "5.40.0", + "resolved": "https://registry.npmjs.org/@wordpress/is-shallow-equal/-/is-shallow-equal-5.40.0.tgz", + "integrity": "sha512-IU11xOcHIGqDLxx9X+8RIk4WFo0qqba0bpeLqrVKsQXNGjP7tXSo2ufylxE9K9CEYXFMF0C65k83XpRZtEkA8g==", + "license": "GPL-2.0-or-later", + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, + "node_modules/@wordpress/keycodes": { + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-4.40.0.tgz", + "integrity": "sha512-laLkfjwkhMdreCl/KQdHucBIQAYwSjkyk3BToq/PCrcxFJBwWK2NgEtSl/t1CEw2HJwe0H2ne3FEWtipY4iDrA==", "license": "GPL-2.0-or-later", "dependencies": { - "@babel/runtime": "7.25.7" + "@wordpress/i18n": "^6.13.0" }, "engines": { "node": ">=18.12.0", "npm": ">=8.19.2" } }, - "node_modules/@wordpress/keycodes": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-4.20.0.tgz", - "integrity": "sha512-GLzp9uTSNOPvX378FInwvLj4riqq1N/By1kd40iAr1hXfRAjy0H//vktJ70r+AkwK0R07txtCPiLnDcW53hLmg==", + "node_modules/@wordpress/keycodes/node_modules/@wordpress/i18n": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-6.13.0.tgz", + "integrity": "sha512-Yx882uFxcg6QpB13fv8UhvM6k5NwMQGfNXKB9SVSNL/APvDWn2m/n4n+5GZYi+wOV+KJLojQZbdRpHWCnX/jFg==", "license": "GPL-2.0-or-later", "dependencies": { - "@babel/runtime": "7.25.7", - "@wordpress/i18n": "^5.20.0" + "@tannin/sprintf": "^1.3.2", + "@wordpress/hooks": "^4.40.0", + "gettext-parser": "^1.3.1", + "memize": "^2.1.0", + "tannin": "^1.2.0" + }, + "bin": { + "pot-to-php": "tools/pot-to-php.js" }, "engines": { "node": ">=18.12.0", @@ -3802,13 +4595,12 @@ } }, "node_modules/@wordpress/primitives": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/primitives/-/primitives-4.20.0.tgz", - "integrity": "sha512-fVs9EnuI2UV1xfAYY//OOfO+O3n4VvPVGcI/zHMAfIdJGWEbCQVDatAnteX/2hkjBe85jqErkU+0bAKsddhpcA==", + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@wordpress/primitives/-/primitives-4.40.0.tgz", + "integrity": "sha512-0gOw3n3kSUsAPo91xNDS9J4GGTrNXU90XmuWn7mNfXAl5uRAMRnxgkfL+pwd0ng0rmdPtjPqrJpljnP2oy3K2w==", "license": "GPL-2.0-or-later", "dependencies": { - "@babel/runtime": "7.25.7", - "@wordpress/element": "^6.20.0", + "@wordpress/element": "^6.40.0", "clsx": "^2.1.1" }, "engines": { @@ -3820,12 +4612,11 @@ } }, "node_modules/@wordpress/priority-queue": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/priority-queue/-/priority-queue-3.20.0.tgz", - "integrity": "sha512-2gOa8LQaTLPgk1GDkkXWALA9yH47yhDZKHKBHy8YH61c+m8ai8RctWegzXA6pSInPW77nbBUNHSOzxWTsDN1Sw==", + "version": "3.40.0", + "resolved": "https://registry.npmjs.org/@wordpress/priority-queue/-/priority-queue-3.40.0.tgz", + "integrity": "sha512-85km9+I7RWi7P73BU/yom41gpdu0watdQ1GscQhQBel6BjHOXO5qWG6P9i3sEH47bz7EyO248l4LC/h8oHqpfQ==", "license": "GPL-2.0-or-later", "dependencies": { - "@babel/runtime": "7.25.7", "requestidlecallback": "^0.3.0" }, "engines": { @@ -3834,25 +4625,21 @@ } }, "node_modules/@wordpress/private-apis": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/private-apis/-/private-apis-1.20.0.tgz", - "integrity": "sha512-DngnywYj6zDt9D0HgnX7k0il5SsdDYUxEg82GqNu3Jd879LlG9MtIxcoV+ErCsH7ryTydXw4sC17W09m2LEMBQ==", + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@wordpress/private-apis/-/private-apis-1.40.0.tgz", + "integrity": "sha512-68cwZKVq8Xy8GBzKoDRuV4b3pQ4nJFItY689HXp+poc0XXrnAeC4ZhjeSgS1qGRpFo6RVvLjjcaZsN2OrSSMvQ==", "license": "GPL-2.0-or-later", - "dependencies": { - "@babel/runtime": "7.25.7" - }, "engines": { "node": ">=18.12.0", "npm": ">=8.19.2" } }, "node_modules/@wordpress/redux-routine": { - "version": "5.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/redux-routine/-/redux-routine-5.20.0.tgz", - "integrity": "sha512-6JZI75oMAWGBgo+x2rmfIGzqVuxiZ3wQBqNCdVDDOGYH9qcRzYgBWRSPVfh4rvGLTtpVnFHnnBQ+jr5iPGHOxQ==", + "version": "5.40.0", + "resolved": "https://registry.npmjs.org/@wordpress/redux-routine/-/redux-routine-5.40.0.tgz", + "integrity": "sha512-V+c1yCBl4i7qvRsWtQpGevbFCGtrRlzDe++4bwnrYJUiu79wbSXWRrmiSFr/EQie2KNM680t2MeFcfO7nsDVoA==", "license": "GPL-2.0-or-later", "dependencies": { - "@babel/runtime": "7.25.7", "is-plain-object": "^5.0.0", "is-promise": "^4.0.0", "rungen": "^0.3.2" @@ -3866,20 +4653,22 @@ } }, "node_modules/@wordpress/rich-text": { - "version": "7.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/rich-text/-/rich-text-7.20.0.tgz", - "integrity": "sha512-irx6cvmoxSSajzGGt5iVxek3vNfG5LslORQ1g7HXcNawfFBxhptU3vzPF2+ywvs6o3BCbTZVfa98rOfX3C2J/Q==", + "version": "7.40.0", + "resolved": "https://registry.npmjs.org/@wordpress/rich-text/-/rich-text-7.40.0.tgz", + "integrity": "sha512-eHImTvzPEg4GWAuzcagyc2tArc6neA2sbqvybpd5JzhEpgv/Q0zcKwLfUKI05kYaaPI/Rg5WXgeXDxjGYpq5hA==", "license": "GPL-2.0-or-later", "dependencies": { - "@babel/runtime": "7.25.7", - "@wordpress/a11y": "^4.20.0", - "@wordpress/compose": "^7.20.0", - "@wordpress/data": "^10.20.0", - "@wordpress/deprecated": "^4.20.0", - "@wordpress/element": "^6.20.0", - "@wordpress/escape-html": "^3.20.0", - "@wordpress/i18n": "^5.20.0", - "@wordpress/keycodes": "^4.20.0", + "@wordpress/a11y": "^4.40.0", + "@wordpress/compose": "^7.40.0", + "@wordpress/data": "^10.40.0", + "@wordpress/deprecated": "^4.40.0", + "@wordpress/dom": "^4.40.0", + "@wordpress/element": "^6.40.0", + "@wordpress/escape-html": "^3.40.0", + "@wordpress/i18n": "^6.13.0", + "@wordpress/keycodes": "^4.40.0", + "@wordpress/private-apis": "^1.40.0", + "colord": "2.9.3", "memize": "^2.1.0" }, "engines": { @@ -3890,14 +4679,33 @@ "react": "^18.0.0" } }, + "node_modules/@wordpress/rich-text/node_modules/@wordpress/i18n": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-6.13.0.tgz", + "integrity": "sha512-Yx882uFxcg6QpB13fv8UhvM6k5NwMQGfNXKB9SVSNL/APvDWn2m/n4n+5GZYi+wOV+KJLojQZbdRpHWCnX/jFg==", + "license": "GPL-2.0-or-later", + "dependencies": { + "@tannin/sprintf": "^1.3.2", + "@wordpress/hooks": "^4.40.0", + "gettext-parser": "^1.3.1", + "memize": "^2.1.0", + "tannin": "^1.2.0" + }, + "bin": { + "pot-to-php": "tools/pot-to-php.js" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + } + }, "node_modules/@wordpress/undo-manager": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/undo-manager/-/undo-manager-1.20.0.tgz", - "integrity": "sha512-IG3/u0uR0nfZ/kXRfC6DVFK52hbbNx4aMB/c5DAMQgKtJElE7Mz1Mf5zgU1XNlpBOdguQp6oo/nMpyJUIasipQ==", + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@wordpress/undo-manager/-/undo-manager-1.40.0.tgz", + "integrity": "sha512-QvhHke/bVaOSPeaV5mNvsuIQpc2dJFDhXZ7gUnpuzyuNHh74Xk6Ar0vvYcfXiALst4ejKqWCoKOBi7ve1h2ppg==", "license": "GPL-2.0-or-later", "dependencies": { - "@babel/runtime": "7.25.7", - "@wordpress/is-shallow-equal": "^5.20.0" + "@wordpress/is-shallow-equal": "^5.40.0" }, "engines": { "node": ">=18.12.0", @@ -3905,12 +4713,11 @@ } }, "node_modules/@wordpress/url": { - "version": "4.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-4.20.0.tgz", - "integrity": "sha512-IUkph25ewBDTxuSC9wXvMbec6IB2A3pNz0Xkm1Ffzm2ngk/f+0+Ko2WSKdXqqR8U67Eyb+ZUZFtBPmEsKvEZ4A==", + "version": "4.40.0", + "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-4.40.0.tgz", + "integrity": "sha512-DVAJlW7bdocKfQp8G7tS73vnobAC8TBbIHHdxeLQKwzT8mOkG4W/rpzN2KTxkiJKFXUu5in4F8a6T+Cy/Lt1eQ==", "license": "GPL-2.0-or-later", "dependencies": { - "@babel/runtime": "7.25.7", "remove-accents": "^0.5.0" }, "engines": { @@ -3919,9 +4726,9 @@ } }, "node_modules/@wordpress/warning": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@wordpress/warning/-/warning-3.20.0.tgz", - "integrity": "sha512-IQRvlWwNWO6kncZ/qQEX/KCvsrm/0FIcuCXrTXlGP4OslRG7XtU9xs2lOP34Y6G3onMwhpD8mXFUK7udq305EQ==", + "version": "3.40.0", + "resolved": "https://registry.npmjs.org/@wordpress/warning/-/warning-3.40.0.tgz", + "integrity": "sha512-0l3OFa1Z+UdhWRRHX9JWWKofo7Lbi2MqOFzzzn0MC26HOyfieQycjLVLNVNXaaodIKUhap6uDQq+JXbbHm881A==", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -3950,7 +4757,9 @@ } }, "node_modules/acorn": { - "version": "8.12.1", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", "bin": { @@ -3960,6 +4769,19 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, "node_modules/acorn-jsx": { "version": "5.3.2", "dev": true, @@ -3980,14 +4802,16 @@ } }, "node_modules/ajv": { - "version": "8.12.0", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -4023,9 +4847,8 @@ }, "node_modules/ansi-escapes": { "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", "dev": true, + "license": "MIT", "dependencies": { "type-fest": "^0.21.3" }, @@ -4046,8 +4869,6 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { @@ -4061,15 +4882,11 @@ } }, "node_modules/ansis": { - "version": "1.5.2", + "version": "4.0.0-node10", "dev": true, "license": "ISC", "engines": { - "node": ">=12.13" - }, - "funding": { - "type": "patreon", - "url": "https://patreon.com/biodiscus" + "node": ">=10" } }, "node_modules/archiver": { @@ -4106,16 +4923,11 @@ "node": ">= 14" } }, - "node_modules/archiver-utils/node_modules/brace-expansion": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/archiver-utils/node_modules/glob": { - "version": "10.4.5", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -4219,6 +5031,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "dev": true, @@ -4235,16 +5057,20 @@ } }, "node_modules/array-includes": { - "version": "3.1.8", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.4", - "is-string": "^1.0.7" + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -4255,8 +5081,6 @@ }, "node_modules/array-union": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", "dev": true, "license": "MIT", "engines": { @@ -4283,16 +5107,19 @@ } }, "node_modules/array.prototype.findlastindex": { - "version": "1.2.5", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", + "es-abstract": "^1.23.9", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -4302,14 +5129,16 @@ } }, "node_modules/array.prototype.flat": { - "version": "1.3.2", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0" + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -4370,10 +5199,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, "node_modules/astral-regex": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", "dev": true, "license": "MIT", "engines": { @@ -4395,6 +5229,8 @@ }, "node_modules/asynckit": { "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, "node_modules/autoprefixer": { @@ -4447,17 +5283,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axe-core": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", + "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/axios": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz", - "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/b4a": { "version": "1.6.6", "dev": true, @@ -4665,13 +5521,28 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz", + "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/boolbase": { "version": "1.0.0", "dev": true, "license": "ISC" }, "node_modules/brace-expansion": { - "version": "1.1.11", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -4691,7 +5562,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.2", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -4709,10 +5582,11 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001669", - "electron-to-chromium": "^1.5.41", - "node-releases": "^2.0.18", - "update-browserslist-db": "^1.1.1" + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -4746,9 +5620,8 @@ }, "node_modules/buffer-crc32": { "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", "dev": true, + "license": "MIT", "engines": { "node": "*" } @@ -4759,30 +5632,26 @@ "license": "MIT" }, "node_modules/cacheable": { - "version": "1.8.10", - "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-1.8.10.tgz", - "integrity": "sha512-0ZnbicB/N2R6uziva8l6O6BieBklArWyiGx4GkwAhLKhSHyQtRfM9T1nx7HHuHDKkYB/efJQhz3QJ6x/YqoZzA==", + "version": "1.9.0", "dev": true, "license": "MIT", "dependencies": { - "hookified": "^1.8.1", - "keyv": "^5.3.2" + "hookified": "^1.8.2", + "keyv": "^5.3.3" } }, "node_modules/cacheable-lookup": { "version": "5.0.4", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", - "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10.6.0" } }, "node_modules/cacheable-request": { "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", - "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", "dev": true, + "license": "MIT", "dependencies": { "clone-response": "^1.0.2", "get-stream": "^5.1.0", @@ -4798,8 +5667,6 @@ }, "node_modules/cacheable/node_modules/keyv": { "version": "5.3.3", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.3.3.tgz", - "integrity": "sha512-Rwu4+nXI9fqcxiEHtbkvoes2X+QfkTRo1TMkPfwzipGsJlJO/z69vqB4FNl9xJ3xCpAcbkvmEabZfPzrwN3+gQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4824,8 +5691,9 @@ } }, "node_modules/call-bind-apply-helpers": { - "version": "1.0.1", - "dev": true, + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4836,12 +5704,14 @@ } }, "node_modules/call-bound": { - "version": "1.0.3", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "get-intrinsic": "^1.2.6" + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { "node": ">= 0.4" @@ -4877,9 +5747,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001727", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", - "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", "dev": true, "funding": [ { @@ -4941,9 +5811,8 @@ }, "node_modules/chardet": { "version": "0.7.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", - "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/chokidar": { "version": "4.0.1", @@ -4987,9 +5856,8 @@ }, "node_modules/cli-cursor": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", "dev": true, + "license": "MIT", "dependencies": { "restore-cursor": "^3.1.0" }, @@ -4997,29 +5865,143 @@ "node": ">=8" } }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "node_modules/cli-spinners": { + "version": "2.9.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-truncate/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, "engines": { - "node": ">=6" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/cli-width": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", - "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", "dev": true, + "license": "ISC", "engines": { "node": ">= 10" } }, "node_modules/clipboard": { "version": "2.0.11", + "resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.11.tgz", + "integrity": "sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==", "license": "MIT", "dependencies": { "good-listener": "^1.2.2", @@ -5027,25 +6009,10 @@ "tiny-emitter": "^2.0.0" } }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/clone": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8" } @@ -5076,9 +6043,8 @@ }, "node_modules/clone-response": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", - "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", "dev": true, + "license": "MIT", "dependencies": { "mimic-response": "^1.0.0" }, @@ -5088,6 +6054,8 @@ }, "node_modules/clsx": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "license": "MIT", "engines": { "node": ">=6" @@ -5099,8 +6067,6 @@ }, "node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5112,8 +6078,6 @@ }, "node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, "license": "MIT" }, @@ -5128,6 +6092,8 @@ }, "node_modules/combined-stream": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -5194,12 +6160,11 @@ }, "node_modules/concat-stream": { "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", "dev": true, "engines": [ "node >= 0.8" ], + "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", @@ -5223,9 +6188,8 @@ }, "node_modules/copy-dir": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/copy-dir/-/copy-dir-1.3.0.tgz", - "integrity": "sha512-Q4+qBFnN4bwGwvtXXzbp4P/4iNk0MaiGAzvQ8OiMtlLjkIKjmNN689uVzShSM0908q7GoFHXIPx4zi75ocoaHw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/core-js": { "version": "3.33.2", @@ -5352,8 +6316,6 @@ }, "node_modules/css-functions-list": { "version": "3.2.3", - "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.2.3.tgz", - "integrity": "sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==", "dev": true, "license": "MIT", "engines": { @@ -5558,9 +6520,18 @@ "license": "CC0-1.0" }, "node_modules/csstype": { - "version": "3.1.2", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/data-view-buffer": { "version": "1.0.2", "dev": true, @@ -5618,9 +6589,9 @@ } }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -5636,9 +6607,8 @@ }, "node_modules/decompress-response": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", "dev": true, + "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" }, @@ -5651,9 +6621,8 @@ }, "node_modules/decompress-response/node_modules/mimic-response": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -5668,6 +6637,8 @@ }, "node_modules/deepmerge": { "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5675,9 +6646,8 @@ }, "node_modules/defaults": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", "dev": true, + "license": "MIT", "dependencies": { "clone": "^1.0.2" }, @@ -5687,9 +6657,8 @@ }, "node_modules/defer-to-connect": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", - "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" } @@ -5728,6 +6697,8 @@ }, "node_modules/delayed-stream": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "license": "MIT", "engines": { "node": ">=0.4.0" @@ -5735,6 +6706,8 @@ }, "node_modules/delegate": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz", + "integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==", "license": "MIT" }, "node_modules/detect-libc": { @@ -5750,7 +6723,9 @@ } }, "node_modules/diff": { - "version": "4.0.2", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -5759,8 +6734,6 @@ }, "node_modules/dir-glob": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", "dev": true, "license": "MIT", "dependencies": { @@ -5772,9 +6745,8 @@ }, "node_modules/docker-compose": { "version": "0.24.8", - "resolved": "https://registry.npmjs.org/docker-compose/-/docker-compose-0.24.8.tgz", - "integrity": "sha512-plizRs/Vf15H+GCVxq2EUvyPK7ei9b/cVesHvjnX4xaXjM9spHe2Ytq0BitndFgvTJ3E3NljPNUEl7BAN43iZw==", "dev": true, + "license": "MIT", "dependencies": { "yaml": "^2.2.2" }, @@ -5862,7 +6834,6 @@ }, "node_modules/dunder-proto": { "version": "1.0.1", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -5879,7 +6850,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.68", + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", "dev": true, "license": "ISC" }, @@ -5897,20 +6870,21 @@ }, "node_modules/end-of-stream": { "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "dev": true, + "license": "MIT", "dependencies": { "once": "^1.4.0" } }, "node_modules/enhanced-resolve": { - "version": "5.17.1", + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" + "tapable": "^2.3.0" }, "engines": { "node": ">=10.13.0" @@ -5946,8 +6920,23 @@ "node": ">=4" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/equivalent-key-map": { "version": "0.2.2", + "resolved": "https://registry.npmjs.org/equivalent-key-map/-/equivalent-key-map-0.2.2.tgz", + "integrity": "sha512-xvHeyCDbZzkpN4VHQj/n+j2lOwL0VWszG30X4cOrc9Y7Tuo2qCdZK/0AMod23Z5dCtNUbaju6p0rwOhHUk05ew==", "license": "MIT" }, "node_modules/error-ex": { @@ -5958,7 +6947,9 @@ } }, "node_modules/es-abstract": { - "version": "1.23.9", + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", "dev": true, "license": "MIT", "dependencies": { @@ -5966,18 +6957,18 @@ "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", - "call-bound": "^1.0.3", + "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", + "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.0", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", @@ -5989,21 +6980,24 @@ "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", + "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.0", + "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.3", + "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.3", + "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", @@ -6012,7 +7006,7 @@ "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.18" + "which-typed-array": "^1.1.19" }, "engines": { "node": ">= 0.4" @@ -6023,7 +7017,6 @@ }, "node_modules/es-define-property": { "version": "1.0.1", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6031,7 +7024,6 @@ }, "node_modules/es-errors": { "version": "1.3.0", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6064,13 +7056,16 @@ } }, "node_modules/es-module-lexer": { - "version": "1.4.0", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.0.0", - "dev": true, + "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==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -6081,7 +7076,6 @@ }, "node_modules/es-set-tostringtag": { "version": "2.1.0", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6094,11 +7088,16 @@ } }, "node_modules/es-shim-unscopables": { - "version": "1.0.2", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.0" + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" } }, "node_modules/es-to-primitive": { @@ -6136,30 +7135,32 @@ } }, "node_modules/eslint": { - "version": "9.20.1", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.19.0", - "@eslint/core": "^0.11.0", - "@eslint/eslintrc": "^3.2.0", - "@eslint/js": "9.20.0", - "@eslint/plugin-kit": "^0.2.5", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.1", + "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.2.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -6212,24 +7213,25 @@ } }, "node_modules/eslint-import-resolver-typescript": { - "version": "3.7.0", + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", "dev": true, "license": "ISC", "dependencies": { "@nolyfill/is-core-module": "1.0.39", - "debug": "^4.3.7", - "enhanced-resolve": "^5.15.0", - "fast-glob": "^3.3.2", - "get-tsconfig": "^4.7.5", - "is-bun-module": "^1.0.2", - "is-glob": "^4.0.3", - "stable-hash": "^0.0.4" + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" }, "engines": { "node": "^14.18.0 || >=16.0.0" }, "funding": { - "url": "https://opencollective.com/unts/projects/eslint-import-resolver-ts" + "url": "https://opencollective.com/eslint-import-resolver-typescript" }, "peerDependencies": { "eslint": "*", @@ -6246,7 +7248,9 @@ } }, "node_modules/eslint-module-utils": { - "version": "2.12.0", + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", "dev": true, "license": "MIT", "dependencies": { @@ -6263,6 +7267,8 @@ }, "node_modules/eslint-module-utils/node_modules/debug": { "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6270,28 +7276,30 @@ } }, "node_modules/eslint-plugin-import": { - "version": "2.31.0", + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", "dependencies": { "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.8", - "array.prototype.findlastindex": "^1.2.5", - "array.prototype.flat": "^1.3.2", - "array.prototype.flatmap": "^1.3.2", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.0", + "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", - "is-core-module": "^2.15.1", + "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", - "object.values": "^1.2.0", + "object.values": "^1.2.1", "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.8", + "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "engines": { @@ -6331,8 +7339,40 @@ "strip-bom": "^3.0.0" } }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, "node_modules/eslint-plugin-react": { - "version": "7.37.4", + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, "license": "MIT", "dependencies": { @@ -6346,7 +7386,7 @@ "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", - "object.entries": "^1.1.8", + "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", @@ -6363,7 +7403,9 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "5.1.0", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", "dev": true, "license": "MIT", "engines": { @@ -6389,6 +7431,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/eslint-plugin-svg-jsx": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-svg-jsx/-/eslint-plugin-svg-jsx-1.3.0.tgz", + "integrity": "sha512-Aqpad1wZSfkKIazxw/WaTBkKyYPDB+8hBgfsor+VSwoyGMfaA6Tz7aQqv9NoA2cxvaMUrjnoVnfDk/ETAnNEUA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=5.0.0" + }, + "peerDependencies": { + "eslint": ">=8" + } + }, "node_modules/eslint-scope": { "version": "5.1.1", "dev": true, @@ -6410,11 +7465,13 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "3.4.3", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" @@ -6471,16 +7528,10 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/eslint/node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, "node_modules/eslint/node_modules/ajv": { "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", "dependencies": { @@ -6495,7 +7546,9 @@ } }, "node_modules/eslint/node_modules/eslint-scope": { - "version": "8.2.0", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -6509,17 +7562,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/eslint/node_modules/glob-parent": { "version": "6.0.2", "dev": true, @@ -6533,40 +7575,22 @@ }, "node_modules/eslint/node_modules/json-schema-traverse": { "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, "license": "MIT" }, "node_modules/espree": { - "version": "10.3.0", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree/node_modules/acorn": { - "version": "8.14.0", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" + "eslint-visitor-keys": "^4.2.1" }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "dev": true, - "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -6574,19 +7598,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/esquery": { "version": "1.6.0", "dev": true, @@ -6633,6 +7644,13 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "dev": true, @@ -6641,11 +7659,89 @@ "node": ">=0.8.x" } }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/execa/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/external-editor": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", - "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", "dev": true, + "license": "MIT", "dependencies": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", @@ -6657,9 +7753,8 @@ }, "node_modules/external-editor/node_modules/iconv-lite": { "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "dev": true, + "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -6669,9 +7764,8 @@ }, "node_modules/extract-zip": { "version": "1.7.0", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz", - "integrity": "sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==", "dev": true, + "license": "BSD-2-Clause", "dependencies": { "concat-stream": "^1.6.2", "debug": "^2.6.9", @@ -6684,18 +7778,16 @@ }, "node_modules/extract-zip/node_modules/debug": { "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "dev": true, + "license": "MIT", "dependencies": { "ms": "2.0.0" } }, "node_modules/extract-zip/node_modules/ms": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fast-deep-equal": { "version": "3.1.3", @@ -6708,8 +7800,6 @@ }, "node_modules/fast-glob": { "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dev": true, "license": "MIT", "dependencies": { @@ -6725,6 +7815,8 @@ }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, "license": "MIT" }, @@ -6733,6 +7825,23 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastest-levenshtein": { "version": "1.0.16", "dev": true, @@ -6751,18 +7860,16 @@ }, "node_modules/fd-slicer": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", "dev": true, + "license": "MIT", "dependencies": { "pend": "~1.2.0" } }, "node_modules/figures": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", "dev": true, + "license": "MIT", "dependencies": { "escape-string-regexp": "^1.0.5" }, @@ -6775,9 +7882,8 @@ }, "node_modules/figures/node_modules/escape-string-regexp": { "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8.0" } @@ -6845,13 +7951,13 @@ }, "node_modules/flatted": { "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.6", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "funding": [ { "type": "individual", @@ -6869,19 +7975,29 @@ } }, "node_modules/for-each": { - "version": "0.3.3", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "dev": true, "license": "MIT", "dependencies": { - "is-callable": "^1.1.3" + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/foreground-child": { - "version": "3.3.0", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, "license": "ISC", "dependencies": { - "cross-spawn": "^7.0.0", + "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" }, "engines": { @@ -6892,11 +8008,15 @@ } }, "node_modules/form-data": { - "version": "4.0.0", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -6940,9 +8060,8 @@ }, "node_modules/fs.realpath": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/fsevents": { "version": "2.3.2", @@ -6950,6 +8069,7 @@ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -7002,24 +8122,37 @@ }, "node_modules/get-caller-file": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true, + "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-intrinsic": { - "version": "1.2.7", + "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==", "dev": true, "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", + "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "get-proto": "^1.0.0", + "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", @@ -7034,7 +8167,6 @@ }, "node_modules/get-proto": { "version": "1.0.1", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -7046,9 +8178,8 @@ }, "node_modules/get-stream": { "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", "dev": true, + "license": "MIT", "dependencies": { "pump": "^3.0.0" }, @@ -7076,7 +8207,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.7.6", + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", "dev": true, "license": "MIT", "dependencies": { @@ -7095,13 +8228,16 @@ } }, "node_modules/glob": { - "version": "11.0.1", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^4.0.1", - "minimatch": "^10.0.0", + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" @@ -7129,32 +8265,35 @@ }, "node_modules/glob-to-regexp": { "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "dev": true, "license": "BSD-2-Clause" }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.1", + "node_modules/glob/node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" } }, "node_modules/glob/node_modules/jackspeak": { - "version": "4.0.1", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/cliui": "^8.0.2" + "@isaacs/cliui": "^9.0.0" }, "engines": { "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" } }, "node_modules/glob/node_modules/lru-cache": { @@ -7166,11 +8305,13 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "10.0.1", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.1.tgz", + "integrity": "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.2" }, "engines": { "node": "20 || >=22" @@ -7204,8 +8345,6 @@ }, "node_modules/global-modules": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", - "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", "dev": true, "license": "MIT", "dependencies": { @@ -7217,8 +8356,6 @@ }, "node_modules/global-prefix": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", - "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", "dev": true, "license": "MIT", "dependencies": { @@ -7232,8 +8369,6 @@ }, "node_modules/global-prefix/node_modules/which": { "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", "dev": true, "license": "ISC", "dependencies": { @@ -7271,8 +8406,6 @@ }, "node_modules/globby": { "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", "dev": true, "license": "MIT", "dependencies": { @@ -7292,13 +8425,13 @@ }, "node_modules/globjoin": { "version": "0.1.4", - "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz", - "integrity": "sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==", "dev": true, "license": "MIT" }, "node_modules/good-listener": { "version": "1.2.2", + "resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz", + "integrity": "sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==", "license": "MIT", "dependencies": { "delegate": "^3.1.2" @@ -7306,7 +8439,6 @@ }, "node_modules/gopd": { "version": "1.2.0", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7317,9 +8449,8 @@ }, "node_modules/got": { "version": "11.8.6", - "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", - "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", "dev": true, + "license": "MIT", "dependencies": { "@sindresorhus/is": "^4.0.0", "@szmarczak/http-timer": "^4.0.5", @@ -7346,16 +8477,11 @@ "license": "ISC" }, "node_modules/gradient-parser": { - "version": "0.1.5", + "version": "1.0.2", "engines": { "node": ">=0.10.0" } }, - "node_modules/graphemer": { - "version": "1.4.0", - "dev": true, - "license": "MIT" - }, "node_modules/has-bigints": { "version": "1.1.0", "dev": true, @@ -7402,7 +8528,6 @@ }, "node_modules/has-symbols": { "version": "1.1.0", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7413,7 +8538,6 @@ }, "node_modules/has-tostringtag": { "version": "1.0.2", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -7459,16 +8583,12 @@ "license": "MIT" }, "node_modules/hookified": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.8.2.tgz", - "integrity": "sha512-5nZbBNP44sFCDjSoB//0N7m508APCgbQ4mGGo1KJGBYyCKNHfry1Pvd0JVHZIxjdnqn8nFRBAN/eFB6Rk/4w5w==", + "version": "1.9.0", "dev": true, "license": "MIT" }, "node_modules/html-tags": { "version": "3.3.1", - "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", - "integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==", "dev": true, "license": "MIT", "engines": { @@ -7480,15 +8600,13 @@ }, "node_modules/http-cache-semantics": { "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "dev": true + "dev": true, + "license": "BSD-2-Clause" }, "node_modules/http2-wrapper": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", - "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", "dev": true, + "license": "MIT", "dependencies": { "quick-lru": "^5.1.1", "resolve-alpn": "^1.0.0" @@ -7497,6 +8615,16 @@ "node": ">=10.19.0" } }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "license": "MIT", @@ -7592,10 +8720,8 @@ }, "node_modules/inflight": { "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, + "license": "ISC", "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -7608,16 +8734,13 @@ }, "node_modules/ini": { "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "dev": true, "license": "ISC" }, "node_modules/inquirer": { "version": "7.3.3", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.3.3.tgz", - "integrity": "sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-escapes": "^4.2.1", "chalk": "^4.1.0", @@ -7726,15 +8849,19 @@ } }, "node_modules/is-bun-module": { - "version": "1.1.0", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", "dev": true, "license": "MIT", "dependencies": { - "semver": "^7.6.3" + "semver": "^7.7.1" } }, "node_modules/is-bun-module/node_modules/semver": { - "version": "7.6.3", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -7756,7 +8883,9 @@ } }, "node_modules/is-core-module": { - "version": "2.15.1", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -7823,8 +8952,6 @@ }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "license": "MIT", "engines": { @@ -7861,9 +8988,8 @@ }, "node_modules/is-interactive": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -7879,6 +9005,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "dev": true, @@ -8025,11 +9164,13 @@ } }, "node_modules/is-weakref": { - "version": "1.1.0", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2" + "call-bound": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -8063,15 +9204,6 @@ "dev": true, "license": "ISC" }, - "node_modules/isnumeric": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/isnumeric/-/isnumeric-0.2.0.tgz", - "integrity": "sha512-uSJoAwnN1eCKDFKi8hL3UCYJSkQv+NwhKzhevUPIn/QZ8ILO21f+wQnlZHU0eh1rsLO1gI4w/HQdeOSTKwlqMg==", - "dev": true, - "engines": { - "node": ">= 0.8.x" - } - }, "node_modules/isobject": { "version": "3.0.1", "dev": true, @@ -8128,6 +9260,8 @@ }, "node_modules/jest-worker": { "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "dev": true, "license": "MIT", "dependencies": { @@ -8141,6 +9275,8 @@ }, "node_modules/jest-worker/node_modules/supports-color": { "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "license": "MIT", "dependencies": { @@ -8166,7 +9302,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -8248,11 +9386,29 @@ }, "node_modules/known-css-properties": { "version": "0.36.0", - "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.36.0.tgz", - "integrity": "sha512-A+9jP+IUmuQsNdsLdcg6Yt7voiMF/D4K83ew0OpJtpu+l34ef7LaohWV0Rc6KNvzw6ZDizkqfyB5JznZnzuKQA==", "dev": true, "license": "MIT" }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/lazystream": { "version": "1.0.1", "dev": true, @@ -8277,7 +9433,9 @@ } }, "node_modules/lilconfig": { - "version": "3.1.2", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", "dev": true, "license": "MIT", "engines": { @@ -8291,12 +9449,87 @@ "version": "1.2.4", "license": "MIT" }, + "node_modules/lint-staged": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.5.2.tgz", + "integrity": "sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.4.1", + "commander": "^13.1.0", + "debug": "^4.4.0", + "execa": "^8.0.1", + "lilconfig": "^3.1.3", + "listr2": "^8.2.5", + "micromatch": "^4.0.8", + "pidtree": "^0.6.0", + "string-argv": "^0.3.2", + "yaml": "^2.7.0" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/lint-staged/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/listr2": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.3.tgz", + "integrity": "sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^4.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/loader-runner": { - "version": "4.3.0", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", "dev": true, "license": "MIT", "engines": { "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/locate-path": { @@ -8314,7 +9547,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "dev": true, "license": "MIT" }, @@ -8335,8 +9570,6 @@ }, "node_modules/lodash.truncate": { "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", - "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", "dev": true, "license": "MIT" }, @@ -8347,9 +9580,8 @@ }, "node_modules/log-symbols": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-3.0.0.tgz", - "integrity": "sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==", "dev": true, + "license": "MIT", "dependencies": { "chalk": "^2.4.2" }, @@ -8359,9 +9591,8 @@ }, "node_modules/log-symbols/node_modules/ansi-styles": { "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^1.9.0" }, @@ -8371,9 +9602,8 @@ }, "node_modules/log-symbols/node_modules/chalk": { "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -8385,47 +9615,202 @@ }, "node_modules/log-symbols/node_modules/color-convert": { "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "1.1.3" } }, - "node_modules/log-symbols/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "node_modules/log-symbols/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "node_modules/log-symbols/node_modules/color-name": { + "version": "1.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols/node_modules/escape-string-regexp": { + "version": "1.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/log-symbols/node_modules/has-flag": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/supports-color": { + "version": "5.5.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, "engines": { - "node": ">=0.8.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/log-symbols/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, "engines": { - "node": ">=4" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/log-symbols/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "node_modules/log-update/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", "dev": true, + "license": "MIT", "dependencies": { - "has-flag": "^3.0.0" + "ansi-regex": "^6.0.1" }, "engines": { - "node": ">=4" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/loose-envify": { @@ -8447,9 +9832,8 @@ }, "node_modules/lowercase-keys": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", - "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -8469,7 +9853,6 @@ }, "node_modules/math-intrinsics": { "version": "1.1.0", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8477,8 +9860,6 @@ }, "node_modules/mathml-tag-names": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", - "integrity": "sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==", "dev": true, "license": "MIT", "funding": { @@ -8501,8 +9882,6 @@ }, "node_modules/meow": { "version": "13.2.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", - "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", "dev": true, "license": "MIT", "engines": { @@ -8556,18 +9935,29 @@ }, "node_modules/mimic-fn": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mimic-response": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", - "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -8612,9 +10002,8 @@ }, "node_modules/mkdirp": { "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, + "license": "MIT", "dependencies": { "minimist": "^1.2.6" }, @@ -8624,13 +10013,17 @@ }, "node_modules/moment": { "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", "license": "MIT", "engines": { "node": "*" } }, "node_modules/moment-timezone": { - "version": "0.5.45", + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", + "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", "license": "MIT", "dependencies": { "moment": "^2.29.4" @@ -8641,19 +10034,18 @@ }, "node_modules/mousetrap": { "version": "1.6.5", + "resolved": "https://registry.npmjs.org/mousetrap/-/mousetrap-1.6.5.tgz", + "integrity": "sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA==", "license": "Apache-2.0 WITH LLVM-exception" }, "node_modules/ms": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, "node_modules/mute-stream": { "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/nanoid": { "version": "3.3.8", @@ -8672,6 +10064,22 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "dev": true, @@ -8697,7 +10105,9 @@ "optional": true }, "node_modules/node-releases": { - "version": "2.0.18", + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", "dev": true, "license": "MIT" }, @@ -8719,9 +10129,8 @@ }, "node_modules/normalize-url": { "version": "6.1.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", - "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -8729,6 +10138,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "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/nth-check": { "version": "2.1.1", "dev": true, @@ -8748,7 +10186,9 @@ } }, "node_modules/object-inspect": { - "version": "1.13.3", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "dev": true, "license": "MIT", "engines": { @@ -8786,13 +10226,16 @@ } }, "node_modules/object.entries": { - "version": "1.1.8", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" + "es-object-atoms": "^1.1.1" }, "engines": { "node": ">= 0.4" @@ -8847,18 +10290,16 @@ }, "node_modules/once": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "dev": true, + "license": "ISC", "dependencies": { "wrappy": "1" } }, "node_modules/onetime": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", "dev": true, + "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" }, @@ -8887,9 +10328,8 @@ }, "node_modules/ora": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-4.1.1.tgz", - "integrity": "sha512-sjYP8QyVWBpBZWD6Vr1M/KwknSw6kJOz41tvGMlwWeClHBtYKTbHMki1PsLZnxKpXMPbTKv9b3pjQu3REib96A==", "dev": true, + "license": "MIT", "dependencies": { "chalk": "^3.0.0", "cli-cursor": "^3.1.0", @@ -8909,9 +10349,8 @@ }, "node_modules/ora/node_modules/chalk": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -8920,15 +10359,6 @@ "node": ">=8" } }, - "node_modules/os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/own-keys": { "version": "1.0.1", "dev": true, @@ -8947,9 +10377,8 @@ }, "node_modules/p-cancelable": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", - "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -9055,9 +10484,8 @@ }, "node_modules/path-is-absolute": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -9115,9 +10543,8 @@ }, "node_modules/pend": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/php-parser": { "version": "3.2.2", @@ -9138,6 +10565,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/pkg-dir": { "version": "4.2.0", "dev": true, @@ -9198,12 +10638,13 @@ } }, "node_modules/playwright": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz", - "integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.55.0" + "playwright-core": "1.58.2" }, "bin": { "playwright": "cli.js" @@ -9216,10 +10657,11 @@ } }, "node_modules/playwright-core": { - "version": "1.55.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz", - "integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", "dev": true, + "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, @@ -9237,8 +10679,6 @@ }, "node_modules/postcss": { "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", "dev": true, "funding": [ { @@ -9279,128 +10719,6 @@ "postcss": "^8.4.38" } }, - "node_modules/postcss-color-hsl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postcss-color-hsl/-/postcss-color-hsl-2.0.0.tgz", - "integrity": "sha512-4DNpOj3NWejHtjV4mLxf+rmE1KA+IKDJH8QSThgJOrjGFuiqOPxkFSZX1RQJ+XQISZD3MW/JDaZoNnmxS9pSBQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss": "^6.0.1", - "postcss-value-parser": "^3.3.0", - "units-css": "^0.4.0" - } - }, - "node_modules/postcss-color-hsl/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-color-hsl/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-color-hsl/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/postcss-color-hsl/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true, - "license": "MIT" - }, - "node_modules/postcss-color-hsl/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/postcss-color-hsl/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-color-hsl/node_modules/postcss": { - "version": "6.0.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz", - "integrity": "sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^2.4.1", - "source-map": "^0.6.1", - "supports-color": "^5.4.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/postcss-color-hsl/node_modules/postcss-value-parser": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", - "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/postcss-color-hsl/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postcss-color-hsl/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/postcss-colormin": { "version": "7.0.2", "dev": true, @@ -9600,8 +10918,6 @@ }, "node_modules/postcss-media-query-parser": { "version": "0.2.3", - "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", - "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", "dev": true, "license": "MIT" }, @@ -9923,15 +11239,11 @@ }, "node_modules/postcss-resolve-nested-selector": { "version": "0.1.6", - "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.6.tgz", - "integrity": "sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==", "dev": true, "license": "MIT" }, "node_modules/postcss-safe-parser": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", - "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", "dev": true, "funding": [ { @@ -9957,8 +11269,6 @@ }, "node_modules/postcss-scss": { "version": "4.0.9", - "resolved": "https://registry.npmjs.org/postcss-scss/-/postcss-scss-4.0.9.tgz", - "integrity": "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==", "dev": true, "funding": [ { @@ -10038,8 +11348,6 @@ }, "node_modules/prismjs": { "version": "1.30.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", - "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", "license": "MIT", "engines": { "node": ">=6" @@ -10077,9 +11385,8 @@ }, "node_modules/pump": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", "dev": true, + "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -10087,6 +11394,8 @@ }, "node_modules/punycode": { "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", "engines": { @@ -10119,9 +11428,8 @@ }, "node_modules/quick-lru": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -10131,6 +11439,8 @@ }, "node_modules/randombytes": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10176,8 +11486,6 @@ }, "node_modules/react-is": { "version": "19.0.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", - "integrity": "sha512-H91OHcwjZsbq3ClIDHMzBShc1rotbfACdWENsmEf0IFvZ3FgGPtdHMcsv45bQ1hAbgdfiA8SnxTKfDS+x/8m2g==", "dev": true, "license": "MIT" }, @@ -10246,14 +11554,6 @@ "minimatch": "^5.1.0" } }, - "node_modules/readdir-glob/node_modules/brace-expansion": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/readdir-glob/node_modules/minimatch": { "version": "5.1.6", "dev": true, @@ -10331,10 +11631,6 @@ "node": ">=4" } }, - "node_modules/regenerator-runtime": { - "version": "0.14.0", - "license": "MIT" - }, "node_modules/regenerator-transform": { "version": "0.15.2", "dev": true, @@ -10344,13 +11640,17 @@ } }, "node_modules/regexp.prototype.flags": { - "version": "1.5.3", + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", "set-function-name": "^2.0.2" }, "engines": { @@ -10394,6 +11694,8 @@ }, "node_modules/rememo": { "version": "4.0.2", + "resolved": "https://registry.npmjs.org/rememo/-/rememo-4.0.2.tgz", + "integrity": "sha512-NVfSP9NstE3QPNs/TnegQY0vnJnstKQSpcrsI2kBTB3dB2PkdfKdTa+abbjMIDqpc63fE5LfjLgfMst0ULMFxQ==", "license": "MIT" }, "node_modules/remove-accents": { @@ -10408,9 +11710,8 @@ }, "node_modules/require-directory": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -10440,9 +11741,8 @@ }, "node_modules/resolve-alpn": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", - "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/resolve-cwd": { "version": "3.0.0", @@ -10472,6 +11772,8 @@ }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "dev": true, "license": "MIT", "funding": { @@ -10480,9 +11782,8 @@ }, "node_modules/responselike": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", - "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", "dev": true, + "license": "MIT", "dependencies": { "lowercase-keys": "^2.0.0" }, @@ -10492,9 +11793,8 @@ }, "node_modules/restore-cursor": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", "dev": true, + "license": "MIT", "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" @@ -10505,9 +11805,8 @@ }, "node_modules/restore-cursor/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 + "dev": true, + "license": "ISC" }, "node_modules/reusify": { "version": "1.0.4", @@ -10518,12 +11817,17 @@ "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/rimraf": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, + "license": "ISC", "dependencies": { "glob": "^7.1.3" }, @@ -10536,10 +11840,8 @@ }, "node_modules/rimraf/node_modules/glob": { "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, + "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -10557,9 +11859,8 @@ }, "node_modules/run-async": { "version": "2.4.1", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", - "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.12.0" } @@ -10594,9 +11895,8 @@ }, "node_modules/rxjs": { "version": "6.6.7", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", - "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", "dev": true, + "license": "Apache-2.0", "dependencies": { "tslib": "^1.9.0" }, @@ -10606,9 +11906,8 @@ }, "node_modules/rxjs/node_modules/tslib": { "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true + "dev": true, + "license": "0BSD" }, "node_modules/safe-array-concat": { "version": "1.1.3", @@ -10747,7 +12046,9 @@ } }, "node_modules/schema-utils": { - "version": "4.2.0", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", "dependencies": { @@ -10757,7 +12058,7 @@ "ajv-keywords": "^5.1.0" }, "engines": { - "node": ">= 12.13.0" + "node": ">= 10.13.0" }, "funding": { "type": "opencollective", @@ -10766,6 +12067,8 @@ }, "node_modules/select": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz", + "integrity": "sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==", "license": "MIT" }, "node_modules/semver": { @@ -10787,6 +12090,8 @@ }, "node_modules/serialize-javascript": { "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -10936,8 +12241,6 @@ }, "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": { @@ -10949,9 +12252,8 @@ }, "node_modules/simple-git": { "version": "3.28.0", - "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.28.0.tgz", - "integrity": "sha512-Rs/vQRwsn1ILH1oBUy8NucJlXmnnLeLCfcvbSehkPzbv3wwoFWIdtfd6Ndo6ZPhlPsCZ60CPI4rxurnwAa+a2w==", "dev": true, + "license": "MIT", "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", @@ -10964,8 +12266,6 @@ }, "node_modules/slash": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true, "license": "MIT", "engines": { @@ -10974,8 +12274,6 @@ }, "node_modules/slice-ansi": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", - "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11015,6 +12313,8 @@ }, "node_modules/source-map-support": { "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, "license": "MIT", "dependencies": { @@ -11024,6 +12324,8 @@ }, "node_modules/source-map-support/node_modules/source-map": { "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -11035,10 +12337,26 @@ "license": "BSD-3-Clause" }, "node_modules/stable-hash": { - "version": "0.0.4", + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", "dev": true, "license": "MIT" }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/streamx": { "version": "2.15.4", "dev": true, @@ -11059,12 +12377,20 @@ "node_modules/string_decoder/node_modules/safe-buffer": { "version": "5.1.2", "dev": true, - "license": "MIT" + "license": "MIT" + }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } }, "node_modules/string-width": { "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", "dependencies": { @@ -11097,11 +12423,24 @@ }, "node_modules/string-width/node_modules/emoji-regex": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, "license": "MIT" }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "dev": true, @@ -11221,6 +12560,19 @@ "node": ">=4" } }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "dev": true, @@ -11253,8 +12605,6 @@ }, "node_modules/style-search": { "version": "0.1.0", - "resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz", - "integrity": "sha512-Dj1Okke1C3uKKwQcetra4jSuk0DqbzbYtXipzFlFMZtowbF1x7BKJwB9AayVMyFARvU8EDrZdcax4At/452cAg==", "dev": true, "license": "ISC" }, @@ -11275,8 +12625,6 @@ }, "node_modules/stylelint": { "version": "16.19.1", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.19.1.tgz", - "integrity": "sha512-C1SlPZNMKl+d/C867ZdCRthrS+6KuZ3AoGW113RZCOL0M8xOGpgx7G70wq7lFvqvm4dcfdGFVLB/mNaLFChRKw==", "dev": true, "funding": [ { @@ -11338,8 +12686,6 @@ }, "node_modules/stylelint-config-recommended": { "version": "16.0.0", - "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-16.0.0.tgz", - "integrity": "sha512-4RSmPjQegF34wNcK1e1O3Uz91HN8P1aFdFzio90wNK9mjgAI19u5vsU868cVZboKzCaa5XbpvtTzAAGQAxpcXA==", "dev": true, "funding": [ { @@ -11361,8 +12707,6 @@ }, "node_modules/stylelint-config-recommended-scss": { "version": "14.1.0", - "resolved": "https://registry.npmjs.org/stylelint-config-recommended-scss/-/stylelint-config-recommended-scss-14.1.0.tgz", - "integrity": "sha512-bhaMhh1u5dQqSsf6ri2GVWWQW5iUjBYgcHkh7SgDDn92ijoItC/cfO/W+fpXshgTQWhwFkP1rVcewcv4jaftRg==", "dev": true, "license": "MIT", "dependencies": { @@ -11385,8 +12729,6 @@ }, "node_modules/stylelint-config-recommended-scss/node_modules/stylelint-config-recommended": { "version": "14.0.1", - "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-14.0.1.tgz", - "integrity": "sha512-bLvc1WOz/14aPImu/cufKAZYfXs/A/owZfSMZ4N+16WGXLoX5lOir53M6odBxvhgmgdxCVnNySJmZKx73T93cg==", "dev": true, "funding": [ { @@ -11408,8 +12750,6 @@ }, "node_modules/stylelint-config-standard": { "version": "38.0.0", - "resolved": "https://registry.npmjs.org/stylelint-config-standard/-/stylelint-config-standard-38.0.0.tgz", - "integrity": "sha512-uj3JIX+dpFseqd/DJx8Gy3PcRAJhlEZ2IrlFOc4LUxBX/PNMEQ198x7LCOE2Q5oT9Vw8nyc4CIL78xSqPr6iag==", "dev": true, "funding": [ { @@ -11434,8 +12774,6 @@ }, "node_modules/stylelint-config-standard-scss": { "version": "14.0.0", - "resolved": "https://registry.npmjs.org/stylelint-config-standard-scss/-/stylelint-config-standard-scss-14.0.0.tgz", - "integrity": "sha512-6Pa26D9mHyi4LauJ83ls3ELqCglU6VfCXchovbEqQUiEkezvKdv6VgsIoMy58i00c854wVmOw0k8W5FTpuaVqg==", "dev": true, "license": "MIT", "dependencies": { @@ -11457,8 +12795,6 @@ }, "node_modules/stylelint-config-standard-scss/node_modules/stylelint-config-recommended": { "version": "14.0.1", - "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-14.0.1.tgz", - "integrity": "sha512-bLvc1WOz/14aPImu/cufKAZYfXs/A/owZfSMZ4N+16WGXLoX5lOir53M6odBxvhgmgdxCVnNySJmZKx73T93cg==", "dev": true, "funding": [ { @@ -11480,8 +12816,6 @@ }, "node_modules/stylelint-config-standard-scss/node_modules/stylelint-config-standard": { "version": "36.0.1", - "resolved": "https://registry.npmjs.org/stylelint-config-standard/-/stylelint-config-standard-36.0.1.tgz", - "integrity": "sha512-8aX8mTzJ6cuO8mmD5yon61CWuIM4UD8Q5aBcWKGSf6kg+EC3uhB+iOywpTK4ca6ZL7B49en8yanOFtUW0qNzyw==", "dev": true, "funding": [ { @@ -11506,8 +12840,6 @@ }, "node_modules/stylelint-scss": { "version": "6.12.0", - "resolved": "https://registry.npmjs.org/stylelint-scss/-/stylelint-scss-6.12.0.tgz", - "integrity": "sha512-U7CKhi1YNkM1pXUXl/GMUXi8xKdhl4Ayxdyceie1nZ1XNIdaUgMV6OArpooWcDzEggwgYD0HP/xIgVJo9a655w==", "dev": true, "license": "MIT", "dependencies": { @@ -11529,8 +12861,6 @@ }, "node_modules/stylelint-scss/node_modules/css-tree": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", "dev": true, "license": "MIT", "dependencies": { @@ -11543,22 +12873,16 @@ }, "node_modules/stylelint-scss/node_modules/css-tree/node_modules/mdn-data": { "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", "dev": true, "license": "CC0-1.0" }, "node_modules/stylelint-scss/node_modules/mdn-data": { "version": "2.21.0", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.21.0.tgz", - "integrity": "sha512-+ZKPQezM5vYJIkCxaC+4DTnRrVZR1CgsKLu5zsQERQx6Tea8Y+wMx5A24rq8A8NepCeatIQufVAekKNgiBMsGQ==", "dev": true, "license": "CC0-1.0" }, "node_modules/stylelint-scss/node_modules/postcss-selector-parser": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", "dependencies": { @@ -11571,8 +12895,6 @@ }, "node_modules/stylelint-use-logical": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/stylelint-use-logical/-/stylelint-use-logical-2.1.2.tgz", - "integrity": "sha512-4ffvPNk/swH4KS3izExWuzQOuzLmi0gb0uOhvxWJ20vDA5W5xKCjcHHtLoAj1kKvTIX6eGIN5xGtaVin9PD0wg==", "dev": true, "license": "CC0-1.0", "engines": { @@ -11584,8 +12906,6 @@ }, "node_modules/stylelint/node_modules/@csstools/selector-specificity": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", - "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", "dev": true, "funding": [ { @@ -11607,15 +12927,11 @@ }, "node_modules/stylelint/node_modules/balanced-match": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-2.0.0.tgz", - "integrity": "sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==", "dev": true, "license": "MIT" }, "node_modules/stylelint/node_modules/cosmiconfig": { "version": "9.0.0", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", - "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", "dependencies": { @@ -11641,8 +12957,6 @@ }, "node_modules/stylelint/node_modules/css-tree": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", "dev": true, "license": "MIT", "dependencies": { @@ -11654,31 +12968,25 @@ } }, "node_modules/stylelint/node_modules/file-entry-cache": { - "version": "10.0.8", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-10.0.8.tgz", - "integrity": "sha512-FGXHpfmI4XyzbLd3HQ8cbUcsFGohJpZtmQRHr8z8FxxtCe2PcpgIlVLwIgunqjvRmXypBETvwhV4ptJizA+Y1Q==", + "version": "10.1.0", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^6.1.8" + "flat-cache": "^6.1.9" } }, "node_modules/stylelint/node_modules/flat-cache": { - "version": "6.1.8", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.8.tgz", - "integrity": "sha512-R6MaD3nrJAtO7C3QOuS79ficm2pEAy++TgEUD8ii1LVlbcgZ9DtASLkt9B+RZSFCzm7QHDMlXPsqqB6W2Pfr1Q==", + "version": "6.1.9", "dev": true, "license": "MIT", "dependencies": { - "cacheable": "^1.8.9", + "cacheable": "^1.9.0", "flatted": "^3.3.3", - "hookified": "^1.8.1" + "hookified": "^1.8.2" } }, "node_modules/stylelint/node_modules/ignore": { "version": "7.0.4", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.4.tgz", - "integrity": "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==", "dev": true, "license": "MIT", "engines": { @@ -11687,15 +12995,11 @@ }, "node_modules/stylelint/node_modules/mdn-data": { "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", "dev": true, "license": "CC0-1.0" }, "node_modules/stylelint/node_modules/postcss-selector-parser": { "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", "dependencies": { @@ -11708,8 +13012,6 @@ }, "node_modules/stylelint/node_modules/resolve-from": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "dev": true, "license": "MIT", "engines": { @@ -11733,8 +13035,6 @@ }, "node_modules/supports-hyperlinks": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", - "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", "dev": true, "license": "MIT", "dependencies": { @@ -11760,8 +13060,6 @@ }, "node_modules/svg-tags": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz", - "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", "dev": true }, "node_modules/svgo": { @@ -11790,8 +13088,6 @@ }, "node_modules/table": { "version": "6.9.0", - "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", - "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -11813,11 +13109,17 @@ } }, "node_modules/tapable": { - "version": "2.2.1", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "dev": true, "license": "MIT", "engines": { "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/tar-stream": { @@ -11832,9 +13134,8 @@ }, "node_modules/terminal-link": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", - "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", "dev": true, + "license": "MIT", "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" @@ -11848,9 +13149,8 @@ }, "node_modules/terminal-link/node_modules/supports-hyperlinks": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", - "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" @@ -11860,12 +13160,14 @@ } }, "node_modules/terser": { - "version": "5.31.6", + "version": "5.46.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz", + "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", + "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, @@ -11877,15 +13179,17 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.10", + "version": "5.3.16", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", + "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.20", + "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", - "schema-utils": "^3.1.1", - "serialize-javascript": "^6.0.1", - "terser": "^5.26.0" + "schema-utils": "^4.3.0", + "serialize-javascript": "^6.0.2", + "terser": "^5.31.1" }, "engines": { "node": ">= 10.13.0" @@ -11909,76 +13213,80 @@ } } }, - "node_modules/terser-webpack-plugin/node_modules/ajv": { - "version": "6.12.6", + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/through": { + "version": "2.3.8", + "dev": true, + "license": "MIT" + }, + "node_modules/tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { - "version": "3.5.2", + "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": { - "ajv": "^6.9.1" + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { - "version": "0.4.1", - "dev": true, - "license": "MIT" - }, - "node_modules/terser-webpack-plugin/node_modules/schema-utils": { - "version": "3.3.0", + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, "engines": { - "node": ">= 10.13.0" + "node": ">=12" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "dev": true, - "license": "MIT" - }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true - }, - "node_modules/tiny-emitter": { - "version": "2.1.0", - "license": "MIT" - }, "node_modules/tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", "dev": true, - "dependencies": { - "os-tmpdir": "~1.0.2" - }, + "license": "MIT", "engines": { - "node": ">=0.6.0" + "node": ">=14.14" } }, "node_modules/to-regex-range": { @@ -11993,7 +13301,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.0.1", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, "license": "MIT", "engines": { @@ -12005,8 +13315,6 @@ }, "node_modules/ts-loader": { "version": "9.5.2", - "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.2.tgz", - "integrity": "sha512-Qo4piXvOTWcMGIgRiuFa6nHNm+54HbYaZCKqc9eeZCLRy3XqafQgwX2F7mofrbJG3g7EEb+lkiR+z2Lic2s3Zw==", "dev": true, "license": "MIT", "dependencies": { @@ -12025,9 +13333,7 @@ } }, "node_modules/ts-loader/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.1", "dev": true, "license": "ISC", "bin": { @@ -12039,8 +13345,6 @@ }, "node_modules/ts-loader/node_modules/source-map": { "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -12119,9 +13423,8 @@ }, "node_modules/type-fest": { "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" }, @@ -12201,9 +13504,8 @@ }, "node_modules/typedarray": { "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/typescript": { "version": "5.7.3", @@ -12218,13 +13520,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.24.0", + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz", + "integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.24.0", - "@typescript-eslint/parser": "8.24.0", - "@typescript-eslint/utils": "8.24.0" + "@typescript-eslint/eslint-plugin": "8.56.0", + "@typescript-eslint/parser": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/utils": "8.56.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -12234,8 +13539,8 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/unbox-primitive": { @@ -12296,19 +13601,45 @@ "node": ">=4" } }, - "node_modules/units-css": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/units-css/-/units-css-0.4.0.tgz", - "integrity": "sha512-WijzYC+chwzg2D6HmNGUSzPAgFRJfuxVyG9oiY28Ei5E+g6fHoPkhXUr5GV+5hE/RTHZNd9SuX2KLioYHdttoA==", + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", "dev": true, + "hasInstallScript": true, "license": "MIT", "dependencies": { - "isnumeric": "^0.2.0", - "viewport-dimensions": "^0.2.0" + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "node_modules/update-browserslist-db": { - "version": "1.1.1", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -12327,7 +13658,7 @@ "license": "MIT", "dependencies": { "escalade": "^3.2.0", - "picocolors": "^1.1.0" + "picocolors": "^1.1.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -12352,6 +13683,8 @@ }, "node_modules/uri-js": { "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -12372,6 +13705,8 @@ }, "node_modules/use-memo-one": { "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.3.tgz", + "integrity": "sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0" @@ -12405,19 +13740,14 @@ "dev": true, "license": "MIT" }, - "node_modules/viewport-dimensions": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/viewport-dimensions/-/viewport-dimensions-0.2.0.tgz", - "integrity": "sha512-94JqlKxEP4m7WO+N3rm4tFRGXZmXXwSPQCoV+EPxDnn8YAGiLU3T+Ha1imLreAjXsHl0K+ELnIqv64i1XZHLFQ==", - "dev": true, - "license": "MIT" - }, "node_modules/w3c-keyname": { "version": "2.2.8", "license": "MIT" }, "node_modules/watchpack": { - "version": "2.4.2", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", "dev": true, "license": "MIT", "dependencies": { @@ -12430,41 +13760,44 @@ }, "node_modules/wcwidth": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", "dev": true, + "license": "MIT", "dependencies": { "defaults": "^1.0.3" } }, "node_modules/webpack": { - "version": "5.97.1", + "version": "5.105.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.2.tgz", + "integrity": "sha512-dRXm0a2qcHPUBEzVk8uph0xWSjV/xZxenQQbLwnwP7caQCYpqG1qddwlyEkIDkYn0K8tvmcrZ+bOrzoQ3HxCDw==", "dev": true, "license": "MIT", "dependencies": { "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.6", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.14.0", - "browserslist": "^4.24.0", + "acorn": "^8.15.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.1", - "es-module-lexer": "^1.2.1", + "enhanced-resolve": "^5.19.0", + "es-module-lexer": "^2.0.0", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", + "loader-runner": "^4.3.1", "mime-types": "^2.1.27", "neo-async": "^2.6.2", - "schema-utils": "^3.2.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.10", - "watchpack": "^2.4.1", - "webpack-sources": "^3.2.3" + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.16", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.3" }, "bin": { "webpack": "bin/webpack.js" @@ -12545,11 +13878,11 @@ } }, "node_modules/webpack-remove-empty-scripts": { - "version": "1.0.4", + "version": "1.1.1", "dev": true, "license": "ISC", "dependencies": { - "ansis": "1.5.2" + "ansis": "4.0.0-node10" }, "engines": { "node": ">=12.14" @@ -12563,69 +13896,15 @@ } }, "node_modules/webpack-sources": { - "version": "3.2.3", + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", + "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", "dev": true, "license": "MIT", "engines": { "node": ">=10.13.0" } }, - "node_modules/webpack/node_modules/acorn": { - "version": "8.14.0", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/webpack/node_modules/ajv": { - "version": "6.12.6", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/webpack/node_modules/ajv-keywords": { - "version": "3.5.2", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/webpack/node_modules/json-schema-traverse": { - "version": "0.4.1", - "dev": true, - "license": "MIT" - }, - "node_modules/webpack/node_modules/schema-utils": { - "version": "3.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/which": { "version": "2.0.2", "dev": true, @@ -12702,14 +13981,17 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.18", + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "for-each": "^0.3.3", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" }, @@ -12734,17 +14016,18 @@ } }, "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, + "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" @@ -12767,16 +14050,80 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/write-file-atomic": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", - "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", "dev": true, "license": "ISC", "dependencies": { @@ -12787,15 +14134,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "engines": { - "node": ">=10" - } - }, "node_modules/yallist": { "version": "3.1.1", "dev": true, @@ -12803,9 +14141,8 @@ }, "node_modules/yaml": { "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "dev": true, + "license": "ISC", "bin": { "yaml": "bin.mjs" }, @@ -12813,38 +14150,10 @@ "node": ">= 14.6" } }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "engines": { - "node": ">=12" - } - }, "node_modules/yauzl": { "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", "dev": true, + "license": "MIT", "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" diff --git a/package.json b/package.json index 1d1404791..417d03066 100644 --- a/package.json +++ b/package.json @@ -2,30 +2,37 @@ "name": "code-snippets", "description": "Manage code snippets running on a WordPress-powered site through a graphical interface.", "homepage": "https://codesnippets.pro", - "version": "3.9.6", + "version": "3.10.0-beta.1", "main": "src/dist/edit.js", "directories": { "test": "tests" }, "scripts": { - "test:playwright": "playwright test -c tests/playwright/playwright.config.ts", + "prepare": "git config core.hooksPath .githooks || true", + "test:php": "WP_TESTS_DIR=./.wp-tests-lib src/vendor/bin/phpunit -c phpunit.xml", + "test:php:watch": "npm run test:php -- --testdox", + "test:playwright": "playwright test -c config/playwright/playwright.config.ts", "test:playwright:debug": "npm run test:playwright -- --debug", "test:playwright:ui": "npm run test:playwright -- --ui", "prepare-environment:ci": "npm ci", "wp-env:start": "wp-env start", "wp-env:stop": "wp-env stop", "wp-env:clean": "wp-env clean all", - "test:setup:playwright": "wp-env run cli wp plugin activate code-snippets", + "test:setup:php": "ts-node scripts/test-setup-phpunit.ts", + "test:setup:playwright": "ts-node scripts/test-setup-playwright.ts", "build": "webpack", "watch": "webpack --watch", "bundle": "ts-node scripts/bundle.ts", "lint": "npm run lint:styles && npm run lint:js && npm run lint:php", + "lint:fix": "npm run lint:styles:fix && npm run lint:js:fix && npm run lint:php:fix", "lint:styles": "stylelint 'src/css/**/*.scss'", "lint:styles:fix": "stylelint --fix 'src/css/**/*.scss'", - "lint:js": "eslint", - "lint:js:fix": "eslint --fix", - "lint:php": "src/vendor/bin/phpcs -s --colors ./src/phpcs.xml", - "lint:php:fix": "src/vendor/bin/phpcbf ./src/phpcs.xml", + "lint:js": "eslint .", + "lint:js:fix": "eslint . --fix", + "lint:php": "src/vendor/bin/phpcs -s --colors --standard=phpcs.xml src/php tests", + "lint:php:fix": "src/vendor/bin/phpcbf --standard=phpcs.xml src/php tests", + "lint:readme": "ts-node scripts/linters/lint-readme.ts", + "lint:changelog": "ts-node scripts/linters/lint-changelog.ts", "version": "ts-node scripts/version.ts", "version-dev": "npm version --git-tag-version=false --preid=dev", "version-alpha": "npm version --git-tag-version=false --preid=alpha", @@ -47,11 +54,12 @@ "dependencies": { "@codemirror/fold": "^0.19.4", "@wordpress/components": "^29.3.0", + "@wordpress/date": "^5.43.0", "@wordpress/dom-ready": "^4.17.0", "@wordpress/element": "^6.28.0", "@wordpress/i18n": "^5.17.0", "@wordpress/url": "^4.20.0", - "axios": "^1.7.9", + "axios": "^1.13.5", "classnames": "^2.5.1", "codemirror": "^5.29", "php-parser": "^3.2.2", @@ -61,8 +69,10 @@ "react-select": "^5.10.0" }, "devDependencies": { - "@eslint/eslintrc": "^3.2.0", + "@axe-core/playwright": "^4.11.2", + "@eslint/eslintrc": "^3.3.3", "@eslint/js": "^9.20.0", + "@playwright/test": "^1.48.0", "@stylistic/eslint-plugin": "^3.1.0", "@stylistic/stylelint-plugin": "^3.1.2", "@tsconfig/node18": "^18.2.4", @@ -78,6 +88,7 @@ "@typescript-eslint/eslint-plugin": "^8.24.0", "@typescript-eslint/parser": "^8.24.0", "@wordpress/babel-preset-default": "^8.17.0", + "@wordpress/env": "^9.0.0", "archiver": "^7.0.1", "autoprefixer": "^10.4.20", "babel-loader": "^9.2.1", @@ -87,14 +98,16 @@ "eslint": "^9.20.1", "eslint-import-resolver-typescript": "^3.7.0", "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.4", "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-svg-jsx": "^1.3.0", "eslint-webpack-plugin": "^4.2.0", - "glob": "^11.0.1", + "glob": "^11.1.0", "globals": "^15.14.0", + "lint-staged": "^15.5.2", "mini-css-extract-plugin": "^2.9.2", "postcss": "^8.5.2", - "postcss-color-hsl": "^2.0.0", "postcss-hexrgba": "^2.1.0", "postcss-load-config": "^6.0.1", "postcss-loader": "^8.1.1", @@ -114,12 +127,30 @@ "webpack": "^5.97.1", "webpack-cli": "^6.0.1", "webpack-merge": "^6.0.1", - "webpack-remove-empty-scripts": "^1.0.4", - "@playwright/test": "^1.48.0", - "@wordpress/env": "^9.0.0" + "webpack-remove-empty-scripts": "^1.0.4" + }, + "lint-staged": { + "*.{js,ts,jsx,tsx}": "npm run lint:js:fix --", + "*.{css,scss}": "npm run lint:styles:fix --", + "*.php": "npm run lint:php:fix --", + "src/readme.txt": "ts-node scripts/linters/lint-readme.ts", + "CHANGELOG.md": "ts-node scripts/linters/lint-changelog.ts" }, "overrides": { - "eslint": "^9.20.1", + "@eslint/eslintrc": { + "ajv": "6.12.6" + }, + "eslint": { + "ajv": "6.12.6" + }, + "@babel/runtime": "7.28.6", + "js-yaml": "4.1.1", + "lodash": "4.17.23", + "brace-expansion": "1.1.12", + "form-data": "4.0.5", + "tmp": "0.2.5", + "eslint-visitor-keys": "4.2.1", + "@typescript-eslint/visitor-keys": "8.55.0", "react": "^18.3.1", "react-dom": "^18.3.1" } diff --git a/src/phpcs.xml b/phpcs.xml similarity index 86% rename from src/phpcs.xml rename to phpcs.xml index 3d7e8ca71..fb812c580 100644 --- a/src/phpcs.xml +++ b/phpcs.xml @@ -8,8 +8,10 @@ - - + + + + @@ -35,20 +37,14 @@ - + - *\.php$ - - - class-*\.php$ - - diff --git a/phpunit.xml b/phpunit.xml index 79b627333..aa5a63135 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -6,8 +6,8 @@ convertNoticesToExceptions="true" convertWarningsToExceptions="true"> - - ./tests/ + + ./tests/unit/ diff --git a/scripts/composer-fix-autoload.php b/scripts/composer-fix-autoload.php new file mode 100644 index 000000000..593089352 --- /dev/null +++ b/scripts/composer-fix-autoload.php @@ -0,0 +1,103 @@ + strlen( $a ); + } +); + +$prefix_segments = explode( '\\', $prefix ); +$prefix_tail = preg_quote( (string) end( $prefix_segments ), '#' ); + +$patterns = []; +$replacements = []; + +foreach ( $roots as $root ) { + $segments = array_map( + static function ( $segment ) { + return preg_quote( $segment, '#' ); + }, + explode( '\\', $root ) + ); + + // A whole leading backslash run then the root, when the preceding segment is not the prefix tail. + $patterns[] = '#(?isFile() || 'php' !== strtolower( $file->getExtension() ) ) { + continue; + } + + if ( 0 === strpos( $file->getPathname(), $skip_prefix ) ) { + continue; + } + + $contents = file_get_contents( $file->getPathname() ); + $updated = preg_replace( $patterns, $replacements, $contents ); + + if ( null !== $updated && $updated !== $contents ) { + file_put_contents( $file->getPathname(), $updated ); + } +} diff --git a/scripts/install-wp-tests.sh b/scripts/install-wp-tests.sh new file mode 100755 index 000000000..8bd2f07be --- /dev/null +++ b/scripts/install-wp-tests.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash + +if [ $# -lt 3 ]; then + echo "usage: $0 [db-host] [wp-version] [skip-database-creation]" + exit 1 +fi + +DB_NAME=$1 +DB_USER=$2 +DB_PASS=$3 +DB_HOST=${4-localhost} +WP_VERSION=${5-latest} +SKIP_DB_CREATE=${6-false} + +TMPDIR=${TMPDIR-/tmp} +TMPDIR=$(echo $TMPDIR | sed -e "s/\/$//") +WP_TESTS_DIR=${WP_TESTS_DIR-$TMPDIR/wordpress-tests-lib} +WP_CORE_DIR=${WP_CORE_DIR-$TMPDIR/wordpress/} + +download() { + if [ `which curl` ]; then + curl -s "$1" > "$2"; + elif [ `which wget` ]; then + wget -nv -O "$2" "$1" + fi +} + +if [[ $WP_VERSION =~ [0-9]+\.[0-9]+(\.[0-9]+)? ]]; then + WP_TESTS_TAG="tags/$WP_VERSION" +else + # http serves a single offer, whereas https serves multiple. we only want one + download http://api.wordpress.org/core/version-check/1.7/ /tmp/wp-latest.json + grep '[0-9]+\.[0-9]+(\.[0-9]+)?' /tmp/wp-latest.json + LATEST_VERSION=$(grep -o '"version":"[^"]*' /tmp/wp-latest.json | sed 's/"version":"//') + if [[ -z "$LATEST_VERSION" ]]; then + echo "Latest WordPress version could not be found" + exit 1 + fi + WP_TESTS_TAG="tags/$LATEST_VERSION" +fi +set -ex + +install_wp() { + + if [ -d $WP_CORE_DIR ]; then + return; + fi + + mkdir -p $WP_CORE_DIR + + if [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then + mkdir -p $TMPDIR/wordpress-trunk + rm -rf $TMPDIR/wordpress-trunk/* + svn export --quiet https://core.svn.wordpress.org/trunk $TMPDIR/wordpress-trunk/wordpress + mv $TMPDIR/wordpress-trunk/wordpress/* $WP_CORE_DIR + else + if [ $WP_VERSION == 'latest' ]; then + local ARCHIVE_NAME='latest' + elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+ ]]; then + # https serves multiple offers, whereas http serves single. + download https://wordpress.org/wordpress-$WP_VERSION.tar.gz $TMPDIR/wordpress.tar.gz + ARCHIVE_NAME="wordpress-$WP_VERSION" + fi + + if [ ! -f $TMPDIR/wordpress.tar.gz ]; then + download https://wordpress.org/${ARCHIVE_NAME}.tar.gz $TMPDIR/wordpress.tar.gz + fi + tar --strip-components=1 -zxmf $TMPDIR/wordpress.tar.gz -C $WP_CORE_DIR + fi + + download https://raw.githubusercontent.com/markoheijnen/wp-mysqli/master/db.php $WP_CORE_DIR/wp-content/db.php +} + +install_test_suite() { + # portable in-place argument for both GNU sed and Mac OSX sed + if [[ $(uname -s) == 'Darwin' ]]; then + local ioption='-i.bak' + else + local ioption='-i' + fi + + # set up testing suite if it doesn't yet exist + if [ ! -d $WP_TESTS_DIR ]; then + # set up testing suite + mkdir -p $WP_TESTS_DIR + rm -rf $WP_TESTS_DIR/{includes,data} + svn export --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/includes/ $WP_TESTS_DIR/includes + svn export --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/data/ $WP_TESTS_DIR/data + fi + + if [ ! -f "$WP_TESTS_DIR/wp-tests-config.php" ]; then + download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php "$WP_TESTS_DIR"/wp-tests-config.php + # remove all forward slashes in the end + WP_CORE_DIR=$(echo $WP_CORE_DIR | sed "s:/\+$::") + # Support both older (/src/) and current (/wordpress/) sample config templates. + sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s:dirname( __FILE__ ) . '/wordpress/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s/youremptytestdbnamehere/$DB_NAME/" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s/yourusernamehere/$DB_USER/" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s/yourpasswordhere/$DB_PASS/" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s|localhost|${DB_HOST}|" "$WP_TESTS_DIR"/wp-tests-config.php + sed $ioption "s|^define( 'WP_TESTS_MULTISITE', false );|define( 'WP_TESTS_MULTISITE', true );|" "$WP_TESTS_DIR"/wp-tests-config.php + fi + +} + +recreate_db() { + shopt -s nocasematch + if [[ $1 =~ ^(y|yes)$ ]] + then + mysqladmin drop $DB_NAME -f --user="$DB_USER" --password="$DB_PASS"$EXTRA + create_db + echo "Recreated the database ($DB_NAME)." + else + echo "Leaving the existing database ($DB_NAME) in place." + fi + shopt -u nocasematch +} + +create_db() { + mysqladmin create $DB_NAME --user="$DB_USER" --password="$DB_PASS"$EXTRA +} + +install_db() { + + if [ ${SKIP_DB_CREATE} = "true" ]; then + return 0 + fi + + # parse DB_HOST for port or socket references + local PARTS=(${DB_HOST//\:/ }) + local DB_HOSTNAME=${PARTS[0]}; + local DB_SOCK_OR_PORT=${PARTS[1]}; + local EXTRA="" + + if ! [ -z $DB_HOSTNAME ] ; then + if [ $(echo $DB_SOCK_OR_PORT | grep -e '^[0-9]\{1,\}$') ]; then + EXTRA=" --host=$DB_HOSTNAME --port=$DB_SOCK_OR_PORT --protocol=tcp" + elif ! [ -z $DB_SOCK_OR_PORT ] ; then + EXTRA=" --socket=$DB_SOCK_OR_PORT" + elif ! [ -z $DB_HOSTNAME ] ; then + EXTRA=" --host=$DB_HOSTNAME --protocol=tcp" + fi + fi + + # create database + if [ $(mysql --user="$DB_USER" --password="$DB_PASS"$EXTRA --execute='show databases;' | grep ^$DB_NAME$) ] + then + echo "Reinstalling will delete the existing test database ($DB_NAME)" + read -p 'Are you sure you want to proceed? [y/N]: ' DELETE_EXISTING_DB + recreate_db $DELETE_EXISTING_DB + else + create_db + fi +} + +install_wp +install_test_suite +install_db diff --git a/scripts/linters/lint-changelog.ts b/scripts/linters/lint-changelog.ts new file mode 100644 index 000000000..aff1abe51 --- /dev/null +++ b/scripts/linters/lint-changelog.ts @@ -0,0 +1,303 @@ +/** + * Lint-changelog.ts + * + * Lints and auto-fixes CHANGELOG.md for formatting consistency. + * + * Rules enforced (based on current file conventions): + * + * File title + * - First line must be exactly: # Changelog + * + * Release headers + * - Format: ## [X.Y.Z] (YYYY-MM-DD) + * or: ## [X.Y.Z-modifier.N] (YYYY-MM-DD) + * - Non-bracketed versions (e.g. "## 3.6.5.1 (...)") are normalised to the + * bracketed form: ## [3.6.5.1] (YYYY-MM-DD) + * - Date format: YYYY-MM-DD (required) + * + * Section sub-headings + * - Allowed: ### Added, ### Changed, ### Fixed, ### Removed, + * ### Deprecated, ### Security + * - Bold variants (**Added**, __Added__) are promoted to ### headings + * - Casing is normalised to the canonical form above + * + * Lists + * - Items start with "* " (not "- ") + * - No trailing whitespace + * + * Spacing + * - Exactly 1 blank line before every ## heading (not before the first one) + * - Exactly 1 blank line after every # and ## headings + * - No blank lines after ### headings + * - Exactly 1 blank line before every ### heading (not immediately after ##) + * - No consecutive blank lines (max 1) + * - File ends with exactly one newline + */ + +import { existsSync, readFileSync, writeFileSync } from 'fs' +import { resolve } from 'path' + +/* ── helpers ─────────────────────────────────────────────────────────── */ + +const KNOWN_CHANGE_TYPES = ['Added', 'Changed', 'Fixed', 'Removed', 'Deprecated', 'Security'] + +const CLI_ARGS_START_INDEX = 2 + +const trimTrailing = (lines: string[]): string[] => + lines.map(l => l.trimEnd()) + +const collapseBlankLines = (lines: string[]): string[] => { + const out: string[] = [] + let prevBlank = false + for (const l of lines) { + const blank = '' === l.trim() + if (blank && prevBlank) {continue} + out.push(l) + prevBlank = blank + } + return out +} + +/** + * Ensure exactly `n` blank lines appear immediately before every line matching + * `headingRe`. Lines matching `skipAfterRe` suppress spacing for the immediately + * following heading (used to avoid a blank line between a ## and its first ###). + */ +const normaliseBlanksBefore = ( + lines: string[], + headingRe: RegExp, + n: number, + skipAfterRe?: RegExp +): string[] => { + const out: string[] = [] + let suppressNext = true // Suppress before very first heading + + for (const line of lines) { + if (skipAfterRe?.test(line)) { + suppressNext = true + out.push(line) + continue + } + + if (headingRe.test(line)) { + if (!suppressNext) { + while (0 < out.length && '' === out[out.length - 1].trim()) {out.pop()} + for (let b = 0; b < n; b += 1) {out.push('')} + } + out.push(line) + suppressNext = false + continue + } + + if ('' !== line.trim()) {suppressNext = false} + out.push(line) + } + return out +} + +/** Ensure exactly 1 blank line immediately after every line matching `headingRe`. */ +const normaliseBlankAfter = (lines: string[], headingRe: RegExp): string[] => { + const out: string[] = [] + let i = 0 + while (i < lines.length) { + const line = lines[i] + out.push(line) + if (headingRe.test(line)) { + i += 1 + while (i < lines.length && '' === lines[i].trim()) {i += 1} + if (i < lines.length) {out.push('')} + continue + } + i += 1 + } + return out +} + +/** Remove any blank lines immediately after lines matching `headingRe`. */ +const removeBlankAfter = (lines: string[], headingRe: RegExp): string[] => { + const out: string[] = [] + let i = 0 + while (i < lines.length) { + const line = lines[i] + out.push(line) + if (headingRe.test(line)) { + i += 1 + // Skip all blank lines following the heading + while (i < lines.length && '' === lines[i].trim()) {i += 1} + continue + } + i += 1 + } + return out +} + +/* ── linter ─────────────────────────────────────────────────────────── */ + +const ensureTitle = (lines: string[], errors: string[]): string[] => { + if ('# Changelog' !== lines[0]) { + if (/^#\s+changelog/i.test(lines[0])) { + lines[0] = '# Changelog' + } else { + errors.push('CHANGELOG.md: First line must be "# Changelog"') + } + } + + return lines +} + +const promoteBoldChangeTypeLabels = (lines: string[]): string[] => + lines.map(line => { + const match = /^\s*(?:\*\*|__)(?\w+)(?:\*\*|__)\s*$/.exec(line) + if (!match?.groups) {return line} + const { type } = match.groups + const canonical = KNOWN_CHANGE_TYPES.find(t => t.toLowerCase() === type.toLowerCase()) + return canonical ? `### ${canonical}` : line + }) + +const normaliseReleaseHeaders = (lines: string[], errors: string[]): string[] => + lines.map(line => { + const bracketed = /^## \[(?[^\]]+)\]\s*\((?\d{4}-\d{2}-\d{2}|[A-Z][A-Z0-9-]*)\)/.exec(line) + if (bracketed?.groups) {return `## [${bracketed.groups.ver}] (${bracketed.groups.date})`} + + const bracketedMissingClose = /^## \[(?[^\]]+)\]\s*\((?\d{4}-\d{2}-\d{2}|[A-Z][A-Z0-9-]*)$/.exec(line) + if (bracketedMissingClose?.groups) { + return `## [${bracketedMissingClose.groups.ver}] (${bracketedMissingClose.groups.date})` + } + + const bracketedNoDate = /^## \[(?[^\]]+)\]/.exec(line) + if (bracketedNoDate) { + errors.push(`CHANGELOG.md: Release header missing or malformed date: ${line}`) + return line + } + + const plain = /^## (?\d[^\s(]+)\s*(?:\((?\d{4}-\d{2}-\d{2}|[A-Z][A-Z0-9-]*)\))?/.exec(line) + if (!plain?.groups) {return line} + if (plain.groups.date) {return `## [${plain.groups.ver}] (${plain.groups.date})`} + errors.push(`CHANGELOG.md: Release header missing date: ${line}`) + return `## [${plain.groups.ver}]` + }) + +const normaliseSectionNames = (lines: string[]): string[] => + lines.map(line => { + const match = /^###\s+(?.+)$/.exec(line) + if (!match?.groups) {return line} + let key = match.groups.name.trim() + let canonical = KNOWN_CHANGE_TYPES.find(t => t.toLowerCase() === key.toLowerCase()) + if (!canonical && /s$/i.test(key)) { + const singular = key.replace(/s$/i, '') + canonical = KNOWN_CHANGE_TYPES.find(t => t.toLowerCase() === singular.toLowerCase()) + if (canonical) {key = singular} + } + return canonical ? `### ${canonical}` : `### ${key}` + }) + +const normaliseIndentedSubListItems = (lines: string[]): string[] => { + const out: string[] = [] + + for (const line of lines) { + const indented = /^ {2}[*-] (?.*)$/.exec(line) + if (!indented?.groups) { + out.push(line) + continue + } + + const text = indented.groups.text.trimEnd() + let parentIdx = -1 + for (let j = out.length - 1; 0 <= j; j -= 1) { + if ('' === out[j].trim()) {break} + if (out[j].startsWith('* ')) { + parentIdx = j + break + } + } + + if (-1 !== parentIdx) { + const parentTrimmed = out[parentIdx].trimEnd() + if (!parentTrimmed.endsWith(':')) { + out[parentIdx] = `${parentTrimmed.replace(/[.,;]$/, '')}:` + } + out.push(` - ${text}`) + } else { + out.push(`* ${text}`) + } + } + + return out +} + +const normaliseTopLevelBulletMarkers = (lines: string[]): string[] => + lines.map(line => { + const match = /^(?[*-]) (?.*)$/.exec(line) + if (match?.groups) {return `* ${match.groups.content.trimEnd()}`} + return line + }) + +const applySpacingRules = (lines: string[]): string[] => { + const RELEASE_RE = /^## / + const SECTION_RE = /^### / + const TITLE_RE = /^# Changelog/ + + let out = lines + out = normaliseBlankAfter(out, TITLE_RE) + out = normaliseBlanksBefore(out, RELEASE_RE, 1, TITLE_RE) + out = normaliseBlankAfter(out, RELEASE_RE) + out = normaliseBlanksBefore(out, SECTION_RE, 1, RELEASE_RE) + out = removeBlankAfter(out, SECTION_RE) + return out +} + +const finaliseLines = (lines: string[]): string[] => { + const out = collapseBlankLines(lines) + while (0 < out.length && '' === out[out.length - 1]) {out.pop()} + out.push('') + return out +} + +export const lintChangelog = (src: string): { fixed: string; errors: string[] } => { + const errors: string[] = [] + let lines = src.split('\n') + + lines = ensureTitle(lines, errors) + lines = trimTrailing(lines) + lines = promoteBoldChangeTypeLabels(lines) + lines = normaliseReleaseHeaders(lines, errors) + lines = normaliseSectionNames(lines) + lines = normaliseIndentedSubListItems(lines) + lines = normaliseTopLevelBulletMarkers(lines) + lines = applySpacingRules(lines) + lines = finaliseLines(lines) + + return { fixed: lines.join('\n'), errors } +} + +/* ── entry point ─────────────────────────────────────────────────────── */ + +const root = resolve(__dirname, '../..') +const args = process.argv.slice(CLI_ARGS_START_INDEX) +const files = 0 < args.length ? args : [resolve(root, 'CHANGELOG.md')] +let anyErrors = false +let anyProcessed = false + +for (const f of files) { + const abs = resolve(f) + if (!abs.endsWith('CHANGELOG.md')) {continue} + anyProcessed = true + + if (!existsSync(abs)) { console.error(`lint-changelog: file not found – ${abs}`); anyErrors = true; continue } + + const src = readFileSync(abs, 'utf8') + const { fixed, errors } = lintChangelog(src) + + errors.forEach(e => console.error(` ✗ ${e}`)) + if (0 < errors.length) {anyErrors = true} + + if (fixed !== src) { + writeFileSync(abs, fixed, 'utf8') + console.log(` ✔ auto-fixed: ${abs}`) + } else { + console.log(` ✔ no changes: ${abs}`) + } +} + +if (!anyProcessed) {process.exit(0)} +process.exit(anyErrors ? 1 : 0) diff --git a/scripts/linters/lint-readme.ts b/scripts/linters/lint-readme.ts new file mode 100644 index 000000000..3f51746c2 --- /dev/null +++ b/scripts/linters/lint-readme.ts @@ -0,0 +1,402 @@ +/** + * Lint-readme.ts + * + * Lints and auto-fixes src/readme.txt for WordPress.org formatting consistency. + * + * Rules enforced (based on current file conventions + WordPress.org spec): + * + * Header block + * - First line: === Plugin Name === + * - Each header field: "Key: Value" (single space after colon, no trailing whitespace) + * - Exactly 1 blank line between the last header field and the short description + * - Required fields must be present: Contributors, Donate link, Tags, License, + * License URI, Stable tag, Requires at least, Tested up to, Requires PHP + * - Short description line immediately follows header (non-empty, ≤ 150 chars, no markup) + * + * Sections + * - Top-level: == Section Name == (known names normalised, title-case otherwise) + * - Sub-sections: = Sub Section = (title-case) + * - Known section names: Description, Installation, Frequently Asked Questions, + * Screenshots, Changelog, Upgrade Notice + * + * Changelog section (inside readme.txt) + * - Version sub-headers: = X.Y.Z (YYYY-MM-DD) = or an uppercase status token, e.g. = X.Y.Z (UPCOMING) = + * - Change-type labels: __Added__, __Changed__, __Fixed__, __Removed__, + * __Deprecated__, __Security__ + * (### headings and **Bold** variants are demoted / normalised) + * + * Lists + * - Items start with "* " (not "- ") + * - No trailing whitespace + * + * Spacing + * - Exactly 1 blank line before every == section (not the very first) + * - Exactly 1 blank line after every == section heading + * - Exactly 1 blank line before every = sub-section (not right after == heading) + * - Exactly 1 blank line after every = sub-section inside == Changelog == only + * - Exactly 1 blank line before __Type__ labels inside Changelog (not right after = heading) + * - Exactly 1 blank line after __Type__ labels inside Changelog + * - No consecutive blank lines (max 1) + * - File ends with exactly one newline + */ + +import { existsSync, readFileSync, writeFileSync } from 'fs' +import { resolve } from 'path' + +/* ── helpers ─────────────────────────────────────────────────────────── */ + +const KNOWN_CHANGE_TYPES = ['Added', 'Changed', 'Fixed', 'Removed', 'Deprecated', 'Security'] + +const CLI_ARGS_START_INDEX = 2 +const SUBSECTION_TITLECASE_MAX_WORDS = 2 +const LIST_MARKER_LENGTH = 2 + +const RE_DATE_SRC = '(?:\\d{4}-\\d{2}-\\d{2}|[A-Z][A-Z0-9-]*)' +const RE_VERSION_SRC = '\\d+\\.\\d+(?:\\.\\d+)*(?:-[a-zA-Z0-9.]+)?' + +/** Known == Section == names (canonical capitalisation). */ +const KNOWN_SECTIONS: Record = { + 'description': 'Description', + 'installation': 'Installation', + 'frequently asked questions': 'Frequently Asked Questions', + 'faq': 'Frequently Asked Questions', + 'screenshots': 'Screenshots', + 'changelog': 'Changelog', + 'upgrade notice': 'Upgrade Notice' +} + +const titleCase = (s: string): string => + s.replace(/\w\S*/g, t => t.charAt(0).toUpperCase() + t.slice(1).toLowerCase()) + +const sentenceCase = (s: string): string => + s.charAt(0).toUpperCase() + s.slice(1) + +const subsectionCase = (s: string): string => { + const wordCount = s.trim().split(/\s+/).length + return SUBSECTION_TITLECASE_MAX_WORDS >= wordCount ? titleCase(s) : sentenceCase(s) +} + +const normaliseSectionName = (raw: string): string => { + const key = raw.trim().toLowerCase() + return KNOWN_SECTIONS[key] ?? titleCase(raw.trim()) +} + +const trimTrailing = (lines: string[]): string[] => + lines.map(l => l.trimEnd()) + +const collapseBlankLines = (lines: string[]): string[] => { + const out: string[] = [] + let prevBlank = false + for (const l of lines) { + const blank = '' === l.trim() + if (blank && prevBlank) { + continue + } + out.push(l) + prevBlank = blank + } + return out +} + +/** + * Ensure exactly `n` blank lines appear immediately before every line matching + * `headingRe`. Lines matching `skipAfterRe` reset the "just-saw-section-start" + * flag, suppressing spacing for the immediately following heading. + */ +const normaliseBlanksBefore = ( + lines: string[], + headingRe: RegExp, + n: number, + skipAfterRe?: RegExp +): string[] => { + const out: string[] = [] + let suppressNext = true + + for (const line of lines) { + if (skipAfterRe?.test(line)) { + suppressNext = true + out.push(line) + continue + } + + if (headingRe.test(line)) { + if (!suppressNext) { + while (0 < out.length && '' === out[out.length - 1].trim()) { + out.pop() + } + for (let b = 0; b < n; b += 1) { + out.push('') + } + } + out.push(line) + suppressNext = false + continue + } + + if ('' !== line.trim()) { + suppressNext = false + } + out.push(line) + } + return out +} + +/** Ensure exactly 1 blank line immediately after every line matching `headingRe`. */ +const normaliseBlankAfter = (lines: string[], headingRe: RegExp): string[] => { + const out: string[] = [] + let i = 0 + while (i < lines.length) { + const line = lines[i] + out.push(line) + if (headingRe.test(line)) { + i += 1 + while (i < lines.length && '' === lines[i].trim()) { + i += 1 + } + if (i < lines.length) { + out.push('') + } + continue + } + i += 1 + } + return out +} + +/** + * Like normaliseBlankAfter but only operates on lines that fall within a + * specific section (between `sectionStartRe` and the next `== ... ==` heading). + */ +const normaliseBlankAfterInSection = ( + lines: string[], + sectionStartRe: RegExp, + headingRe: RegExp +): string[] => { + const out: string[] = [] + let inSection = false + let i = 0 + while (i < lines.length) { + const line = lines[i] + if (sectionStartRe.test(line)) { + inSection = true + } else if (/^== .+ ==$/.test(line)) { + inSection = false + } + + out.push(line) + if (inSection && headingRe.test(line)) { + i += 1 + while (i < lines.length && '' === lines[i].trim()) { + i += 1 + } + if (i < lines.length) { + out.push('') + } + continue + } + i += 1 + } + return out +} + +/* ── linter ─────────────────────────────────────────────────────────── */ + +const normalisePluginHeader = (lines: string[], errors: string[]): string[] => { + if (!/^=== .+ ===$/.test(lines[0])) { + errors.push('readme.txt: First line must be "=== Plugin Name ==="') + } + return lines +} + +const normaliseHeaderFieldSpacing = (lines: string[]): string[] => { + let inHeader = true + return lines.map((line, i) => { + if (inHeader && 0 < i && '' === line.trim()) { + inHeader = false + return line + } + if (!inHeader) { + return line + } + const match = /^(?[A-Za-z][A-Za-z ]+):\s*(?.*)$/.exec(line) + if (match?.groups) { + return `${match.groups.key.trim()}: ${match.groups.value.trim()}` + } + return line + }) +} + +const ensureBlankAfterLastHeaderField = (lines: string[]): string[] => { + const FIELD_RE = /^[A-Za-z][A-Za-z ]+: / + let lastFieldIdx = -1 + + for (const [i, line] of lines.entries()) { + if (0 === i) { + continue + } + if (line.startsWith('== ')) { + break + } + if (FIELD_RE.test(line)) { + lastFieldIdx = i + } + } + + if (-1 !== lastFieldIdx && lastFieldIdx + 1 < lines.length) { + if ('' !== lines[lastFieldIdx + 1].trim()) { + lines.splice(lastFieldIdx + 1, 0, '') + } + } + + return lines +} + +const normaliseSectionHeadings = (lines: string[]): string[] => + lines.map(line => { + const match = /^==\s+(?.+?)\s+==$/.exec(line) + if (match?.groups) { + return `== ${normaliseSectionName(match.groups.name)} ==` + } + return line + }) + +const normaliseSubSectionHeadings = (lines: string[]): string[] => + lines.map(line => { + const match = /^=\s+(?.+?)\s+=$/.exec(line) + if (!match?.groups) { + return line + } + const inner = match.groups.name.trim() + const ver = new RegExp(`^(?${RE_VERSION_SRC})\\s+\\((?${RE_DATE_SRC})\\)$`).exec(inner) + if (ver?.groups) { + return `= ${ver.groups.ver} (${ver.groups.date}) =` + } + return `= ${subsectionCase(inner)} =` + }) + +const normaliseChangelogChangeTypes = (lines: string[]): string[] => { + let inChangelog = false + return lines.map(line => { + if (/^== Changelog ==$/.test(line)) { + inChangelog = true + return line + } + if (line.startsWith('== ')) { + inChangelog = false + return line + } + if (!inChangelog) { + return line + } + + const hashM = /^###\s+(?\w+)\s*$/.exec(line) + if (hashM?.groups) { + const canonical = KNOWN_CHANGE_TYPES.find(t => t.toLowerCase() === hashM.groups?.type.toLowerCase()) + if (canonical) { + return `__${canonical}__` + } + } + const boldM = /^\*\*(?\w+)\*\*\s*$/.exec(line) + if (boldM?.groups) { + const canonical = KNOWN_CHANGE_TYPES.find(t => t.toLowerCase() === boldM.groups?.type.toLowerCase()) + if (canonical) { + return `__${canonical}__` + } + } + const underM = /^__(?\w+)__\s*$/.exec(line) + if (underM?.groups) { + const canonical = KNOWN_CHANGE_TYPES.find(t => t.toLowerCase() === underM.groups?.type.toLowerCase()) + if (canonical) { + return `__${canonical}__` + } + } + return line + }) +} + +const normaliseListItems = (lines: string[]): string[] => + lines.map(line => line.startsWith('- ') ? `* ${line.slice(LIST_MARKER_LENGTH)}` : line) + +const applySpacingRules = (lines: string[]): string[] => { + const SECTION_RE = /^== .+ ==$/ + const SUBSECTION_RE = /^= .+ =$/ + const CHANGETYPE_RE = /^__(?:Added|Changed|Fixed|Removed|Deprecated|Security)__$/ + + let out = lines + out = normaliseBlanksBefore(out, SECTION_RE, 1, /^=== .+ ===/) + out = normaliseBlankAfter(out, SECTION_RE) + out = normaliseBlanksBefore(out, SUBSECTION_RE, 1, SECTION_RE) + out = normaliseBlankAfterInSection(out, /^== Changelog ==$/, SUBSECTION_RE) + out = normaliseBlanksBefore(out, CHANGETYPE_RE, 1, SUBSECTION_RE) + out = normaliseBlankAfterInSection(out, /^== Changelog ==$/, CHANGETYPE_RE) + return out +} + +const finaliseLines = (lines: string[]): string[] => { + const out = collapseBlankLines(lines) + while (0 < out.length && '' === out[out.length - 1]) { + out.pop() + } + out.push('') + return out +} + +export const lintReadme = (src: string): { fixed: string; errors: string[] } => { + const errors: string[] = [] + let lines = src.split('\n') + + lines = normalisePluginHeader(lines, errors) + lines = trimTrailing(lines) + lines = normaliseHeaderFieldSpacing(lines) + lines = ensureBlankAfterLastHeaderField(lines) + lines = normaliseSectionHeadings(lines) + lines = normaliseSubSectionHeadings(lines) + lines = normaliseChangelogChangeTypes(lines) + lines = normaliseListItems(lines) + lines = applySpacingRules(lines) + lines = finaliseLines(lines) + + return { fixed: lines.join('\n'), errors } +} + +/* ── entry point ─────────────────────────────────────────────────────── */ + +const root = resolve(__dirname, '../..') +const args = process.argv.slice(CLI_ARGS_START_INDEX) +const files = 0 < args.length ? args : [resolve(root, 'src/readme.txt')] +let anyErrors = false +let anyProcessed = false + +for (const f of files) { + const abs = resolve(f) + if (!abs.endsWith('readme.txt')) { + continue + } + anyProcessed = true + + if (!existsSync(abs)) { + console.error(`lint-readme: file not found – ${abs}`) + anyErrors = true + continue + } + + const src = readFileSync(abs, 'utf8') + const { fixed, errors } = lintReadme(src) + + errors.forEach(e => console.error(` ✗ ${e}`)) + if (0 < errors.length) { + anyErrors = true + } + + if (fixed !== src) { + writeFileSync(abs, fixed, 'utf8') + console.log(` ✔ auto-fixed: ${abs}`) + } else { + console.log(` ✔ no changes: ${abs}`) + } +} + +if (!anyProcessed) { + process.exit(0) +} +process.exit(anyErrors ? 1 : 0) diff --git a/scripts/test-setup-phpunit.ts b/scripts/test-setup-phpunit.ts new file mode 100644 index 000000000..7bd57f27b --- /dev/null +++ b/scripts/test-setup-phpunit.ts @@ -0,0 +1,95 @@ +#!/usr/bin/env ts-node + +import { execFileSync } from 'node:child_process' +import { resolve } from 'node:path' + +const getEnv = (key: string, fallback: string): string => process.env[key] ?? fallback + +const run = (cmd: string, args: readonly string[], options: { env?: NodeJS.ProcessEnv } = {}) => { + const extraEnv = options.env ?? {} + execFileSync(cmd, args, { stdio: 'inherit', env: { ...process.env, ...extraEnv } }) +} + +const buildMysqlArgs = (options: { user: string; password: string; host: string }) => { + const args = ['-u', options.user] + + if (options.password) { + args.push(`--password=${options.password}`) + } + + if (options.host) { + args.push('-h', options.host) + } + + return args +} + +const assertSafeDbName = (dbName: string) => { + if (!/^[A-Za-z0-9_]+$/.test(dbName)) { + throw new Error(`Invalid DB name "${dbName}". Use only letters, numbers, and underscore.`) + } +} + +const main = () => { + const dbName = getEnv('WP_PHPUNIT_DB_NAME', 'code_snippets_phpunit') + const dbUser = getEnv('WP_PHPUNIT_DB_USER', 'root') + const dbPass = getEnv('WP_PHPUNIT_DB_PASS', '') + const dbHost = getEnv('WP_PHPUNIT_DB_HOST', '127.0.0.1') + const wpVersion = getEnv('WP_PHPUNIT_WP_VERSION', 'latest') + + assertSafeDbName(dbName) + + const wpTestsDir = resolve(process.cwd(), '.wp-tests-lib') + const wpCoreDir = resolve(process.cwd(), '.wp-core') + const wpTestsConfig = resolve(wpTestsDir, 'wp-tests-config.php') + const installScript = resolve(process.cwd(), 'scripts', 'install-wp-tests.sh') + + // Create the database if needed (avoid install-wp-tests.sh prompt / destructive behavior). + const mysqlArgs = buildMysqlArgs({ user: dbUser, password: dbPass, host: dbHost }) + run('mysql', [...mysqlArgs, '-e', `CREATE DATABASE IF NOT EXISTS \`${dbName}\`;`]) + + // Ensure config is regenerated with current DB settings. + run('rm', ['-f', wpTestsConfig, `${wpTestsConfig}.bak`]) + + run('bash', [ + installScript, + dbName, + dbUser, + dbPass, + dbHost, + wpVersion, + 'true' + ], { + env: { + WP_TESTS_DIR: wpTestsDir, + WP_CORE_DIR: wpCoreDir + } + }) + + // Ensure a clean test schema before WordPress bootstraps installation. + run('mysql', [ + ...mysqlArgs, + dbName, + '-e', + [ + 'SET FOREIGN_KEY_CHECKS = 0', + 'SET @tables = (SELECT GROUP_CONCAT(table_name)' + + ` FROM information_schema.tables WHERE table_schema = '${dbName}' AND table_name LIKE 'wptests\\_%')`, + "SET @drop = IF(@tables IS NULL, 'SELECT 1', CONCAT('DROP TABLE ', @tables))", + 'PREPARE stmt FROM @drop', + 'EXECUTE stmt', + 'DEALLOCATE PREPARE stmt', + 'SET FOREIGN_KEY_CHECKS = 1' + ].join('; ') + ]) + + // Initialize WordPress test tables so `npm run test:php` works on a fresh DB. + run('php', [resolve(wpTestsDir, 'includes', 'install.php'), wpTestsConfig]) +} + +try { + main() +} catch (error: unknown) { + console.error(error) + process.exitCode = 1 +} diff --git a/scripts/test-setup-playwright.ts b/scripts/test-setup-playwright.ts new file mode 100644 index 000000000..7ebdcd3b4 --- /dev/null +++ b/scripts/test-setup-playwright.ts @@ -0,0 +1,65 @@ +#!/usr/bin/env ts-node + +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +const run = (cmd: string, args: readonly string[]) => { + execFileSync(cmd, args, { stdio: 'inherit' }) +} + +const runWpEnvCli = (args: readonly string[]) => run('npx', ['wp-env', 'run', 'cli', ...args]) + +const getPluginSlug = (): string => { + const prefix = 'wp-content/plugins/' + const config = <{ mappings?: Record }>JSON.parse(readFileSync(resolve(process.cwd(), '.wp-env.json'), 'utf8')) + const mapping = Object.keys(config.mappings ?? {}).find(key => key.startsWith(prefix)) + + if (!mapping) { + throw new Error('No plugin mapping found in .wp-env.json') + } + + return mapping.slice(prefix.length) +} + +const main = () => { + // Ensure a clean slate for file-based execution tests: + // - remove flat-file execution directory (stale indexes can break the WP site) + // - ensure plugin is active + // - force enable_flat_files=false so the Playwright setup test can flip it to true + // - delete all DB snippets with an E2E prefix (keeps list clean across runs) + + runWpEnvCli(['sh', '-lc', 'rm -rf wp-content/code-snippets']) + runWpEnvCli(['wp', 'plugin', 'activate', getPluginSlug()]) + + runWpEnvCli([ + 'wp', + 'eval', + ` + $settings = get_option('code_snippets_settings', []); + $settings['general']['enable_flat_files'] = false; + update_option('code_snippets_settings', $settings); + ` + ]) + + runWpEnvCli([ + 'wp', + 'eval', + ` + global $wpdb; + $wpdb->query( + $wpdb->prepare( + "DELETE FROM {$wpdb->prefix}snippets WHERE name LIKE %s", + "E2E%" + ) + ); + ` + ]) +} + +try { + main() +} catch (error: unknown) { + console.error(error) + process.exitCode = 1 +} diff --git a/scripts/utils/files.ts b/scripts/utils/files.ts index fa79628dc..002123e37 100644 --- a/scripts/utils/files.ts +++ b/scripts/utils/files.ts @@ -1,5 +1,5 @@ import { mkdir, rm, stat } from 'fs/promises' -import { createReadStream, createWriteStream } from 'fs' +import { createReadStream, createWriteStream, readFileSync, writeFileSync } from 'fs' import { dirname, join } from 'path' import { glob } from 'glob' @@ -29,3 +29,9 @@ export const copy = async (patterns: string[], dest: string, transform?: (filena } } } + +export const replaceInFile = (filename: string, transform: (contents: string) => string) => { + const file = resolve(filename) + const contents = readFileSync(file, 'utf8') + writeFileSync(file, transform(contents), 'utf8') +} diff --git a/scripts/version.ts b/scripts/version.ts index c5b70e018..6e28a2c09 100644 --- a/scripts/version.ts +++ b/scripts/version.ts @@ -1,12 +1,5 @@ -import { readFileSync, writeFileSync } from 'fs' import plugin from '../package.json' -import { resolve } from './utils/files' - -const replaceInFile = (filename: string, transform: (contents: string) => string) => { - const file = resolve(filename) - const contents = readFileSync(file, 'utf8') - writeFileSync(file, transform(contents), 'utf8') -} +import { replaceInFile } from './utils/files' replaceInFile( 'src/code-snippets.php', diff --git a/src/code-snippets.php b/src/code-snippets.php index 6bd3a959d..f9df2c2cc 100644 --- a/src/code-snippets.php +++ b/src/code-snippets.php @@ -8,14 +8,14 @@ * License: GPL-2.0-or-later * License URI: license.txt * Text Domain: code-snippets - * Version: 3.9.6 + * Version: 3.10.0-beta.1 * Requires PHP: 7.4 - * Requires at least: 5.0 + * Requires at least: 5.5 * - * @version 3.9.6 + * @version 3.10.0-beta.1 * @package Code_Snippets * @author Shea Bunge - * @copyright 2012-2024 Code Snippets Pro + * @copyright 2012-2026 Code Snippets Pro * @license GPL-2.0-or-later https://spdx.org/licenses/GPL-2.0-or-later.html * @link https://github.com/codesnippetspro/code-snippets * @@ -37,7 +37,7 @@ * * @const string */ - define( 'CODE_SNIPPETS_VERSION', '3.9.6' ); + define( 'CODE_SNIPPETS_VERSION', '3.10.0-beta.1' ); /** * The full path to the main file of this plugin. @@ -54,11 +54,11 @@ * Used to determine which version of Code Snippets is running. * * @since 3.0.0 - * @onst boolean + * @const bool */ define( 'CODE_SNIPPETS_PRO', false ); - require_once dirname( __FILE__ ) . '/php/load.php'; + require_once dirname( __FILE__ ) . '/php/Core/load.php'; } else { - require_once dirname( __FILE__ ) . '/php/deactivation-notice.php'; + require_once dirname( __FILE__ ) . '/php/Core/deactivation-notice.php'; } diff --git a/src/composer.json b/src/composer.json index be43b7946..ecb505d47 100644 --- a/src/composer.json +++ b/src/composer.json @@ -21,23 +21,28 @@ "source": "https://github.com/codesnippetspro/code-snippets" }, "autoload": { - "classmap": [ - "php/" + "files": [ + "php/Admin/Menus/Manage/Manage_Menu.php", + "php/Utils/requests.php" ], "psr-4": { + "Code_Snippets\\": "php/" } }, "require": { "php": ">=7.4", "ext-dom": "*", "ext-json": "*", + "ext-zip": "*", "composer/installers": "^2.3", "typisttech/imposter-plugin": "^0.6.2" }, "require-dev": { "wp-coding-standards/wpcs": "^3.1", "phpcompatibility/phpcompatibility-wp": "^2.1", - "dealerdirect/phpcodesniffer-composer-installer": "^1.0" + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "phpunit/phpunit": "^9.6", + "yoast/phpunit-polyfills": "^2.0" }, "config": { "platform": { @@ -53,5 +58,9 @@ "imposter": { "namespace": "Code_Snippets\\Vendor" } + }, + "scripts": { + "post-install-cmd": "@php ../scripts/composer-fix-autoload.php", + "post-update-cmd": "@php ../scripts/composer-fix-autoload.php" } } diff --git a/src/composer.lock b/src/composer.lock index 56ee40d10..8ab294720 100644 --- a/src/composer.lock +++ b/src/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c322fb32f6db8844392d9f78341fcefb", + "content-hash": "118984772f6b448937a6130f407c8a8b", "packages": [ { "name": "composer/installers", @@ -330,16 +330,16 @@ "packages-dev": [ { "name": "dealerdirect/phpcodesniffer-composer-installer", - "version": "v1.2.0", + "version": "v1.2.1", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/composer-installer.git", - "reference": "845eb62303d2ca9b289ef216356568ccc075ffd1" + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/845eb62303d2ca9b289ef216356568ccc075ffd1", - "reference": "845eb62303d2ca9b289ef216356568ccc075ffd1", + "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/963f0c67bffde0eac41b56be71ac0e8ba132f0bd", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd", "shasum": "" }, "require": { @@ -422,7 +422,312 @@ "type": "thanks_dev" } ], - "time": "2025-11-11T04:32:07+00:00" + "time": "2026-05-06T08:26:05+00:00" + }, + { + "name": "doctrine/instantiator", + "version": "1.5.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/0a0fa9780f5d4e507415a065172d26a98d02047b", + "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^11", + "ext-pdo": "*", + "ext-phar": "*", + "phpbench/phpbench": "^0.16 || ^1", + "phpstan/phpstan": "^1.4", + "phpstan/phpstan-phpunit": "^1", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "vimeo/psalm": "^4.30 || ^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "https://ocramius.github.io/" + } + ], + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "keywords": [ + "constructor", + "instantiate" + ], + "support": { + "issues": "https://github.com/doctrine/instantiator/issues", + "source": "https://github.com/doctrine/instantiator/tree/1.5.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", + "type": "tidelift" + } + ], + "time": "2022-12-30T00:15:36+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" }, { "name": "phpcompatibility/php-compatibility", @@ -639,21 +944,21 @@ }, { "name": "phpcsstandards/phpcsextra", - "version": "1.5.0", + "version": "1.5.1", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHPCSExtra.git", - "reference": "b598aa890815b8df16363271b659d73280129101" + "reference": "39467533fdb742446d68c1d10ac33d625ee0311c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/b598aa890815b8df16363271b659d73280129101", - "reference": "b598aa890815b8df16363271b659d73280129101", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/39467533fdb742446d68c1d10ac33d625ee0311c", + "reference": "39467533fdb742446d68c1d10ac33d625ee0311c", "shasum": "" }, "require": { "php": ">=5.4", - "phpcsstandards/phpcsutils": "^1.2.0", + "phpcsstandards/phpcsutils": "^1.2.3", "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" }, "require-dev": { @@ -717,20 +1022,20 @@ "type": "thanks_dev" } ], - "time": "2025-11-12T23:06:57+00:00" + "time": "2026-07-27T11:13:17+00:00" }, { "name": "phpcsstandards/phpcsutils", - "version": "1.2.0", + "version": "1.2.3", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHPCSUtils.git", - "reference": "fa82d14ad1c1713224a52c66c78478145fe454ba" + "reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/fa82d14ad1c1713224a52c66c78478145fe454ba", - "reference": "fa82d14ad1c1713224a52c66c78478145fe454ba", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/5f35d9408c54d7b529501f3c688b6eae562aea1f", + "reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f", "shasum": "" }, "require": { @@ -810,117 +1115,1592 @@ "type": "thanks_dev" } ], - "time": "2025-11-11T00:17:56+00:00" + "time": "2026-07-27T10:28:41+00:00" }, { - "name": "squizlabs/php_codesniffer", - "version": "3.13.5", + "name": "phpunit/php-code-coverage", + "version": "9.2.32", "source": { "type": "git", - "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", - "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4" + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4", - "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5", + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5", "shasum": "" }, "require": { - "ext-simplexml": "*", - "ext-tokenizer": "*", + "ext-dom": "*", + "ext-libxml": "*", "ext-xmlwriter": "*", - "php": ">=5.4.0" + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=7.3", + "phpunit/php-file-iterator": "^3.0.6", + "phpunit/php-text-template": "^2.0.4", + "sebastian/code-unit-reverse-lookup": "^2.0.3", + "sebastian/complexity": "^2.0.3", + "sebastian/environment": "^5.1.5", + "sebastian/lines-of-code": "^1.0.4", + "sebastian/version": "^3.0.2", + "theseer/tokenizer": "^1.2.3" }, "require-dev": { - "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + "phpunit/phpunit": "^9.6" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" }, - "bin": [ - "bin/phpcbf", - "bin/phpcs" - ], "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.2.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, "notification-url": "https://packagist.org/downloads/", "license": [ "BSD-3-Clause" ], "authors": [ { - "name": "Greg Sherwood", - "role": "Former lead" - }, - { - "name": "Juliette Reinders Folmer", - "role": "Current lead" - }, - { - "name": "Contributors", - "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", - "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", "keywords": [ - "phpcs", - "standards", - "static analysis" + "coverage", + "testing", + "xunit" ], "support": { - "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", - "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", - "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", - "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32" }, "funding": [ { - "url": "https://github.com/PHPCSStandards", - "type": "github" - }, - { - "url": "https://github.com/jrfnl", + "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://opencollective.com/php_codesniffer", - "type": "open_collective" - }, - { - "url": "https://thanks.dev/u/gh/phpcsstandards", - "type": "thanks_dev" } ], - "time": "2025-11-04T16:30:35+00:00" + "time": "2024-08-22T04:23:01+00:00" }, { - "name": "wp-coding-standards/wpcs", - "version": "3.2.0", + "name": "phpunit/php-file-iterator", + "version": "3.0.6", "source": { "type": "git", - "url": "https://github.com/WordPress/WordPress-Coding-Standards.git", - "reference": "d2421de7cec3274ae622c22c744de9a62c7925af" + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/d2421de7cec3274ae622c22c744de9a62c7925af", - "reference": "d2421de7cec3274ae622c22c744de9a62c7925af", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", "shasum": "" }, "require": { - "ext-filter": "*", - "ext-libxml": "*", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2021-12-02T12:48:52+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "3.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:58:55+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T05:33:50+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "5.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:16:10+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "9.6.35", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "0edba2f3a0c48df3553cb9b640810b30df60302b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0edba2f3a0c48df3553cb9b640810b30df60302b", + "reference": "0edba2f3a0c48df3553cb9b640810b30df60302b", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.5.0 || ^2", + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=7.3", + "phpunit/php-code-coverage": "^9.2.32", + "phpunit/php-file-iterator": "^3.0.6", + "phpunit/php-invoker": "^3.1.1", + "phpunit/php-text-template": "^2.0.4", + "phpunit/php-timer": "^5.0.3", + "sebastian/cli-parser": "^1.0.2", + "sebastian/code-unit": "^1.0.8", + "sebastian/comparator": "^4.0.10", + "sebastian/diff": "^4.0.6", + "sebastian/environment": "^5.1.5", + "sebastian/exporter": "^4.0.8", + "sebastian/global-state": "^5.0.8", + "sebastian/object-enumerator": "^4.0.4", + "sebastian/resource-operations": "^3.0.4", + "sebastian/type": "^3.2.1", + "sebastian/version": "^3.0.2" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "9.6-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.35" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-07-06T14:48:07+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T06:27:43+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "1.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:08:54+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:30:19+00:00" + }, + { + "name": "sebastian/comparator", + "version": "4.0.10", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/diff": "^4.0", + "sebastian/exporter": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:22:56+00:00" + }, + { + "name": "sebastian/complexity", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a", + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-22T06:19:30+00:00" + }, + { + "name": "sebastian/diff", + "version": "4.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc", + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T06:30:58+00:00" + }, + { + "name": "sebastian/environment", + "version": "5.1.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "http://www.github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:03:51+00:00" + }, + { + "name": "sebastian/exporter", + "version": "4.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c", + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-mbstring": "*", + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:03:27+00:00" + }, + { + "name": "sebastian/global-state", + "version": "5.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-uopz": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "http://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2025-08-10T07:10:35+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "1.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-22T06:20:34+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:12:34+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:14:26+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "4.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0", + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-10T06:57:39+00:00" + }, + { + "name": "sebastian/resource-operations", + "version": "3.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/resource-operations.git", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides a list of PHP built-in functions that operate on resources", + "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "support": { + "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-14T16:00:52+00:00" + }, + { + "name": "sebastian/type", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/3.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:13:03+00:00" + }, + { + "name": "sebastian/version", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c6c1022351a901512170118436c764e473f6de8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", + "reference": "c6c1022351a901512170118436c764e473f6de8c", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T06:39:44+00:00" + }, + { + "name": "squizlabs/php_codesniffer", + "version": "3.13.5", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + }, + "bin": [ + "bin/phpcbf", + "bin/phpcs" + ], + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Greg Sherwood", + "role": "Former lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "Current lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "keywords": [ + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", + "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", + "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-11-04T16:30:35+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + }, + { + "name": "wp-coding-standards/wpcs", + "version": "3.4.1", + "source": { + "type": "git", + "url": "https://github.com/WordPress/WordPress-Coding-Standards.git", + "reference": "ec2ff942335f33683a5957a85d138753876a05cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/ec2ff942335f33683a5957a85d138753876a05cf", + "reference": "ec2ff942335f33683a5957a85d138753876a05cf", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "ext-libxml": "*", "ext-tokenizer": "*", "ext-xmlreader": "*", - "php": ">=5.4", - "phpcsstandards/phpcsextra": "^1.4.0", - "phpcsstandards/phpcsutils": "^1.1.0", - "squizlabs/php_codesniffer": "^3.13.0" + "php": ">=7.2", + "phpcsstandards/phpcsextra": "^1.5.1", + "phpcsstandards/phpcsutils": "^1.2.3", + "squizlabs/php_codesniffer": "^3.13.5" }, "require-dev": { "php-parallel-lint/php-console-highlighter": "^1.0.0", "php-parallel-lint/php-parallel-lint": "^1.4.0", - "phpcompatibility/php-compatibility": "^9.0", + "phpcompatibility/php-compatibility": "^10.0.0@dev", "phpcsstandards/phpcsdevtools": "^1.2.0", - "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.0" + "phpunit/phpunit": "^8.0 || ^9.0" }, "suggest": { "ext-iconv": "For improved results", @@ -955,7 +2735,70 @@ "type": "custom" } ], - "time": "2025-07-24T20:08:31+00:00" + "time": "2026-07-27T11:53:23+00:00" + }, + { + "name": "yoast/phpunit-polyfills", + "version": "2.0.5", + "source": { + "type": "git", + "url": "https://github.com/Yoast/PHPUnit-Polyfills.git", + "reference": "1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27", + "reference": "1a6aecc9ebe4a9cea4e1047d0e6c496e52314c27", + "shasum": "" + }, + "require": { + "php": ">=5.6", + "phpunit/phpunit": "^5.7.21 || ^6.0 || ^7.0 || ^8.0 || ^9.0 || ^10.0" + }, + "require-dev": { + "php-parallel-lint/php-console-highlighter": "^1.0.0", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "yoast/yoastcs": "^3.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.x-dev" + } + }, + "autoload": { + "files": [ + "phpunitpolyfills-autoload.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Team Yoast", + "email": "support@yoast.com", + "homepage": "https://yoast.com" + }, + { + "name": "Contributors", + "homepage": "https://github.com/Yoast/PHPUnit-Polyfills/graphs/contributors" + } + ], + "description": "Set of polyfills for changed PHPUnit functionality to allow for creating PHPUnit cross-version compatible tests", + "homepage": "https://github.com/Yoast/PHPUnit-Polyfills", + "keywords": [ + "phpunit", + "polyfill", + "testing" + ], + "support": { + "issues": "https://github.com/Yoast/PHPUnit-Polyfills/issues", + "security": "https://github.com/Yoast/PHPUnit-Polyfills/security/policy", + "source": "https://github.com/Yoast/PHPUnit-Polyfills" + }, + "time": "2025-08-10T05:13:49+00:00" } ], "aliases": [], @@ -966,11 +2809,12 @@ "platform": { "php": ">=7.4", "ext-dom": "*", - "ext-json": "*" + "ext-json": "*", + "ext-zip": "*" }, "platform-dev": {}, "platform-overrides": { "php": "7.4" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/src/css/admin-bar.scss b/src/css/admin-bar.scss new file mode 100644 index 000000000..8da2b46fc --- /dev/null +++ b/src/css/admin-bar.scss @@ -0,0 +1,119 @@ +#wpadminbar { + #wp-admin-bar-code-snippets > .ab-item { + .code-snippets-admin-bar-icon { + inline-size: 16px; + block-size: 16px; + inset-block-start: 5px; + + &::before { + content: ''; + display: block; + inline-size: 16px; + block-size: 16px; + mask-image: url('../assets/menu-icon.svg'); + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + background-color: rgb(240 245 250 / 60%); + } + } + } + + .code-snippets-pagination-node > .ab-item { + padding: 0; + margin-block-end: 6px; + } + + .code-snippets-safe-mode-active { + > .ab-item, + &.ab-item { + background: #b32d2e; + color: #fff; + } + } + + .code-snippets-disabled > .ab-item { + opacity: 0.6; + } + + .code-snippets-pagination-controls { + display: flex; + align-items: stretch; + inline-size: 100%; + white-space: nowrap; + + .code-snippets-pagination-button { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 1 1 0; + min-block-size: 32px; + padding: 0 10px; + background: rgb(240 245 250 / 8%); + color: inherit; + text-decoration: none; + line-height: 1.2; + box-sizing: border-box; + + & + .code-snippets-pagination-button { + border-inline-start: 1px solid rgb(240 245 250 / 15%); + } + + &[aria-disabled='true'] { + opacity: 0.4; + pointer-events: none; + } + } + + a.code-snippets-pagination-button:hover { + background: rgb(240 245 250 / 16%); + } + + a[data-action='first'], + a[data-action='last'] { + flex: 0 0 36px; + padding: 0; + } + + .code-snippets-pagination-page { + flex: 0 0 auto; + cursor: default; + opacity: 0.85; + } + } + + .code-snippets-safe-mode-active:hover > .ab-item, + .code-snippets-safe-mode-active.hover > .ab-item { + background: #d63638; + color: #fff; + } + + .code-snippets-disabled:hover > .ab-item, + .code-snippets-disabled.hover > .ab-item { + opacity: 1; + } + + .code-snippets-safe-mode.code-snippets-safe-mode-active > .ab-item, + .code-snippets-safe-mode.code-snippets-safe-mode-active.ab-item { + font-weight: 600; + } + + .code-snippets-safe-mode .code-snippets-external-icon { + font-family: dashicons; + display: inline-block; + position: relative; + line-height: 1; + opacity: .8; + inset-block-start: 6px; + } + + .code-snippets-safe-mode:hover .code-snippets-external-icon, + .code-snippets-safe-mode.hover .code-snippets-external-icon { + opacity: 1; + } + + #wp-admin-bar-code-snippets-active-snippets > .ab-sub-wrapper > .ab-submenu, + #wp-admin-bar-code-snippets-inactive-snippets > .ab-sub-wrapper > .ab-submenu { + padding-block-start: 0; + } +} diff --git a/src/css/common/_badges.scss b/src/css/common/_badges.scss index 9d842b451..b00ea2f6c 100644 --- a/src/css/common/_badges.scss +++ b/src/css/common/_badges.scss @@ -3,6 +3,21 @@ @use 'sass:color'; @use 'theme'; +// Badges are wrapped in links that filter by type. The white ring separates the +// focus indicator from the badge, matching the activation switch. `:focus` is +// included because a click otherwise leaves the WordPress ring, which hugs the +// badge. +@mixin badge-link { + display: inline-flex; + border-radius: 3px; + + &:focus, + &:focus-visible { + outline: none; + box-shadow: 0 0 0 2px #fff, 0 0 0 4px theme.$accent; + } +} + .badge { font-size: 12px; font-weight: 700; @@ -35,7 +50,7 @@ } .network-shared { - color: #2271b1; + color: theme.$accent; font-size: 22px; inline-size: 100%; cursor: help; @@ -62,6 +77,11 @@ gap: 2px; } +.nav-tab-button .dashicons-external { + font-size: 15px; + color: #666; +} + @each $name, $colors in theme.$badges { $text-color: #fff; $background-color: list.nth($colors, 1); @@ -77,16 +97,19 @@ background-color: $background-color; } - .badge.#{$name}-badge:hover { + a.badge.#{$name}-badge:hover, + button.badge.#{$name}-badge:hover { color: $text-color; background-color: color.adjust($background-color, $lightness: -5%); } } +// #646970 (WP gray-50) keeps white badge text at 5.5:1 so locked/unlicensed +// states stay WCAG AA without hover; #a7aaad was 2.3:1. .nav-tab-inactive .badge, .inverted-badges .badge { color: #fff; - background-color: #a7aaad; + background-color: #646970; border-color: #fff !important; .dashicons { @@ -99,6 +122,8 @@ $text-color: list.nth($colors, 2); $background-color: list.nth($colors, 1); + background: transparent; + .badge.pro-badge { color: $text-color; background-color: $background-color; @@ -116,11 +141,29 @@ } } -.nav-tab-inactive { - background: transparent; +// Override WordPress' .wp-core-ui .button .dashicons { line-height: 1.9 } — +// inside a button the inflated line-box would shift the glyph below the +// badge's bottom border. Placed at end-of-file so it follows all other +// .dashicons rules in source order (no-descending-specificity). +.wp-core-ui .button .badge .dashicons { + line-height: 1; } -.nav-tab-button .dashicons-external { - font-size: 15px; - color: #666; +// Pro badge consolidated to the navigation pro-chip style: a light accent pill +// in every context, overriding the badge colour map and the inverted/nav-tab +// variants (whose white !important border must be beaten here). This is a +// deliberate final override, so it intentionally follows higher-specificity +// context rules above. +/* stylelint-disable no-descending-specificity */ +.badge.pro-badge, +.inverted-badges .badge.pro-badge, +.nav-tab-inactive .badge.pro-badge { + color: theme.$accent; + background-color: #eff5f9; + border: 1px solid rgb(34 113 177 / 10%) !important; + border-radius: 999px; + padding-block: 3px; + padding-inline: 10px; + line-height: normal; } +/* stylelint-enable no-descending-specificity */ diff --git a/src/css/common/_banners.scss b/src/css/common/_banners.scss new file mode 100644 index 000000000..8769e0ac7 --- /dev/null +++ b/src/css/common/_banners.scss @@ -0,0 +1,43 @@ +@use '../common/theme'; +@use 'sass:map'; +@use 'sass:list'; + +@mixin banners { + .banner { + border: 0; + border-radius: 5px; + display: flex; + align-items: center; + padding: 6px 10px; + gap: 8px; + margin: 0; + + .banner-dismiss { + position: unset; + margin-inline-start: auto; + padding: 0; + + &, &::before { + color: inherit; + } + } + + .wp-core-ui &.is-dismissible { + position: unset; + padding-inline-end: 10px; + } + } + + @each $name, $colors in theme.$notices { + .banner-#{$name} { + color: list.nth($colors, 2); + background-color: list.nth($colors, 1); + } + } + + .banner-success::before { + content: '✓'; + font-weight: bold; + font-size: 16px; + } +} diff --git a/src/css/common/_cards.scss b/src/css/common/_cards.scss new file mode 100644 index 000000000..055bea08a --- /dev/null +++ b/src/css/common/_cards.scss @@ -0,0 +1,99 @@ +@use 'checkbox'; +@use 'theme'; + +.code-snippets-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(580px, 1fr)); + gap: 20px; + margin: 0; +} + +.code-snippets-card { + background: #fff; + border: 1px solid theme.$control-border; + border-radius: 5px; + margin: 0; + display: flex; + flex-flow: column; + box-sizing: border-box; + + a { + text-decoration: none; + color: theme.$accent; + } + + .card-inner { + padding: 24px; + } + + footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + background: #f7f7f8; + margin-block-start: auto; + border-block-start: 1px solid theme.$control-border; + padding-inline: 24px; + padding-block: 12px; + border-end-start-radius: 5px; + border-end-end-radius: 5px; + } + + // The status label shrinks and clips away under pressure rather than + // pushing the action buttons onto a second line. + .snippet-card-footer-status { + display: flex; + align-items: center; + gap: 8px; + flex: 0 1 auto; + min-inline-size: 0; + overflow: hidden; + white-space: nowrap; + } + + .snippet-card-footer-actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: nowrap; + gap: 8px; + flex: 0 0 auto; + + .button:not(.kebab-menu-trigger) { + min-inline-size: 103px; + text-align: center; + justify-content: center; + } + } + + &.is-selectable { + position: relative; + } + + .snippet-card-corner { + position: absolute; + inset-block-start: 20px; + inset-inline-end: 24px; + display: flex; + align-items: center; + gap: 12px; + + .snippet-card-select { + margin: 0; + } + } + + input[type='checkbox'].snippet-card-select { + @include checkbox.canonical; + } + + &.is-selectable .card-inner > h3 { + margin-inline-end: 48px; + } + + &.is-selected { + border-color: theme.$accent; + box-shadow: 0 0 0 1px theme.$accent; + } +} diff --git a/src/css/common/_checkbox.scss b/src/css/common/_checkbox.scss new file mode 100644 index 000000000..91d507b6f --- /dev/null +++ b/src/css/common/_checkbox.scss @@ -0,0 +1,39 @@ +@use 'theme'; + +@mixin canonical { + appearance: none; + display: inline-grid; + place-content: center; + vertical-align: middle; + inline-size: 20px; + block-size: 20px; + padding: 0; + margin: 0; + box-sizing: border-box; + background: #fff; + border: 1.5px solid theme.$control-border; + border-radius: 5px; + box-shadow: 0 2px 2px rgb(0 0 0 / 5%); + cursor: pointer; + + &:focus { + outline: 2px solid var(--wp-admin-theme-color); + } + + &::before { + content: none; + } + + &:checked { + background: theme.$accent; + border-color: theme.$accent; + + &::before { + content: ''; + inline-size: 20px; + block-size: 20px; + margin: 0; + background: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M14.83 4.89l1.34.94-5.81 8.38H9.02L5.78 9.67l1.34-1.25 2.57 2.4z' fill='%23fff'/%3E%3C/svg%3E") center/20px no-repeat; + } + } +} diff --git a/src/css/common/_codemirror.scss b/src/css/common/_codemirror.scss index f92e36d0a..f6852c474 100644 --- a/src/css/common/_codemirror.scss +++ b/src/css/common/_codemirror.scss @@ -1,4 +1,4 @@ -.CodeMirror { +.CodeMirror, .snippet-condition-editor-container { border: 1px solid #dfdfdf; border-radius: 3px; block-size: auto !important; @@ -102,6 +102,10 @@ color: #666; } +.CodeMirror .codemirror-colorview { + border: 0; +} + .CodeMirror-foldmarker { color: inherit; margin-inline: 0.25em; diff --git a/src/css/common/_kebab-menu.scss b/src/css/common/_kebab-menu.scss new file mode 100644 index 000000000..83f225eac --- /dev/null +++ b/src/css/common/_kebab-menu.scss @@ -0,0 +1,106 @@ +@use 'theme'; + +// Generic kebab ("more actions") menu: a square accent-outlined trigger +// button that opens a small popover of actions anchored to its +// bottom-end corner, flipping above the trigger near the viewport edge. +.kebab-menu { + position: relative; + display: inline-flex; +} + +.kebab-menu-trigger { + display: flex; + align-items: center; + justify-content: center; + inline-size: 38px; + block-size: 38px; + box-sizing: border-box; + padding: 0; + background: #fff; + color: theme.$accent; + border: 1px solid theme.$accent; + border-radius: 5px; + cursor: pointer; + + svg { + display: block; + } + + &:hover, + &:focus { + background: #f0f6fc; + } +} + +.kebab-menu-popover { + position: absolute; + inset-block-start: calc(100% + 4px); + inset-inline-end: 0; + z-index: 1000; + inline-size: 224px; + box-sizing: border-box; + margin: 0; + padding: 6px 0; + list-style: none; + background: #fff; + border: 1px solid #e2e2e4; + border-radius: 5px; + overflow: hidden; + box-shadow: 0 3px 16px rgb(0 0 0 / 15%); + + &.kebab-menu-popover-top { + inset-block: auto calc(100% + 4px); + } + + li { + margin: 0; + } +} + +.kebab-menu-item { + display: block; + inline-size: 100%; + box-sizing: border-box; + padding-block: 10px; + padding-inline: 14px; + background: none; + border: none; + font-family: inherit; + font-size: 14px; + line-height: 1.4; + color: #2c3337; + text-align: start; + cursor: pointer; + + &:hover, + &:focus { + background: #f0f0f1; + } + + &:disabled { + color: #a7aaad; + cursor: default; + background: none; + } + + &.kebab-menu-item-destructive { + color: #d63638; + } +} + +.kebab-menu-divider { + margin-block: 6px; + border-block-start: 1px solid #e2e2e4; +} + +.kebab-menu-row { + display: flex; + align-items: center; + gap: 8px; + box-sizing: border-box; + padding-block: 10px; + padding-inline: 14px; + font-size: 14px; + line-height: 1.4; + color: #2c3337; +} diff --git a/src/css/common/_list-table.scss b/src/css/common/_list-table.scss new file mode 100644 index 000000000..89c5eb866 --- /dev/null +++ b/src/css/common/_list-table.scss @@ -0,0 +1,4 @@ +@use 'list-table/layout'; +@use 'list-table/navigation'; +@use 'list-table/pagination'; +@use 'list-table/responsive'; diff --git a/src/css/common/_modal.scss b/src/css/common/_modal.scss index dab7cca7b..bda203688 100644 --- a/src/css/common/_modal.scss +++ b/src/css/common/_modal.scss @@ -51,3 +51,119 @@ } } } + +.components-modal__frame.code-snippets-preview-modal { + min-inline-size: 520px; + min-block-size: 240px; + inline-size: min(900px, 90vw); + max-block-size: 85vh; + + @media (width <= 600px) { + min-inline-size: 90vw; + } + + .components-modal__header { + border-block-end-color: #ddd; + } + + .code-snippets-preview-modal__badge { + display: flex; + flex-shrink: 0; + margin-inline-end: 8px; + } + + // Header and footer stay pinned; the CodeMirror editor is the only + // scroll region, scrolling long code both vertically and horizontally. + .components-modal__content { + display: flex; + flex-flow: column; + min-block-size: 0; + padding: 0; + overflow: hidden; + } + + // Newer Modal versions wrap children in an unstyled focus container. + .components-modal__header + div { + display: flex; + flex-flow: column; + flex: 1; + min-block-size: 0; + overflow: hidden; + } + + .code-snippets-preview-modal__editor { + display: flex; + flex-flow: column; + flex: 1; + min-block-size: 0; + + .CodeMirror { + flex: 1; + min-block-size: 0; + block-size: 100%; + } + + textarea { + flex: 1; + min-block-size: 0; + inline-size: 100%; + resize: none; + font-family: monospace; + } + } + + .code-snippets-preview-modal__footer { + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + gap: 16px; + padding: 11px 19px; + border-block-start: 1px solid #e2e2e4; + background: #f6f7f7; + } + + .code-snippets-preview-modal__priority { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: #646970; + + .code-snippets-preview-modal__priority-value { + font-weight: 600; + color: #2c3337; + } + } + + .code-snippets-preview-modal__buttons { + display: flex; + align-items: center; + gap: 8px; + + .button { + border-radius: 5px; + font-size: 14px; + } + + .button-link.code-snippets-preview-modal__trash { + min-block-size: 38px; + padding-block: 0; + padding-inline: 16px; + border: 1px solid #b32d2e; + border-radius: 5px; + background: #f6f7f7; + line-height: 2.5715; + font-weight: 400; + color: #b32d2e; + text-decoration: none; + + &:hover, + &:focus { + border-color: #d63638; + background: #f0f0f1; + color: #d63638; + } + } + } +} diff --git a/src/css/common/_notices.scss b/src/css/common/_notices.scss new file mode 100644 index 000000000..8d5f6909c --- /dev/null +++ b/src/css/common/_notices.scss @@ -0,0 +1,53 @@ +@use '../common/theme'; +@use 'sass:map'; +@use 'sass:list'; + +@mixin notices { + .notice { + border: 0; + border-radius: 5px; + display: flex; + align-items: center; + padding: 6px 10px; + gap: 8px; + margin: 0; + + .notice-dismiss { + position: unset; + margin-inline-start: auto; + padding: 0; + + &, &::before { + color: inherit; + } + } + + .wp-core-ui &.is-dismissible { + position: unset; + padding-inline-end: 10px; + } + } + + @each $name, $colors in theme.$notices { + .notice-#{$name} { + color: list.nth($colors, 2); + background-color: list.nth($colors, 1); + } + } + + .notice-success::before { + content: '✓'; + font-weight: bold; + font-size: 16px; + } +} + + +.code-snippets-notice { + .notice-dismiss { + position: absolute; + transform: initial; + inset-inline-end: 0; + inset-block-start: 0; + } +} diff --git a/src/css/common/_page-header.scss b/src/css/common/_page-header.scss new file mode 100644 index 000000000..60d07f53d --- /dev/null +++ b/src/css/common/_page-header.scss @@ -0,0 +1,52 @@ +@use 'theme'; + +// Contextual page header row shared by the plugin admin screens: the page +// title on the inline-start side and the primary page action, if any, +// aligned to the inline-end side of the same row. +.snippets-page-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 16px 24px; + margin-block: 8px 16px; + + h1, + h2 { + margin: 0; + padding: 0; + font-size: 26px; + font-weight: 510; + line-height: 1.25; + } + + h1 { + color: #1d2327; + } + + h2 { + color: theme.$control-text; + } +} + +.wrap .snippets-page-header .button.button-primary { + padding: 12px; + min-block-size: 0; + border-radius: 5px; + line-height: 1; + font-size: 14px; + font-weight: 700; +} + +// One-line supporting description shown directly beneath the page title. +.snippets-page-description { + margin-block: 0 16px; + font-size: 14px; + line-height: 1.5; + color: #646970; + + a { + color: theme.$accent; + text-decoration: underline; + } +} diff --git a/src/css/common/_select.scss b/src/css/common/_select.scss index 452145df1..170e25eba 100644 --- a/src/css/common/_select.scss +++ b/src/css/common/_select.scss @@ -1,5 +1,5 @@ .code-snippets-select { - input[type="text"]:focus { + input[type='text']:focus { box-shadow: none; } } diff --git a/src/css/common/_subnav.scss b/src/css/common/_subnav.scss new file mode 100644 index 000000000..d0c5033a2 --- /dev/null +++ b/src/css/common/_subnav.scss @@ -0,0 +1,275 @@ +@use 'theme'; + +// Snippet-type navigation: a boxed segmented control on a white band, with +// each tab separated by a vertical rule and the active tab shown as a +// filled tile. +.snippet-type-nav-wrapper { + position: relative; + + // Bleed across the full admin content width, flush against the plugin toolbar. + box-sizing: border-box; + min-block-size: 53px; + margin-block-start: -1px; + margin-inline-start: -22px; + inline-size: calc(100% + 42px); + overflow: hidden; + background: #fff; + + &::before, + &::after { + position: absolute; + inset-block: 0; + z-index: 1; + inline-size: 32px; + content: ''; + opacity: 0; + pointer-events: none; + transition: opacity 150ms ease; + } + + &::before { + inset-inline-start: 0; + background: linear-gradient(to right, #fff, transparent); + } + + &::after { + inset-inline-end: 0; + background: linear-gradient(to left, #fff, transparent); + } + + &.has-scroll-start::before, + &.has-scroll-end::after { + opacity: 1; + } + + @media (width <= 782px) { + margin-inline-start: -10px; + inline-size: calc(100% + 22px); + } +} + +.snippet-type-nav { + // The tab band scrolls sideways instead of wrapping at any width. + overflow-x: auto; + scrollbar-width: thin; + + // The row line is painted on the scrollport so it remains fixed while tabs scroll. + background: linear-gradient(to top, #e2e2e4 1px, #fff 1px); + + @media (width <= 960px) { + li { + flex: 1 1 0; + } + + .snippet-type-link { + inline-size: 100%; + min-inline-size: 100px; + padding-inline: 4px; + } + } + + @media (width <= 782px) { + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + } + + ul { + display: flex; + align-items: stretch; + flex-wrap: nowrap; + min-block-size: 53px; + margin: 0; + padding: 0; + list-style: none; + } + + li { + display: flex; + flex: 0 0 auto; + margin: 0; + } + + button.snippet-type-link { + background: none; + border-block: none; + border-inline-start: none; + font-family: inherit; + } + + .snippet-type-icon { + font-size: 22px; + inline-size: 24px; + block-size: 24px; + } + + .snippet-type-link { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + min-inline-size: 100px; + padding-block: 0; + padding-inline: 24px; + cursor: pointer; + font-size: 14px; + font-weight: 600; + line-height: 1.5; + white-space: nowrap; + color: #646970; + text-decoration: none; + box-sizing: border-box; + border-inline-end: 1px solid #e2e2e4; + + // The text-only "All Snippets" tab uses a slightly wider gap between + // its label and count than tabs that lead with a badge or icon. + &.all-type-link { + gap: 12px; + } + + svg { + display: block; + inline-size: 24px; + block-size: 24px; + } + + &:hover, + &:focus, + &:active { + color: #2c3337; + } + + &.active-type { + background: #f0f0f1; + color: theme.$accent; + } + + // Suppress the persistent focus ring left behind after a mouse click; + // keyboard navigation still gets a visible outline via :focus-visible. + &:focus-visible { + outline: 2px solid theme.$accent; + outline-offset: -2px; + box-shadow: none; + } + + &:focus:not(:focus-visible) { + outline: none; + box-shadow: none; + } + + &.pro-locked-type { + color: #646970; + } + } + +} + +// Card/table view toggle: a two-icon segmented pair of square tiles with +// the active view filled in the accent colour. +.snippet-view-toggle { + display: flex; + align-items: center; + gap: 4px; + + .snippet-view-toggle-option { + display: flex; + align-items: center; + justify-content: center; + inline-size: 38px; + block-size: 38px; + box-sizing: border-box; + padding: 0; + margin: 0; + background: #f0f0f1; + color: #646970; + border: none; + border-radius: 4.5px; + cursor: pointer; + + svg { + display: block; + } + + &:hover, + &:focus { + color: #2c3337; + } + + &.active-view { + background: theme.$accent; + color: #fff; + + &:hover, + &:focus { + color: #fff; + } + } + } +} + +// Slot that adopts the WordPress Screen Options / Help tabs so they appear +// directly below the snippet-type nav instead of above the page content. +.snippets-screen-meta-slot { + #screen-meta-links { + float: none; + display: flex; + justify-content: flex-end; + margin: 0; + border-color: #e2e2e4; + + .show-settings { + border-color: #e2e2e4; + } + } + + #screen-meta { + margin: 0; + } +} + +// Item count shown after a subnav tab's label, using the label colour at reduced opacity. +.snippet-type-nav .subnav-count { + font-size: 14px; + font-weight: 600; + line-height: 1.5; + color: currentcolor; + opacity: 0.65; +} + +// Keep the active count tied to its active label rather than assigning a separate colour. +.snippet-type-nav .snippet-type-link.active-type .subnav-count { + color: currentcolor; +} + +// The short "All" form of the all-snippets label only replaces "All Snippets" +// once the bar collapses; the full form is shown at every wider width. +.snippet-type-name-short { + display: none; +} + +// Collapse at mid-width and below (through mobile): drop the type names so each +// tab shows just its badge and count, and shorten "All Snippets" to "All". Only +// the badged manage nav owns a .snippet-type-name; the text-only import/settings +// subnav tabs are untouched. Below 782px the bar still scrolls sideways (above), +// now with the compact collapsed tabs. +@media (width <= 1210px) { + .snippet-type-nav .snippet-type-link:not(.all-type-link) .snippet-type-name { + display: none; + } + + .snippet-type-nav .all-type-link .snippet-type-name-full { + display: none; + } + + .snippet-type-nav .all-type-link .snippet-type-name-short { + display: inline; + } +} + +// When an admin notice pushes the page content down, restore the top border +// above the subnav bar (no extra margin — the wrapper sits flush). +.notice + .wrap .snippet-type-nav-wrapper { + border-block-start: 1px solid #e2e2e4; +} diff --git a/src/css/common/_switch.scss b/src/css/common/_switch.scss index 3704c81fa..32e5f2145 100644 --- a/src/css/common/_switch.scss +++ b/src/css/common/_switch.scss @@ -1,7 +1,5 @@ @use 'theme'; -$off-color: #789; - .snippet-execution-button, .snippet-activation-switch, input[type='checkbox'].switch { @@ -9,17 +7,19 @@ input[type='checkbox'].switch { position: relative; } +// Activation switch: an accent-coloured 36x19 pill shared by the list-table +// activate column, snippet cards and settings toggles. .snippet-activation-switch, input[type='checkbox'].switch { appearance: none; outline: 0; cursor: pointer; margin: 0; - inline-size: 32px; + inline-size: 36px; block-size: 19px; border-radius: 34px; text-align: start; - border: 1px solid $off-color; + border: 1px solid theme.$accent; box-sizing: border-box; &::before { @@ -28,19 +28,24 @@ input[type='checkbox'].switch { block-size: 13px; inline-size: 13px; display: inline-block; - background-color: $off-color; + background-color: theme.$accent; border-radius: 50%; margin: 2px; + + @media (prefers-reduced-motion: reduce) { + transition-duration: 0.01s; + } } } .active-snippet .snippet-activation-switch, input[type='checkbox'].switch:checked { - background-color: #0073aa; + background-color: theme.$accent; + // Travel distance: pill width minus the knob, its margins and the borders. &::before { background-color: white; - transform: translateX(calc(100% * var(--cs-direction-multiplier))); + transform: translateX(calc((100% + 4px) * var(--cs-direction-multiplier))); } } @@ -50,7 +55,7 @@ input[type='checkbox'].switch:checked { text-align: center; font-weight: bold; line-height: 1; - color: #bbb; + color: #1d2327; } a.snippet-condition-count { @@ -98,5 +103,18 @@ a.snippet-condition-count { border-color: theme.$accent; transition: border-color 0.6s; } + + @media (prefers-reduced-motion: reduce) { + transition-duration: 0.01s; + + &::before { + transition-duration: 0.01s; + } + } } } + +.code-snippets-card .snippet-execution-button { + margin-block-start: 0; + margin-inline-end: 9px; +} diff --git a/src/css/common/_theme.scss b/src/css/common/_theme.scss index 4cb4346ce..f4f40c544 100644 --- a/src/css/common/_theme.scss +++ b/src/css/common/_theme.scss @@ -9,30 +9,61 @@ $brand-facebook: #3b5998; $cloud: #00bcd4; $cloud-update: #ff9800; +// Plugin admin control tokens, applied to native form controls on all plugin +// screens regardless of WordPress version (see common/_wp-admin.scss). +$accent-hover: #0a4b78; +$control-border: #c3c4c7; +$control-text: #2c3337; +$control-height: 38px; +$control-radius: 5px; + +// Runtime design tokens. Sass variables keep compile-time helpers available; +// custom properties expose the approved system to every rendered component. +:root { + --cs-color-accent: #2271b1; + --cs-color-accent-hover: #0a4b78; + --cs-color-text: #2c3337; + --cs-color-text-muted: #646970; + --cs-color-surface: #fff; + --cs-color-surface-subtle: #f6f7f7; + --cs-color-border: #c3c4c7; + --cs-color-border-subtle: #dcdcde; + --cs-control-height: 38px; + --cs-control-radius: 5px; + --cs-font-size-body: 14px; + --cs-line-height-body: 1.5; +} + /* format: background-color [color] */ $badges: ( - php: #1d97c6, - html: #ef6a36, + // php/html/cond darkened for WCAG AA — at least 4.5:1 against white 12px + // bold text; php reuses $accent, html/cond keep hue at reduced lightness. + php: $accent, + html: #cd4510, css: #9b59b6, - js: #ffeb3b #1c1f20, - cond: #2eae95, - core: #61c5cb, + js: #f7d67a #1c1f20, + cond: #22826f, + core: #4fa1a6, pro: #f7e8e3 #df9279, - cloud: $cloud, + cloud: #009fb4, bundles: #50575e, - cloud_search: #ff9800, - private: #f7e6be #ca961b, + cloud_search: #d27c00, + private: #f7e6be #a27813, public: #dbebf7 $accent, success: #d3e8d1 #447340, failure: #fad7c1 #a24b16, info: #d2e6f4 #2b71a3, - neutral: #e2e5e5 #6c7e7e, + neutral: #f6f7f7 #646970, + neutralDark: #e2e5e5 #6c7e7e, special: #dfc5ef #6e249c ); $notices: ( success: #d3e9d3 #377a37, warning: #f2ebc3 #b0730a, error: #f8d7da #721c24, + info: #d2e6f4 #2b71a3, + neutral: #e2e5e5 #6c7e7e, + special: #dfc5ef #6e249c ); @function contrasting-text-color($bg-color) { diff --git a/src/css/common/_toolbar.scss b/src/css/common/_toolbar.scss new file mode 100644 index 000000000..d3a16967a --- /dev/null +++ b/src/css/common/_toolbar.scss @@ -0,0 +1,331 @@ +@use 'theme'; +@use 'upsell'; + +$toolbar-block-size: 136px; +$wpcontent-inline-start-indent: 20px; + +#wpbody { + padding-block-start: $toolbar-block-size; +} + +.code-snippets-toolbar { + color: #2c3337; + background: #fff; + font-family: 'SF Pro', sans-serif; + margin-inline-start: -$wpcontent-inline-start-indent - 2; + position: absolute; + inline-size: calc(100% + $wpcontent-inline-start-indent); + block-size: $toolbar-block-size; + inset-block-start: 0; + display: flex; + flex-direction: column; + + ul, li { + margin: 0; + padding: 0; + list-style: none; + } +} + +.code-snippets-toolbar-upper { + justify-content: space-between; + block-size: 76px; + box-sizing: border-box; + padding-block: 16px; + padding-inline: 24px; + border-block-end: 1px solid rgb(195 196 199 / 50%); + display: flex; + align-items: center; + + .logo { + display: flex; + align-items: center; + gap: 8px; + + img { + block-size: 42px; + } + + div { + font-size: 18px; + font-weight: 700; + } + } + + h1 { + font-size: 18px; + font-weight: 700; + line-height: 1.5; + } + + ul { + display: flex; + gap: 22px; + align-items: center; + } + + a { + color: inherit; + text-decoration: none; + } + + @media (width <= 480px) { + padding-inline: 10px; + + .logo img { + block-size: 34px; + } + + .logo div { + display: none; + } + + .toolbar-upgrade-item .button { + padding-inline: 10px; + } + } +} + +.code-snippets-toolbar-lower { + border-block-end: 1px solid #e2e2e4; + block-size: 60px; + box-sizing: border-box; + + nav, + ul { + block-size: 100%; + } + + ul { + display: flex; + align-items: stretch; + } + + li { + display: flex; + } + + // Items fill the 60px band; the active tab is indicated by accent colour on + // its icon and label, with no underline. + li a { + display: flex; + margin: 0; + block-size: 100%; + padding-block: 0; + padding-inline: 24px; + color: #2c3337; + gap: 12px; + align-items: center; + font-size: 16px; + font-weight: 600; + letter-spacing: -0.16px; + text-decoration: none; + box-sizing: border-box; + cursor: pointer; + + svg { + inline-size: 26px; + block-size: 26px; + } + + &.active-link, &:hover, &:focus, &:active { + color: theme.$accent; + z-index: 1; + } + } + + li:not(.toolbar-end-item) + li.toolbar-end-item { + margin-inline-start: auto; + } + + // Narrow widths: centre each item's label directly beneath its icon and + // shrink the type so the nav still fits on one row. The pro chip becomes a + // small badge pinned to the item's top corner, out of the flow so it never + // shifts the icon off-centre from its label. Below 782px the row scrolls + // sideways (see the mobile block below) rather than wrapping. + @media (width <= 1140px) { + li a { + position: relative; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 4px; + padding-inline: 14px; + font-size: 13px; + text-align: center; + + .pro-chip { + position: absolute; + inset-block-start: 7px; + inset-inline-end: 4px; + padding-block: 1px; + padding-inline: 5px; + font-size: 8px; + line-height: normal; + } + } + } + + // On narrow screens Settings remains available through the WordPress submenu. + // The remaining primary destinations fill the toolbar evenly rather than + // becoming a second horizontally-scrolling navigation row. + @media (width <= 782px) { + li.toolbar-end-item { + display: none; + } + + li:not(.toolbar-end-item) { + flex: 1 1 0; + min-inline-size: 0; + } + + li:not(.toolbar-end-item) + li.toolbar-end-item { + margin-inline-start: 0; + } + + li a { + inline-size: 100%; + padding-inline: 8px; + white-space: normal; + } + } +} + +.code-snippets-toolbar-upper li a.active-link, +.code-snippets-toolbar-upper li a:hover, +.code-snippets-toolbar-upper li a:focus, +.code-snippets-toolbar-upper li a:active { + color: theme.$accent; +} + +.code-snippets-toolbar-lower .pro-chip, +.nav-tab .pro-chip, +.snippet-type-link .pro-chip { + color: theme.$accent; + background: #eff5f9; + border: 1px solid rgb(34 113 177 / 10%); + text-transform: uppercase; + border-radius: 999px; + display: flex; + justify-content: center; + align-items: center; + box-sizing: border-box; + padding: 5px 12px; + font-weight: 700; + line-height: 1; +} + + +.code-snippets-toolbar-lower .pro-chip { + font-size: 12px; +} + +.nav-tab .pro-chip, +.snippet-type-link .pro-chip { + font-size: 10px; +} + +.code-snippets-return-link { + float: inline-end; +} + +.code-snippets-toolbar-upper .toolbar-more-item { + display: none; +} + +.code-snippets-toolbar-upper .toolbar-more-menu { + position: relative; + + .dashicons, .dashicons::before { + inline-size: 16px; + block-size: 16px; + font-size: 16px; + } +} + +.code-snippets-toolbar-upper .toolbar-more-menu summary { + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; +} + +.code-snippets-toolbar-upper .toolbar-more-menu ul { + position: absolute; + inset-block-start: calc(100% + 8px); + inset-inline-end: 0; + z-index: 2; + display: grid; + min-inline-size: 180px; + block-size: auto; + margin: 0; + padding: 8px; + background: #fff; + border: 1px solid theme.$control-border; + box-shadow: 0 3px 8px rgb(0 0 0 / 15%); +} + +.code-snippets-toolbar-upper .toolbar-more-menu li { + margin: 0; +} + +// The upper and lower toolbars are distinct navigation trees; keep this rule +// after the lower-toolbar link state rules so the compact menu owns its hover state. +// stylelint-disable-next-line no-descending-specificity +.code-snippets-toolbar-upper .toolbar-more-menu a { + display: block; + padding: 8px; + white-space: nowrap; +} + +.code-snippets-toolbar-upper .toolbar-more-menu a:hover, +.code-snippets-toolbar-upper .toolbar-more-menu a:focus { + background: #f6f7f7; +} + +@media (width <= 960px) { + .code-snippets-toolbar-upper { + gap: 12px; + padding-inline: 16px; + } + + .code-snippets-toolbar-upper .logo { + flex: 0 1 auto; + min-inline-size: 0; + } + + .code-snippets-toolbar-upper nav { + margin-inline-start: auto; + } + + .code-snippets-toolbar-upper > nav > ul { + gap: 8px; + } + + .code-snippets-toolbar-upper > nav > ul > li:not(.toolbar-upgrade-item, .toolbar-more-item) { + display: none; + } + + .code-snippets-toolbar-upper .toolbar-more-item { + display: flex; + } + + .code-snippets-toolbar-upper .toolbar-more-menu summary { + padding: 8px; + white-space: nowrap; + } + + .code-snippets-toolbar-upper .toolbar-upgrade-item .button { + display: block; + max-inline-size: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +@media (width <= 640px) { + .code-snippets-toolbar-upper .logo div { + display: none; + } +} diff --git a/src/css/common/_tooltips.scss b/src/css/common/_tooltips.scss index 3feec5298..dfc7a99d0 100644 --- a/src/css/common/_tooltips.scss +++ b/src/css/common/_tooltips.scss @@ -1,38 +1,29 @@ -$bg-color: hsl(0deg 0% 20% / 90%); +$bg-color: rgb(19 18 18 / 90%); -.help-tooltip { - display: inline-flex; - flex-direction: column; - justify-content: center; - border-block-end: 1px dotted; +.tooltip { position: relative; - vertical-align: middle; -} - -.help-tooltip-anchor { - cursor: help; - padding-block: 0.3em 0; - padding-inline: 0.3em; display: inline-block; - font-size: 10px; - background: transparent !important; } -.tooltip { - cursor: help !important; - position: relative; - display: inline-block; +.tooltip-trigger { + display: inline-flex; + align-items: center; + background: transparent; + padding: 0; + margin: 0; + border: 0; + border-radius: 50%; + color: inherit; + font: inherit; - .dashicons { - color: lightslategrey; + &:hover, + &:focus { + box-shadow: none; } - &.badge { - display: inline-flex; - - .dashicons { - color: inherit; - } + &:focus-visible { + outline: 2px solid #2271b1; + outline-offset: 2px; } } @@ -46,6 +37,10 @@ $bg-color: hsl(0deg 0% 20% / 90%); transform 0.2s cubic-bezier(0.71, 1.7, 0.77, 1.24); transform: translate3d(0, 0, 0); pointer-events: none; + + @media (prefers-reduced-motion: reduce) { + transition-duration: 0.01s; + } } .tooltip::before { @@ -62,14 +57,15 @@ $bg-color: hsl(0deg 0% 20% / 90%); .tooltip-content { z-index: 1000; padding: 8px; - background-color: $bg-color; + background-color: rgb(19 18 18 / 90%); color: #fff; border-radius: 6px; position: absolute; - font-size: small; + font-size: 12px; font-weight: normal; text-transform: none; - min-inline-size: 200px; + inline-size: max-content; + max-inline-size: 200px; backdrop-filter: blur(3px); .tooltip-block & { @@ -148,7 +144,8 @@ $bg-color: hsl(0deg 0% 20% / 90%); } .tooltip:hover, -.tooltip:focus { +.tooltip:focus, +.tooltip:focus-within { &::before, .tooltip-content { visibility: visible; opacity: 1; @@ -156,29 +153,54 @@ $bg-color: hsl(0deg 0% 20% / 90%); } .tooltip-block.tooltip-start:hover, -.tooltip-block.tooltip-start:focus { +.tooltip-block.tooltip-start:focus, +.tooltip-block.tooltip-start:focus-within { &::before, .tooltip-content { transform: translateY(-10px); } } .tooltip-block.tooltip-end:hover, -.tooltip-block.tooltip-end:focus { +.tooltip-block.tooltip-end:focus, +.tooltip-block.tooltip-end:focus-within { &::before, .tooltip-content { transform: translateY(10px); } } .tooltip-inline.tooltip-end:hover, -.tooltip-inline.tooltip-end:focus { +.tooltip-inline.tooltip-end:focus, +.tooltip-inline.tooltip-end:focus-within { &::before, .tooltip-content { transform: translateX(calc(10px * var(--cs-direction-multiplier))); } } .tooltip-inline.tooltip-start:hover, -.tooltip-inline.tooltip-start:focus { +.tooltip-inline.tooltip-start:focus, +.tooltip-inline.tooltip-start:focus-within { &::before, .tooltip-content { transform: translateX(calc(-10px * var(--cs-direction-multiplier))); } } + + +.help-tooltip { + cursor: help !important; + + .tooltip-trigger { + cursor: help; + } + + .dashicons { + color: lightslategrey; + } + + &.badge { + display: inline-flex; + + .dashicons { + color: inherit; + } + } +} diff --git a/src/css/common/_upsell.scss b/src/css/common/_upsell.scss index 7f6b54b17..afa61420c 100644 --- a/src/css/common/_upsell.scss +++ b/src/css/common/_upsell.scss @@ -1,4 +1,3 @@ -@use 'sass:color'; @use 'theme'; .code-snippets-upsell-banner { @@ -26,7 +25,7 @@ margin-inline-start: auto; &:hover, &:focus { - background-color: color.adjust(theme.$secondary, $lightness: -10%); + background-color: #0ca0a9; } } @@ -34,36 +33,14 @@ text-decoration: none; font-weight: normal; padding: 0; - color: #a7aaad; + color: #646970; line-height: 1; + border: none; } } -.code-snippets-upsell-dialog { - background: linear-gradient(116.04deg, #edfcff -0.75%, #fcdfd4 93.04%); - inline-size: 794px; - box-shadow: 0 4px 80px rgb(0 0 0 / 10%); - border-radius: 8px; - font-family: 'SF Pro', sans-serif; - color: #1c3f41; - line-height: 1.5; - max-block-size: unset; - - .components-modal__content { - margin: 0; - padding-block: 48px; - padding-inline: 80px; - } - - .components-modal__content > div:last-child { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - gap: 18px; - overflow: auto; - } - +.code-snippets-upsell-dialog, +.wrap .code-snippets-upsell-page { h1 { font-size: 32px; margin: 0; @@ -76,26 +53,20 @@ p { margin-block: 0; margin-inline: 2em; - } - - h1 + p { font-size: 16px; + max-inline-size: 750px; } img { inline-size: 82px; } - h2 { - text-transform: uppercase; - font-size: 12px; - } - ul { display: grid; inline-size: 100%; grid-auto-flow: column; grid-template-rows: 1fr 1fr 1fr 1fr 1fr; + max-inline-size: 750px; } li { @@ -127,8 +98,57 @@ margin-inline: auto; border-color: currentcolor; - &:hover { - background-color: color.adjust(#d46f4d, $lightness: -10%); + // Matches the specificity of the native-control override in _wp-admin.scss + // (.wrap .button-primary:not(.button-link):hover:not(:disabled)) so this + // upsell-specific hover styling isn't overridden by the shared button rule. + &:hover:not(.button-link, :disabled) { + background-color: #0ca0a9; + border-color: #0ca0a9; } } } + +.code-snippets-upsell-page, +.code-snippets-upsell-dialog .components-modal__content { + margin: 0; + padding-block: 48px; + padding-inline: 80px; +} + +.code-snippets-upsell-page, +.code-snippets-upsell-dialog { + color: #1c3f41; + line-height: 1.5; + background: linear-gradient(116.04deg, #edfcff -0.75%, #fcdfd4 93.04%); + box-shadow: 0 4px 80px rgb(0 0 0 / 10%); + border-radius: 8px; +} + +// Bleed the upsell to the full width of the admin content area and fill the +// remaining viewport height, so it reads as a full page rather than a small +// boxed card floating in the subpage. 168px accounts for the WP admin bar +// (32px) plus the plugin toolbar ($toolbar-block-size, 136px in _toolbar.scss). +.code-snippets-upsell-page { + box-sizing: border-box; + margin-inline-start: -22px; + inline-size: calc(100% + 42px); + min-block-size: calc(100vh - 168px); + border-radius: 0; + justify-content: center; +} + +.code-snippets-upsell-page, +.code-snippets-upsell-dialog .components-modal__content > div:last-child { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 18px; +} + +.code-snippets-upsell-dialog { + max-block-size: unset; + font-family: 'SF Pro', sans-serif; + inline-size: 794px; + overflow: auto; +} diff --git a/src/css/common/_wp-admin.scss b/src/css/common/_wp-admin.scss new file mode 100644 index 000000000..1dd6f035a --- /dev/null +++ b/src/css/common/_wp-admin.scss @@ -0,0 +1,185 @@ +@use 'checkbox'; +@use 'theme'; + +// Match the active subnavigation tile so it blends into the admin canvas. +body.wp-admin { + background: #f0f0f1; +} + +// Remove the WordPress core 10px top margin on the manage snippets container +// so the subnav band sits flush against the toolbar. +#manage-snippets-container.wrap { + margin-block-start: 0; +} + +/** + * Native form control styling. + * + * WordPress core ships different control metrics between versions (30/32px classic, + * 40px with a 2px radius in the WP 7.0 "modern" redesign) and recolours secondary + * buttons with the modern accent (#3858E9). These rules apply the plugin's own control + * tokens — 38px height, 5px radius, theme control borders and the brand accent — + * to native elements inside our admin pages so the layout renders consistently across + * WP versions. They deliberately leave the admin menu, admin bar and + * `@wordpress/components` React widgets untouched — only native inputs/buttons + * rendered inside our own markup are normalised. + */ + +// Scope to our admin page content. We intentionally keep this to a single `.wrap` class so +// the rules sit just above WordPress core defaults (which we must override) but below the +// plugin's own more-specific per-control rules, which are emitted later in each bundle and so +// win on equal specificity. This lets bespoke controls (conditions button, row-action links, +// priority input, etc.) keep their intended styling without per-element exclusions here. +.wrap, +// Modals render through a portal on `document.body`, outside the page wrapper, so +// the plugin's own dialogs are listed here to pick up the same control tokens. +.code-snippets-preview-modal, +.code-snippets-modal { + // Native single-line text controls and selects: token height + border. + // `@wordpress/components` inputs/selects (`.components-*`) keep their own design — they + // are excluded so React widgets such as the tags token field are left untouched. + // `.snippet-priority` (the borderless list-table priority field) is excluded so it keeps + // its plain transparent styling rather than gaining a bordered box. + input[type='text']:not([class*='components-'], [id^='react-select-']), + input[type='search']:not([class*='components-']), + input[type='number']:not([class*='components-'], .snippet-priority), + input[type='email']:not([class*='components-']), + input[type='url']:not([class*='components-']), + input[type='password']:not([class*='components-']), + input[type='tel']:not([class*='components-']) { + // `min-block-size` (not a fixed `block-size`) so controls floor at the token height but + // can still flex-stretch where the layout calls for it (e.g. the 54px cloud search bar). + min-block-size: var(--cs-control-height); + padding-block: 0; + padding-inline: 8px; + border: 1px solid var(--cs-color-border); + border-radius: var(--cs-control-radius); + line-height: 2; + color: var(--cs-color-text); + font-size: 14px; + + &:focus { + border-color: var(--cs-color-accent); + box-shadow: 0 0 0 1px var(--cs-color-accent); + outline: 2px solid transparent; + } + } + + // Selects: same metrics, but preserve room for the WP dropdown chevron on the inline-end + // side. WP 7.0 keeps the chevron as a background image; clamping both paddings to 8px (as + // for text inputs) would let the arrow overlap the text, so the inline-end padding is wider. + select:not([class*='components-']) { + min-block-size: var(--cs-control-height); + padding-block: 0; + padding-inline: 8px 24px; + border: 1px solid var(--cs-color-border); + border-radius: var(--cs-control-radius); + + // WP 7.0 leaves a tall (≈38px) line-height on selects, pushing the value off-centre. + // Pin it so the text stays vertically centred within the token height. + line-height: 2; + color: var(--cs-color-text); + font-size: 14px; + + &:focus { + border-color: var(--cs-color-accent); + box-shadow: 0 0 0 1px var(--cs-color-accent); + outline: 2px solid transparent; + } + } + + // Secondary buttons: token geometry, the grey fill and the brand accent border/text in + // place of WP 7.0's transparent + modern-blue treatment. `.button-primary` is excluded + // so its solid fill below is not overwritten. The line-height centres text vertically + // in anchor-based buttons, which lack a button element's automatic centring. + .button:not(.button-primary, .button-link), + .button-secondary, + .page-title-action { + min-block-size: var(--cs-control-height); + padding-block: 0; + padding-inline: 12px; + border-radius: var(--cs-control-radius); + line-height: 2.5715; + font-size: 14px; + font-weight: 400; + background: #f6f7f7; + border-color: var(--cs-color-accent); + color: var(--cs-color-accent); + + &:hover:not(:disabled) { + background: #f0f0f1; + border-color: var(--cs-color-accent-hover); + color: var(--cs-color-accent-hover); + } + + &:focus:not(:disabled) { + border-color: #3582c4; + box-shadow: 0 0 0 1px #3582c4; + outline: 2px solid transparent; + } + } + + // Primary buttons: WP 7.0 recolours these with the modern accent and drops the solid + // fill. Restore the classic solid brand-accent fill with white text. The `:not` + // matches the secondary rule's specificity so a `.button-small.button-primary` + // keeps the token height instead of collapsing to the 26px small size. + .button-primary:not(.button-link) { + min-block-size: var(--cs-control-height); + padding-block: 0; + padding-inline: 12px; + border-radius: var(--cs-control-radius); + line-height: 2.5715; + font-size: 14px; + font-weight: 700; + background: var(--cs-color-accent); + border-color: var(--cs-color-accent); + color: #fff; + + &:hover:not(:disabled) { + background: var(--cs-color-accent-hover); + border-color: var(--cs-color-accent-hover); + color: #fff; + } + + &:focus:not(:disabled) { + background: var(--cs-color-accent-hover); + border-color: var(--cs-color-accent-hover); + box-shadow: 0 0 0 1px #fff, 0 0 0 3px var(--cs-color-accent); + outline: 2px solid transparent; + } + } + + // Small buttons stay compact rather than inheriting the 40px control height. + .button-small { + min-block-size: 26px; + line-height: 2.1818; + font-size: 11px; + } +} + +.wp-core-ui .wrap .button .dashicons { + color: inherit; + + // WP 7.0's taller button line-height leaks onto dashicon glyphs inside buttons (e.g. the + // conditions badge icon), pushing them out of vertical alignment. Reset it so icons stay + // centred — mirrors the existing `.button svg` rule for SVG icons. + line-height: 1; +} + +// The Screen Options panel sits outside the page wrapper, in WordPress' own +// screen meta region. These stylesheets are only loaded on the plugin's screens, +// so its checkboxes are styled to match the ones in the page below it. +#adv-settings { + input[type='checkbox'] { + @include checkbox.canonical; + } + + // The canonical checkbox drops the margin WordPress relies on here, and the + // gap otherwise comes from whitespace in the markup, which differs between + // the fieldsets this plugin prints and the ones WordPress prints. + label { + display: inline-flex; + align-items: center; + gap: 6px; + } +} diff --git a/src/css/common/list-table/_layout.scss b/src/css/common/list-table/_layout.scss new file mode 100644 index 000000000..713742696 --- /dev/null +++ b/src/css/common/list-table/_layout.scss @@ -0,0 +1,335 @@ +@use '../badges'; +@use '../checkbox'; +@use '../theme'; + +.column-name { + .extra-icons { + float: inline-end; + display: flex; + flex-wrap: wrap; + gap: 5px; + } + + .dashicons-lock { + color: #646970; + opacity: 0.7; + + &:hover { + opacity: 1; + } + } +} + +.active-snippet { + td, th { + background-color: rgb(120 200 230 / 6%); + } + + th.check-column { + border-inline-start: 2px solid #2ea2cc; + } + + .column-name > .snippet-name { + font-weight: 600; + } +} + +.inactive-snippet { + @include theme.link-colors(#579); +} + +.paging-input { + display: inline-flex; + align-items: center; + gap: 2px; +} + +.wp-list-table { + .check-column input[type='checkbox'] { + @include checkbox.canonical; + } + + // Shared cell rhythm for snippet and Cloud tables. Padding, rather than a + // fixed row height, keeps multi-line values comfortable and uncropped. + td, + th { + padding-block: 16px; + padding-inline: 8px; + } + + th:first-child, + td:first-child { + padding-inline-start: 16px; + } + + th:last-child, + td:last-child { + padding-inline-end: 16px; + } + + // Shared horizontal rhythm for every snippet table, so the cloud table lines + // up with the snippets table rather than keeping the wider WordPress default. + th:not(.check-column), + td:not(.check-column) { + padding-inline: 8px; + } + + td.column-id { + text-align: center; + } + + tr { + background: #fff; + } + + ol, ul { + margin-block: 0 1.5em; + margin-inline: 1.5em 0; + } + + ul { + list-style: disc; + } + + .sortable-column-title { + display: flex; + } + + th.sortable .list-table-sort-button, + th.sorted .list-table-sort-button { + display: flex; + flex-direction: row; + align-items: center; + inline-size: 100%; + margin: 0; + border: none; + background: none; + font: inherit; + color: theme.$accent; + cursor: pointer; + text-align: start; + overflow: hidden; + padding-block: 8px; + padding-inline: 0; + } + + th.sortable .list-table-sort-button:focus-visible, + th.sorted .list-table-sort-button:focus-visible { + outline: 2px solid theme.$accent; + outline-offset: 2px; + border-radius: 2px; + } + + // The type badge is wrapped in an unclassed link that lays out as an inline + // box shorter than the badge, so wrap the badge exactly and draw our own + // focus ring: the inherited one resolves to a transparent outline. + .column-type a { + @include badges.badge-link; + } + + .row-actions { + color: #646970; + position: relative; + inset-inline-start: 0; + + a:not(.delete) { + color: theme.$accent; + + &:hover, + &:focus { + color: theme.$accent-hover; + } + } + + // Row-action buttons render as plain inline links. On WP 7.0 the generic `.button` + // compatibility styling would otherwise give them a fill, border, radius and fixed + // height, so fully neutralise it here (this rule is emitted after the wp-admin layer + // at equal specificity, so it wins). Non-delete links inherit the accent text colour. + .button-link { + block-size: auto; + min-block-size: 0; + padding: 0; + border: 0; + border-radius: 0; + background: none; + box-shadow: none; + line-height: inherit; + font-weight: 400; + color: theme.$accent; + + &:hover:not(:disabled), + &:focus:not(:disabled) { + background: none; + border: 0; + box-shadow: none; + } + + &:hover:not(:disabled, .delete, .snippet-cloud-update), + &:focus:not(:disabled, .delete, .snippet-cloud-update) { + color: theme.$accent-hover; + } + + &.delete { + color: #b32d2e; + } + + &.snippet-cloud-update { + color: #ff851b; + } + } + + .snippet-row-action-feedback, + .snippet-row-action-error { + display: inline-flex; + align-items: center; + gap: .5em; + + .components-spinner { + margin: 0; + inline-size: 1em; + block-size: 1em; + } + } + + .snippet-row-action-error { + color: #b32d2e; + } + + .delete.disabled { + color: #a7aaad; + cursor: not-allowed; + pointer-events: none; + } + } + + .column-activate { + text-align: center; + + .snippet-activation-switch, + input[type='checkbox'].switch { + margin-inline: auto; + } + } + + .clear-filters { + vertical-align: middle; + } + + thead th.check-column, + thead td.check-column, + tfoot th.check-column, + tfoot td.check-column, + tbody th.check-column, + tbody td.check-column { + padding-block: 16px; + padding-inline-start: 8px; + vertical-align: middle; + } + + // Line every checkbox up on the same starting edge. Rows flagged as active + // draw a 2px accent border on this cell, so the same width is reserved as a + // transparent border elsewhere; without it the header, footer and inactive + // rows sit 2px further in, and rows shift as snippets are toggled. + thead th.check-column, + thead td.check-column, + tfoot th.check-column, + tfoot td.check-column, + tbody tr:not(.active-snippet) th.check-column, + tbody tr:not(.active-snippet) td.check-column { + border-inline-start: 2px solid transparent; + } + + .active-snippet, .inactive-snippet { + td, th { + box-shadow: inset 0 -1px 0 rgb(0 0 0 / 10%); + vertical-align: middle; + } + } + + tr.active-snippet + tr.inactive-snippet th, + tr.active-snippet + tr.inactive-snippet td { + border-block-start: 1px solid rgb(0 0 0 / 3%); + box-shadow: inset 0 1px 0 rgb(0 0 0 / 2%), inset 0 -1px 0 #e1e1e1; + } + + .delete { + color: #b32d2e; + } + + a.delete:not(.disabled) { + &:hover, &:focus, &:active { + border-block-end: 1px solid #f00; + color: #f00; + } + } + + td.column-date, th.column-date { + white-space: nowrap; + inline-size: 130px; /* fixed column width */ + min-inline-size: 130px; + max-inline-size: 130px; + text-align: end; + overflow: hidden; + text-overflow: ellipsis; + } + + td.column-date .modified-column-content { + display: block; + text-align: start; + } + + // Snippet names always stay on a single line, truncating gracefully; + // the full name is exposed through the title attribute. + td.column-name > .snippet-name { + display: block; + max-inline-size: min(15rem, 30vw); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &.truncate-row-values td.column-desc .snippet-description-content { + display: block; + max-inline-size: min(25rem, 45vw); + overflow: clip; + text-overflow: ellipsis; + } +} + +.snippets-table-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + + // The preceding page header owns the 16px gap above; a `wp-header-end`
+ // sits between them and prevents margin collapse, so this row keeps only its + // own 16px gap below (to the tablenav) and no top margin of its own. + margin-block: 0 16px; + + .subsubsub { + display: flex; + align-items: center; + gap: 9px; + margin: 0; + padding: 0; + font-size: 13px; + line-height: 1.5; + + li { + display: flex; + align-items: center; + gap: 9px; + margin: 0; + padding: 0; + } + + // A thin vertical rule precedes every status after the first, sized to the + // 13px text rather than the taller line box. + li + li::before { + content: ''; + inline-size: 1px; + block-size: 13px; + background: theme.$control-border; + } + } +} diff --git a/src/css/common/list-table/_navigation.scss b/src/css/common/list-table/_navigation.scss new file mode 100644 index 000000000..ffd19344e --- /dev/null +++ b/src/css/common/list-table/_navigation.scss @@ -0,0 +1,58 @@ +@use '../theme'; + +.tablenav { + margin: 16px 0; + display: flex; + align-items: center; + flex-wrap: wrap; + + // The row gap only shows once controls wrap onto a second row (see the + // tablet collapse below, where the selection + pagination cluster drops to + // its own row); the 18px column gap spaces the left cluster on one line. + gap: 12px 18px; +} + +/* WP 7.0 changes the default admin link colour to the modern blue. Pin the classic accent on active rows (names and + row-action links) so links keep their original colour; inactive rows are handled by the muted link-colors mixin, + and the trash button keeps its dark red from the `.delete` rules. */ +.wp-list-table .active-snippet a { + color: theme.$accent; + + &:hover, + &:focus { + color: theme.$accent-hover; + } +} + +/* Status filter links (All | Active | Inactive | …): the current view reads as plain + body text while the remaining views are accent-coloured links; every count stays + in the body-text colour regardless of its link state. */ +/* stylelint-disable no-descending-specificity -- unrelated element; ordering is fine. */ +.subsubsub a.current { + color: #2c3337; +} + +.subsubsub a:not(.current) { + color: theme.$accent; + + &:hover, + &:focus { + color: theme.$accent-hover; + } +} + +.snippets-table-toolbar .subsubsub a .count { + color: #2c3337; +} +/* stylelint-enable no-descending-specificity */ + +.wp-core-ui .button.clear-filters { + vertical-align: baseline; +} + +.snippet-type-description { + border-block-end: 1px solid #ccc; + margin: 0; + padding-block: 1em; + padding-inline: 0; +} diff --git a/src/css/common/list-table/_pagination.scss b/src/css/common/list-table/_pagination.scss new file mode 100644 index 000000000..137f791fb --- /dev/null +++ b/src/css/common/list-table/_pagination.scss @@ -0,0 +1,119 @@ +@use '../checkbox'; +@use '../theme'; + +// The pagination group and view toggle wrap together as one end-pinned +// cluster so narrow viewports never split them across rows. +.tablenav .tablenav-end-group { + display: flex; + align-items: center; + gap: 24px; + margin-inline-start: auto; +} + +.tablenav .tablenav-pages-nav { + display: flex; + align-items: center; + gap: 8px; + + .tablenav-pages { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + } +} + +// "Select all" control shown in the toolbar beside the bulk actions. +.tablenav .tablenav-select-all { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + + input[type='checkbox'] { + @include checkbox.canonical; + } +} + +.snippets-search-area { + display: flex; + align-items: center; + gap: 12px; + + search { + flex: 1 1 auto; + min-inline-size: 0; + } + + .search-box { + float: none; + display: flex; + align-items: center; + gap: 8px; + margin: 0; + + input[type='search'] { + flex: 1 1 auto; + min-inline-size: 0; + } + } +} + +// Inside the results toolbar the search area is fixed to the design width, +// with the input flexing to fill the available space. +.tablenav .snippets-search-area { + inline-size: 237px; +} + +.wrap .snippets-search-area input[type='search'] { + border-color: theme.$control-border; +} + +// Tablet widths: let the search area grow to fill the remainder of its row +// while the fixed-width selects keep their sizing and groups wrap naturally. +@media (width <= 1024px) { + .tablenav .snippets-search-area { + inline-size: auto; + flex: 1 1 237px; + max-inline-size: 100%; + } +} + +@media (width <= 782px) { + // Wide table columns scroll inside the results region instead of extending + // the WordPress admin document horizontally. + .snippets-list-view { + max-inline-size: 100%; + overflow-x: auto; + overscroll-behavior-inline: contain; + } + + p.search-box { + float: inline-start; + position: initial; + margin-block: 1em 0; + margin-inline: 0; + block-size: auto; + } + + + // Mobile keeps the filters compact. Pagination remains below the results, + // avoiding a second navigation cluster above the table. + .tablenav.top .tablenav-pages-nav { + display: none; + } + + .tablenav.top .tablenav-end-group { + margin-inline-start: 0; + } + + .tablenav .snippets-search-area { + flex: 0 1 20rem; + inline-size: min(100%, 20rem); + max-inline-size: 20rem; + } + + .tablenav .alignleft.actions { + display: flex; + } +} diff --git a/src/css/common/list-table/_responsive.scss b/src/css/common/list-table/_responsive.scss new file mode 100644 index 000000000..245159842 --- /dev/null +++ b/src/css/common/list-table/_responsive.scss @@ -0,0 +1,190 @@ +@use '../theme'; + +// Toolbar squeeze: above the mobile breakpoint the row never wraps — +// components shrink gracefully instead (flex-wrap would otherwise win +// before flex-shrink ever engages). Wrapping remains the small-screen +// fallback only. +.snippets-list-view .tablenav { + flex-wrap: wrap; + + @media (width > 782px) { + flex-wrap: nowrap; + } + + .alignleft.actions { + display: flex; + align-items: center; + gap: 8px; + float: none; + margin: 0; + padding: 0; + flex: 0 1 auto; + min-inline-size: 0; + + select { + inline-size: auto; + flex: 1 1 154px; + min-inline-size: 104px; + max-inline-size: 154px; + } + } + + .tablenav-select-all { + flex-shrink: 0; + } + + .snippets-search-area { + inline-size: auto; + flex: 1 1 237px; + min-inline-size: 150px; + max-inline-size: 237px; + + // The inner search row never wraps — the field shrinks instead. + search, + form, + p.search-box { + display: flex; + align-items: center; + flex-wrap: nowrap; + gap: 8px; + min-inline-size: 0; + margin: 0; + } + + input[type='search'] { + min-inline-size: 110px; + } + + .button { + flex: 0 0 auto; + } + } + + // Pagination labels stay on one line while the row squeezes. + .tablenav-pages, + .tablenav-pages .paging-input, + .tablenav-pages .tablenav-paging-text { + white-space: nowrap; + flex-wrap: nowrap; + } + + // Square pagination tiles with a compact page-number box between them. + // Enabled arrows pick up the accent border/text from the shared control + // styling; disabled arrows keep the native WordPress treatment. + .pagination-links { + display: flex; + align-items: center; + gap: 4px; + + .button { + display: inline-flex; + align-items: center; + justify-content: center; + inline-size: 38px; + block-size: 38px; + min-block-size: 38px; + box-sizing: border-box; + padding: 0; + margin: 0; + border-radius: 5.6px; + } + + a.button { + color: theme.$accent; + } + + .paging-input { + gap: 8px; + margin-inline: 4px; + } + + .current-page { + inline-size: 45px; + min-block-size: 38px; + box-sizing: border-box; + margin: 0; + text-align: center; + color: #2c3337; + } + + .tablenav-paging-text { + font-size: 14px; + color: #646970; + } + } + + .tablenav-pages .displaying-num { + font-size: 14px; + color: #646970; + white-space: nowrap; + } + + // The trailing float-clearing break would otherwise register as an + // empty flex item and contribute a stray column gap. + br.clear { + display: none; + } + + .tablenav-end-group { + flex: 0 1 auto; + min-inline-size: 0; + } + + // The item count is the first thing to give way when space runs short. + @media (width <= 1240px) { + .tablenav-pages .displaying-num { + display: none; + } + } +} + +// Tablet collapse (iPad-class widths): rather than let the single toolbar row +// overflow, break the top toolbar onto two rows. Row one keeps the working +// controls — bulk actions, the tag filter and the search box (stretched to +// fill the remainder). Row two carries the selection + navigation cluster: +// "Select all" at the start, the pagination + view-toggle group pinned to the +// end. A forced full-width break keeps the split deterministic regardless of +// content width. +@media (782px < width <= 1210px) { + .snippets-list-view .tablenav.top { + flex-wrap: wrap; + row-gap: 8px; + + // Both rows spread edge-to-edge: the first control sits at the start, + // the last at the end, with the slack distributed between. + justify-content: space-between; + + // Row one: bulk actions, tag filter, search — at its design width so the + // space-between slack lands in the gaps rather than stretching the field. + .bulkactions { + order: 1; + } + + .alignleft.actions:not(.bulkactions) { + order: 2; + } + + .snippets-search-area { + order: 3; + flex: 0 1 237px; + inline-size: 237px; + max-inline-size: 100%; + } + + &::after { + content: ''; + order: 4; + flex-basis: 100%; + block-size: 0; + } + + // Row two: select all at the start, pagination + view-toggle at the end. + .tablenav-select-all { + order: 5; + } + + .tablenav-end-group { + order: 6; + } + } +} diff --git a/src/css/edit.scss b/src/css/edit.scss index 2485a6d43..6f270cea9 100644 --- a/src/css/edit.scss +++ b/src/css/edit.scss @@ -10,13 +10,17 @@ @use 'common/tooltips'; @use 'common/modal'; @use 'common/upsell'; +@use 'common/toolbar'; +@use 'common/wp-admin'; +@use 'common/list-table'; @use 'edit/form'; @use 'edit/sidebar'; @use 'edit/editor'; +@use 'edit/notices'; @use 'edit/conditions'; @use 'edit/gpt'; -.notice.error blockquote { +.banner.error blockquote { margin-block-end: 0; } @@ -27,7 +31,17 @@ margin: 0; } +#adminmenu a.code-snippets-edit-menu-link { + cursor: pointer; +} + .snippet-description-container { + label { + font-size: 1.16em; + font-weight: 600; + text-transform: unset; + } + .wp-editor-tools { padding-block-start: 5px; } @@ -44,20 +58,17 @@ } } -.components-form-token-field__input-container { - background: #fff; - border-color: #c3c4c7; +.snippet-tags-container { + .components-form-token-field__input-container { + background: #fff; + border-color: #c3c4c7; - > .components-flex { - padding: 12px; + > .components-flex { + padding: 12px; + } } } -#titlediv, -.snippet-type-container { - margin-block-end: 24px; -} - .above-snippet-code { margin-block: 0 15px; display: flex; @@ -65,15 +76,16 @@ margin-inline: 0; gap: 8px; - h2 { - margin: 0; + label { + font-size: 1.16em; + font-weight: 600; } .expand-editor-button { display: flex; - gap: 5px; align-items: center; margin-inline-end: auto; + gap: 5px; .dashicons { inline-size: 18px; @@ -83,6 +95,14 @@ } } +.wrap > h1:first-of-type { + font-size: 1.5rem; + font-weight: 400; + line-height: 1.3; + padding: 0; + margin-block: 50px 1rem; +} + .snippet-name-wrapper { display: flex; gap: 0.5em; @@ -95,13 +115,3 @@ form.condition-snippet .snippet-code-container { display: none; } - -.cs-back { - cursor: pointer; - - &::before { - content: '<'; - color: #2271b1; - margin-inline-end: 3px; - } -} \ No newline at end of file diff --git a/src/css/edit/_editor.scss b/src/css/edit/_editor.scss index 8063c8f68..2fd9e9a2b 100644 --- a/src/css/edit/_editor.scss +++ b/src/css/edit/_editor.scss @@ -59,9 +59,9 @@ } .snippet-editor-help { - position: absolute; inset-inline-end: 5px; inset-block-start: 5px; + position: absolute; td { &:first-child { @@ -72,18 +72,27 @@ white-space: nowrap; } } +} + +.mac-keyboard-shortcut { + text-align: end; - .mac-key { - display: none; + kbd { + font-family: inherit; + padding: 0; + margin: 0; } +} - .platform-mac { - .mac-key { - display: inline; - } +.pc-keyboard-shortcut { + display: inline-flex; + align-items: center; + gap: 3px; - .pc-key { - display: none; - } + kbd { + font-family: inherit; + margin: 0; + padding-block: 0; + padding-inline: 2px; } } diff --git a/src/css/edit/_form.scss b/src/css/edit/_form.scss index 45ead76fd..a9b1bbcee 100644 --- a/src/css/edit/_form.scss +++ b/src/css/edit/_form.scss @@ -3,12 +3,34 @@ $sidebar-width: 321px; $sidebar-gap: 30px; -.snippet-form #titlediv #title, -.snippet-type-container { - border-color: #ccc; +.snippet-form #titlediv #title { + border-color: theme.$control-border; block-size: 45px } +// The type selector is a react-select control that sizes to its content; pin +// its bordered control box to the same 45px as the adjacent title field and +// keep the selected value vertically centred within it. +.snippet-type-container .code-snippets-select { + block-size: 100%; + + // Target the control box only (:first-of-type). The open menu is a later + // sibling div; matching it here would clamp and flex-row the option list. + > div:first-of-type { + border-color: theme.$control-border; + min-block-size: 45px; + block-size: 45px; + max-block-size: 45px; + } + + > div:first-of-type > div:first-child { + display: flex; + align-items: center; + block-size: 45px; + padding-block: 0; + } +} + .snippet-type-option { display: flex; align-items: center; @@ -16,26 +38,45 @@ $sidebar-gap: 30px; flex-flow: row; gap: 2em; - .small-badge { - margin-inline-start: 0.5em; + .snippet-type-option-main { + display: flex; + align-items: center; + gap: 8px; } - .badge { - float: inline-end; + .small-badge { + margin-inline-start: 0.5em; } } .conditions-editor-open { + .conditions-editor-header { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + margin-block-end: 8px; + + strong { + font-size: 14px; + line-height: 20px; + } + } + .button.button-large { - block-size: 100%; display: flex; align-items: center; gap: 8px; - overflow: hidden; - border-color: #ccc; - padding-block: 6px; - padding-inline: 12px; + inline-size: 100%; + border-color: theme.$control-border; + background: transparent; + padding: 6px 12px; + + &:hover:not(:disabled), + &:focus:not(:disabled) { + background: #f6f7f7; + } } &.no-condition .cond-badge { @@ -44,7 +85,7 @@ $sidebar-gap: 30px; border: 1px solid currentcolor; } - &.no-condition:hover .cond-badge { + &.no-condition:hover :not([disabled]) .cond-badge { color: #fff; background: theme.$accent; border-color: theme.$accent; @@ -59,6 +100,10 @@ $sidebar-gap: 30px; grid-template-areas: 'upper sidebar' 'lower sidebar'; transition: all 700ms; + @media (prefers-reduced-motion: reduce) { + transition-duration: 0.01s; + } + &.snippet-form-expanded { grid-template-areas: 'upper upper' 'lower sidebar'; } @@ -71,12 +116,26 @@ $sidebar-gap: 30px; grid-area: lower; } + .snippet-form-upper, + .snippet-form-lower { + display: flex; + flex-flow: column; + gap: 24px; + } + .snippet-editor-sidebar { grid-area: span 3 / sidebar; max-inline-size: $sidebar-width; position: sticky; - inset-block-start: 32px; + + // Keep the sidebar clear of the fixed WordPress admin bar and leave a + // usable visual gap beneath it while the editor scrolls. + inset-block-start: 48px; align-self: start; + + @media (width <= 782px) { + position: static; + } } @media (width <= 1024px) { @@ -97,3 +156,25 @@ $sidebar-gap: 30px; } } } + +.above-editor-container { + display: flex; + flex-flow: row wrap; + gap: 1em; + + > * { + flex: 1; + display: flex; + flex-flow: column; + min-inline-size: 180px; + } +} + +// Match the location selector's control height to the adjacent conditions +// button so the two sidebar controls line up. This targets a different +// container than the type selector rules above, so their relative order does +// not affect the cascade. +// stylelint-disable-next-line no-descending-specificity +.snippet-editor-sidebar .code-snippets-select.code-snippets-select-location > div:first-of-type { + min-block-size: 48px; +} diff --git a/src/css/edit/_gpt.scss b/src/css/edit/_gpt.scss index 70702bb75..00778816b 100644 --- a/src/css/edit/_gpt.scss +++ b/src/css/edit/_gpt.scss @@ -12,7 +12,7 @@ box-shadow: none; } - .notice { + .banner { margin-inline: 0; } } @@ -32,6 +32,14 @@ } } +.snippet-tags-container { + label { + font-size: 1.16em; + font-weight: 600; + text-transform: unset; + } +} + .code-line-explanation { display: flex; cursor: default; diff --git a/src/css/edit/_notices.scss b/src/css/edit/_notices.scss new file mode 100644 index 000000000..98cfd1584 --- /dev/null +++ b/src/css/edit/_notices.scss @@ -0,0 +1,28 @@ + +.code-snippets-notice { + .notice-dismiss { + position: absolute; + transform: initial; + inset-inline-end: 0; + inset-block-start: 0; + } + + details { + margin: .5em 0; + padding: 2px; + + summary { + cursor: pointer; + } + + pre { + max-inline-size: 100%; + overflow: auto hidden; + white-space: pre; + } + + .stack-trace-hint { + opacity: 0.5; + } + } +} diff --git a/src/css/edit/_sidebar.scss b/src/css/edit/_sidebar.scss index 919ce70ef..3685abf51 100644 --- a/src/css/edit/_sidebar.scss +++ b/src/css/edit/_sidebar.scss @@ -1,66 +1,75 @@ @use '../common/theme'; -.code-snippets-modal { - p h4 { - margin-block-start: 0; +.snippet-priority { + .priority-input-tooltip { + margin-inline-end: auto; + } + + input { + inline-size: 4em; } } -.snippet-editor-sidebar { - .button-large { - block-size: 48px; +.code-snippets-copy-text.button { + display: flex; + align-items: center; + gap: 3px; + + .dashicons { + block-size: 18px; + inline-size: 18px; + font-size: 18px; } - .row-actions { + .spinner-wrapper { + block-size: 18px; + inline-size: 18px; display: flex; + align-items: center; + } - .button { - background: none; - border: none; - } + .components-spinner { + block-size: 12px; } +} - .delete-button { - color: #cc1818; +.activation-switch-container { + inline-size: 100%; + display: flex; + flex-flow: row; + gap: 5px; + justify-content: center; + align-items: center; - &:hover { - color: #9e1313; - } + label { + font-weight: 600; + } - &:focus { - color: #710d0d; - border-color: #710d0d; - } + span:first-of-type { + margin-inline-start: auto; } +} - .help-tooltip { - margin-block: 0; - margin-inline: 5px auto; +.snippet-editor-sidebar { + .button-large { + block-size: 48px; } .box { background-color: #fff; - border: 1px solid #ccc; + border: 1px solid theme.$control-border; border-radius: 4px; padding: 1.5em; display: flex; flex-flow: column; gap: 1em; - h4 { - margin-block: 0.5em; - margin-inline: 0; - } - .inline-form-field { display: flex; flex-flow: row wrap; + justify-content: space-between; align-items: center; gap: 5px; - - > :last-child { - margin-inline-start: auto; - } } .block-form-field { @@ -68,23 +77,104 @@ flex-flow: column; gap: 4px; - h4 { - margin-block-end: 0; - } } } - h4 .badge { - float: inline-end; + .inline-form-field.row-actions { + display: flex; - + .badge { - margin-inline-end: 5px; + // Keep the export/download, trash and lock controls on a single row; the + // base .inline-form-field sets `row wrap`, which would otherwise drop the + // lock control onto a second line in the narrow sidebar. + flex-wrap: nowrap; + gap: 0; + + // Lay the export + download pair out inline too — the group is a plain + // block by default, so its two links would otherwise stack vertically. + .snippet-export-buttons { + display: flex; + flex-wrap: nowrap; + gap: 12px; + min-inline-size: 0; } + + // Row-action buttons render as plain inline links. Neutralise the generic `.button` + // WP 7.0 compatibility styling (fill/border/radius/40px height) here — emitted after + // the wp-admin layer at equal specificity, so it wins. Export/Download inherit the accent. + .button { + block-size: auto; + min-block-size: theme.$control-height; + padding-block: 0; + padding-inline: 8px; + font-weight: 400; + display: inline-flex; + justify-content: center; + align-items: center; + + .dashicons { + color: inherit; + line-height: 1; + vertical-align: text-bottom; + } + + &:not(.snippet-lock-button) { + border: 0; + border-radius: 0; + padding-inline: 0; + background: none; + box-shadow: none; + + // WordPress paints a fill on disabled buttons with !important; + // keep the disabled trash flat like the rest of the row. + &:disabled { + background: none !important; + border: 0; + box-shadow: none; + } + + &:hover:not(:disabled), + &:focus:not(:disabled), + &:active:not(:disabled) { + background: none; + border: 0; + box-shadow: none; + } + } + } + + // Nested under `.row-actions` so the dark-red trash colour outranks the generic + // `.button` accent text colour from the wp-admin compatibility layer. + .delete-button { + color: #cc1818; + + &:disabled { + color: #a7aaad; + cursor: not-allowed; + } + + &:hover:not(:disabled) { + color: #9e1313; + } + + &:focus:not(:disabled) { + color: #710d0d; + } + } + } + + .help-tooltip { + margin-block: 0; + margin-inline: 5px auto; + } + + label { + font-weight: 600; } .beta-badge { color: theme.$accent; border: 1px solid currentcolor; + margin-inline-start: auto; } .components-form-token-field { @@ -96,10 +186,6 @@ } } -.snippet-priority input { - inline-size: 4em; -} - p.submit { display: flex; flex-flow: column; @@ -108,13 +194,6 @@ p.submit { padding-block-start: 0; } -.activation-switch-container label { - display: flex; - flex-flow: row; - gap: 5px; - justify-content: center; -} - .shortcode-tag-wrapper { background: #fff; min-block-size: 54px; @@ -130,26 +209,3 @@ p.submit { text-indent: -0.5em; } } - -.code-snippets-copy-text.button { - display: flex; - align-items: center; - gap: 3px; - - .dashicons { - block-size: 18px; - inline-size: 18px; - font-size: 18px; - } - - .spinner-wrapper { - block-size: 18px; - inline-size: 18px; - display: flex; - align-items: center; - } - - .components-spinner { - block-size: 12px; - } -} diff --git a/src/css/import.scss b/src/css/import.scss new file mode 100644 index 000000000..7906ac04f --- /dev/null +++ b/src/css/import.scss @@ -0,0 +1,13 @@ +@use 'common/checkbox'; +@use 'common/toolbar'; +@use 'common/subnav'; +@use 'common/wp-admin'; +@use 'common/page-header'; +@use 'import/page'; +@use 'import/card'; +@use 'import/upload'; +@use 'import/migrate'; + +.wp-list-table .check-column input[type='checkbox'] { + @include checkbox.canonical; +} diff --git a/src/css/import/_card.scss b/src/css/import/_card.scss new file mode 100644 index 000000000..4f96219e4 --- /dev/null +++ b/src/css/import/_card.scss @@ -0,0 +1,38 @@ + +.snippets-table-card { + .wp-list-table { + border-radius: 5px; + } + + th.check-column { + padding: 8px 0; + } + + .tablenav { + display: flex; + gap: 10px; + block-size: auto; + padding: 0; + } + + .tablenav.bottom { + margin-block-start: 1em; + } +} + +.import-snippets-card { + background-color: #fff; + padding: 25px; + border-radius: 5px; + border: 1px solid #e0e0e0; + margin-block-end: 10px; + inline-size: 100%; + box-sizing: border-box; + + &.status-display { + display: flex; + align-items: flex-start; + gap: 12px; + margin-block-end: 20px; + } +} diff --git a/src/css/import/_migrate.scss b/src/css/import/_migrate.scss new file mode 100644 index 000000000..d80360d20 --- /dev/null +++ b/src/css/import/_migrate.scss @@ -0,0 +1,149 @@ + +.importer-selector-card { + h2 { + margin: 0 0 1em; + } + + select { + display: block; + margin-block-start: 5px; + inline-size: 100%; + max-inline-size: 300px; + } + + p { + margin: 10px 0 0; + color: #666; + font-size: 14px; + } +} + +.import-options-card { + h3 { + margin: 0 0 1em; + } + + label { + display: flex; + align-items: flex-start; + gap: 8px; + cursor: pointer; + + .description { + color: #666; + font-size: 0.9em; + } + } + + input { + margin-block-start: 2px; + } + + > div { + flex: 1; + } + + .import-tag-entry { + margin-block-start: 12px; + + input { + inline-size: 100%; + max-inline-size: 300px; + } + } +} + + +.import-section-status { + display: flex; + align-items: flex-start; + gap: 12px; + + .error, .success { + border-radius: 50%; + inline-size: 24px; + block-size: 24px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-block-start: 2px; + + span { + color: white; + font-size: 14px; + font-weight: bold; + } + } + + .success { + background-color: #00a32a; + } + + .error { + background-color: #d63638; + } + + h4 { + margin: 0 0 8px; + font-size: 16px; + font-weight: 600; + } + + p { + margin: 0; + color: #666; + } + + a { + color: #2271b1; + text-decoration: none; + } +} + +.no-snippets-card { + .card-inner { + text-align: center; + padding: 40px 20px; + color: #666; + + p { + margin: 0; + font-size: 14px; + } + } + + .card-icon { + font-size: 48px; + margin-block-end: 16px; + } + + h4 { + margin: 0 0 8px; + font-size: 18px; + color: #333; + } + +} + +.migrate-snippets-table-card { + > div:first-child { + display: flex; + justify-content: space-between; + align-items: center; + margin-block-end: 10px; + + h3 { + margin: 0; + } + + p { + margin: 0.5em 0 1em; + } + } + + .column-id { + text-align: end; + inline-size: 50px; + } +} diff --git a/src/css/import/_page.scss b/src/css/import/_page.scss new file mode 100644 index 000000000..885701a62 --- /dev/null +++ b/src/css/import/_page.scss @@ -0,0 +1,18 @@ +.wrap > h2:first-of-type { + font-size: 32px; + font-weight: 510; + line-height: 1.25; + color: #2c3337; + margin-block: 24px 16px; + padding: 0; +} + +.import-snippets-section { + padding-block-start: 0; + display: none; + max-inline-size: 800px; + + &.active-section { + display: block; + } +} diff --git a/src/css/import/_upload.scss b/src/css/import/_upload.scss new file mode 100644 index 000000000..37c9ff4eb --- /dev/null +++ b/src/css/import/_upload.scss @@ -0,0 +1,342 @@ +$type-colors: ( + css: #9b59b6, + js: #ffeb3b, + html: #ef6a36 +); + +.import-upload-card { + h2 { + margin: 0 0 1em; + } + + p.description { + margin-block-end: 1em; + } + + footer { + text-align: center; + + .button { + min-inline-size: 200px; + } + } +} + +.upload-drop-zone { + border: 2px dashed #ccd0d4; + border-radius: 4px; + padding: 40px 20px; + text-align: center; + cursor: pointer; + background-color: #fafafa; + transition: all 0.3s ease; + opacity: 1; + display: block; + + @media (prefers-reduced-motion: reduce) { + transition-duration: 0.01s; + } + + &.drag-over { + border-color: #0073aa; + background-color: #f0f6fc; + } + + &.disabled { + cursor: not-allowed; + background-color: #f6f7f7; + opacity: 0.6; + } + + &:focus-visible { + outline: 2px solid #2271b1; + outline-offset: 2px; + } + + .drop-zone-icon { + font-size: 48px; + margin-block-end: 20px; + color: #666; + } + + p:first-of-type { + margin: 0 0 8px; + font-size: 16px; + font-weight: 500; + } + + p:last-of-type { + margin: 0; + color: #666; + font-size: 14px; + } +} + +.upload-drop-zone-wrapper { + position: relative; + margin-block-end: 20px; + + &:focus-within .upload-drop-zone:not(.disabled) { + box-shadow: 0 0 0 2px #fff, 0 0 0 4px #2271b1; + } +} + +.upload-drop-zone-file-input { + position: absolute; + inline-size: 1px; + block-size: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; + clip-path: inset(50%); + white-space: nowrap; +} + +.import-result-display-card { + > div { + display: flex; + align-items: flex-start; + gap: 12px; + } +} + +.selected-files { + margin-block-end: 20px; + + h3 { + margin: 0 0 12px; + font-size: 14px; + font-weight: 600; + } +} + +.selected-files-list { + display: flex; + flex-direction: column; + gap: 8px; + + > div { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 4px; + background-color: #f9f9f9; + } + + button { + background: none; + border: none; + color: #d63638; + cursor: pointer; + font-size: 16px; + padding: 4px; + + &:focus-visible { + outline: 2px solid #2271b1; + outline-offset: 2px; + } + } + + .selected-file-details { + display: flex; + align-items: center; + gap: 8px; + + strong { + font-weight: 500; + } + + .file-icon { + font-size: 16px; + } + + .file-size { + font-size: 12px; + color: #666; + } + } +} + +.duplicate-action-selector-card { + h2 { + margin: 0 0 1em; + } + + p.description { + margin-block-end: 1em; + } + + fieldset { + > div { + display: flex; + flex-direction: column; + gap: 8px; + } + + label { + display: flex; + align-items: flex-start; + gap: 8px; + cursor: pointer; + } + + input { + margin-block-start: 2px; + } + } +} + +.import-result { + display: flex; + align-items: flex-start; + gap: 12px; + + > div:last-child { + flex: 1; + } + + h2 { + margin: 0 0 8px; + font-size: 16px; + font-weight: 600 + } + + p.import-result-message { + margin: 0 0 8px; + color: #666; + } + + p.import-result-link { + margin: 0; + color: #666; + + a { + color: #2271b1; + text-decoration: none; + } + } + + .import-result-warnings { + margin-block-start: 12px; + + h3 { + margin: 0 0 8px; + font-size: 14px; + color: #d63638; + } + + ul { + margin: 0; + padding-inline-start: 20px; + } + + li { + color: #666; + font-size: 14px; + } + } +} + +.import-result-icon { + border-radius: 50%; + inline-size: 24px; + block-size: 24px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-block-start: 2px; + + .import-result-success & { + background-color: #00a32a; + } + + .import-result-failure & { + background-color: #d63638; + } + + span { + color: white; + font-size: 14px; + font-weight: bold; + } +} + +.import-select-card { + .return-link { + display: flex; + align-items: center; + justify-content: space-between; + margin-block-end: 20px; + } + + .tablenav.top { + display: flex; + justify-content: space-between; + align-items: center; + margin-block-end: 10px; + + h2 { + margin: 0; + } + + p { + margin: 0.5em 0 1em; + color: #666; + } + } + + .table-actions .button { + margin-inline-end: 10px; + } + + .wp-list-table { + table-layout: fixed; + } + + th.check-column { + inline-size: 40px; + } + + th.column-name { + inline-size: 200px; + } + + td.column-name div { + font-size: 12px; + color: #666; + margin-block-start: 2px; + } + + .column-type { + inline-size: 90px; + text-align: center; + + span { + background-color: #1d97c6; + color: white; + padding: 3px 6px; + font-size: 10px; + text-transform: uppercase; + border-radius: 3px; + + @each $type, $color in $type-colors { + @at-root .import-select-card .#{$type}-snippet & { + background-color: $color; + } + } + } + } + + th.column-desc { + inline-size: auto; + } + + th.column-tags { + inline-size: 120px; + } +} diff --git a/src/css/manage.scss b/src/css/manage.scss index f3a4a3737..79c481287 100644 --- a/src/css/manage.scss +++ b/src/css/manage.scss @@ -1,169 +1,43 @@ -/** - * Custom styling for the snippets table - */ - -@use 'sass:map'; -@use 'sass:color'; @use 'common/theme'; @use 'common/badges'; @use 'common/switch'; +@use 'common/tooltips'; @use 'common/direction'; @use 'common/select'; -@use 'manage/cloud'; - -.column-name, -.column-type { - .dashicons { - font-size: 16px; - inline-size: 16px; - block-size: 16px; - vertical-align: middle; - } - - .dashicons-clock { - vertical-align: middle; - } -} - -.active-snippet .column-name > .snippet-name { - font-weight: 600; -} - -.active-snippet { - td, th { - background-color: rgba(#78c8e6, 0.06); - } - - th.check-column { - border-inline-start: 2px solid #2ea2cc; - } -} - -.column-priority input { - appearance: none; - background: none; - border: none; - box-shadow: none; - inline-size: 4em; - color: #666; - text-align: center; - - &:hover, &:focus, &:active { - color: #000; - background-color: #f5f5f5; - background-color: rgb(0 0 0 / 10%); - border-radius: 6px; - } - - &:disabled { - color: inherit; - } -} - -.clear-filters { - vertical-align: baseline !important; -} - -.snippets { - td.column-id { - text-align: center; - } - - tr { - background: #fff; - } - - ol, ul { - margin-block: 0 1.5em; - margin-inline: 1.5em 0; - } - - ul { - list-style: disc; - } - - th.sortable a, th.sorted a { - display: flex; - flex-direction: row; - } - - .row-actions { - color: #ddd; - position: relative; - inset-inline-start: 0; - } - - .column-activate { - padding-inline-end: 0 !important; - } - - .clear-filters { - vertical-align: middle; - } - - tfoot th.check-column { - padding-block: 13px 0; - padding-inline: 3px 0; - } - - thead th.check-column, - tfoot th.check-column, - .inactive-snippet th.check-column { - padding-inline-start: 5px; - } - - .active-snippet, .inactive-snippet { - td, th { - padding-block: 10px; - padding-inline: 9px; - border: none; - box-shadow: inset 0 -1px 0 rgb(0 0 0 / 10%); - } - } - - tr.active-snippet + tr.inactive-snippet th, - tr.active-snippet + tr.inactive-snippet td { - border-block-start: 1px solid rgb(0 0 0 / 3%); - box-shadow: inset 0 1px 0 rgb(0 0 0 / 2%), inset 0 -1px 0 #e1e1e1; - } +@use 'common/modal'; +@use 'common/notices'; +@use 'common/upsell'; +@use 'common/toolbar'; +@use 'common/subnav'; +@use 'common/wp-admin'; +@use 'common/page-header'; +@use 'common/kebab-menu'; +@use 'manage/snippets-table'; +@use 'manage/cloud-community'; +@use 'manage/cloud-community-cards'; + +.tablenav .tablenav-pages { + margin-block-end: 0; +} + +.create-snippet-button { + margin-inline-start: auto; + float: inline-end; + clear: both; + margin-block-start: 1em; +} + +.nav-tab { + display: flex; + flex-flow: row wrap; + align-items: center; + gap: 8px; - &, #all-snippets-table, #search-snippets-table { - a.delete:hover { - border-block-end: 1px solid #f00; - color: #f00; + @media (width <= 1190px) { + span:first-child:not(:last-child) { + display: none; } } - - #wpbody-content & .column-name { - white-space: nowrap; /* prevents wrapping of snippet title */ - } -} - -td.column-description { - max-inline-size: 700px; - - pre { - white-space: unset; - } -} - -.inactive-snippet { - @include theme.link-colors(#579); -} - -@media (width <= 782px) { - p.search-box { - float: inline-start; - position: initial; - margin-block: 1em 0; - margin-inline: 0; - block-size: auto; - } -} - -.wp-list-table .is-expanded td.column-activate.activate { - /* fix for mobile layout */ - display: table-cell !important; } .nav-tab-wrapper + .subsubsub, p.search-box { @@ -171,15 +45,19 @@ td.column-description { margin-inline: 0; } -.snippet-type-description { - border-block-end: 1px solid #ccc; - margin: 0; - padding-block: 1em; - padding-inline: 0; -} +.wrap > h2:first-of-type { + font-size: 1.5rem; + font-weight: 400; + line-height: 1.3; + padding: 0; + margin-block: 50px 1rem; -.code-snippets-notice a.notice-dismiss { - text-decoration: none; + .subtitle { + vertical-align: middle; + display: inline-flex; + align-items: center; + gap: 1em; + } } .refresh-button-container { @@ -197,7 +75,7 @@ td.column-description { line-height: 1.4; } -.wrap h2.nav-tab-wrapper { +.wrap .nav-tab-wrapper { .nav-tab { display: flex; flex-flow: row wrap; diff --git a/src/css/manage/_cloud-community-cards.scss b/src/css/manage/_cloud-community-cards.scss new file mode 100644 index 000000000..82ead781a --- /dev/null +++ b/src/css/manage/_cloud-community-cards.scss @@ -0,0 +1,179 @@ +@use '../common/theme'; +@use '../common/cards'; + +.cloud-search-result { + p:last-child { + margin-block-end: 0; + } + + .card-inner { + display: flex; + flex-flow: column; + gap: 16px; + } + + .snippet-card-header { + display: flex; + align-items: center; + gap: 10px; + + // Keep header content clear of the absolutely-positioned selection checkbox. + padding-inline-end: 32px; + + h3 { + margin: 0; + font-size: 18px; + font-weight: 700; + line-height: 1.5; + min-inline-size: 0; + } + + .cloud-snippet-title-button { + display: block; + max-inline-size: 100%; + margin: 0; + padding: 0; + border: none; + background: none; + font: inherit; + color: theme.$accent; + cursor: pointer; + text-align: start; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + &:hover, &:focus { + text-decoration: underline; + } + + &:focus-visible { + outline: 2px solid theme.$accent; + outline-offset: 2px; + border-radius: 2px; + } + } + } + + .snippet-card-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 16px; + font-size: 14px; + line-height: 1.5; + + .cloud-snippet-tags { + color: theme.$accent; + } + + .snippet-card-modified { + color: #6c7e7e; + margin-inline-start: auto; + } + } + + .snippet-description-content { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow: hidden; + margin: 0; + font-size: 14px; + line-height: 1.5; + color: #646970; + border-block-start: 1px solid #dcdcde; + padding-block-start: 16px; + } + + .cloud-snippet-author { + margin: 0; + font-size: 14px; + color: #646970; + + a { + color: theme.$accent; + } + } + + footer { + .dashicons-warning { + color: #b32d2e; + } + + .cloud-snippet-update svg { + display: block; + } + + .button { + font-size: 14px; + line-height: 2.5714; + border-radius: 5px; + } + + .button:not(.button-primary, .cloud-pro-button) { + background: transparent; + } + } +} + +.cloud-search-results .cloud-search-result footer .components-spinner { + margin: 0; +} + +// Pro-only actions use the same quiet outline treatment in card and table views. +.cloud-pro-button.button { + background-color: transparent; + border-color: theme.$accent; + color: theme.$accent; + + &:hover, + &:focus { + background-color: #eff5f9; + border-color: theme.$accent; + color: theme.$accent; + } +} + +// Three equal-height snippet cards per row, matching the manage snippets +// grid. minmax(0, 1fr) lets columns shrink below their content width so +// the grid never overflows the page. +ul.cloud-search-results.code-snippets-cards { + grid-template-columns: repeat(3, minmax(0, 1fr)); + + @media (width <= 1100px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + @media (width <= 782px) { + grid-template-columns: minmax(0, 1fr); + } + + .code-snippets-card { + min-inline-size: 0; + + footer { + flex-wrap: wrap; + } + } + + // Reveal the bulk-select checkbox on hover or keyboard focus, and keep + // every checkbox visible while any card in the grid is selected so an + // in-progress selection is never hidden. + .snippet-card-select { + opacity: 0; + transition: opacity 0.15s ease; + + @media (prefers-reduced-motion: reduce) { + transition-duration: 0.01s; + } + } + + .snippet-card-select:checked, + &.has-selection .snippet-card-select, + .code-snippets-card:hover .snippet-card-select, + .code-snippets-card:focus-within .snippet-card-select { + opacity: 1; + } +} diff --git a/src/css/manage/_cloud-community.scss b/src/css/manage/_cloud-community.scss new file mode 100644 index 000000000..686c52088 --- /dev/null +++ b/src/css/manage/_cloud-community.scss @@ -0,0 +1,180 @@ +@use '../common/banners'; +@use '../common/checkbox'; +@use '../common/theme'; + +.cloud-snippet-status { + display: inline-flex; + align-items: center; + gap: 8px; + color: #646970; + font-size: 12px; + font-weight: 500; + white-space: nowrap; + + &::before { + content: ''; + block-size: 10px; + inline-size: 10px; + border-radius: 50%; + display: inline-block; + } +} + +$status-colors: ( + public #64baba, + private #cc96fb, + unverified #ea835e, + ai-verified #1cabcf, + pro-verified #41a269, +); + +@each $status, $color in $status-colors { + .cloud-snippet-status-#{$status}::before { + background: $color; + } +} + +.bundle-share-code-form, +.cloud-search-form { + display: flex; + gap: 8px; + margin-block: 0 47px; + block-size: 54px; + + > select { + flex: 0 0 250px; + } + + .button { + flex: 0 0 165px; + } + + .cloud-search-query { + flex: 1; + position: relative; + + input { + inline-size: 100%; + block-size: 100%; + } + + > .components-spinner { + position: absolute; + inset-inline-end: 1.5em; + inset-block-start: 25%; + } + } + + button[type='submit'] { + display: inline-flex; + align-items: center; + justify-content: center; + + .components-spinner { + margin: 0; + } + } +} + +.cloud-search { + @include banners.banners; + + padding-block-start: 10px; + + .banner { + justify-content: center; + } + + .cloud-search-filters select { + inline-size: 245px; + } +} + +.bundle-share-code-form { + max-inline-size: 60%; +} + +.cloud-snippet-action-buttons { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; + + // Keep the Download / Edit / Pro Only button a consistent width so + // rows line up regardless of which state the snippet is in. + .button-primary, + .cloud-pro-button, + .button:not(.button-primary) { + inline-size: 103px; + min-inline-size: 103px; + text-align: center; + justify-content: center; + } +} + +.cloud-snippets-table { + // Body rows inherit the shared table-cell padding so descriptions can grow + // naturally instead of being constrained to a fixed row height. + tbody td { + vertical-align: middle; + } + + .check-column { + vertical-align: middle; + + input[type='checkbox'] { + @include checkbox.canonical; + } + } + + .column-name { + inline-size: 22%; + } + + .column-type { + inline-size: 90px; + } + + .column-status { + inline-size: 120px; + } + + .column-actions { + inline-size: 234px; + } + + .cloud-table-name-button { + background: none; + border: 0; + padding: 0; + margin: 0; + font-size: inherit; + font-weight: 600; + color: theme.$accent; + text-decoration: none; + cursor: pointer; + text-align: start; + + &:hover, + &:focus-visible { + color: #135e96; + text-decoration: underline; + } + } + + // Clamp descriptions so every row stays the same height. + .cloud-table-description { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow: hidden; + } +} + +// Community bundles tab shares the same top padding as .cloud-search so the +// two tabs' descriptions and inputs align (scoped so the My Library bundles +// tab is unaffected). +.community-bundles { + padding-block-start: 10px; +} diff --git a/src/css/manage/_cloud.scss b/src/css/manage/_cloud.scss deleted file mode 100644 index a24a6a8b1..000000000 --- a/src/css/manage/_cloud.scss +++ /dev/null @@ -1,383 +0,0 @@ -@use '../common/theme'; -@use '../common/tooltips'; - -.cloud-legend-tooltip { - h3 { - font-size: 16px; - color: #fff; - text-align: center; - } - - td { - vertical-align: top; - } -} - -.cloud-search-info { - text-align: justify; - - small { - color: #646970; - float: inline-end; - } -} - -.thickbox-code-viewer { - min-block-size: 250px; - background-color: hsl(0deg 0% 96.5%); - padding: 20px; - border-radius: 10px; -} - -#snippet-code-thickbox { - display: block; - inline-size: 100%; -} - -.no-results { - font-size: 15px; -} - -.dashicons.cloud-synced { - color: theme.$cloud; -} - -.dashicons.cloud-downloaded { - color: #e91e63; -} - -.dashicons.cloud-not-downloaded { - color: theme.$outline; -} - -.dashicons.cloud-update { - color: theme.$cloud-update; -} - -.cloud_update a { - color: theme.$cloud-update !important; - text-decoration: underline; -} - -.updated.column-updated span { - text-decoration: dotted underline; -} - -td.column-name { - .cloud-icon { - margin-inline-end: 3px; - } -} - -.cloud-snippet-download { - color: theme.$accent !important; -} - -.cloud-snippet-downloaded, .cloud-snippet-preview-style { - color: #616161 !important; -} - -.cloud-snippet-update { - color: theme.$cloud-update !important; -} - -#cloud-search-form { - margin-block: 30px; - text-align: center; -} - -.input-group { - position: relative; - display: flex; - flex-wrap: wrap; - align-items: stretch; - max-inline-size: 900px; - margin-block: 0; - margin-inline: auto; -} - -#cloud_search { - display: block; - padding-block: 0.375rem; - padding-inline: 0.75rem; - font-size: 1rem; - color: #495057; - background-clip: padding-box; - border-radius: 0; - transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out; - position: relative; - flex: 1 1 auto; - inline-size: 1%; - margin-block-end: 0; - - &:focus { - outline: 0; - border: 1px solid #8c8f94; - box-shadow: none; - } -} - -#cloud-select-prepend { - margin-inline-end: -3px; - border-start-end-radius: 0; - border-end-end-radius: 0; - position: relative; - z-index: 2; - color: theme.$accent; - border-color: theme.$accent; - background-color: #f6f7f7; - - &:hover { - background-color: #f0f0f1; - border-color: #0a4b78; - color: #0a4b78; - } -} - -#cloud-search-submit { - padding-block: 0; - padding-inline: 15px; - margin-inline-start: -3px; - display: flex; - justify-content: center; - align-items: center; -} - -.cloud-search { - margin-inline-start: 5px; -} - -.bundle-group { - margin-block-start: 10px; - justify-content: space-between; - display: flex; - gap: 5px; - flex-wrap: nowrap; -} - -#cloud-bundles { - color: #495057; - display: flex; - flex: 1 1 auto; - font-size: 1rem; - padding-block: 0.375rem; - padding-inline: 0.75rem; - position: relative; - inline-size: 50%; -} - -#cloud-bundle-show { - inline-size: 10%; -} - -#cloud-bundle-run { - inline-size: 15%; -} - -#bundle_share_name { - color: #495057; - font-size: 1rem; - inline-size: 25%; -} - -.heading-box { - max-inline-size: 900px; - margin: auto; - padding-block-end: 1rem; -} - -.cloud-search-heading { - font-size: 23px; - font-weight: 400; - padding-block: 9px 4px; - padding-inline: 0; - line-height: 1.3; - text-align: center; - margin-block-end: 0; -} - -.cloud-badge.ai-icon { - font-size: 12px; - padding: 3px; - margin-inline-start: 5px; - color: #b22222; -} - -.cloud-search-card-bottom { - min-block-size: 40px; -} - -#cloud-search-results .cloud-snippets #the-list { - display: flex; - flex-wrap: wrap; - justify-content: center; - - .plugin-card { - display: flex; - flex-direction: column; - justify-content: space-between; - - .cloud-meta-row { - display: flex; - justify-content: space-between; - align-items: center; - flex-grow: 1; - } - - .column-name { - display: flex; - justify-content: space-between; - - h3 { - display: inline-flex; - flex-shrink: 1; - } - - .title-icon { - block-size: 90px; - margin-block-start: -7px; - } - } - - .column-votes { - display: inline-flex; - gap: 3px; - - &:hover { - .thumbs-up { - stroke: #059669; - fill: #6ee7b7; - animation: thumb 1s ease-in-out infinite; - } - } - - .num-votes { - display: inline-flex; - align-items: flex-end; - } - } - } - - .action-buttons { - margin: 0; - align-items: flex-end; - - .button { - inline-size: 100%; - text-align: center; - } - } -} - -.cloud-snippets #the-list { - .column-download { - display: flex; - flex-flow: column; - text-align: end; - - li { - list-style: none; - } - } -} - -.cloud-connect-wrap { - display: flex; - justify-content: space-between; - align-items: center; - max-block-size: 35px; - margin-block: 0; - margin-inline: 3px; - float: inline-end; - gap: 5px; -} - -.cloud-table > tbody > tr { - block-size: 80px; - box-shadow: inset 0 -1px 0 rgb(0 0 0 / 10%); -} - -.cloud-table > tbody > tr > td { - max-inline-size: 250px; -} - -.cloud-table tbody .active-snippet .column-name { - font-weight: 400; - max-inline-size: 400px; - white-space: normal !important; -} - -.cloud-table td .no-results { - margin-block-start: 15px; - color: #e32121; - text-align: center; -} - -.cloud-status-dot { - block-size: 10px; - inline-size: 10px; - background-color: #ce0000; - border-radius: 50%; - - - .cloud-connect-active & { - background-color: #25a349; - } -} - -.cloud-connect-text { - color: #ce0000; - - .cloud-connect-active & { - color: #2e7d32; - } -} - -.thumbs-up { - inline-size: 1.25rem; /* 20px */ - block-size: 1.25rem; /* 20px */ - transform-origin: bottom left; - - &:hover { - stroke: #059669; - fill: #6ee7b7; - } -} - -.plugin-card-bottom { - overflow: visible !important; - display: flex; - align-items: center; -} - -.beta-test-notice { - margin-block-start: 20px; -} - -.highlight-yellow { - background: #fefdba; - padding: 3px; - border-radius: 3px; -} - -@keyframes thumb { - 0% { - transform: rotate(0); - } - - 33% { - transform: rotate(7deg); - } - - 66% { - transform: rotate(-15deg); - } - - 90% { - transform: rotate(5deg); - } - - 100% { - transform: rotate(0); - } -} diff --git a/src/css/manage/_snippets-table.scss b/src/css/manage/_snippets-table.scss new file mode 100644 index 000000000..5a8e23f28 --- /dev/null +++ b/src/css/manage/_snippets-table.scss @@ -0,0 +1,270 @@ +@use '../common/badges'; +@use '../common/list-table'; +@use '../common/theme'; + +.column-name, +.column-type { + .dashicons { + font-size: 16px; + inline-size: 16px; + block-size: 16px; + vertical-align: middle; + } + + .dashicons-clock { + vertical-align: middle; + } +} + +// `.wp-list-table` prefix keeps this above WP 7.0's 40px control height so the boxed +// priority field stays 30px tall (below the 38px control token, which would stretch the +// table rows) and vertically aligned with the rest of the row. +.wp-list-table .priority-column input { + appearance: none; + background: #fff; + border: 1px solid theme.$control-border; + border-radius: 5px; + box-shadow: none; + box-sizing: border-box; + min-block-size: 30px; + block-size: 30px; + inline-size: 4em; + + // Pin the box model so padding/margins/line-height match WP 6.9 regardless of WP 7.0's + // taller default control metrics; this keeps the value aligned in both the resting and + // hovered (native spinner) states. + padding-block: 0; + padding-inline: 8px; + margin-block: 0; + margin-inline: 1px; + line-height: 2; + color: #2c3337; + text-align: center; + + &:disabled { + color: inherit; + } + + &:hover, &:focus, &:active { + color: #000; + background-color: rgb(0 0 0 / 10%); + border-radius: 6px; + appearance: unset; + + &:disabled { + color: inherit; + background-color: transparent; + } + } +} + +.wp-list-table .is-expanded td.column-activate.activate { + /* fix for mobile layout */ + display: table-cell !important; +} + +.snippets-card-grid { + // Three equal-height cards per row; descriptions are clamped so height + // stays uniform regardless of text length. The compound selector wins + // over the base .code-snippets-cards auto-fill column rule, which is + // emitted later in the compiled stylesheet. + // minmax(0, 1fr) lets tracks shrink below their content's min-content + // width (wide footer button rows), which plain 1fr tracks cannot, + // preventing the grid from overflowing the page horizontally. + &.code-snippets-cards { + grid-template-columns: repeat(3, minmax(0, 1fr)); + + @media (width <= 1100px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + @media (width <= 782px) { + grid-template-columns: minmax(0, 1fr); + } + } + + .code-snippets-card { + min-inline-size: 0; + } + + // Reveal the bulk-select checkbox on hover or keyboard focus, and keep + // every checkbox visible while any card in the grid is selected so an + // in-progress selection is never hidden. + .snippet-card-select { + opacity: 0; + transition: opacity 0.15s ease; + + @media (prefers-reduced-motion: reduce) { + transition-duration: 0.01s; + } + } + + .snippet-card-select:checked, + &.has-selection .snippet-card-select, + .code-snippets-card:hover .snippet-card-select, + .code-snippets-card:focus-within .snippet-card-select { + opacity: 1; + } + + // Card body bands: header row, meta row, then a separated description. + .card-inner { + display: flex; + flex-flow: column; + gap: 16px; + } + + .snippet-card-header { + display: flex; + align-items: center; + gap: 10px; + + // Keep header content clear of the absolutely-positioned selection checkbox. + padding-inline-end: 32px; + + > a { + @include badges.badge-link; + } + + h3 { + margin: 0; + font-size: 18px; + font-weight: 700; + line-height: 1.5; + + // Let the heading shrink inside the flex row so long names + // truncate instead of pushing the header icons out of the card. + min-inline-size: 0; + + .snippet-name { + display: block; + color: theme.$accent; + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + .extra-icons:empty { + display: none; + } + + // The accent pill styling lives in common/_switch.scss; keep the + // switch from shrinking inside the flex card header. + input[type='checkbox'].switch { + flex-shrink: 0; + } + } + + .snippet-card-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 16px; + font-size: 14px; + line-height: 1.5; + + .snippet-card-tags-label { + color: #646970; + } + + .snippet-card-tags a { + color: theme.$accent; + text-decoration: none; + } + + .snippet-card-modified { + color: var(--cs-color-text-muted); + } + + &.has-tags .snippet-card-modified { + margin-inline-start: auto; + } + } + + .snippet-description-content { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow: hidden; + font-size: 14px; + line-height: 1.5; + color: #646970; + border-block-start: 1px solid #dcdcde; + padding-block-start: 16px; + } + + // Align the selection checkbox with the taller card header row. + .snippet-card-corner { + inset-block-start: 28px; + } + + .code-snippets-card footer { + .button { + font-size: 14px; + line-height: 2.5714; + border-radius: 5px; + } + + .button:not(.button-primary) { + background: transparent; + } + } + + .kebab-menu-priority { + justify-content: space-between; + + // Balances the shorter input so the row matches the height of the menu + // items above and below it. + padding-block: 5px; + + input.snippet-priority { + inline-size: 52px; + block-size: 30px; + + // WordPress sizes number inputs with a min-height, which would + // otherwise win over the height set here. + min-block-size: 0; + box-sizing: border-box; + margin: 0; + padding-block: 0; + padding-inline: 4px; + border: 1px solid #e2e2e4; + border-radius: 4px; + text-align: center; + color: #2c3337; + } + } +} + +// Shared table-navigation layout for both snippet views and both toolbars: +// bulk actions, select all, tag filter and search on the left, with the item +// count, pagination and view toggle pinned to the far end of the row. The +// bottom toolbar repeats only the bulk actions and pagination group. +.snippets-list-view .tablenav { + block-size: auto; + margin-block-end: 16px; +} + +.snippets-search-subtitle { + margin-block: 0 12px; + font-size: 14px; + font-style: italic; + color: #646970; + text-align: center; + display: flex; + align-items: center; + justify-content: center; + gap: 1em; +} + +// Keep every table row the same height by clamping the description cell +// to two lines, matching the two-line name + row-actions column. +.snippets-list-view .wp-list-table td.column-desc .snippet-description-content { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow: hidden; +} diff --git a/src/css/menu.scss b/src/css/menu.scss index 8c3717055..c4a9ec286 100644 --- a/src/css/menu.scss +++ b/src/css/menu.scss @@ -1,3 +1,4 @@ + #adminmenu { .toplevel_page_snippets div.wp-menu-image::before { content: ''; @@ -11,6 +12,16 @@ color: #fff; background-color: #d46f4d; border: none; + + // WP 7.0 restyles `.button`/`.button-primary` with wider padding, a 2px radius and a + // 40px min-height; pin these (independent of the plugin control tokens, which are + // sized for page content rather than the admin menu) so the pill keeps its compact + // dimensions and stays aligned with the surrounding submenu items across WP versions. + border-radius: 3px; + padding-block: 0; + padding-inline: 10px; + min-block-size: 30px; + block-size: auto; text-align: center; font-weight: bold; transition: background-color .1s linear; @@ -20,12 +31,23 @@ justify-content: center; align-items: center; + @media (prefers-reduced-motion: reduce) { + transition-duration: 0.01s; + } + &:hover { - background-color: #08c5d1; + background-color: #0ca0a9; } .dashicons { vertical-align: text-bottom; + color: inherit; + font-size: 16px; + padding-block-start: 2px; + + // WP 7.0's taller button line-height (≈38px) leaks onto the icon and pushes it out + // of alignment; reset it so the glyph stays centred within the flex pill. + line-height: 1; } } diff --git a/src/css/settings.scss b/src/css/settings.scss index e2a040ae4..d8d931e19 100644 --- a/src/css/settings.scss +++ b/src/css/settings.scss @@ -1,10 +1,42 @@ +@use 'common/checkbox'; @use 'common/codemirror'; +@use 'common/subnav'; +@use 'common/theme'; +@use 'common/toolbar'; +@use 'common/wp-admin'; +@use 'common/page-header'; $sections: general, editor, debug, version-switch; +.wrap.code-snippets-settings { + margin-block-start: 0; +} + +.settings-section input[type='checkbox']:not(.switch) { + @include checkbox.canonical; + + margin-inline-end: 8px; +} + +.snippets-page-header h1 { + color: #2c3337; +} + p.submit { display: flex; - justify-content: space-between; + justify-content: flex-start; + gap: 14px; + + .button-secondary { + background: transparent; + } +} + +.settings-section .form-table th { + inline-size: 261px; + font-size: 14px; + font-weight: 700; + color: #2c3337; } .settings-section, @@ -16,7 +48,7 @@ p.submit { margin-block-end: 1em; } -input[type="number"] { +input[type='number'] { inline-size: 4em; } @@ -73,7 +105,7 @@ body.js { display: inline-block; padding-inline-end: 2em; line-height: 2; - color: #aaa; + color: #646970; } .license-status-valid { @@ -84,7 +116,7 @@ body.js { color: #dc3232; } -.wrap[data-active-tab="license"] .submit { +.wrap[data-active-tab='license'] .submit { display: none; } @@ -120,8 +152,8 @@ body.js { } .refresh-success { - background: #2271b1; - color: #ffeb3b; + background: #2e7d4f; + color: #fff; } .cloud-settings tbody tr:nth-child(n+5) { @@ -138,7 +170,7 @@ body.js { background: #f0f6fc; padding: 2px 8px; border-radius: 3px; - border: 1px solid #c3c4c7; + border: 1px solid theme.$control-border; } #target_version { @@ -155,7 +187,7 @@ body.js { border-color: #dcdcde !important; } } - + // Warning box styling #version-switch-warning { margin-block-start: 20px !important; @@ -163,11 +195,11 @@ body.js { border-inline-start: 4px solid #dba617; background: #fff8e5; border-radius: 4px; - + p { margin: 0; color: #8f6914; - + strong { color: #8f6914; } @@ -176,7 +208,7 @@ body.js { #version-switch-result { margin-block-start: 12px; - + &.notice { padding: 8px 12px; border-radius: 4px; @@ -211,3 +243,77 @@ body.js { } } } + +#settings-sections-tabs { + margin: 0; + padding: 0; + + ul { + display: flex; + align-items: stretch; + margin: 0; + padding: 0; + list-style: none; + } + + .snippet-type-link { + display: flex; + align-items: center; + min-block-size: 52px; + margin: 0; + padding-inline: 24px; + color: #646970; + font-size: 14px; + font-weight: 600; + line-height: 1.5; + border: 0; + border-inline-end: 1px solid #e2e2e4; + background: transparent; + + &:hover, + &:focus { + color: #2c3337; + background: #f6f7f7; + } + + &.active-type, + &.active-type:hover, + &.active-type:focus { + position: relative; + z-index: 1; + margin-block-end: -1px; + color: theme.$accent; + background: #f0f0f1; + border-color: #f0f0f1; + } + } +} + +@media (width <= 782px) { + body.js { + .settings-type-nav-wrapper { + display: none; + } + + .settings-section-title { + position: static; + inline-size: auto; + block-size: auto; + margin: 0 0 16px; + overflow: visible; + clip: auto; + clip-path: none; + font-size: 20px; + line-height: 1.3; + } + + .settings-section { + display: block !important; + margin-block-end: 40px; + } + + .wrap[data-active-tab='license'] .submit { + display: flex; + } + } +} diff --git a/src/css/welcome.scss b/src/css/welcome.scss index 5ebfe850d..591d3a6e8 100644 --- a/src/css/welcome.scss +++ b/src/css/welcome.scss @@ -1,369 +1,213 @@ @use 'sass:color'; @use 'common/theme'; @use 'common/badges'; +@use 'common/toolbar'; +@use 'common/wp-admin'; $breakpoint: 1060px; -.csp-welcome-wrap { - padding: 25px; - - h1, h2, h3 { - font-weight: 700; - margin-block: 10px; - - .dashicons { - font-size: 90%; - line-height: inherit; - inline-size: auto; - } - } - +.code-snippets-welcome { h1 { - font-size: 1.6rem; + font-size: 32px; + font-weight: 510; + line-height: 1.25; + color: #2c3337; + padding: 0; + margin-block: 50px 1rem; } +} - h2 { - font-size: 1.4rem; - } +.code-snippets-updates { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 20px; - .dashicons-external { - float: inline-end; - color: #666; + > * { + background: #fff; + border-radius: 8px; + padding: 32px; } -} - -.csp-welcome-header { - display: flex; - flex-flow: row wrap; - justify-content: space-between; - align-items: center; - header { + .code-snippets-header-wrapper { display: flex; - flex-direction: row; + flex-flow: row; + justify-content: space-between; align-items: center; - gap: 10px; - - h1 { - font-size: 1.4rem; - font-weight: bold; - line-height: 1; - margin: 0; - - span { - text-decoration: underline theme.$primary wavy 3px; - text-decoration-skip-ink: none; - text-underline-offset: 11px; - text-transform: capitalize; - } - } + border-block-end: 1px solid #e2e2e4; + margin-block-end: 20px; + padding-block-end: 20px; } -} - -.csp-welcome-header nav { - column-gap: 15px; - ul { - display: flex; - flex-flow: row wrap; - justify-content: space-evenly; + h2 { + font-size: 18px; margin: 0; } +} - li { - margin-block-end: 0; - } - - li a { - margin-block: 10px; - align-items: center; - border-width: 1px; - border-style: solid; - color: white; - cursor: pointer; - display: flex; - font-weight: 400; - gap: 3px; - text-decoration: none; - transition: all .1s ease-in-out; - border-radius: 3px; - padding: 8px; - - &:hover { - background: transparent; - } - - .dashicons, svg { - text-decoration: none; - margin-block-start: -1px; - margin-inline-start: 3px; - } - - svg { - fill: #fff; - inline-size: 20px; - block-size: 20px; - font-size: 20px; - vertical-align: top; - } - - &:hover svg { - fill: currentcolor; - } - } - - $link-colors: ( - pro: theme.$secondary, - cloud: #08c5d1, - resources: #424242, - discord: theme.$brand-discord, - facebook: theme.$brand-facebook - ); +.code-snippets-hero { + display: flex; + flex-flow: column; - @each $link-name, $color in $link-colors { - .csp-link-#{$link-name} { - background: $color; - border-color: $color; + figure { + margin: 1em 0 0; + overflow: hidden; + border-radius: 0.5rem; + position: relative; + block-size: auto; + background: #efefef; + flex: 1; + text-align: center; - &:hover { - color: $color; - } + img { + inline-size: 100%; + block-size: 100%; + overflow: hidden; + object-fit: cover; } } } -.csp-cards { - display: grid; - grid-auto-rows: 1fr; - grid-template-columns: repeat(4, 1fr); - gap: 40px 15px; - - @media (width <= $breakpoint) { - grid-template-columns: 1fr !important; - } -} - -.csp-card { - border: 1px solid theme.$outline; - background: white; - border-radius: 10px; +.code-snippets-partners, +.code-snippets-articles { display: flex; - flex-flow: column; -} + flex-direction: row; + gap: 16px; + margin: 0; -a.csp-card { - text-decoration: none; - - &:hover { - background: color.adjust(theme.$primary, $lightness: 55%); - transition: .5s background-color; - box-shadow: 0 1px 1px rgb(255 255 255 / 50%); + figure { + margin: 0; + padding: 0; - .dashicons-external { - color: #000; + img { + inline-size: 100%; + block-size: 220px; + overflow: hidden; + object-fit: cover; } } } -.csp-section-changes { - border: 1px solid theme.$outline; - border-inline-start: 0; - border-inline-end: 0; - padding-block: 40px 50px; - padding-inline: 0; +.code-snippets-card { + flex: 1; + background: #fff; + border-radius: 8px; display: flex; - flex-direction: column; - row-gap: 20px; - margin-block-start: 30px; - - .csp-cards { - grid-template-columns: 2fr 1fr; - gap: 20px; - } - - .csp-card { - padding: 20px; - box-shadow: 0 1px 1px rgb(0 0 0 / 5%); + margin: 0; + flex-flow: column; + color: #2c3337; - h2 { - color: theme.$primary; - } + figure img { + border-start-start-radius: 8px; + border-start-end-radius: 8px; } +} - .csp-changelog-wrapper { - overflow-y: scroll; +.code-snippets-partners { + .code-snippets-header-wrapper { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: 24px; } +} - .csp-section-changelog { - font-size: 0.9rem; - line-height: 1.5; - color: #333; - block-size: 400px; - - h3 { - float: inline-end; - color: #666; - } - - h4 { - margin-block: 30px 10px; - margin-inline: 0; - } - - ul { - margin-block-start: 5px; - } - - li { - display: grid; - grid-template-columns: 40px 1fr; - grid-template-rows: 1fr; - align-items: baseline; - gap: 7px; - } - - li .badge { - text-align: center; - } - - > article::after { - border-block-end: 1px solid #666; - content: ' '; - display: block; - inline-size: 50%; - margin-block: 3em 0; - margin-inline: auto; - } - - > article:last-child { - padding-block-end: 1px; - - &::after { - border: 0; - } - } +.code-snippets-articles { + .code-snippets-header-wrapper { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 24px; + gap: 14px; + block-size: 100%; } - figure { - margin-block: 1em 0; - margin-inline: 0; - overflow: hidden; - border-radius: 0.5rem; - border: 1px solid grey; - position: relative; - block-size: auto; - background: #646970; - - img { - inline-size: 100%; - block-size: 100%; - overflow: hidden; - object-fit: cover; - } + .button { + margin-block-start: auto; } - .dashicons-lightbulb { - color: #f1c40f; + h3 { + font-size: 18px; + margin: 0; } - .dashicons-chart-line { - color: #85144b; + p { + margin: 0; } - .dashicons-buddicons-replies { - color: #3d9970; + .item-category { + color: white; + background: color.adjust(theme.$secondary, $lightness: -15%); + display: block; + font-size: 12px; + letter-spacing: 1px; + margin-block: 0; + text-transform: uppercase; + inline-size: fit-content; + padding: 3px 10px; + border-radius: 3px; + font-weight: bold; } } -.csp-section-links { - padding-block: 40px 50px; - padding-inline: 0; - - .csp-card { - margin-block-start: 20px; - justify-content: flex-start; - color: black; - position: relative; - overflow: hidden; - row-gap: 10px; - padding: 1rem; - inline-size: 85%; - - header { - flex: 1; - } - - figure { - margin-block: 1em 0; - margin-inline: 0; - - img { - border-radius: 5px; - inline-size: 100%; - block-size: 100%; - max-block-size: 300px; - overflow: hidden; - object-fit: cover; - } - } +.code-snippets-changelog-entries { + font-size: 0.9rem; + line-height: 1.5; + overflow-y: scroll; + max-block-size: 500px; + color: #2c3337; + padding-inline-end: 1em; - .csp-card-item-category { - color: white; - background: theme.$secondary; - display: block; - font-size: .9rem; - letter-spacing: 1px; - margin-block: 0; - text-transform: uppercase; - inline-size: fit-content; - padding-block: 5px; - padding-inline: 15px; - border-radius: 50px; - } + h3 { + font-size: 16px; + display: flex; - h3 { - font-size: 1.7rem; - color: theme.$primary; - line-height: normal; + &:not(:first-of-type) { + margin-block-start: 100px; } - .csp-card-item-description { - color: #51525c; - font-size: 1rem; - font-weight: 300; + span { + font-size: 14px; + font-weight: normal; + margin-inline-start: auto; } + } - footer { - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - } + h4 { + font-size: 14px; + margin: 1em 0; } - &.csp-section-partners { - border-block-start: 1px solid theme.$outline; + ul { + margin-block-start: 5px; + } - header { - display: flex; - flex-direction: row-reverse; - justify-content: space-between; - align-items: center; - } + li { + display: grid; + grid-template-columns: 40px 1fr; + grid-template-rows: 1fr; + align-items: baseline; + gap: 10px; } - &.csp-section-articles { - h2 { - font-size: 1.1rem; - } + $icon-colors: ( + lightbulb #f1c40f, + chart-line #85144b, + buddicons-replies #3d9970, + remove #ffbf00, + trash #c0c0c0, + shield #0074d9, + open-folder #5d2f27 + ); - figure img { - aspect-ratio: 1; + @each $icon, $color in $icon-colors { + .dashicons-#{$icon} { + color: $color; } } } -.csp-loading-spinner { +.code-snippets-loading-spinner { block-size: 0; inline-size: 0; padding: 15px; @@ -374,6 +218,22 @@ a.csp-card { position: absolute; inset-inline-start: 47%; inset-block-start: 45%; + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + +.csp-welcome-header nav { + column-gap: 15px; + + ul { + display: flex; + flex-flow: row wrap; + justify-content: space-evenly; + gap: 5px; + margin: 0; + } } @keyframes loading-rotate { diff --git a/src/js/components/ConditionModal/ConditionModalButton.tsx b/src/js/components/EditMenu/ConditionModal/ConditionModalButton.tsx similarity index 62% rename from src/js/components/ConditionModal/ConditionModalButton.tsx rename to src/js/components/EditMenu/ConditionModal/ConditionModalButton.tsx index 270d3e38f..38c3faab9 100644 --- a/src/js/components/ConditionModal/ConditionModalButton.tsx +++ b/src/js/components/EditMenu/ConditionModal/ConditionModalButton.tsx @@ -1,11 +1,11 @@ import React from 'react' import classnames from 'classnames' import { __ } from '@wordpress/i18n' -import { isLicensed } from '../../utils/screen' -import { isCondition } from '../../utils/snippets/snippets' -import { Badge } from '../common/Badge' -import { Button } from '../common/Button' -import { useSnippetForm } from '../../hooks/useSnippetForm' +import { isLicensed } from '../../../utils/screen' +import { isCondition } from '../../../utils/snippets/snippets' +import { Badge } from '../../common/Badge' +import { Button } from '../../common/Button' +import { useSnippetForm } from '../SnippetForm/WithSnippetFormContext' import type { Dispatch, SetStateAction } from 'react' export interface ConditionModalButtonProps { @@ -18,14 +18,16 @@ export const ConditionModalButton: React.FC = ({ setI const hasCondition = 0 !== snippet.conditionId return ( -
+
{isCondition(snippet) ? null : <> -

- {__('Conditions', 'code-snippets')} - {__('beta', 'code-snippets')} +
+ {__('Conditions', 'code-snippets')} {!isLicensed() && {__('Pro', 'code-snippets')}} -

+
+ + +
)}

@@ -50,7 +69,8 @@ export const EditorSidebar: React.FC = ({ setIsUpgradeDialog {isWorking ? : ''}

- + + ) } diff --git a/src/js/components/EditorSidebar/actions/ExportButtons.tsx b/src/js/components/EditMenu/EditorSidebar/actions/ExportButtons.tsx similarity index 57% rename from src/js/components/EditorSidebar/actions/ExportButtons.tsx rename to src/js/components/EditMenu/EditorSidebar/actions/ExportButtons.tsx index d4b08d818..82d84aa71 100644 --- a/src/js/components/EditorSidebar/actions/ExportButtons.tsx +++ b/src/js/components/EditMenu/EditorSidebar/actions/ExportButtons.tsx @@ -1,11 +1,12 @@ import React from 'react' import { __ } from '@wordpress/i18n' -import { useRestAPI } from '../../../hooks/useRestAPI' -import { Button } from '../../common/Button' -import { downloadSnippetExportFile } from '../../../utils/files' -import { useSnippetForm } from '../../../hooks/useSnippetForm' -import type { Snippet } from '../../../types/Snippet' -import type { SnippetsExport } from '../../../types/schema/SnippetsExport' +import { useSnippetsAPI } from '../../../../hooks/useSnippetsAPI' +import { getSnippetType } from '../../../../utils/snippets/snippets' +import { useSnippetForm } from '../../SnippetForm/WithSnippetFormContext' +import { downloadSnippetExportFile } from '../../../../utils/files' +import { Button } from '../../../common/Button' +import type { SnippetsExport } from '../../../../types/schema/SnippetsExport' +import type { Snippet } from '../../../../types/Snippet' interface ExportButtonProps { name: string @@ -34,23 +35,23 @@ const ExportButton: React.FC = ({ name, label, makeRequest }) } export const ExportButtons: React.FC = () => { - const { snippetsAPI } = useRestAPI() + const api = useSnippetsAPI() + const { snippet } = useSnippetForm() return (
- {window.CODE_SNIPPETS_EDIT?.enableDownloads - ? - : null} + label={__('Download Code', 'code-snippets')} + makeRequest={api.exportCode} + />)}
) } diff --git a/src/js/components/EditorSidebar/actions/ShortcodeInfo.tsx b/src/js/components/EditMenu/EditorSidebar/actions/ShortcodeInfo.tsx similarity index 86% rename from src/js/components/EditorSidebar/actions/ShortcodeInfo.tsx rename to src/js/components/EditMenu/EditorSidebar/actions/ShortcodeInfo.tsx index c5128ada0..5bf243390 100644 --- a/src/js/components/EditorSidebar/actions/ShortcodeInfo.tsx +++ b/src/js/components/EditMenu/EditorSidebar/actions/ShortcodeInfo.tsx @@ -1,10 +1,10 @@ import React, { useState } from 'react' import { CheckboxControl, ExternalLink, Modal } from '@wordpress/components' import { __ } from '@wordpress/i18n' -import { useSnippetForm } from '../../../hooks/useSnippetForm' -import { Button } from '../../common/Button' -import { CopyToClipboardButton } from '../../common/CopyToClipboardButton' -import type { Dispatch, SetStateAction } from 'react' +import { useSnippetForm } from '../../SnippetForm/WithSnippetFormContext' +import { Button } from '../../../common/Button' +import { CopyToClipboardButton } from '../../../common/CopyToClipboardButton' +import type { Dispatch, SetStateAction} from 'react' type ShortcodeAtts = Record @@ -107,18 +107,18 @@ const ModalContent = () => {

-

-

{__('Shortcode Options', 'code-snippets')}

+

{__('Shortcode Options', 'code-snippets')}

- -

+
+ + ) } @@ -129,7 +129,8 @@ export const ShortcodeInfo: React.FC = () => { return 'content' === snippet.scope && snippet.id ?
-

{__('Shortcode', 'code-snippets')}

+ {__('Shortcode', 'code-snippets')} + diff --git a/src/js/components/EditorSidebar/actions/SubmitButtons.tsx b/src/js/components/EditMenu/EditorSidebar/actions/SubmitButtons.tsx similarity index 82% rename from src/js/components/EditorSidebar/actions/SubmitButtons.tsx rename to src/js/components/EditMenu/EditorSidebar/actions/SubmitButtons.tsx index 01167a09b..5da32c960 100644 --- a/src/js/components/EditorSidebar/actions/SubmitButtons.tsx +++ b/src/js/components/EditMenu/EditorSidebar/actions/SubmitButtons.tsx @@ -1,11 +1,11 @@ import React from 'react' import { __ } from '@wordpress/i18n' -import { SubmitSnippetAction } from '../../../hooks/useSubmitSnippet' -import { isCondition } from '../../../utils/snippets/snippets' -import { isNetworkAdmin } from '../../../utils/screen' -import { useSnippetForm } from '../../../hooks/useSnippetForm' -import { SubmitButton } from '../../common/SubmitButton' -import type { SubmitButtonProps } from '../../common/SubmitButton' +import { SubmitSnippetAction } from '../../../../hooks/useSubmitSnippet' +import { isCondition } from '../../../../utils/snippets/snippets' +import { isNetworkAdmin } from '../../../../utils/screen' +import { useSnippetForm } from '../../SnippetForm/WithSnippetFormContext' +import { SubmitButton } from '../../../common/SubmitButton' +import type { SubmitButtonProps } from '../../../common/SubmitButton' const SaveButton = (props: SubmitButtonProps) => { const { snippet } = useSnippetForm() diff --git a/src/js/components/EditMenu/EditorSidebar/controls/ActivationSwitch.tsx b/src/js/components/EditMenu/EditorSidebar/controls/ActivationSwitch.tsx new file mode 100644 index 000000000..aee563f3b --- /dev/null +++ b/src/js/components/EditMenu/EditorSidebar/controls/ActivationSwitch.tsx @@ -0,0 +1,42 @@ +import React, { useId } from 'react' +import { __ } from '@wordpress/i18n' +import { useSnippetForm } from '../../SnippetForm/WithSnippetFormContext' +import { SubmitSnippetAction, useSubmitSnippet } from '../../../../hooks/useSubmitSnippet' +import { handleUnknownError } from '../../../../utils/errors' + +export const ActivationSwitch = () => { + const { snippet, isWorking } = useSnippetForm() + const { submitSnippet } = useSubmitSnippet() + const activationSwitchId = useId() + + return ( +
+ + + + {snippet.active + ? __('Active', 'code-snippets') + : __('Inactive', 'code-snippets')} + + + { + submitSnippet( + { id: snippet.id, network: snippet.network, active: !snippet.active }, + snippet.active ? SubmitSnippetAction.SAVE_AND_DEACTIVATE : SubmitSnippetAction.SAVE_AND_ACTIVATE + ) + .then(() => undefined) + .catch(handleUnknownError) + }} + /> +
+ ) +} diff --git a/src/js/components/EditMenu/EditorSidebar/controls/LockControl.tsx b/src/js/components/EditMenu/EditorSidebar/controls/LockControl.tsx new file mode 100644 index 000000000..84df9dcf1 --- /dev/null +++ b/src/js/components/EditMenu/EditorSidebar/controls/LockControl.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import classnames from 'classnames' +import { __ } from '@wordpress/i18n' +import { useSnippetsAPI } from '../../../../hooks/useSnippetsAPI' +import { TooltipButton } from '../../../common/TooltipButton' +import { useSnippetForm } from '../../SnippetForm/WithSnippetFormContext' + +export const LockControl: React.FC = () => { + const { update } = useSnippetsAPI() + const { acceptSnippet, snippet, setSnippet, isWorking, setIsWorking, setCurrentNotice } = useSnippetForm() + + const handleToggle = () => { + setIsWorking(true) + setSnippet(previous => ({ ...previous, locked: !previous.locked })) + + update({ id: snippet.id, network: snippet.network, locked: !snippet.locked }) + .then(result => { + acceptSnippet(result) + + setCurrentNotice(['updated', result.locked + ? __('Snippet locked.', 'code-snippets') + : __('Snippet unlocked.', 'code-snippets')]) + }) + .catch(() => setCurrentNotice(['error', __('Unable to lock snippet.', 'code-snippets')])) + .finally(() => setIsWorking(false)) + } + + return ( +
+ + {snippet.locked + ? +
+ ) +} diff --git a/src/js/components/EditMenu/EditorSidebar/controls/MultisiteSharingSettings.tsx b/src/js/components/EditMenu/EditorSidebar/controls/MultisiteSharingSettings.tsx new file mode 100644 index 000000000..a56bf7933 --- /dev/null +++ b/src/js/components/EditMenu/EditorSidebar/controls/MultisiteSharingSettings.tsx @@ -0,0 +1,43 @@ +import React, { useId } from 'react' +import { __ } from '@wordpress/i18n' +import { useSnippetForm } from '../../SnippetForm/WithSnippetFormContext' +import { Tooltip } from '../../../common/Tooltip' + +export const MultisiteSharingSettings: React.FC = () => { + const { snippet, setSnippet, isReadOnly } = useSnippetForm() + const sharingId = useId() + + return ( +
+ + + + {__('Instead of running on every site, allow this snippet to be activated on individual sites on the network.', 'code-snippets')} + + + + {snippet.shared_network + ? __('Enabled', 'code-snippets') + : __('Disabled', 'code-snippets')} + + + + setSnippet(previous => ({ + ...previous, + active: false, + shared_network: event.target.checked + }))} + /> +
+ ) +} diff --git a/src/js/components/EditorSidebar/controls/PriorityInput.tsx b/src/js/components/EditMenu/EditorSidebar/controls/PriorityInput.tsx similarity index 58% rename from src/js/components/EditorSidebar/controls/PriorityInput.tsx rename to src/js/components/EditMenu/EditorSidebar/controls/PriorityInput.tsx index 2f8128df0..844716c67 100644 --- a/src/js/components/EditorSidebar/controls/PriorityInput.tsx +++ b/src/js/components/EditMenu/EditorSidebar/controls/PriorityInput.tsx @@ -1,29 +1,29 @@ -import React from 'react' +import React, { useId } from 'react' import { __ } from '@wordpress/i18n' -import { useSnippetForm } from '../../../hooks/useSnippetForm' -import { Tooltip } from '../../common/Tooltip' +import { useSnippetForm } from '../../SnippetForm/WithSnippetFormContext' +import { Tooltip } from '../../../common/Tooltip' export const PriorityInput = () => { const { snippet, isReadOnly, setSnippet } = useSnippetForm() + const priorityId = useId() return (
-

- -

+ - + {__('Snippets with a lower priority number will run before those with a higher number.', 'code-snippets')} setSnippet(previous => ({ ...previous, priority: parseInt(event.target.value, 10) diff --git a/src/js/components/EditorSidebar/controls/RTLControl.tsx b/src/js/components/EditMenu/EditorSidebar/controls/RTLControl.tsx similarity index 61% rename from src/js/components/EditorSidebar/controls/RTLControl.tsx rename to src/js/components/EditMenu/EditorSidebar/controls/RTLControl.tsx index 2dce2b256..42b8d7d03 100644 --- a/src/js/components/EditorSidebar/controls/RTLControl.tsx +++ b/src/js/components/EditMenu/EditorSidebar/controls/RTLControl.tsx @@ -1,19 +1,18 @@ -import React from 'react' +import React, { useId } from 'react' import { __ } from '@wordpress/i18n' -import { useSnippetForm } from '../../../hooks/useSnippetForm' +import { useSnippetForm } from '../../SnippetForm/WithSnippetFormContext' export const RTLControl: React.FC = () => { const { codeEditorInstance } = useSnippetForm() + const directionId = useId() return (
-

- -

+ - codeEditorInstance?.codemirror.setOption('direction', 'rtl' === event.target.value ? 'rtl' : 'ltr') }> diff --git a/src/js/components/EditorSidebar/index.ts b/src/js/components/EditMenu/EditorSidebar/index.ts similarity index 100% rename from src/js/components/EditorSidebar/index.ts rename to src/js/components/EditMenu/EditorSidebar/index.ts diff --git a/src/js/components/SnippetForm/SnippetForm.tsx b/src/js/components/EditMenu/SnippetForm/SnippetForm.tsx similarity index 58% rename from src/js/components/SnippetForm/SnippetForm.tsx rename to src/js/components/EditMenu/SnippetForm/SnippetForm.tsx index 10ac3ff68..aa9b8398e 100644 --- a/src/js/components/SnippetForm/SnippetForm.tsx +++ b/src/js/components/EditMenu/SnippetForm/SnippetForm.tsx @@ -1,25 +1,28 @@ -import React, { useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' import classnames from 'classnames' import { __ } from '@wordpress/i18n' -import { addQueryArgs } from '@wordpress/url' -import { WithRestAPIContext } from '../../hooks/useRestAPI' -import { WithSnippetsListContext, useSnippetsList } from '../../hooks/useSnippetsList' -import { SubmitSnippetAction, useSubmitSnippet } from '../../hooks/useSubmitSnippet' -import { handleUnknownError } from '../../utils/errors' -import { createSnippetObject, getSnippetType, isCondition, validateSnippet } from '../../utils/snippets/snippets' -import { WithSnippetFormContext, useSnippetForm } from '../../hooks/useSnippetForm' -import { ConfirmDialog } from '../common/ConfirmDialog' -import { UpsellDialog } from '../common/UpsellDialog' +import { WithRestAPIContext } from '../../../hooks/useRestAPI' +import { WithSnippetsAPIContext } from '../../../hooks/useSnippetsAPI' +import { WithSnippetsListContext, useSnippetsList } from '../../../hooks/useSnippetsList' +import { SubmitSnippetAction, useSubmitSnippet } from '../../../hooks/useSubmitSnippet' +import { handleUnknownError } from '../../../utils/errors' +import { createSnippetObject, getSnippetType, isCondition, validateSnippet } from '../../../utils/snippets/snippets' +import { buildUrl } from '../../../utils/urls' +import { ConfirmDialog } from '../../common/ConfirmDialog' +import { Toolbar } from '../../common/Toolbar' +import { UpsellBanner } from '../../common/UpsellBanner' +import { UpsellDialog } from '../../common/UpsellDialog' import { EditorSidebar } from '../EditorSidebar' -import { UpsellBanner } from '../common/UpsellBanner' +import { WithSnippetFormContext, useSnippetForm } from './WithSnippetFormContext' import { SnippetTypeInput } from './fields/SnippetTypeInput' import { TagsEditor } from './fields/TagsEditor' import { CodeEditor } from './fields/CodeEditor' import { DescriptionEditor } from './fields/DescriptionEditor' import { NameInput } from './fields/NameInput' +import { Notices } from './page/Notices' import { PageHeading } from './page/PageHeading' import type { PropsWithChildren } from 'react' -import type { Snippet } from '../../types/Snippet' +import type { Snippet } from '../../../types/Snippet' const editFormClassName = ({ snippet, isReadOnly, isExpanded }: { snippet: Snippet, @@ -84,16 +87,16 @@ const EditForm: React.FC = ({ children, className }) => { const [submitAction, setSubmitAction] = useState() const doSubmit = (action?: SubmitSnippetAction) => { - submitSnippet(action) + submitSnippet(snippet, action) .then(response => { if (response && 0 !== response.id && window.CODE_SNIPPETS) { - if (window.location.href.toString().includes(window.CODE_SNIPPETS.urls.addNew)) { + if (window.location.href.includes(window.CODE_SNIPPETS.urls.addNew)) { document.title = document.title - .replace(__('Add New Snippet', 'code-snippets'), __('Edit Snippet', 'code-snippets')) - .replace(__('Add New Condition', 'code-snippets'), __('Edit Condition', 'code-snippets')) + .replace(__('Create New Snippet', 'code-snippets'), __('Edit Snippet', 'code-snippets')) + .replace(__('Create New Condition', 'code-snippets'), __('Edit Condition', 'code-snippets')) - const newUrl = addQueryArgs(window.CODE_SNIPPETS.urls.edit, { id: response.id }) - window.history.pushState({}, document.title, newUrl) + const newUrl = buildUrl(window.CODE_SNIPPETS.urls.edit, { id: response.id }) + window.history.replaceState({}, document.title, newUrl) } } }) @@ -123,7 +126,9 @@ const EditForm: React.FC = ({ children, className }) => { {children} - + ) } @@ -138,27 +143,66 @@ const ConditionsEditor: React.FC = () => { : null } +const useReloadOnPopState = (isDirty: boolean) => { + const currentUrl = useRef(window.location.href) + const skipNextUnloadPrompt = useRef(false) + + useEffect(() => { + currentUrl.current = window.location.href + }) + + useEffect(() => { + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + if (skipNextUnloadPrompt.current) { + skipNextUnloadPrompt.current = false + return + } + + event.preventDefault() + // Required by Chrome and Edge versions before 119. + // eslint-disable-next-line @typescript-eslint/no-deprecated + event.returnValue = true + } + + if (isDirty) { + window.addEventListener('beforeunload', handleBeforeUnload) + } + + return () => window.removeEventListener('beforeunload', handleBeforeUnload) + }, [isDirty]) + + useEffect(() => { + const handlePopState = () => { + if (isDirty && !window.confirm( + __('You have unsaved changes. Leave this page and discard them?', 'code-snippets') + )) { + window.history.pushState({}, document.title, currentUrl.current) + return + } + + skipNextUnloadPrompt.current = isDirty + window.location.reload() + } + + window.addEventListener('popstate', handlePopState) + return () => window.removeEventListener('popstate', handlePopState) + }, [isDirty]) +} + const EditFormWrap: React.FC = () => { - const { snippet, isReadOnly } = useSnippetForm() + const { snippet, isReadOnly, isDirty } = useSnippetForm() const [isExpanded, setIsExpanded] = useState(false) const [isUpgradeDialogOpen, setIsUpgradeDialogOpen] = useState(false) - return ( -
-

- {isCondition(snippet) - ? - {__('Back to all conditions', 'code-snippets')} - - : - {__('Back to all snippets', 'code-snippets')} - } -

+ useReloadOnPopState(isDirty) + return ( + <> + -
+
@@ -166,7 +210,7 @@ const EditFormWrap: React.FC = () => { -
+
@@ -178,15 +222,20 @@ const EditFormWrap: React.FC = () => { -
+ ) } export const SnippetForm: React.FC = () => - - createSnippetObject(window.CODE_SNIPPETS_EDIT?.snippet)}> - - - + + + createSnippetObject(window.CODE_SNIPPETS_EDIT?.snippet)} + > + + + + + diff --git a/src/js/hooks/useSnippetForm.tsx b/src/js/components/EditMenu/SnippetForm/WithSnippetFormContext.tsx similarity index 53% rename from src/js/hooks/useSnippetForm.tsx rename to src/js/components/EditMenu/SnippetForm/WithSnippetFormContext.tsx index 458b9f390..a006df03f 100644 --- a/src/js/hooks/useSnippetForm.tsx +++ b/src/js/components/EditMenu/SnippetForm/WithSnippetFormContext.tsx @@ -1,18 +1,20 @@ import { isAxiosError } from 'axios' import React, { useCallback, useMemo, useState } from 'react' -import { createContextHook } from '../utils/hooks' -import { isLicensed } from '../utils/screen' -import { isProSnippet } from '../utils/snippets/snippets' +import { createContextHook } from '../../../utils/bootstrap' +import { isLicensed } from '../../../utils/screen' +import { isProSnippet } from '../../../utils/snippets/snippets' import type { Dispatch, PropsWithChildren, SetStateAction } from 'react' -import type { ScreenNotice } from '../types/ScreenNotice' -import type { Snippet } from '../types/Snippet' -import type { CodeEditorInstance } from '../types/WordPressCodeEditor' +import type { ScreenNotice } from '../../../types/ScreenNotice' +import type { Snippet } from '../../../types/Snippet' +import type { CodeEditorInstance } from '../../../types/vendor/WordPressCodeEditor' export interface SnippetFormContext { snippet: Snippet isWorking: boolean isReadOnly: boolean + isDirty: boolean setSnippet: Dispatch> + acceptSnippet: (snippet: Snippet) => void updateSnippet: Dispatch> setIsWorking: Dispatch> currentNotice: ScreenNotice | undefined @@ -22,19 +24,52 @@ export interface SnippetFormContext { setCodeEditorInstance: Dispatch> } -export const [SnippetFormContext, useSnippetForm] = createContextHook('SnippetForm') +const [Context, useSnippetForm] = createContextHook('useSnippetForm') export interface WithSnippetFormContextProps extends PropsWithChildren { initialSnippet: () => Snippet } +const getSnippetDraftState = (snippet: Snippet) => ({ + name: snippet.name, + desc: snippet.desc, + code: snippet.code, + tags: snippet.tags, + scope: snippet.scope, + priority: snippet.priority, + active: snippet.active, + locked: snippet.locked, + network: snippet.network, + sharedNetwork: snippet.shared_network, + conditionId: snippet.conditionId +}) + +const isSnippetDraftDirty = (snippet: Snippet, savedSnippet: Snippet): boolean => { + const draftState = JSON.stringify(getSnippetDraftState(snippet)) + const savedDraftState = JSON.stringify(getSnippetDraftState(savedSnippet)) + return draftState !== savedDraftState +} + export const WithSnippetFormContext: React.FC = ({ children, initialSnippet }) => { - const [snippet, setSnippet] = useState(initialSnippet) + const [initialValue] = useState(initialSnippet) + const [snippet, setSnippet] = useState(initialValue) + const [savedSnippet, setSavedSnippet] = useState(initialValue) const [isWorking, setIsWorking] = useState(false) const [currentNotice, setCurrentNotice] = useState() const [codeEditorInstance, setCodeEditorInstance] = useState() - const isReadOnly = useMemo(() => !isLicensed() && isProSnippet({ scope: snippet.scope }), [snippet.scope]) + const isReadOnly = useMemo( + () => snippet.locked || !isLicensed() && isProSnippet({ scope: snippet.scope }), + [snippet.locked, snippet.scope] + ) + const isDirty = useMemo( + () => isSnippetDraftDirty(snippet, savedSnippet), + [snippet, savedSnippet] + ) + const acceptSnippet = useCallback((value: Snippet) => { + setSnippet(value) + setSavedSnippet(value) + }, []) const handleRequestError = useCallback((error: unknown, message?: string) => { console.error('Request failed', error) @@ -55,7 +90,9 @@ export const WithSnippetFormContext: React.FC = ({ snippet, isWorking, isReadOnly, + isDirty, setSnippet, + acceptSnippet, setIsWorking, updateSnippet, currentNotice, @@ -65,5 +102,7 @@ export const WithSnippetFormContext: React.FC = ({ setCodeEditorInstance } - return {children} + return {children} } + +export { useSnippetForm } diff --git a/src/js/components/SnippetForm/fields/CodeEditor.tsx b/src/js/components/EditMenu/SnippetForm/fields/CodeEditor.tsx similarity index 54% rename from src/js/components/SnippetForm/fields/CodeEditor.tsx rename to src/js/components/EditMenu/SnippetForm/fields/CodeEditor.tsx index 076e5d0cf..7a3deadde 100644 --- a/src/js/components/SnippetForm/fields/CodeEditor.tsx +++ b/src/js/components/EditMenu/SnippetForm/fields/CodeEditor.tsx @@ -1,29 +1,63 @@ -import React, { useEffect, useRef } from 'react' +import React, { useEffect, useId, useRef } from 'react' import { __ } from '@wordpress/i18n' -import { useSubmitSnippet } from '../../../hooks/useSubmitSnippet' -import { handleUnknownError } from '../../../utils/errors' -import { isMacOS } from '../../../utils/screen' -import { useSnippetForm } from '../../../hooks/useSnippetForm' -import { Button } from '../../common/Button' -import { ExpandIcon } from '../../common/icons/ExpandIcon' -import { MinimiseIcon } from '../../common/icons/MinimiseIcon' +import { useSubmitSnippet } from '../../../../hooks/useSubmitSnippet' +import { handleUnknownError } from '../../../../utils/errors' +import { isMacOS } from '../../../../utils/screen' +import { useSnippetForm } from '../WithSnippetFormContext' +import { Button } from '../../../common/Button' +import { ExpandIcon } from '../../../common/icons/ExpandIcon' +import { MinimiseIcon } from '../../../common/icons/MinimiseIcon' import { CodeEditorShortcuts } from './CodeEditorShortcuts' import type { Dispatch, RefObject, SetStateAction } from 'react' interface EditorTextareaProps { textareaRef: RefObject + snippetCodeId: string } -const EditorTextarea: React.FC = ({ textareaRef }) => { +const useFocusEditorShortcut = ( + textareaRef: RefObject +) => { + const { codeEditorInstance } = useSnippetForm() + + useEffect(() => { + const focusEditor = () => { + if (codeEditorInstance) { + codeEditorInstance.codemirror.focus() + return + } + + textareaRef.current?.focus() + } + + window.addEventListener('code_snippets_focus_editor', focusEditor) + + return () => { + window.removeEventListener('code_snippets_focus_editor', focusEditor) + } + }, [codeEditorInstance, textareaRef]) +} + +const EditorTextarea: React.FC = ({ textareaRef, snippetCodeId }) => { + const descriptionId = useId() const { snippet, setSnippet } = useSnippetForm() return ( -
+
+

+ {__('In the editing area, the Tab key enters a tab character. To exit the code editor, press the Escape key and then the Tab key.', 'code-snippets')} +

'; + } +} diff --git a/src/php/settings/class-setting-field.php b/src/php/Settings/Setting_Field.php similarity index 81% rename from src/php/settings/class-setting-field.php rename to src/php/Settings/Setting_Field.php index e9887456d..119c3d6ed 100644 --- a/src/php/settings/class-setting-field.php +++ b/src/php/Settings/Setting_Field.php @@ -1,16 +1,9 @@ type . '_field'; + switch ( $this->type ) { + case 'callback': + if ( is_callable( $this->render_callback ) ) { + call_user_func( $this->render_callback, $this->args ); + } + break; + + case 'checkbox': + $this->render_checkbox( $this->input_name, $this->label, $this->get_saved_value() ?? false ); + break; + + case 'checkboxes': + $this->render_checkboxes_field(); + break; + + case 'text': + $this->render_text_field(); + break; + + case 'number': + $this->render_number_field(); + break; + + case 'select': + $this->render_select_field(); + break; + + case 'action': + $this->render_action_field(); + break; + + default: + // Error message, not necessary to translate. + printf( 'Cannot render a %s field.', esc_html( $this->type ) ); + return; - if ( method_exists( $this, $method_name ) ) { - call_user_func( array( $this, $method_name ) ); - } else { - // Error message, not necessary to translate. - printf( 'Cannot render a %s field.', esc_html( $this->type ) ); - return; } if ( $this->desc ) { @@ -113,33 +134,21 @@ public function render() { } } - /** - * Render a callback field. - */ - public function render_callback_field() { - if ( ! is_callable( $this->render_callback ) ) { - return; - } - - call_user_func( $this->render_callback, $this->args ); - } - /** * Render a single checkbox field. * - * @param string $input_name Input name. - * @param string $label Input label. - * @param boolean $checked Whether the checkbox should be checked. + * @param string $input_name Input name. + * @param string $label Input label. + * @param bool $checked Whether the checkbox should be checked. */ private static function render_checkbox( string $input_name, string $label, bool $checked ) { - $checkbox = sprintf( - '', + '', esc_attr( $input_name ), checked( $checked, true, false ) ); - $kses = [ + $allowed_html = [ 'input' => [ 'type' => [], 'name' => [], @@ -150,24 +159,14 @@ private static function render_checkbox( string $input_name, string $label, bool if ( $label ) { printf( '', - wp_kses( $checkbox, $kses ), + wp_kses( $checkbox, $allowed_html ), wp_kses_post( $label ) ); } else { - echo wp_kses( $checkbox, $kses ); + echo wp_kses( $checkbox, $allowed_html ); } } - /** - * Render a checkbox field for a setting - * - * @return void - * @since 2.0.0 - */ - public function render_checkbox_field() { - $this->render_checkbox( $this->input_name, $this->label, $this->get_saved_value() ?? false ); - } - /** * Render a checkbox field for a setting * @@ -246,7 +245,7 @@ private function render_select_field() { foreach ( $this->options as $option => $option_label ) { printf( - '', + '', esc_attr( $option ), selected( $option, $saved_value, false ), esc_html( $option_label ) diff --git a/src/php/Settings/Settings_Fields.php b/src/php/Settings/Settings_Fields.php new file mode 100644 index 000000000..b11b3e903 --- /dev/null +++ b/src/php/Settings/Settings_Fields.php @@ -0,0 +1,335 @@ +> + */ + private array $fields; + + /** + * The default settings values. + * + * @var array> + */ + private array $defaults; + + /** + * Constructor. + * + * Initializes the settings fields and default values. + */ + public function __construct() { + $this->init_fields(); + $this->init_defaults(); + } + + /** + * Retrieve the instance of this class. + * + * @return Settings_Fields + */ + private static function get_instance(): Settings_Fields { + if ( ! isset( self::$instance ) ) { + self::$instance = new self(); + } + + return self::$instance; + } + + /** + * Retrieve the default setting values + * + * @return array> + */ + public static function get_default_values(): array { + return self::get_instance()->defaults; + } + + /** + * Retrieve the settings fields. + * + * @return array> + */ + public static function get_field_definitions(): array { + return self::get_instance()->fields; + } + + /** + * Initialise default settings values. + * + * @return void + */ + private function init_defaults() { + $this->defaults = [ + 'general' => [ + 'activate_by_default' => true, + 'enable_tags' => true, + 'enable_description' => true, + 'visual_editor_rows' => 5, + 'list_order' => 'priority-asc', + 'disable_prism' => false, + 'hide_upgrade_menu' => false, + 'complete_uninstall' => false, + 'enable_flat_files' => false, + 'enable_admin_bar' => true, + 'admin_bar_snippet_limit' => 20, + ], + 'editor' => [ + 'indent_with_tabs' => true, + 'tab_size' => 4, + 'indent_unit' => 4, + 'font_size' => 14, + 'wrap_lines' => true, + 'code_folding' => true, + 'line_numbers' => true, + 'auto_close_brackets' => true, + 'highlight_selection_matches' => true, + 'highlight_active_line' => true, + 'keymap' => 'default', + 'theme' => 'default', + ], + 'version-switch' => [ + 'selected_version' => '', + ], + 'debug' => [ + 'enable_version_change' => false, + ], + ]; + + $this->defaults = apply_filters( 'code_snippets_settings_defaults', $this->defaults ); + } + + /** + * Initialise the settings fields values. + * + * @return void + */ + private function init_fields() { + $this->fields = []; + + $this->fields['debug'] = [ + 'database_update' => [ + 'name' => __( 'Database Table Upgrade', 'code-snippets' ), + 'type' => 'action', + 'label' => __( 'Upgrade Database Table', 'code-snippets' ), + 'desc' => __( 'Use this button to manually upgrade the Code Snippets database table. This action will only affect the snippets table and should be used only when necessary.', 'code-snippets' ), + ], + 'reset_caches' => [ + 'name' => __( 'Reset Caches', 'code-snippets' ), + 'type' => 'action', + 'desc' => __( 'Use this button to manually clear snippets caches.', 'code-snippets' ), + ], + 'enable_version_change' => [ + 'name' => __( 'Version Change', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( 'Enable the ability to switch or rollback versions of the Code Snippets core plugin.', 'code-snippets' ), + ], + ]; + + $this->fields['version-switch'] = [ + 'version_switcher' => [ + 'name' => __( 'Switch Version', 'code-snippets' ), + 'type' => 'callback', + 'render_callback' => [ '\\Code_Snippets\\Settings\\Version_Switch', 'render_version_switch_field' ], + ], + 'refresh_versions' => [ + 'name' => __( 'Refresh Versions', 'code-snippets' ), + 'type' => 'callback', + 'render_callback' => [ '\\Code_Snippets\\Settings\\Version_Switch', 'render_refresh_versions_field' ], + ], + 'version_warning' => [ + 'name' => '', + 'type' => 'callback', + 'render_callback' => [ '\\Code_Snippets\\Settings\\Version_Switch', 'render_version_switch_warning' ], + ], + ]; + + $this->fields['general'] = [ + 'activate_by_default' => [ + 'name' => __( 'Activate by Default', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( "Make the 'Save and Activate' button the default action when saving a snippet.", 'code-snippets' ), + ], + 'enable_tags' => [ + 'name' => __( 'Enable Snippet Tags', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( 'Show snippet tags on admin pages.', 'code-snippets' ), + ], + 'enable_description' => [ + 'name' => __( 'Enable Snippet Descriptions', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( 'Show snippet descriptions on admin pages.', 'code-snippets' ), + ], + 'visual_editor_rows' => [ + 'name' => __( 'Description Editor Height', 'code-snippets' ), + 'type' => 'number', + 'label' => _x( 'rows', 'unit', 'code-snippets' ), + 'min' => 0, + ], + 'list_order' => [ + 'name' => __( 'Snippets List Order', 'code-snippets' ), + 'type' => 'select', + 'desc' => __( 'Default way to order snippets on the All Snippets admin menu.', 'code-snippets' ), + 'options' => [ + 'priority-asc' => __( 'Priority', 'code-snippets' ), + 'name-asc' => __( 'Name (A-Z)', 'code-snippets' ), + 'name-desc' => __( 'Name (Z-A)', 'code-snippets' ), + 'modified-desc' => __( 'Modified (latest first)', 'code-snippets' ), + 'modified-asc' => __( 'Modified (oldest first)', 'code-snippets' ), + ], + ], + 'disable_prism' => [ + 'name' => __( 'Disable Syntax Highlighter', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( 'Disable syntax highlighting when displaying snippet code on the front-end.', 'code-snippets' ), + ], + ]; + + if ( ! code_snippets()->licensing->is_licensed() ) { + $this->fields['general']['hide_upgrade_menu'] = [ + 'name' => __( 'Hide Upgrade Notices', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( 'Hide notices inviting you to upgrade to Code Snippets Pro.', 'code-snippets' ), + ]; + } + + if ( ! is_multisite() || is_main_site() ) { + $this->fields['general']['complete_uninstall'] = [ + 'name' => __( 'Complete Uninstall', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( 'When the plugin is deleted from the Plugins menu, also delete all snippets and plugin settings.', 'code-snippets' ), + ]; + } + + $this->fields['general']['enable_admin_bar'] = [ + 'name' => __( 'Enable Admin Bar Menu', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( 'Show a Snippets menu in the admin bar for quick access to snippets.', 'code-snippets' ), + ]; + + $this->fields['general']['admin_bar_snippet_limit'] = [ + 'name' => __( 'Admin Bar Snippets Per Page', 'code-snippets' ), + 'type' => 'number', + 'desc' => __( 'Number of snippets to show in the admin bar Active/Inactive menus before paginating.', 'code-snippets' ), + 'label' => __( 'snippets', 'code-snippets' ), + 'min' => 1, + 'max' => 100, + 'show_if' => [ + 'section' => 'general', + 'field' => 'enable_admin_bar', + 'value' => true, + ], + ]; + + $this->fields['editor'] = [ + 'indent_with_tabs' => [ + 'name' => __( 'Indent With Tabs', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( 'Use hard tabs instead of spaces for indentation.', 'code-snippets' ), + 'codemirror' => 'indentWithTabs', + ], + 'tab_size' => [ + 'name' => __( 'Tab Size', 'code-snippets' ), + 'type' => 'number', + 'desc' => __( 'The width of a tab character.', 'code-snippets' ), + 'label' => _x( 'spaces', 'unit', 'code-snippets' ), + 'codemirror' => 'tabSize', + 'min' => 0, + ], + 'indent_unit' => [ + 'name' => __( 'Indent Unit', 'code-snippets' ), + 'type' => 'number', + 'desc' => __( 'The number of spaces to indent a block.', 'code-snippets' ), + 'label' => _x( 'spaces', 'unit', 'code-snippets' ), + 'codemirror' => 'indentUnit', + 'min' => 0, + ], + 'font_size' => [ + 'name' => __( 'Font Size', 'code-snippets' ), + 'type' => 'number', + 'label' => _x( 'px', 'unit', 'code-snippets' ), + 'codemirror' => 'fontSize', + 'min' => 8, + 'max' => 28, + ], + 'wrap_lines' => [ + 'name' => __( 'Wrap Lines', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( 'Soft-wrap long lines of code instead of horizontally scrolling.', 'code-snippets' ), + 'codemirror' => 'lineWrapping', + ], + 'code_folding' => [ + 'name' => __( 'Code Folding', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( 'Allow folding functions or other blocks into a single line.', 'code-snippets' ), + 'codemirror' => 'foldGutter', + ], + 'line_numbers' => [ + 'name' => __( 'Line Numbers', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( 'Show line numbers to the left of the editor.', 'code-snippets' ), + 'codemirror' => 'lineNumbers', + ], + 'auto_close_brackets' => [ + 'name' => __( 'Auto Close Brackets', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( 'Auto-close brackets and quotes when typed.', 'code-snippets' ), + 'codemirror' => 'autoCloseBrackets', + ], + 'highlight_selection_matches' => [ + 'name' => __( 'Highlight Selection Matches', 'code-snippets' ), + 'label' => __( 'Highlight all instances of a currently selected word.', 'code-snippets' ), + 'type' => 'checkbox', + 'codemirror' => 'highlightSelectionMatches', + ], + 'highlight_active_line' => [ + 'name' => __( 'Highlight Active Line', 'code-snippets' ), + 'label' => __( 'Highlight the line that is currently being edited.', 'code-snippets' ), + 'type' => 'checkbox', + 'codemirror' => 'styleActiveLine', + ], + 'keymap' => [ + 'name' => __( 'Keymap', 'code-snippets' ), + 'type' => 'select', + 'desc' => __( 'The set of keyboard shortcuts to use in the code editor.', 'code-snippets' ), + 'options' => [ + 'default' => __( 'Default', 'code-snippets' ), + 'vim' => __( 'Vim', 'code-snippets' ), + 'emacs' => __( 'Emacs', 'code-snippets' ), + 'sublime' => __( 'Sublime Text', 'code-snippets' ), + ], + 'codemirror' => 'keyMap', + ], + 'theme' => [ + 'name' => __( 'Theme', 'code-snippets' ), + 'type' => 'select', + 'options' => Editor_Preview::get_editor_theme_list(), + 'codemirror' => 'theme', + ], + ]; + + $this->fields = apply_filters( 'code_snippets_settings_fields', $this->fields ); + } +} diff --git a/src/php/Settings/Version_Switch.php b/src/php/Settings/Version_Switch.php new file mode 100644 index 000000000..e2588cfe4 --- /dev/null +++ b/src/php/Settings/Version_Switch.php @@ -0,0 +1,497 @@ + $download_url ) { + if ( 'trunk' !== $version ) { + $versions[] = [ + 'version' => $version, + 'url' => $download_url, + ]; + } + } + + // Sort versions in descending order. + usort( + $versions, + function ( $a, $b ) { + return version_compare( $b['version'], $a['version'] ); + } + ); + + // Cache for configured duration. + set_transient( self::CACHE_KEY, $versions, self::VERSION_CACHE_DURATION ); + } + + return $versions; + } + + /** + * Retrieve the current plugin version. + * + * @return string + */ + public static function get_current_version(): string { + return defined( 'CODE_SNIPPETS_VERSION' ) ? CODE_SNIPPETS_VERSION : '0.0.0'; + } + + /** + * Determine if a version switch is currently taking place. + * + * @return bool + */ + public static function is_version_switch_in_progress(): bool { + return get_transient( self::PROGRESS_KEY ) !== false; + } + + /** + * Purge transient data associated with this class. + * + * @return void + */ + public static function clear_version_caches(): void { + delete_transient( self::CACHE_KEY ); + delete_transient( self::PROGRESS_KEY ); + } + + /** + * Validate that a target version is valid. + * + * @param string $target_version Target version for switching. + * @param array $available_versions List of available versions. + * + * @return array + */ + public static function validate_target_version( string $target_version, array $available_versions ): array { + if ( empty( $target_version ) ) { + return [ + 'success' => false, + 'message' => __( 'No target version specified.', 'code-snippets' ), + 'download_url' => '', + ]; + } + + foreach ( $available_versions as $version_info ) { + if ( $version_info['version'] === $target_version ) { + return [ + 'success' => true, + 'message' => '', + 'download_url' => $version_info['url'], + ]; + } + } + + return [ + 'success' => false, + 'message' => __( 'Invalid version specified.', 'code-snippets' ), + 'download_url' => '', + ]; + } + + /** + * Create a response indicating an error occurred. + * + * @param string $message Error message. + * @param string $technical_details Additional details. + * + * @return array + * + * phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_error_log + */ + public static function create_error_response( string $message, string $technical_details = '' ): array { + if ( ! empty( $technical_details ) ) { + if ( function_exists( 'error_log' ) ) { + error_log( sprintf( 'Code Snippets version switch error: %s. Details: %s', $message, $technical_details ) ); + } + } + + return [ + 'success' => false, + 'message' => $message, + ]; + } + + /** + * Install a plugin version from a URL. + * + * @param string $download_url Download URL. + * + * @return array|bool|WP_Error + */ + public static function perform_version_install( string $download_url ) { + if ( ! function_exists( 'wp_update_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/update.php'; + } + if ( ! function_exists( 'show_message' ) ) { + require_once ABSPATH . 'wp-admin/includes/misc.php'; + } + if ( ! class_exists( 'Plugin_Upgrader' ) ) { + require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; + } + + $update_handler = new WP_Ajax_Upgrader_Skin(); + $upgrader = new Plugin_Upgrader( $update_handler ); + + global $code_snippets_last_update_handler, $code_snippets_last_upgrader; + $code_snippets_last_update_handler = $update_handler; + $code_snippets_last_upgrader = $upgrader; + + return $upgrader->install( + $download_url, + [ + 'overwrite_package' => true, + 'clear_update_cache' => true, + ] + ); + } + + /** + * Extract error message from an upgrade handler. + * + * @param WP_Upgrader_Skin|null $update_handler Update handler. + * @param Plugin_Upgrader|null $upgrader Plugin upgrader. + * + * @return string + * + * phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_print_r + */ + public static function extract_handler_messages( ?WP_Upgrader_Skin $update_handler, ?Plugin_Upgrader $upgrader ): string { + $handler_messages = ''; + + if ( isset( $update_handler ) ) { + if ( method_exists( $update_handler, 'get_errors' ) ) { + $errs = $update_handler->get_errors(); + if ( $errs instanceof WP_Error && $errs->has_errors() ) { + $handler_messages .= implode( "\n", $errs->get_error_messages() ); + } + } + if ( method_exists( $update_handler, 'get_error_messages' ) ) { + $em = $update_handler->get_error_messages(); + if ( $em ) { + $handler_messages .= "\n" . $em; + } + } + if ( method_exists( $update_handler, 'get_upgrade_messages' ) ) { + $upgrade_msgs = $update_handler->get_upgrade_messages(); + if ( is_array( $upgrade_msgs ) ) { + $handler_messages .= "\n" . implode( "\n", $upgrade_msgs ); + } elseif ( $upgrade_msgs ) { + $handler_messages .= "\n" . $upgrade_msgs; + } + } + } + + if ( empty( $handler_messages ) && isset( $upgrader->result ) ) { + if ( is_wp_error( $upgrader->result ) ) { + $handler_messages = implode( "\n", $upgrader->result->get_error_messages() ); + } else { + $handler_messages = is_scalar( $upgrader->result ) + ? (string) $upgrader->result + : print_r( $upgrader->result, true ); + } + } + + return trim( $handler_messages ); + } + + /** + * Report the failure of a version switch attempt. + * + * @param string $target_version Version number of attempted upgrade. + * @param mixed $result Result of upgrade. + * @param string $details Additional details. + * + * @return void + * + * phpcs:disable WordPress.PHP.DevelopmentFunctions + */ + private static function log_version_switch_attempt( string $target_version, $result, string $details = '' ): void { + if ( function_exists( 'error_log' ) ) { + error_log( sprintf( 'Code Snippets version switch failed. target=%s, result=%s, details=%s', $target_version, var_export( $result, true ), $details ) ); + } + } + + /** + * Handle the failure to install a new version. + * + * @param string $target_version Version used for attempted installation. + * @param string $download_url URL used for downloading new version. + * @param mixed $install_result Result of installation attempt. + * + * @return array + */ + private static function handle_installation_failure( string $target_version, string $download_url, $install_result ): array { + global $code_snippets_last_update_handler, $code_snippets_last_upgrader; + + $handler_messages = self::extract_handler_messages( $code_snippets_last_update_handler, $code_snippets_last_upgrader ); + self::log_version_switch_attempt( $target_version, $install_result, "URL: $download_url, Messages: $handler_messages" ); + + $fallback_message = __( 'Failed to switch versions. Please try again.', 'code-snippets' ); + + if ( ! empty( $handler_messages ) ) { + $short = wp_trim_words( wp_strip_all_tags( $handler_messages ), 40 ); + $fallback_message = sprintf( '%s %s', $fallback_message, $short ); + } + + return [ + 'success' => false, + 'message' => $fallback_message, + ]; + } + + /** + * Handle switching to a different plugin version. + * + * @param string $target_version Target version to switch to. + * + * @return array Result data. + */ + public static function handle_version_switch( string $target_version ): array { + if ( ! current_user_can( 'update_plugins' ) ) { + return self::create_error_response( __( 'You do not have permission to update plugins.', 'code-snippets' ) ); + } + + $available_versions = self::get_available_versions(); + $validation = self::validate_target_version( $target_version, $available_versions ); + + if ( ! $validation['success'] ) { + return self::create_error_response( $validation['message'] ); + } + + if ( self::get_current_version() === $target_version ) { + return self::create_error_response( __( 'Already on the specified version.', 'code-snippets' ) ); + } + + set_transient( self::PROGRESS_KEY, $target_version, self::PROGRESS_TIMEOUT ); + + $install_result = self::perform_version_install( $validation['download_url'] ); + + delete_transient( self::PROGRESS_KEY ); + + if ( is_wp_error( $install_result ) ) { + return self::create_error_response( $install_result->get_error_message() ); + } + + if ( $install_result ) { + delete_transient( self::CACHE_KEY ); + + // translators: %s: new version number. + $message = esc_html__( 'Successfully switched to version %s. Please refresh the page to see changes.', 'code-snippets' ); + + return [ + 'success' => true, + 'message' => sprintf( $message, $target_version ), + ]; + } else { + return self::handle_installation_failure( $target_version, $validation['download_url'], $install_result ); + } + } + + /** + * Render settings page field for the version switcher. + * + * @return void + */ + public static function render_version_switch_field(): void { + $current_version = self::get_current_version(); + $available_versions = self::get_available_versions(); + $is_switching = self::is_version_switch_in_progress(); + + ?> +
+

+ + +

+ + +
+

+
+ +

+ + +

+ +

+ +

+ + + +
__( 'You do not have permission to update plugins.', 'code-snippets' ) ] ); + } + + $target_version = sanitize_text_field( wp_unslash( $_POST['target_version'] ?? '' ) ); + + if ( empty( $target_version ) ) { + wp_send_json_error( [ 'message' => __( 'No target version specified.', 'code-snippets' ) ] ); + } + + $result = self::handle_version_switch( $target_version ); + + if ( $result['success'] ) { + wp_send_json_success( $result ); + } else { + wp_send_json_error( $result ); + } + } + + /** + * Render settings page field for the refresh version button. + * + * @return void + */ + public static function render_refresh_versions_field(): void { + printf( + '', + esc_html__( 'Refresh Available Versions', 'code-snippets' ) + ); + + printf( + '

%s

', + esc_html__( 'Check for the latest available plugin versions from WordPress.org.', 'code-snippets' ) + ); + } + + /** + * AJAX handler for refreshing the installed version. + * + * @return void + */ + public static function ajax_refresh_versions(): void { + check_ajax_referer( 'code_snippets_refresh_versions', sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) ) ); + + if ( ! code_snippets()->current_user_can() ) { + wp_send_json_error( [ 'message' => __( 'You do not have permission to manage options.', 'code-snippets' ) ] ); + } + + delete_transient( self::CACHE_KEY ); + self::get_available_versions(); + + wp_send_json_success( [ 'message' => __( 'Available versions updated successfully.', 'code-snippets' ) ] ); + } + + /** + * Render warning notice. + * + * @return void + */ + public static function render_version_switch_warning(): void { + ?> + + > @@ -85,12 +51,13 @@ function get_settings_values(): array { return $settings; } - $settings = get_default_settings(); - $saved = get_self_option( are_settings_unified(), OPTION_NAME, array() ); + $settings = Settings_Fields::get_default_values(); + $saved = get_self_option( are_settings_unified(), OPTION_NAME, [] ); - foreach ( $settings as $section => $fields ) { + // Deep merge the saved settings with the default values. + foreach ( $settings as $section => $section_fields ) { if ( isset( $saved[ $section ] ) ) { - $settings[ $section ] = array_replace( $fields, $saved[ $section ] ); + $settings[ $section ] = array_replace( $section_fields, $saved[ $section ] ); } } @@ -137,9 +104,9 @@ function update_setting( string $section, string $field, $new_value ): bool { */ function get_settings_sections(): array { $sections = array( - 'general' => __( 'General', 'code-snippets' ), - 'editor' => __( 'Code Editor', 'code-snippets' ), - 'debug' => __( 'Debug', 'code-snippets' ), + 'general' => __( 'General', 'code-snippets' ), + 'editor' => __( 'Code Editor', 'code-snippets' ), + 'debug' => __( 'Debug', 'code-snippets' ), ); // Only show the Version section when the debug setting to enable version changes is enabled. @@ -155,14 +122,12 @@ function get_settings_sections(): array { * Register settings sections, fields, etc */ function register_plugin_settings() { - if ( are_settings_unified() ) { - if ( ! get_site_option( OPTION_NAME ) ) { - add_site_option( OPTION_NAME, get_default_settings() ); - } - } elseif ( ! get_option( OPTION_NAME ) ) { - add_option( OPTION_NAME, get_default_settings() ); + if ( ! get_self_option( are_settings_unified(), OPTION_NAME ) ) { + add_self_option( are_settings_unified(), OPTION_NAME, Settings_Fields::get_default_values() ); } + $current_settings = get_settings_values(); + // Register the setting. register_setting( OPTION_GROUP, @@ -177,29 +142,87 @@ function register_plugin_settings() { // Register settings fields. Only register fields for sections that exist (some sections may be gated by settings). $registered_sections = get_settings_sections(); - foreach ( get_settings_fields() as $section_id => $fields ) { + foreach ( Settings_Fields::get_field_definitions() as $section_id => $fields ) { if ( ! isset( $registered_sections[ $section_id ] ) ) { continue; } foreach ( $fields as $field_id => $field ) { + if ( ! should_render_setting_field( $field, $current_settings ) ) { + continue; + } + $field_object = new Setting_Field( $section_id, $field_id, $field ); add_settings_field( $field_id, $field['name'], [ $field_object, 'render' ], 'code-snippets', $section_id ); } } + $editor_preview = new Editor_Preview(); + // Add editor preview as a field. add_settings_field( 'editor_preview', __( 'Editor Preview', 'code-snippets' ), - __NAMESPACE__ . '\\render_editor_preview', + [ $editor_preview, 'render' ], 'code-snippets', 'editor' ); + + Version_Switch::init(); } add_action( 'admin_init', __NAMESPACE__ . '\\register_plugin_settings' ); +/** + * Determine whether a setting field should be rendered. + * + * @param array $field Field definition. + * @param array> $settings Current settings values. + * @param array>|null $input Optional raw input values. + * + * @return bool + */ +function should_render_setting_field( array $field, array $settings, ?array $input = null ): bool { + if ( empty( $field['show_if'] ) || ! is_array( $field['show_if'] ) ) { + return true; + } + + $show_if = array_merge( + [ + 'section' => '', + 'field' => '', + 'value' => true, + ], + $field['show_if'] + ); + + $section = is_string( $show_if['section'] ) ? $show_if['section'] : ''; + $field_id = is_string( $show_if['field'] ) ? $show_if['field'] : ''; + $expected = $show_if['value']; + + if ( '' === $section || '' === $field_id ) { + return true; + } + + $actual = null; + + if ( is_array( $input ) && isset( $input[ $section ] ) && is_array( $input[ $section ] ) && array_key_exists( $field_id, $input[ $section ] ) ) { + $actual = $input[ $section ][ $field_id ]; + } elseif ( isset( $settings[ $section ] ) && array_key_exists( $field_id, $settings[ $section ] ) ) { + $actual = $settings[ $section ][ $field_id ]; + } + + if ( is_bool( $expected ) ) { + if ( is_bool( $actual ) ) { + return $actual === $expected; + } + + return ( 'on' === $actual ) === $expected; + } + + return $actual === $expected; +} + /** * Sanitize a single setting value. * @@ -281,7 +304,8 @@ function process_settings_actions( array $input ): ?array { } if ( isset( $input['debug']['reset_caches'] ) ) { - Welcome_API::clear_cache(); + Welcome_Client::clear_cache(); + Cloud_Search_Controller::clear_caches(); clean_snippets_cache( code_snippets()->db->get_table_name( false ) ); if ( is_multisite() ) { @@ -318,17 +342,20 @@ function sanitize_settings( array $input ): array { $updated = false; // Don't directly loop through $input as it does not include as deselected checkboxes. - foreach ( get_settings_fields() as $section_id => $fields ) { + foreach ( Settings_Fields::get_field_definitions() as $section_id => $fields ) { foreach ( $fields as $field_id => $field ) { + if ( ! should_render_setting_field( $field, $settings, $input ) ) { + continue; + } // Fetch the corresponding input value from the posted data. $input_value = $input[ $section_id ][ $field_id ] ?? null; + $stored_value = $settings[ $section_id ][ $field_id ] ?? null; // Attempt to sanitize the setting value. $sanitized_value = sanitize_setting_value( $field, $input_value ); - $current_value = $settings[ $section_id ][ $field_id ] ?? null; - if ( ! is_null( $sanitized_value ) && $current_value !== $sanitized_value ) { + if ( ! is_null( $sanitized_value ) && $stored_value !== $sanitized_value ) { $settings[ $section_id ][ $field_id ] = $sanitized_value; $updated = true; } diff --git a/src/php/Utils/Code_Highlighter.php b/src/php/Utils/Code_Highlighter.php new file mode 100644 index 000000000..cbebadb54 --- /dev/null +++ b/src/php/Utils/Code_Highlighter.php @@ -0,0 +1,54 @@ + true ] + ); + + wp_register_style( + self::PRISM_HANDLE, + plugins_url( 'dist/prism.css', PLUGIN_FILE ), + [], + PLUGIN_VERSION + ); + } + + + /** + * Enqueue all available Prism themes. + * + * @return void + */ + public static function enqueue_all_prism_themes() { + self::register_prism_assets(); + + wp_enqueue_style( self::PRISM_HANDLE ); + wp_enqueue_script( self::PRISM_HANDLE ); + } +} diff --git a/src/php/class-validator.php b/src/php/Utils/Validator.php similarity index 83% rename from src/php/class-validator.php rename to src/php/Utils/Validator.php index 27effb432..d034f1c97 100644 --- a/src/php/class-validator.php +++ b/src/php/Utils/Validator.php @@ -1,6 +1,6 @@ defined_identifiers[ $type ] ) ) { switch ( $type ) { case T_FUNCTION: $defined_functions = get_defined_functions(); - $this->defined_identifiers[ T_FUNCTION ] = array_merge( $defined_functions['internal'], $defined_functions['user'] ); + $this->defined_identifiers[ T_FUNCTION ] = array_map( + 'strtolower', + array_merge( $defined_functions['internal'], $defined_functions['user'] ) + ); break; case T_CLASS: - $this->defined_identifiers[ T_CLASS ] = get_declared_classes(); + $this->defined_identifiers[ T_CLASS ] = array_map( 'strtolower', get_declared_classes() ); break; case T_INTERFACE: - $this->defined_identifiers[ T_INTERFACE ] = get_declared_interfaces(); + $this->defined_identifiers[ T_INTERFACE ] = array_map( 'strtolower', get_declared_interfaces() ); break; default: @@ -122,10 +127,15 @@ private function check_duplicate_identifier( string $type, string $identifier ): } } - $duplicate = in_array( $identifier, $this->defined_identifiers[ $type ], true ); + $duplicate_identifier = in_array( $identifier, $this->defined_identifiers[ $type ], true ); + $duplicate_namespaced = in_array( $namespaced_identifier, $this->defined_identifiers[ $type ], true ); + $exceptions = $this->exceptions[ $type ] ?? []; + $exception_identifier = in_array( $identifier, $exceptions, true ); + $exception_namespaced = in_array( $namespaced_identifier, $exceptions, true ); + array_unshift( $this->defined_identifiers[ $type ], $identifier ); - return $duplicate && ! ( isset( $this->exceptions[ $type ] ) && in_array( $identifier, $this->exceptions[ $type ], true ) ); + return ( $duplicate_identifier && ! $exception_identifier ) || ( $duplicate_namespaced && ! $exception_namespaced ); } /** @@ -154,8 +164,12 @@ public function validate() { } // Add the identifier to the list of exceptions. - $this->exceptions[ $type ] = $this->exceptions[ $type ] ?? []; - $this->exceptions[ $type ][] = trim( $token[1], '\'"' ); + $identifier = strtolower( ltrim( trim( $token[1], '\'"' ), '\\' ) ); + + if ( '' !== $identifier ) { + $this->exceptions[ $type ] = $this->exceptions[ $type ] ?? []; + $this->exceptions[ $type ][] = $identifier; + } continue; } diff --git a/src/php/editor.php b/src/php/Utils/editor.php similarity index 71% rename from src/php/editor.php rename to src/php/Utils/editor.php index bdc7670f4..c082eb76e 100644 --- a/src/php/editor.php +++ b/src/php/Utils/editor.php @@ -5,9 +5,13 @@ * @package Code_Snippets */ -namespace Code_Snippets; +namespace Code_Snippets\Utils; +use Code_Snippets\Settings\Settings_Fields; use function Code_Snippets\Settings\get_setting; +use function Code_Snippets\Settings\get_settings_values; +use const Code_Snippets\PLUGIN_FILE; +use const Code_Snippets\PLUGIN_VERSION; /** * Register and load the CodeMirror library. @@ -16,8 +20,6 @@ * @param array $extra_atts Pass a list of attributes to override the saved ones. */ function enqueue_code_editor( string $type, array $extra_atts = [] ) { - $plugin = code_snippets(); - $modes = [ 'css' => 'text/css', 'php' => 'php-snippet', @@ -49,10 +51,10 @@ function enqueue_code_editor( string $type, array $extra_atts = [] ) { ]; // Add relevant saved setting values to the default attributes. - $plugin_settings = Settings\get_settings_values(); - $setting_fields = Settings\get_settings_fields(); + $plugin_settings = get_settings_values(); + $field_definitions = Settings_Fields::get_field_definitions(); - foreach ( $setting_fields['editor'] as $field_id => $field ) { + foreach ( $field_definitions['editor'] as $field_id => $field ) { // The 'codemirror' setting field specifies the name of the attribute. $default_atts[ $field['codemirror'] ] = $plugin_settings['editor'][ $field_id ]; } @@ -86,21 +88,57 @@ function enqueue_code_editor( string $type, array $extra_atts = [] ) { wp_enqueue_script( 'code-snippets-code-editor', - plugins_url( 'dist/editor.js', $plugin->file ), + plugins_url( 'dist/editor.js', PLUGIN_FILE ), [ 'code-editor' ], - $plugin->version, - true + PLUGIN_VERSION, + [ 'in_footer' => true ] ); - // CodeMirror Theme. + enqueue_code_editor_theme(); +} + +/** + * Load the CodeMirror assets required for read-only code previews. + * + * @param string $type Type of code editor – either 'php', 'css', 'js', or 'html'. + * + * @return void + */ +function enqueue_code_preview_editor( string $type ): void { + $modes = [ + 'css' => 'text/css', + 'php' => 'text/x-php', + 'js' => 'javascript', + 'html' => 'application/x-httpd-php', + ]; + + wp_enqueue_code_editor( + [ + 'type' => $modes[ $type ] ?? $modes['php'], + 'codemirror' => [ + 'lint' => false, + 'readOnly' => true, + ], + ] + ); + + enqueue_code_editor_theme(); +} + +/** + * Load the configured CodeMirror theme. + * + * @return void + */ +function enqueue_code_editor_theme(): void { $theme = get_setting( 'editor', 'theme' ); if ( 'default' !== $theme ) { wp_enqueue_style( 'code-snippets-editor-theme-' . $theme, - plugins_url( "dist/editor-themes/$theme.css", $plugin->file ), + plugins_url( "dist/editor-themes/$theme.css", PLUGIN_FILE ), [ 'code-editor' ], - $plugin->version + PLUGIN_VERSION ); } } diff --git a/src/php/strings.php b/src/php/Utils/i18n.php similarity index 80% rename from src/php/strings.php rename to src/php/Utils/i18n.php index 42083b687..405907a4e 100644 --- a/src/php/strings.php +++ b/src/php/Utils/i18n.php @@ -24,7 +24,10 @@ 'site-css' => __( 'Site front-end stylesheet', 'code-snippets' ), 'admin-css' => __( 'Administration area stylesheet', 'code-snippets' ), 'site-head-js' => __( 'JavaScript loaded in the site &lt;head&gt; section', 'code-snippets' ), - 'site-footer-js' => __( 'JavaScript loaded just before the closing &lt;/body&gt; tag', 'code-snippets' ), + 'site-footer-js' => __( 'JavaScript loaded at the end of the &lt;body&gt; tag', 'code-snippets' ), + 'head-content' => __( 'HTML output in the &lt;head&gt; section', 'code-snippets' ), + 'body-content' => __( 'HTML output at the start of the &lt;body&gt; tag', 'code-snippets' ), + 'footer-content' => __( 'HTML output at the end of the &lt;body&gt; tag', 'code-snippets' ), ); // class-content-widget.php. diff --git a/src/php/Utils/options.php b/src/php/Utils/options.php new file mode 100644 index 000000000..47ba8e1cb --- /dev/null +++ b/src/php/Utils/options.php @@ -0,0 +1,67 @@ +|null Associative array of JSON data on success, null on failure. + */ +function unpack_response_body( $response ): ?array { + $body = wp_remote_retrieve_body( $response ); + + if ( $body ) { + $json = json_decode( $body, true ); + return is_array( $json ) ? $json : null; + } + + return null; +} diff --git a/src/php/admin-menus/class-manage-menu.php b/src/php/admin-menus/class-manage-menu.php deleted file mode 100644 index d342dde93..000000000 --- a/src/php/admin-menus/class-manage-menu.php +++ /dev/null @@ -1,337 +0,0 @@ -is_compact_menu() ) { - add_action( 'admin_menu', array( $this, 'register_compact_menu' ), 2 ); - add_action( 'network_admin_menu', array( $this, 'register_compact_menu' ), 2 ); - } - - add_action( 'admin_menu', array( $this, 'register_upgrade_menu' ), 500 ); - add_filter( 'set-screen-option', array( $this, 'save_screen_option' ), 10, 3 ); - add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_menu_css' ] ); - add_action( 'wp_ajax_update_code_snippet', array( $this, 'ajax_callback' ) ); - } - - /** - * Register the top-level 'Snippets' menu and associated 'Manage' subpage - */ - public function register() { - add_menu_page( - __( 'Snippets', 'code-snippets' ), - _x( 'Snippets', 'top-level menu label', 'code-snippets' ), - code_snippets()->get_cap(), - code_snippets()->get_menu_slug(), - array( $this, 'render' ), - 'none', // Added through CSS as a mask to prevent loading 'blinking'. - apply_filters( 'code_snippets/admin/menu_position', is_network_admin() ? 21 : 67 ) - ); - - // Register the sub-menu. - parent::register(); - } - - /** - * Register the 'upgrade' menu item. - * - * @return void - */ - public function register_upgrade_menu() { - if ( code_snippets()->licensing->is_licensed() || get_setting( 'general', 'hide_upgrade_menu' ) ) { - return; - } - - $menu_title = sprintf( - '%s %s', - _x( 'Go Pro', 'top-level menu label', 'code-snippets' ), - '' - ); - - $hook = add_submenu_page( - code_snippets()->get_menu_slug(), - __( 'Upgrade to Pro', 'code-snippets' ), - $menu_title, - code_snippets()->get_cap(), - 'code_snippets_upgrade', - '__return_empty_string', - 100 - ); - - add_action( "load-$hook", [ $this, 'load_upgrade_menu' ] ); - } - - /** - * Print CSS required for the admin menu icon. - * - * @return void - */ - public function enqueue_menu_css() { - wp_enqueue_style( - 'code-snippets-menu', - plugins_url( 'dist/menu.css', PLUGIN_FILE ), - [], - PLUGIN_VERSION - ); - } - - /** - * Redirect the user upon opening the upgrade menu. - * - * @return void - */ - public function load_upgrade_menu() { - wp_safe_redirect( 'https://snipco.de/JE2f' ); - exit; - } - - /** - * Add menu pages for the compact menu - */ - public function register_compact_menu() { - - if ( ! code_snippets()->is_compact_menu() ) { - return; - } - - $sub = code_snippets()->get_menu_slug( isset( $_GET['sub'] ) ? sanitize_key( $_GET['sub'] ) : 'snippets' ); - - $classmap = array( - 'snippets' => 'manage', - 'add-snippet' => 'edit', - 'edit-snippet' => 'edit', - 'import-code-snippets' => 'import', - 'snippets-settings' => 'settings', - ); - - $menus = code_snippets()->admin->menus; - $class = isset( $classmap[ $sub ], $menus[ $classmap[ $sub ] ] ) ? $menus[ $classmap[ $sub ] ] : $this; - - /* Add a submenu to the Tools menu */ - $hook = add_submenu_page( - 'tools.php', - __( 'Snippets', 'code-snippets' ), - _x( 'Snippets', 'tools submenu label', 'code-snippets' ), - code_snippets()->get_cap(), - code_snippets()->get_menu_slug(), - array( $class, 'render' ) - ); - - add_action( 'load-' . $hook, array( $class, 'load' ) ); - } - - /** - * Executed when the admin page is loaded - */ - public function load() { - parent::load(); - - $contextual_help = new Contextual_Help( 'manage' ); - $contextual_help->load(); - - $this->cloud_search_list_table = new Cloud_Search_List_Table(); - $this->cloud_search_list_table->prepare_items(); - - $this->list_table = new List_Table(); - $this->list_table->prepare_items(); - } - - /** - * Enqueue scripts and stylesheets for the admin page. - */ - public function enqueue_assets() { - $plugin = code_snippets(); - - wp_enqueue_style( - 'code-snippets-manage', - plugins_url( 'dist/manage.css', $plugin->file ), - [], - $plugin->version - ); - - wp_enqueue_script( - 'code-snippets-manage-js', - plugins_url( 'dist/manage.js', $plugin->file ), - [ 'wp-i18n' ], - $plugin->version, - true - ); - - wp_set_script_translations( 'code-snippets-manage-js', 'code-snippets' ); - - if ( 'cloud' === $this->get_current_type() || 'cloud_search' === $this->get_current_type() ) { - Front_End::enqueue_all_prism_themes(); - } - } - - /** - * Get the currently displayed snippet type. - * - * @return string - */ - protected function get_current_type(): string { - $types = Plugin::get_types(); - $current_type = isset( $_GET['type'] ) ? sanitize_key( wp_unslash( $_GET['type'] ) ) : 'all'; - return isset( $types[ $current_type ] ) ? $current_type : 'all'; - } - - /** - * Print the status and error messages - * - * @return void - */ - protected function print_messages() { - $this->render_view( 'partials/list-table-notices' ); - } - - /** - * Handles saving the user's snippets per page preference - * - * @param mixed $status Current screen option status. - * @param string $option The screen option name. - * @param mixed $value Screen option value. - * - * @return mixed - */ - public function save_screen_option( $status, string $option, $value ) { - return 'snippets_per_page' === $option ? $value : $status; - } - - /** - * Update the priority value for a snippet. - * - * @param Snippet $snippet Snippet to update. - * - * @return void - */ - private function update_snippet_priority( Snippet $snippet ) { - global $wpdb; - $table = code_snippets()->db->get_table_name( $snippet->network ); - - $wpdb->update( - $table, - array( 'priority' => $snippet->priority ), - array( 'id' => $snippet->id ), - array( '%d' ), - array( '%d' ) - ); - - clean_snippets_cache( $table ); - } - - /** - * Handle AJAX requests - */ - public function ajax_callback() { - check_ajax_referer( 'code_snippets_manage_ajax' ); - - if ( ! isset( $_POST['field'], $_POST['snippet'] ) ) { - wp_send_json_error( - array( - 'type' => 'param_error', - 'message' => 'incomplete request', - ) - ); - } - - // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized - $snippet_data = array_map( 'sanitize_text_field', json_decode( wp_unslash( $_POST['snippet'] ), true ) ); - - $snippet = new Snippet( $snippet_data ); - $field = sanitize_key( $_POST['field'] ); - - if ( 'priority' === $field ) { - - if ( ! isset( $snippet_data['priority'] ) || ! is_numeric( $snippet_data['priority'] ) ) { - wp_send_json_error( - array( - 'type' => 'param_error', - 'message' => 'missing snippet priority data', - ) - ); - } - - $this->update_snippet_priority( $snippet ); - - } elseif ( 'active' === $field ) { - - if ( ! isset( $snippet_data['active'] ) ) { - wp_send_json_error( - array( - 'type' => 'param_error', - 'message' => 'missing snippet active data', - ) - ); - } - - if ( $snippet->shared_network ) { - $active_shared_snippets = get_option( 'active_shared_network_snippets', array() ); - - if ( in_array( $snippet->id, $active_shared_snippets, true ) !== $snippet->active ) { - - $active_shared_snippets = $snippet->active ? - array_merge( $active_shared_snippets, array( $snippet->id ) ) : - array_diff( $active_shared_snippets, array( $snippet->id ) ); - - update_option( 'active_shared_network_snippets', $active_shared_snippets ); - clean_active_snippets_cache( code_snippets()->db->ms_table ); - } - } elseif ( $snippet->active ) { - $result = activate_snippet( $snippet->id, $snippet->network ); - if ( is_string( $result ) ) { - wp_send_json_error( - array( - 'type' => 'action_error', - 'message' => $result, - ) - ); - } - } else { - deactivate_snippet( $snippet->id, $snippet->network ); - } - } - - wp_send_json_success(); - } -} diff --git a/src/php/admin-menus/class-settings-menu.php b/src/php/admin-menus/class-settings-menu.php deleted file mode 100644 index a5c72d377..000000000 --- a/src/php/admin-menus/class-settings-menu.php +++ /dev/null @@ -1,239 +0,0 @@ -update_network_options(); - } else { - wp_safe_redirect( code_snippets()->get_menu_url( 'settings', 'admin' ) ); - exit; - } - } - } - - /** - * Enqueue the stylesheet for the settings menu - */ - public function enqueue_assets() { - $plugin = code_snippets(); - - Settings\enqueue_editor_preview_assets(); - - wp_enqueue_style( - 'code-snippets-settings', - plugins_url( 'dist/settings.css', $plugin->file ), - [ 'code-editor' ], - $plugin->version - ); - } - - /** - * Retrieve the list of settings sections. - * - * @return array> - */ - private function get_sections(): array { - global $wp_settings_sections; - - if ( ! isset( $wp_settings_sections[ self::SETTINGS_PAGE ] ) ) { - return array(); - } - - return (array) $wp_settings_sections[ self::SETTINGS_PAGE ]; - } - - /** - * Retrieve the name of the settings section currently being viewed. - * - * @param string $default_section Name of the default tab displayed. - * - * @return string - */ - public function get_current_section( string $default_section = 'general' ): string { - $sections = $this->get_sections(); - - if ( ! $sections ) { - return $default_section; - } - - $active_tab = isset( $_REQUEST['section'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['section'] ) ) : $default_section; - return isset( $sections[ $active_tab ] ) ? $active_tab : $default_section; - } - - /** - * Render the admin screen - */ - public function render() { - $update_url = is_network_admin() ? add_query_arg( 'update_site_option', true ) : admin_url( 'options.php' ); - $current_section = $this->get_current_section(); - - ?> -
-

- is_compact_menu() ) { - $actions = [ - _x( 'Manage', 'snippets', 'code-snippets' ) => code_snippets()->get_menu_url(), - _x( 'Add New', 'snippet', 'code-snippets' ) => code_snippets()->get_menu_url( 'add' ), - _X( 'Import', 'snippets', 'code-snippets' ) => code_snippets()->get_menu_url( 'import' ), - ]; - - foreach ( $actions as $label => $url ) { - printf( - '%s', - esc_url( $url ), - esc_html( $label ) - ); - } - } - ?> -

- - - -
- - do_settings_tabs(); - ?> -

- -

-
-
- get_sections(); - $active_tab = $this->get_current_section(); - - echo ''; - - foreach ( $sections as $section ) { - if ( 'license' === $section['id'] ) { - continue; - } - - if ( $section['title'] ) { - printf( - '

%s

' . "\n", - esc_attr( $section['id'] ), - esc_html( $section['title'] ) - ); - } - - if ( $section['callback'] ) { - call_user_func( $section['callback'], $section ); - } - - printf( '
', esc_attr( $section['id'] ) ); - - do_settings_fields( self::SETTINGS_PAGE, $section['id'] ); - echo '
'; - } - } - - /** - * Fill in for the Settings API in the Network Admin - */ - public function update_network_options() { - - // Ensure the settings have been saved. - if ( empty( $_GET['update_site_option'] ) || empty( $_POST[ OPTION_NAME ] ) ) { - return; - } - - check_admin_referer( 'code-snippets-options' ); - - // Retrieve the saved options and save them to the database. - $value = map_deep( wp_unslash( $_POST[ OPTION_NAME ] ), 'sanitize_key' ); - update_site_option( OPTION_NAME, $value ); - wp_cache_delete( CACHE_KEY ); - - // Add an updated notice. - if ( ! count( get_settings_errors() ) ) { - add_settings_error( 'general', 'settings_updated', __( 'Settings saved.', 'code-snippets' ), 'updated' ); - } - - set_transient( 'settings_errors', get_settings_errors(), 30 ); - - // Redirect back to the settings menu. - $redirect = add_query_arg( 'settings-updated', 'true', remove_query_arg( 'update_site_option', wp_get_referer() ) ); - wp_safe_redirect( esc_url_raw( $redirect ) ); - exit; - } - - /** - * Empty implementation for print_messages. - * - * @return void - */ - protected function print_messages() { - // none required. - } -} diff --git a/src/php/admin-menus/class-welcome-menu.php b/src/php/admin-menus/class-welcome-menu.php deleted file mode 100644 index 02788d4e1..000000000 --- a/src/php/admin-menus/class-welcome-menu.php +++ /dev/null @@ -1,88 +0,0 @@ -api = $api; - } - - /** - * Enqueue assets necessary for the welcome menu. - * - * @return void - */ - public function enqueue_assets() { - wp_enqueue_style( - 'code-snippets-welcome', - plugins_url( 'dist/welcome.css', PLUGIN_FILE ), - [], - PLUGIN_VERSION - ); - } - - /** - * Retrieve a list of links to display in the page header. - * - * @return array - */ - protected function get_header_links(): array { - $links = [ - 'cloud' => [ - 'url' => 'https://codesnippets.cloud', - 'icon' => 'cloud', - 'label' => __( 'Cloud', 'code-snippets' ), - ], - 'resources' => [ - 'url' => 'https://codesnippets.pro/support/', - 'icon' => 'sos', - 'label' => __( 'Support', 'code-snippets' ), - ], - 'facebook' => [ - 'url' => 'https://www.facebook.com/groups/282962095661875/', - 'icon' => 'facebook', - 'label' => __( 'Community', 'code-snippets' ), - ], - 'discord' => [ - 'url' => 'https://snipco.de/discord', - 'icon' => 'discord', - 'label' => __( 'Discord', 'code-snippets' ), - ], - ]; - - if ( ! code_snippets()->licensing->is_licensed() ) { - $links['pro'] = [ - 'url' => 'https://codesnippets.pro/pricing/', - 'icon' => 'cart', - 'label' => __( 'Upgrade to Pro', 'code-snippets' ), - ]; - } - - return $links; - } -} diff --git a/src/php/class-list-table.php b/src/php/class-list-table.php deleted file mode 100644 index 41ed13799..000000000 --- a/src/php/class-list-table.php +++ /dev/null @@ -1,1518 +0,0 @@ - - */ - public array $statuses = [ 'all', 'active', 'inactive', 'recently_activated', 'shared_network', 'trashed' ]; - - /** - * Column name to use when ordering the snippets list. - * - * @var string - */ - protected string $order_by; - - /** - * Direction to use when ordering the snippets list. Either 'asc' or 'desc'. - * - * @var string - */ - protected string $order_dir; - - /** - * List of active snippets indexed by attached condition ID. - * - * @var array - */ - protected array $active_by_condition = []; - - /** - * The constructor function for our class. - * Registers hooks, initializes variables, setups class. - * - * @phpcs:disable WordPress.WP.GlobalVariablesOverride.Prohibited - */ - public function __construct() { - global $status, $page; - $this->is_network = is_network_admin(); - - // Determine the status. - $status = apply_filters( 'code_snippets/list_table/default_view', 'all' ); - if ( isset( $_REQUEST['status'] ) && in_array( sanitize_key( $_REQUEST['status'] ), $this->statuses, true ) ) { - $status = sanitize_key( $_REQUEST['status'] ); - } - - // Add the search query to the URL. - if ( isset( $_REQUEST['s'] ) ) { - $_SERVER['REQUEST_URI'] = add_query_arg( 's', sanitize_text_field( wp_unslash( $_REQUEST['s'] ) ) ); - } - - // Add a snippets per page screen option. - $page = $this->get_pagenum(); - - add_screen_option( - 'per_page', - array( - 'label' => __( 'Snippets per page', 'code-snippets' ), - 'default' => 999, - 'option' => 'snippets_per_page', - ) - ); - - add_filter( 'default_hidden_columns', array( $this, 'default_hidden_columns' ) ); - - // Strip the result query arg from the URL. - $_SERVER['REQUEST_URI'] = remove_query_arg( 'result' ); - - // Add filters to format the snippet description in the same way the post content is formatted. - $filters = [ 'wptexturize', 'convert_smilies', 'convert_chars', 'wpautop', 'shortcode_unautop', 'capital_P_dangit', [ $this, 'wp_kses_desc' ] ]; - foreach ( $filters as $filter ) { - add_filter( 'code_snippets/list_table/column_description', $filter ); - } - - // Set up the class. - parent::__construct( - array( - 'ajax' => true, - 'plural' => 'snippets', - 'singular' => 'snippet', - ) - ); - } - - /** - * Determine if a condition is considered 'active' by checking if it is attached to any active snippets. - * - * @param Snippet $condition Condition snippet to check. - * - * @return bool - */ - protected function is_condition_active( Snippet $condition ): bool { - return $condition->is_condition() - && isset( $this->active_by_condition[ $condition->id ] ) - && count( $this->active_by_condition[ $condition->id ] ) > 0; - } - - /** - * Apply a more permissive version of wp_kses_post() to the snippet description. - * - * @param string $data Description content to filter. - * - * @return string Filtered description content with allowed HTML tags and attributes intact. - */ - public function wp_kses_desc( string $data ): string { - $safe_style_filter = function ( $styles ) { - $styles[] = 'display'; - return $styles; - }; - - add_filter( 'safe_style_css', $safe_style_filter ); - $data = wp_kses_post( $data ); - remove_filter( 'safe_style_css', $safe_style_filter ); - - return $data; - } - - /** - * Set the 'id' column as hidden by default. - * - * @param array $hidden List of hidden columns. - * - * @return array Modified list of hidden columns. - */ - public function default_hidden_columns( array $hidden ): array { - array_push( $hidden, 'id', 'code', 'cloud_id', 'revision' ); - return $hidden; - } - - /** - * Set the 'name' column as the primary column. - * - * @return string - */ - protected function get_default_primary_column_name(): string { - return 'name'; - } - - /** - * Define the output of all columns that have no callback function - * - * @param Snippet $item The snippet used for the current row. - * @param string $column_name The name of the column being printed. - * - * @return string The content of the column to output. - */ - protected function column_default( $item, $column_name ): string { - switch ( $column_name ) { - case 'id': - return $item->id; - - case 'description': - return apply_filters( 'code_snippets/list_table/column_description', $item->desc ); - - case 'type': - $type = $item->type; - $url = add_query_arg( 'type', $type ); - - return sprintf( - '%s', - esc_attr( $type ), - esc_url( $url ), - 'cond' === $type ? '' : esc_html( $type ) - ); - - case 'date': - return $item->modified ? $item->format_modified() : '—'; - - default: - return apply_filters( "code_snippets/list_table/column_$column_name", '—', $item ); - } - } - - /** - * Retrieve a URL to perform an action on a snippet - * - * @param string $action Name of action to produce a link for. - * @param Snippet $snippet Snippet object to produce link for. - * - * @return string URL to perform action. - */ - public function get_action_link( string $action, Snippet $snippet ): string { - - // Redirect actions to the network dashboard for shared network snippets. - $local_actions = array( 'activate', 'activate-shared', 'run-once', 'run-once-shared' ); - $network_redirect = $snippet->shared_network && ! $this->is_network && ! in_array( $action, $local_actions, true ); - - // Edit links go to a different menu. - if ( 'edit' === $action ) { - return code_snippets()->get_snippet_edit_url( $snippet->id, $network_redirect ? 'network' : 'self' ); - } - - $query_args = array( - 'action' => $action, - 'id' => $snippet->id, - 'scope' => $snippet->scope, - ); - - $url = $network_redirect ? - add_query_arg( $query_args, code_snippets()->get_menu_url( 'manage', 'network' ) ) : - add_query_arg( $query_args ); - - // Add a nonce to the URL for security purposes. - return wp_nonce_url( $url, 'code_snippets_manage_snippet_' . $snippet->id ); - } - - /** - * Build a list of action links for individual snippets - * - * @param Snippet $snippet The current snippet. - * - * @return array The action links HTML. - */ - private function get_snippet_action_links( Snippet $snippet ): array { - $actions = array(); - - if ( $snippet->shared_network && ! $this->is_network ) { - $actions['network_shared'] = sprintf( - '%s', - esc_html__( 'Network Snippet', 'code-snippets' ) - ); - - if ( is_multisite() && is_super_admin() ) { - $actions['edit'] = sprintf( - '%s', - esc_url( $this->get_action_link( 'edit', $snippet ) ), - esc_html__( 'Edit', 'code-snippets' ) - ); - } - - return apply_filters( 'code_snippets/list_table/row_actions', $actions, $snippet ); - } - - if ( $snippet->is_trashed() ) { - $actions['restore'] = sprintf( - '%s', - esc_url( $this->get_action_link( 'restore', $snippet ) ), - esc_html__( 'Restore', 'code-snippets' ) - ); - - $actions['delete_permanently'] = sprintf( - '%1$s', - esc_html__( 'Delete Permanently', 'code-snippets' ), - esc_url( $this->get_action_link( 'delete_permanently', $snippet ) ), - esc_js( - sprintf( - 'return confirm("%s");', - esc_html__( 'You are about to permanently delete the selected item.', 'code-snippets' ) . "\n" . - esc_html__( "'Cancel' to stop, 'OK' to delete.", 'code-snippets' ) - ) - ) - ); - } elseif ( ! $this->is_network && $snippet->network && ! $snippet->shared_network ) { - // Display special links if on a subsite and dealing with a network-active snippet. - if ( $snippet->active ) { - $actions['network_active'] = esc_html__( 'Network Active', 'code-snippets' ); - } else { - $actions['network_only'] = esc_html__( 'Network Only', 'code-snippets' ); - } - } elseif ( ! $snippet->shared_network || current_user_can( code_snippets()->get_network_cap_name() ) ) { - - // If the snippet is a shared network snippet, only display extra actions if the user has network permissions. - $simple_actions = array( - 'edit' => esc_html__( 'Edit', 'code-snippets' ), - 'clone' => esc_html__( 'Clone', 'code-snippets' ), - 'export' => esc_html__( 'Export', 'code-snippets' ), - ); - - foreach ( $simple_actions as $action => $label ) { - $actions[ $action ] = sprintf( '%s', esc_url( $this->get_action_link( $action, $snippet ) ), $label ); - } - - $actions['delete'] = sprintf( - '%1$s', - esc_html__( 'Trash', 'code-snippets' ), - esc_url( $this->get_action_link( 'delete', $snippet ) ) - ); - } - - return apply_filters( 'code_snippets/list_table/row_actions', $actions, $snippet ); - } - - /** - * Retrieve the code for a snippet activation switch - * - * @param Snippet $snippet Snippet object. - * - * @return string Output for activation switch. - */ - protected function column_activate( Snippet $snippet ): string { - if ( $snippet->is_trashed() ) { - return ''; - } - - // Show icon for shared network snippets on network admin. - if ( $snippet->shared_network && $this->is_network ) { - return ''; - } - - if ( ! $this->is_network && $snippet->network && ! $snippet->shared_network ) { - return ''; - } - - switch ( $snippet->scope ) { - case 'single-use': - $class = 'snippet-execution-button'; - $action = 'run-once'; - $label = esc_html__( 'Run Once', 'code-snippets' ); - break; - - case 'condition': - $edit_url = code_snippets()->get_snippet_edit_url( $snippet->id, $snippet->network ? 'network' : 'admin' ); - - return sprintf( - '%s', - esc_url( $edit_url ), - isset( $this->active_by_condition[ $snippet->id ] ) - ? esc_html( count( $this->active_by_condition[ $snippet->id ] ) ) - : 0 - ); - - default: - $class = 'snippet-activation-switch'; - $action = $snippet->active ? 'deactivate' : 'activate'; - $label = $snippet->network && ! $snippet->shared_network ? - ( $snippet->active ? __( 'Network Deactivate', 'code-snippets' ) : __( 'Network Activate', 'code-snippets' ) ) : - ( $snippet->active ? __( 'Deactivate', 'code-snippets' ) : __( 'Activate', 'code-snippets' ) ); - break; - } - - if ( $snippet->shared_network ) { - $action .= '-shared'; - } - - return $action && $label - ? sprintf( - '  ', - esc_attr( $class ), - esc_url( $this->get_action_link( $action, $snippet ) ), - esc_attr( $label ) - ) - : ''; - } - - /** - * Build the content of the snippet name column - * - * @param Snippet $snippet The snippet being used for the current row. - * - * @return string The content of the column to output. - */ - protected function column_name( Snippet $snippet ): string { - - $row_actions = $this->row_actions( - $this->get_snippet_action_links( $snippet ), - apply_filters( 'code_snippets/list_table/row_actions_always_visible', true ) - ); - - $out = esc_html( $snippet->display_name ); - $user_can_manage_network = current_user_can( code_snippets()->get_network_cap_name() ); - - // Add a link to the snippet if it isn't an unreadable network-only snippet and isn't trashed. - if ( ! $snippet->is_trashed() && ( $this->is_network || ! $snippet->network || $user_can_manage_network ) ) { - $out = sprintf( - '%s', - esc_attr( code_snippets()->get_snippet_edit_url( $snippet->id, $snippet->network ? 'network' : 'admin' ) ), - $out - ); - } else { - $out = sprintf( '%s', $out ); - } - - $out = apply_filters( 'code_snippets/list_table/column_name', $out, $snippet ); - return $out . $row_actions; - } - - /** - * Handles the checkbox column output. - * - * @param Snippet $item The snippet being used for the current row. - * - * @return string The column content to be printed. - */ - protected function column_cb( $item ): string { - $out = sprintf( - '', - $item->shared_network ? 'shared_ids' : 'ids', - $item->id - ); - - return apply_filters( 'code_snippets/list_table/column_cb', $out, $item ); - } - - /** - * Handles the tags column output. - * - * @param Snippet $snippet The snippet being used for the current row. - * - * @return string The column output. - */ - protected function column_tags( Snippet $snippet ): string { - - // Return now if there are no tags. - if ( empty( $snippet->tags ) ) { - return ''; - } - - $out = array(); - - // Loop through the tags and create a link for each one. - foreach ( $snippet->tags as $tag ) { - $out[] = sprintf( - '%s', - esc_url( add_query_arg( 'tag', esc_attr( $tag ) ) ), - esc_html( $tag ) - ); - } - - return join( ', ', $out ); - } - - /** - * Handles the priority column output. - * - * @param Snippet $snippet The snippet being used for the current row. - * - * @return string The column output. - */ - protected function column_priority( Snippet $snippet ): string { - return sprintf( '', $snippet->priority ); - } - - /** - * Define the column headers for the table - * - * @return array The column headers, ID paired with label - */ - public function get_columns(): array { - $columns = array( - 'cb' => '', - 'activate' => '', - 'name' => __( 'Name', 'code-snippets' ), - 'type' => __( 'Type', 'code-snippets' ), - 'description' => __( 'Description', 'code-snippets' ), - 'tags' => __( 'Tags', 'code-snippets' ), - 'date' => __( 'Modified', 'code-snippets' ), - 'priority' => __( 'Priority', 'code-snippets' ), - 'id' => __( 'ID', 'code-snippets' ), - ); - - if ( ! get_setting( 'general', 'enable_description' ) ) { - unset( $columns['description'] ); - } - - if ( ! get_setting( 'general', 'enable_tags' ) ) { - unset( $columns['tags'] ); - } - - return apply_filters( 'code_snippets/list_table/columns', $columns ); - } - - /** - * Define the columns that can be sorted. The format is: - * 'internal-name' => 'orderby' - * or - * 'internal-name' => array( 'orderby', true ) - * - * The second format will make the initial sorting order be descending. - * - * @return array> The IDs of the columns that can be sorted - */ - public function get_sortable_columns(): array { - $sortable_columns = [ - 'id' => [ 'id', true ], - 'name' => 'name', - 'type' => [ 'type', true ], - 'date' => [ 'modified', true ], - 'priority' => [ 'priority', true ], - ]; - - return apply_filters( 'code_snippets/list_table/sortable_columns', $sortable_columns ); - } - - /** - * Define the bulk actions to include in the drop-down menus - * - * @return array An array of menu items with the ID paired to the label - */ - public function get_bulk_actions(): array { - global $status; - - if ( 'trashed' === $status ) { - $actions = [ - 'restore-selected' => __( 'Restore', 'code-snippets' ), - 'delete-permanently-selected' => __( 'Delete Permanently', 'code-snippets' ), - ]; - } else { - $actions = [ - 'activate-selected' => $this->is_network ? __( 'Network Activate', 'code-snippets' ) : __( 'Activate', 'code-snippets' ), - 'deactivate-selected' => $this->is_network ? __( 'Network Deactivate', 'code-snippets' ) : __( 'Deactivate', 'code-snippets' ), - 'clone-selected' => __( 'Clone', 'code-snippets' ), - 'download-selected' => __( 'Export Code', 'code-snippets' ), - 'export-selected' => __( 'Export', 'code-snippets' ), - 'delete-selected' => __( 'Move to Trash', 'code-snippets' ), - ]; - } - - return apply_filters( 'code_snippets/list_table/bulk_actions', $actions ); - } - - /** - * Retrieve the classes for the table - * - * We override this in order to add 'snippets' as a class for custom styling - * - * @return array The classes to include on the table element - */ - public function get_table_classes(): array { - $classes = array( 'widefat', $this->_args['plural'] ); - - return apply_filters( 'code_snippets/list_table/table_classes', $classes ); - } - - /** - * Retrieve the 'views' of the table - * - * Example: active, inactive, recently active - * - * @return array A list of the view labels linked to the view - */ - public function get_views(): array { - global $totals, $status; - $status_links = parent::get_views(); - - // Loop through the view counts. - foreach ( $totals as $type => $count ) { - if ( ! $count ) { - continue; - } - - switch ( $type ) { - case 'all': - // translators: %s: total number of snippets. - $template = _n( - 'All (%s)', - 'All (%s)', - $count, - 'code-snippets' - ); - break; - - case 'active': - // translators: %s: total number of active snippets. - $template = _n( - 'Active (%s)', - 'Active (%s)', - $count, - 'code-snippets' - ); - break; - - case 'inactive': - // translators: %s: total number of inactive snippets. - $template = _n( - 'Inactive (%s)', - 'Inactive (%s)', - $count, - 'code-snippets' - ); - break; - - case 'recently_activated': - // translators: %s: total number of recently activated snippets. - $template = _n( - 'Recently Active (%s)', - 'Recently Active (%s)', - $count, - 'code-snippets' - ); - break; - - case 'shared_network': - if ( ! is_multisite() ) { - continue 2; - } - - $shared_label_template = $this->is_network - ? _n_noop( - 'Shared with Subsites (%s)', - 'Shared with Subsites (%s)', - 'code-snippets' - ) - : _n_noop( - 'Network Snippets (%s)', - 'Network Snippets (%s)', - 'code-snippets' - ); - - $template = translate_nooped_plural( $shared_label_template, $count, 'code-snippets' ); - break; - - case 'trashed': - // translators: %s: total number of trashed snippets. - $template = _n( - 'Trashed (%s)', - 'Trashed (%s)', - $count, - 'code-snippets' - ); - break; - - default: - continue 2; - } - - $url = esc_url( add_query_arg( 'status', $type ) ); - $class = $type === $status ? ' class="current"' : ''; - $text = sprintf( $template, number_format_i18n( $count ) ); - - $status_links[ $type ] = sprintf( '%s', $url, $class, $text ); - } - - return apply_filters( 'code_snippets/list_table/views', $status_links ); - } - - /** - * Gets the tags of the snippets currently being viewed in the table - * - * @since 2.0 - */ - public function get_current_tags() { - global $snippets, $status; - - // If we're not viewing a snippets table, get all used tags instead. - if ( ! isset( $snippets, $status ) ) { - $tags = get_all_snippet_tags(); - } else { - $tags = array(); - - // Merge all tags into a single array. - foreach ( $snippets[ $status ] as $snippet ) { - $tags = array_merge( $snippet->tags, $tags ); - } - - // Remove duplicate tags. - $tags = array_unique( $tags ); - } - - sort( $tags ); - - return $tags; - } - - /** - * Add filters and extra actions above and below the table - * - * @param string $which Whether the actions are displayed on the before (true) or after (false) the table. - */ - public function extra_tablenav( $which ) { - /** - * Status global. - * - * @var string $status - */ - global $status; - - if ( 'top' === $which ) { - - // Tags dropdown filter. - $tags = $this->get_current_tags(); - - if ( count( $tags ) ) { - $query = isset( $_GET['tag'] ) ? sanitize_text_field( wp_unslash( $_GET['tag'] ) ) : ''; - - echo '
'; - echo ''; - - submit_button( __( 'Filter', 'code-snippets' ), 'button', 'filter_action', false ); - echo '
'; - } - } - - echo '
'; - - if ( 'recently_activated' === $status ) { - submit_button( __( 'Clear List', 'code-snippets' ), 'secondary', 'clear-recent-list', false ); - } - - do_action( 'code_snippets/list_table/actions', $which ); - - echo '
'; - } - - /** - * Output form fields needed to preserve important - * query vars over form submissions - * - * @param string $context The context in which the fields are being outputted. - */ - public static function required_form_fields( string $context = 'main' ) { - $vars = apply_filters( - 'code_snippets/list_table/required_form_fields', - array( 'page', 's', 'status', 'paged', 'tag' ), - $context - ); - - if ( 'search_box' === $context ) { - // Remove the 's' var if we're doing this for the search box. - $vars = array_diff( $vars, array( 's' ) ); - } - - foreach ( $vars as $var ) { - if ( ! empty( $_REQUEST[ $var ] ) ) { - $value = sanitize_text_field( wp_unslash( $_REQUEST[ $var ] ) ); - printf( '', esc_attr( $var ), esc_attr( $value ) ); - echo "\n"; - } - } - - do_action( 'code_snippets/list_table/print_required_form_fields', $context ); - } - - /** - * Perform an action on a single snippet. - * - * @param int $id Snippet ID. - * @param string $action Action to perform. - * - * @return bool|string Result of performing action - */ - private function perform_action( int $id, string $action ) { - switch ( $action ) { - - case 'activate': - activate_snippet( $id, $this->is_network ); - return 'activated'; - - case 'deactivate': - deactivate_snippet( $id, $this->is_network ); - return 'deactivated'; - - case 'run-once': - $this->perform_action( $id, 'activate' ); - return 'executed'; - - case 'run-once-shared': - $this->perform_action( $id, 'activate-shared' ); - return 'executed'; - - case 'activate-shared': - $active_shared_snippets = get_option( 'active_shared_network_snippets', array() ); - - if ( ! in_array( $id, $active_shared_snippets, true ) ) { - $active_shared_snippets[] = $id; - update_option( 'active_shared_network_snippets', $active_shared_snippets ); - clean_active_snippets_cache( code_snippets()->db->ms_table ); - } - - return 'activated'; - - case 'deactivate-shared': - $active_shared_snippets = get_option( 'active_shared_network_snippets', array() ); - update_option( 'active_shared_network_snippets', array_diff( $active_shared_snippets, array( $id ) ) ); - clean_active_snippets_cache( code_snippets()->db->ms_table ); - return 'deactivated'; - - case 'clone': - $this->clone_snippets( [ $id ] ); - return 'cloned'; - - case 'delete': - trash_snippet( $id, $this->is_network ); - return 'deleted'; - - case 'restore': - restore_snippet( $id, $this->is_network ); - return 'restored'; - - case 'delete_permanently': - delete_snippet( $id, $this->is_network ); - return 'deleted_permanently'; - - case 'export': - $export = new Export_Attachment( [ $id ], $this->is_network ); - $export->download_snippets_json(); - break; - - case 'download': - $export = new Export_Attachment( [ $id ], $this->is_network ); - $export->download_snippets_code(); - break; - } - - return false; - } - - /** - * Processes actions requested by the user. - * - * @return void - */ - public function process_requested_actions() { - - // Clear the recent snippets list if requested to do so. - if ( isset( $_POST['clear-recent-list'] ) ) { - check_admin_referer( 'bulk-' . $this->_args['plural'] ); - - if ( $this->is_network ) { - update_site_option( 'recently_activated_snippets', array() ); - } else { - update_option( 'recently_activated_snippets', array() ); - } - } - - // Check if there are any single snippet actions to perform. - if ( isset( $_GET['action'], $_GET['id'] ) ) { - $id = absint( $_GET['id'] ); - $scope = isset( $_GET['scope'] ) ? sanitize_key( wp_unslash( $_GET['scope'] ) ) : ''; - - // Verify they were sent from a trusted source. - $nonce_action = 'code_snippets_manage_snippet_' . $id; - if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_key( wp_unslash( $_GET['_wpnonce'] ) ), $nonce_action ) ) { - wp_nonce_ays( $nonce_action ); - } - - $_SERVER['REQUEST_URI'] = remove_query_arg( array( 'action', 'id', 'scope', '_wpnonce' ) ); - - // If so, then perform the requested action and inform the user of the result. - $result = $this->perform_action( $id, sanitize_key( $_GET['action'] ) ); - - if ( $result ) { - $redirect_args = array( 'result' => $result ); - - if ( 'deleted' === $result ) { - $redirect_args['ids'] = $id; - } - - wp_safe_redirect( esc_url_raw( add_query_arg( $redirect_args ) ) ); - exit; - } - } - - if ( isset( $_GET['action'] ) && 'restore' === $_GET['action'] && isset( $_GET['ids'] ) ) { - $ids = array_map( 'intval', explode( ',', sanitize_text_field( $_GET['ids'] ) ) ); - - if ( ! empty( $ids ) ) { - check_admin_referer( 'bulk-' . $this->_args['plural'] ); - - foreach ( $ids as $id ) { - restore_snippet( $id, $this->is_network ); - } - - wp_safe_redirect( esc_url_raw( add_query_arg( 'result', 'restored' ) ) ); - exit; - } - } - - // Only continue from this point if there are bulk actions to process. - if ( ! isset( $_POST['ids'] ) && ! isset( $_POST['shared_ids'] ) ) { - return; - } - - check_admin_referer( 'bulk-' . $this->_args['plural'] ); - - $ids = isset( $_POST['ids'] ) ? array_map( 'intval', $_POST['ids'] ) : array(); - $_SERVER['REQUEST_URI'] = remove_query_arg( 'action' ); - - switch ( $this->current_action() ) { - - case 'activate-selected': - activate_snippets( $ids ); - - // Process the shared network snippets. - if ( isset( $_POST['shared_ids'] ) && is_multisite() && ! $this->is_network ) { - $active_shared_snippets = get_option( 'active_shared_network_snippets', array() ); - - foreach ( array_map( 'intval', $_POST['shared_ids'] ) as $id ) { - if ( ! in_array( $id, $active_shared_snippets, true ) ) { - $active_shared_snippets[] = $id; - } - } - - update_option( 'active_shared_network_snippets', $active_shared_snippets ); - clean_active_snippets_cache( code_snippets()->db->ms_table ); - } - - $result = 'activated-multi'; - break; - - case 'deactivate-selected': - foreach ( $ids as $id ) { - deactivate_snippet( $id, $this->is_network ); - } - - // Process the shared network snippets. - if ( isset( $_POST['shared_ids'] ) && is_multisite() && ! $this->is_network ) { - $active_shared_snippets = get_option( 'active_shared_network_snippets', array() ); - $active_shared_snippets = ( '' === $active_shared_snippets ) ? array() : $active_shared_snippets; - $active_shared_snippets = array_diff( $active_shared_snippets, array_map( 'intval', $_POST['shared_ids'] ) ); - update_option( 'active_shared_network_snippets', $active_shared_snippets ); - clean_active_snippets_cache( code_snippets()->db->ms_table ); - } - - $result = 'deactivated-multi'; - break; - - case 'export-selected': - $export = new Export_Attachment( $ids, $this->is_network ); - $export->download_snippets_json(); - break; - - case 'download-selected': - $export = new Export_Attachment( $ids, $this->is_network ); - $export->download_snippets_code(); - break; - - case 'clone-selected': - $this->clone_snippets( $ids ); - $result = 'cloned-multi'; - break; - - case 'delete-selected': - foreach ( $ids as $id ) { - trash_snippet( $id, $this->is_network ); - } - $result = 'deleted-multi'; - break; - - case 'restore-selected': - foreach ( $ids as $id ) { - restore_snippet( $id, $this->is_network ); - } - $result = 'restored-multi'; - break; - - case 'delete-permanently-selected': - foreach ( $ids as $id ) { - delete_snippet( $id, $this->is_network ); - } - $result = 'deleted-permanently-multi'; - break; - } - - if ( isset( $result ) ) { - $redirect_args = array( 'result' => $result ); - - // Add snippet IDs for undo functionality on bulk delete - if ( 'deleted-multi' === $result && ! empty( $ids ) ) { - $redirect_args['ids'] = implode( ',', $ids ); - } - - wp_safe_redirect( esc_url_raw( add_query_arg( $redirect_args ) ) ); - exit; - } - } - - /** - * Message to display if no snippets are found. - * - * @return void - */ - public function no_items() { - - if ( ! empty( $GLOBALS['s'] ) || ! empty( $_GET['tag'] ) ) { - esc_html_e( 'No snippets were found matching the current search query. Please enter a new query or use the "Clear Filters" button above.', 'code-snippets' ); - - } else { - $add_url = code_snippets()->get_menu_url( 'add' ); - - if ( empty( $_GET['type'] ) ) { - esc_html_e( "It looks like you don't have any snippets.", 'code-snippets' ); - } else { - esc_html_e( "It looks like you don't have any snippets of this type.", 'code-snippets' ); - $add_url = add_query_arg( 'type', sanitize_key( wp_unslash( $_GET['type'] ) ), $add_url ); - } - - printf( - ' %s', - esc_url( $add_url ), - esc_html__( 'Perhaps you would like to add a new one?', 'code-snippets' ) - ); - } - } - - /** - * Fetch all shared network snippets for the current site. - * - * @param array $all_snippets List of snippets to merge with. - * - * @return array Updated list of snippets. - */ - private function fetch_shared_network_snippets( array $all_snippets ): array { - if ( ! is_multisite() ) { - return $all_snippets; - } - - $shared_ids = get_site_option( 'shared_network_snippets' ); - - if ( ! $shared_ids || ! is_array( $shared_ids ) ) { - return $all_snippets; - } - - if ( $this->is_network ) { - // Mark shared network snippets on the network admin page. - foreach ( $all_snippets as $snippet ) { - if ( in_array( $snippet->id, $shared_ids, true ) ) { - $snippet->shared_network = true; - $snippet->active = false; - } - } - } else { - // Fetch shared network snippets for subsites. - $active_shared_snippets = get_option( 'active_shared_network_snippets', array() ); - $shared_snippets = get_snippets( $shared_ids, true ); - - foreach ( $shared_snippets as $snippet ) { - $snippet->shared_network = true; - $snippet->active = in_array( $snippet->id, $active_shared_snippets, true ); - } - - $all_snippets = array_merge( $all_snippets, $shared_snippets ); - } - - return $all_snippets; - } - - /** - * Prepares the items to later display in the table. - * Should run before any headers are sent. - * - * @phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited - * - * @return void - */ - public function prepare_items() { - /** - * Global variables. - * - * @var string $status Current status view. - * @var array $snippets List of snippets for views. - * @var array $totals List of total items for views. - * @var string $s Current search term. - */ - global $status, $snippets, $totals, $s; - - wp_reset_vars( array( 'orderby', 'order', 's' ) ); - - // Redirect tag filter from POST to GET. - if ( isset( $_POST['filter_action'] ) ) { - $location = empty( $_POST['tag'] ) ? - remove_query_arg( 'tag' ) : - add_query_arg( 'tag', sanitize_text_field( wp_unslash( $_POST['tag'] ) ) ); - wp_safe_redirect( esc_url_raw( $location ) ); - exit; - } - - $this->process_requested_actions(); - $snippets = array_fill_keys( $this->statuses, array() ); - - $all_snippets = apply_filters( 'code_snippets/list_table/get_snippets', $this->fetch_shared_network_snippets( get_snippets() ) ); - - // Separate trashed snippets from the main collection - $snippets['trashed'] = array_filter( $all_snippets, function( $snippet ) { - return $snippet->is_trashed(); - }); - - // Filter out trashed snippets from the 'all' collection - $snippets['all'] = array_filter( $all_snippets, function( $snippet ) { - return ! $snippet->is_trashed(); - }); - - foreach ( $snippets['all'] as $snippet ) { - if ( $snippet->active ) { - $this->active_by_condition[ $snippet->condition_id ][] = $snippet; - } - } - - // Filter snippets by type. - $type = sanitize_key( wp_unslash( $_GET['type'] ?? '' ) ); - - if ( $type && 'all' !== $type ) { - $snippets['all'] = array_filter( - $snippets['all'], - function ( Snippet $snippet ) use ( $type ) { - return $type === $snippet->type; - } - ); - - // Filter trashed snippets by type - $snippets['trashed'] = array_filter( - $snippets['trashed'], - function ( Snippet $snippet ) use ( $type ) { - return $type === $snippet->type; - } - ); - } - - // Add scope tags to all snippets (including trashed). - foreach ( $snippets['all'] as $snippet ) { - if ( 'global' !== $snippet->scope ) { - $snippet->add_tag( $snippet->scope ); - } - } - - foreach ( $snippets['trashed'] as $snippet ) { - if ( 'global' !== $snippet->scope ) { - $snippet->add_tag( $snippet->scope ); - } - } - - // Filter snippets by tag. - if ( ! empty( $_GET['tag'] ) ) { - $snippets['all'] = array_filter( $snippets['all'], array( $this, 'tags_filter_callback' ) ); - $snippets['trashed'] = array_filter( $snippets['trashed'], array( $this, 'tags_filter_callback' ) ); - } - - // Filter snippets based on search query. - if ( $s ) { - $snippets['all'] = array_filter( $snippets['all'], array( $this, 'search_by_line_callback' ) ); - $snippets['trashed'] = array_filter( $snippets['trashed'], array( $this, 'search_by_line_callback' ) ); - } - - if ( is_multisite() ) { - $snippets['shared_network'] = array_values( - array_filter( - $snippets['all'], - static function ( Snippet $snippet ) { - return $snippet->shared_network; - } - ) - ); - } else { - $snippets['shared_network'] = array(); - } - - // Clear recently activated snippets older than a week. - $recently_activated = $this->is_network ? - get_site_option( 'recently_activated_snippets', array() ) : - get_option( 'recently_activated_snippets', array() ); - - foreach ( $recently_activated as $key => $time ) { - if ( $time + WEEK_IN_SECONDS < time() ) { - unset( $recently_activated[ $key ] ); - } - } - - $this->is_network ? - update_site_option( 'recently_activated_snippets', $recently_activated ) : - update_option( 'recently_activated_snippets', $recently_activated ); - - /** - * Filter snippets into individual sections - * - * @var Snippet $snippet - */ - foreach ( $snippets['all'] as $snippet ) { - // Skip trashed snippets (they're already in their own section) - if ( $snippet->is_trashed() ) { - continue; - } - - if ( $snippet->active || $this->is_condition_active( $snippet ) ) { - $snippets['active'][] = $snippet; - } else { - $snippets['inactive'][] = $snippet; - - // Was the snippet recently deactivated? - if ( isset( $recently_activated[ $snippet->id ] ) ) { - $snippets['recently_activated'][] = $snippet; - } - } - } - - // Count the totals for each section. - $totals = array_map( - function ( $section_snippets ) { - return count( $section_snippets ); - }, - $snippets - ); - - // If the current status is empty, default to all. - if ( empty( $snippets[ $status ] ) ) { - $status = 'all'; - } - - // Get the current data. - $data = $snippets[ $status ]; - - // Decide how many records per page to show by getting the user's setting in the Screen Options panel. - $sort_by = $this->screen->get_option( 'per_page', 'option' ); - $per_page = get_user_meta( get_current_user_id(), $sort_by, true ); - - if ( empty( $per_page ) || $per_page < 1 ) { - $per_page = $this->screen->get_option( 'per_page', 'default' ); - } - - $per_page = (int) $per_page; - - $this->set_order_vars(); - usort( $data, array( $this, 'usort_reorder_callback' ) ); - - // Determine what page the user is currently looking at. - $current_page = $this->get_pagenum(); - - // Check how many items are in the data array. - $total_items = count( $data ); - - // The WP_List_Table class does not handle pagination for us, so we need to ensure that the data is trimmed to only the current page. - $data = array_slice( $data, ( ( $current_page - 1 ) * $per_page ), $per_page ); - - // Now we can add our *sorted* data to the 'items' property, where it can be used by the rest of the class. - $this->items = $data; - - // We register our pagination options and calculations. - $this->set_pagination_args( - [ - 'total_items' => $total_items, // Calculate the total number of items. - 'per_page' => $per_page, // Determine how many items to show on a page. - 'total_pages' => ceil( $total_items / $per_page ), // Calculate the total number of pages. - ] - ); - } - - /** - * Determine the sort ordering for two pieces of data. - * - * @param mixed $a_data First piece of data. - * @param mixed $b_data Second piece of data. - * - * @return int Returns -1 if $a_data is less than $b_data; 0 if they are equal; 1 otherwise - * @ignore - */ - private function get_sort_direction( $a_data, $b_data ) { - - // If the data is numeric, then calculate the ordering directly. - if ( is_numeric( $a_data ) && is_numeric( $b_data ) ) { - return $a_data - $b_data; - } - - // If only one of the data points is empty, then place it before the one which is not. - if ( empty( $a_data ) xor empty( $b_data ) ) { - return empty( $a_data ) ? 1 : -1; - } - - // Sort using the default string sort order if possible. - if ( is_string( $a_data ) && is_string( $b_data ) ) { - return strcasecmp( $a_data, $b_data ); - } - - // Otherwise, use basic comparison operators. - return $a_data === $b_data ? 0 : ( $a_data < $b_data ? -1 : 1 ); - } - - /** - * Set the $order_by and $order_dir class variables. - */ - private function set_order_vars() { - $order = Settings\get_setting( 'general', 'list_order' ); - - // set the order by based on the query variable, if set. - if ( ! empty( $_REQUEST['orderby'] ) ) { - $this->order_by = sanitize_key( wp_unslash( $_REQUEST['orderby'] ) ); - } else { - // otherwise, fetch the order from the setting, ensuring it is valid. - $valid_fields = [ 'id', 'name', 'type', 'modified', 'priority' ]; - $order_parts = explode( '-', $order, 2 ); - - $this->order_by = in_array( $order_parts[0], $valid_fields, true ) ? $order_parts[0] : - apply_filters( 'code_snippets/list_table/default_orderby', 'priority' ); - } - - // set the order dir based on the query variable, if set. - if ( ! empty( $_REQUEST['order'] ) ) { - $this->order_dir = sanitize_key( wp_unslash( $_REQUEST['order'] ) ); - } elseif ( '-desc' === substr( $order, -5 ) ) { - $this->order_dir = 'desc'; - } elseif ( '-asc' === substr( $order, -4 ) ) { - $this->order_dir = 'asc'; - } else { - $this->order_dir = apply_filters( 'code_snippets/list_table/default_order', 'asc' ); - } - } - - /** - * Callback for usort() used to sort snippets - * - * @param Snippet $a The first snippet to compare. - * @param Snippet $b The second snippet to compare. - * - * @return int The sort order. - * @ignore - */ - private function usort_reorder_callback( Snippet $a, Snippet $b ) { - $orderby = $this->order_by; - $result = $this->get_sort_direction( $a->$orderby, $b->$orderby ); - - if ( 0 === $result && 'id' !== $orderby ) { - $result = $this->get_sort_direction( $a->id, $b->id ); - } - - // Apply the sort direction to the calculated order. - return ( 'asc' === $this->order_dir ) ? $result : -$result; - } - - /** - * Callback for search function - * - * @param Snippet $snippet The snippet being filtered. - * - * @return bool The result of the filter - * @ignore - */ - private function search_callback( Snippet $snippet ): bool { - global $s; - - $query = sanitize_text_field( wp_unslash( $s ) ); - $fields = [ 'name', 'desc', 'code', 'tags_list' ]; - - foreach ( $fields as $field ) { - if ( false !== stripos( $snippet->$field, $query ) ) { - return true; - } - } - - return false; - } - - /** - * Callback for search function - * - * @param Snippet $snippet The snippet being filtered. - * - * @return bool The result of the filter - * @ignore - */ - private function search_by_line_callback( Snippet $snippet ): bool { - global $s; - static $line_num; - - if ( is_null( $line_num ) ) { - - if ( preg_match( '/@line:(?P\d+)/', $s, $matches ) ) { - $s = trim( str_replace( $matches[0], '', $s ) ); - $line_num = (int) $matches['line'] - 1; - } else { - $line_num = -1; - } - } - - if ( $line_num < 0 ) { - return $this->search_callback( $snippet ); - } - - $code_lines = explode( "\n", $snippet->code ); - - return isset( $code_lines[ $line_num ] ) && false !== stripos( $code_lines[ $line_num ], $s ); - } - - /** - * Callback for filtering snippets by tag. - * - * @param Snippet $snippet The snippet being filtered. - * - * @return bool The result of the filter. - * @ignore - */ - private function tags_filter_callback( Snippet $snippet ): bool { - $tags = isset( $_GET['tag'] ) ? - explode( ',', sanitize_text_field( wp_unslash( $_GET['tag'] ) ) ) : - array(); - - foreach ( $tags as $tag ) { - if ( in_array( $tag, $snippet->tags, true ) ) { - return true; - } - } - - return false; - } - - /** - * Display a notice showing the current search terms - * - * @since 1.7 - */ - public function search_notice() { - if ( ! empty( $_REQUEST['s'] ) || ! empty( $_GET['tag'] ) ) { - - echo '' . esc_html__( 'Search results', 'code-snippets' ); - - if ( ! empty( $_REQUEST['s'] ) ) { - $s = sanitize_text_field( wp_unslash( $_REQUEST['s'] ) ); - - if ( preg_match( '/@line:(?P\d+)/', $s, $matches ) ) { - - // translators: 1: search query, 2: line number. - $text = __( ' for “%1$s” on line %2$d', 'code-snippets' ); - printf( - esc_html( $text ), - esc_html( trim( str_replace( $matches[0], '', $s ) ) ), - intval( $matches['line'] ) - ); - - } else { - // translators: %s: search query. - echo esc_html( sprintf( __( ' for “%s”', 'code-snippets' ), $s ) ); - } - } - - if ( ! empty( $_GET['tag'] ) ) { - $tag = sanitize_text_field( wp_unslash( $_GET['tag'] ) ); - // translators: %s: tag name. - echo esc_html( sprintf( __( ' in tag “%s”', 'code-snippets' ), $tag ) ); - } - - echo ''; - - // translators: 1: link URL, 2: link text. - printf( - ' %s', - esc_url( remove_query_arg( array( 's', 'tag', 'cloud_search' ) ) ), - esc_html__( 'Clear Filters', 'code-snippets' ) - ); - } - } - - /** - * Outputs content for a single row of the table - * - * @param Snippet $item The snippet being used for the current row. - */ - public function single_row( $item ) { - $status = $item->active || $this->is_condition_active( $item ) ? 'active' : 'inactive'; - $row_class = "snippet $status-snippet $item->type-snippet $item->scope-scope"; - - if ( $item->shared_network ) { - $row_class .= ' shared-network-snippet'; - } - - printf( '', esc_attr( $row_class ), esc_attr( $item->scope ) ); - $this->single_row_columns( $item ); - echo ''; - } - - /** - * Clone a selection of snippets - * - * @param array $ids List of snippet IDs. - */ - private function clone_snippets( array $ids ) { - $snippets = get_snippets( $ids, $this->is_network ); - - foreach ( $snippets as $snippet ) { - $snippet->id = 0; - $snippet->active = false; - $snippet->cloud_id = ''; - - // translators: %s: snippet title. - $snippet->name = sprintf( __( '%s [CLONE]', 'code-snippets' ), $snippet->name ); - $snippet = apply_filters( 'code_snippets/list_table/clone_snippet', $snippet ); - - save_snippet( $snippet ); - } - } -} diff --git a/src/php/class-plugin.php b/src/php/class-plugin.php deleted file mode 100644 index 7f5390c63..000000000 --- a/src/php/class-plugin.php +++ /dev/null @@ -1,433 +0,0 @@ -version = $version; - $this->file = $file; - - wp_cache_add_global_groups( CACHE_GROUP ); - - add_filter( 'code_snippets/execute_snippets', array( $this, 'disable_snippet_execution' ), 5 ); - - if ( isset( $_REQUEST['snippets-safe-mode'] ) ) { - add_filter( 'home_url', array( $this, 'add_safe_mode_query_var' ) ); - add_filter( 'admin_url', array( $this, 'add_safe_mode_query_var' ) ); - } - - add_action( 'rest_api_init', [ $this, 'init_rest_api' ] ); - add_action( 'allowed_redirect_hosts', [ $this, 'allow_code_snippets_redirect' ] ); - } - - /** - * Initialise classes and include files - */ - public function load_plugin() { - $includes_path = __DIR__; - - // Database operation functions. - $this->db = new DB(); - - // Snippet operation functions. - require_once $includes_path . '/snippet-ops.php'; - $this->evaluate_content = new Evaluate_Content( $this->db ); - $this->evaluate_functions = new Evaluate_Functions( $this->db ); - - // CodeMirror editor functions. - require_once $includes_path . '/editor.php'; - - // General Administration functions. - if ( is_admin() ) { - $this->admin = new Admin(); - } - - // Settings component. - require_once $includes_path . '/settings/settings-fields.php'; - require_once $includes_path . '/settings/editor-preview.php'; - require_once $includes_path . '/settings/class-version-switch.php'; - require_once $includes_path . '/settings/settings.php'; - - // Cloud List Table shared functions. - require_once $includes_path . '/cloud/list-table-shared-ops.php'; - - // Snippet files. - $this->snippet_handler_registry = new Snippet_Handler_Registry( [ - 'php' => new Php_Snippet_Handler(), - 'html' => new Html_Snippet_Handler(), - ] ); - - $fs = new WordPress_File_System_Adapter(); - - $config_repo = new Snippet_Config_Repository( $fs ); - - ( new Snippet_Files( $this->snippet_handler_registry, $fs, $config_repo ) )->register_hooks(); - - $this->front_end = new Front_End(); - $this->cloud_api = new Cloud_API(); - - $upgrade = new Upgrade( $this->version, $this->db ); - add_action( 'plugins_loaded', array( $upgrade, 'run' ), 0 ); - $this->licensing = new Licensing(); - - // Importers. - new Plugins_Import_Manager(); - new Files_Import_Manager(); - } - - /** - * Register custom REST API controllers. - * - * @return void - */ - public function init_rest_api() { - $snippets_controller = new Snippets_REST_Controller(); - $snippets_controller->register_routes(); - } - - /** - * Disable snippet execution if the necessary query var is set. - * - * @param bool $execute_snippets Current filter value. - * - * @return bool New filter value. - */ - public function disable_snippet_execution( bool $execute_snippets ): bool { - return ! empty( $_REQUEST['snippets-safe-mode'] ) && $this->current_user_can() ? false : $execute_snippets; - } - - /** - * Determine whether the menu is full or compact. - * - * @return bool - */ - public function is_compact_menu(): bool { - return ! is_network_admin() && apply_filters( 'code_snippets_compact_menu', false ); - } - - /** - * Fetch the admin menu slug for a menu. - * - * @param string $menu Name of menu to retrieve the slug for. - * - * @return string The menu's slug. - */ - public function get_menu_slug( string $menu = '' ): string { - $add = array( 'single', 'add', 'add-new', 'add-snippet', 'new-snippet', 'add-new-snippet' ); - $edit = array( 'edit', 'edit-snippet' ); - $import = array( 'import', 'import-snippets', 'import-code-snippets' ); - $settings = array( 'settings', 'snippets-settings' ); - $cloud = array( 'cloud', 'cloud-snippets' ); - $welcome = array( 'welcome', 'getting-started', 'code-snippets' ); - - if ( in_array( $menu, $edit, true ) ) { - return 'edit-snippet'; - } elseif ( in_array( $menu, $add, true ) ) { - return 'add-snippet'; - } elseif ( in_array( $menu, $import, true ) ) { - return 'import-code-snippets'; - } elseif ( in_array( $menu, $settings, true ) ) { - return 'snippets-settings'; - } elseif ( in_array( $menu, $cloud, true ) ) { - return 'snippets&type=cloud'; - } elseif ( in_array( $menu, $welcome, true ) ) { - return 'code-snippets-welcome'; - } else { - return 'snippets'; - } - } - - /** - * Fetch the URL to a snippets admin menu. - * - * @param string $menu Name of menu to retrieve the URL to. - * @param string $context URL scheme to use. - * - * @return string The menu's URL. - */ - public function get_menu_url( string $menu = '', string $context = 'self' ): string { - $slug = $this->get_menu_slug( $menu ); - - if ( $this->is_compact_menu() && 'network' !== $context ) { - $base_slug = $this->get_menu_slug(); - $url = 'tools.php?page=' . $base_slug; - - if ( $slug !== $base_slug ) { - $url .= '&sub=' . $slug; - } - } else { - $url = 'admin.php?page=' . $slug; - } - - if ( 'network' === $context ) { - return network_admin_url( $url ); - } elseif ( 'admin' === $context ) { - return admin_url( $url ); - } else { - return self_admin_url( $url ); - } - } - - /** - * Fetch the admin menu slug for a snippets admin menu. - * - * @param integer $snippet_id Snippet ID. - * @param string $context URL scheme to use. - * - * @return string The URL to the edit snippet page for that snippet. - */ - public function get_snippet_edit_url( int $snippet_id, string $context = 'self' ): string { - return add_query_arg( - 'id', - absint( $snippet_id ), - $this->get_menu_url( 'edit', $context ) - ); - } - - /** - * Allow redirecting to the Code Snippets site. - * - * @param array $hosts Allowed hosts. - * - * @return array Modified allowed hosts. - */ - public function allow_code_snippets_redirect( array $hosts ): array { - $hosts[] = 'codesnippets.pro'; - $hosts[] = 'snipco.de'; - return $hosts; - } - - /** - * Determine whether the current user can perform actions on snippets. - * - * @return boolean Whether the current user has the required capability. - * - * @since 2.8.6 - */ - public function current_user_can(): bool { - return current_user_can( $this->get_cap() ); - } - - /** - * Retrieve the name of the capability required to manage sub-site snippets. - * - * @return string - */ - public function get_cap_name(): string { - return apply_filters( 'code_snippets_cap', 'manage_options' ); - } - - /** - * Retrieve the name of the capability required to manage network snippets. - * - * @return string - */ - public function get_network_cap_name(): string { - return apply_filters( 'code_snippets_network_cap', 'manage_network_options' ); - } - - /** - * Determine if a subsite user menu is enabled via *Network Settings > Enable administration menus*. - * - * @return bool - */ - public function is_subsite_menu_enabled(): bool { - if ( ! is_multisite() ) { - return true; - } - - $menu_perms = get_site_option( 'menu_items', array() ); - return ! empty( $menu_perms['snippets'] ); - } - - /** - * Determine if the current user should have the network snippets capability. - * - * @return bool - */ - public function user_can_manage_network_snippets(): bool { - return is_super_admin() || current_user_can( $this->get_network_cap_name() ); - } - - /** - * Determine whether the current request originates in the network admin. - * - * @return bool - */ - public function is_network_context(): bool { - return is_network_admin(); - } - - /** - * Get the required capability to perform a certain action on snippets. - * Does not check if the user has this capability or not. - * - * If multisite, adjusts the capability based on whether the user is viewing - * the network dashboard or a subsite and whether the menu is enabled for subsites. - * - * @return string The capability required to manage snippets. - * - * @since 2.0 - */ - public function get_cap(): string { - if ( is_multisite() && $this->is_network_context() ) { - return $this->get_network_cap_name(); - } - - if ( is_multisite() && ! $this->is_subsite_menu_enabled() ) { - return $this->get_network_cap_name(); - } - - return $this->get_cap_name(); - } - - /** - * Inject the safe mode query var into URLs - * - * @param string $url Original URL. - * - * @return string Modified URL. - */ - public function add_safe_mode_query_var( string $url ): string { - return isset( $_REQUEST['snippets-safe-mode'] ) ? - add_query_arg( 'snippets-safe-mode', (bool) $_REQUEST['snippets-safe-mode'], $url ) : - $url; - } - - /** - * Retrieve a list of available snippet types and their labels. - * - * @return array Snippet types. - */ - public static function get_types(): array { - return apply_filters( - 'code_snippets_types', - array( - 'php' => __( 'Functions', 'code-snippets' ), - 'html' => __( 'Content', 'code-snippets' ), - 'css' => __( 'Styles', 'code-snippets' ), - 'js' => __( 'Scripts', 'code-snippets' ), - 'cloud' => __( 'Codevault', 'code-snippets' ), - 'cloud_search' => __( 'Cloud Search', 'code-snippets' ), - 'bundles' => __( 'Bundles', 'code-snippets' ), - ) - ); - } - - /** - * Localise a plugin script to provide the CODE_SNIPPETS object. - * - * @param string $handle Script handle. - * - * @return void - */ - public function localize_script( string $handle ) { - wp_localize_script( - $handle, - 'CODE_SNIPPETS', - [ - 'isLicensed' => $this->licensing->is_licensed(), - 'isCloudConnected' => Cloud_API::is_cloud_connection_available(), - 'restAPI' => [ - 'base' => esc_url_raw( rest_url() ), - 'snippets' => esc_url_raw( rest_url( Snippets_REST_Controller::get_base_route() ) ), - 'nonce' => wp_create_nonce( 'wp_rest' ), - 'localToken' => $this->cloud_api->get_local_token(), - ], - 'urls' => [ - 'plugin' => esc_url_raw( plugins_url( '', PLUGIN_FILE ) ), - 'manage' => esc_url_raw( $this->get_menu_url() ), - 'edit' => esc_url_raw( $this->get_menu_url( 'edit' ) ), - 'addNew' => esc_url_raw( $this->get_menu_url( 'add' ) ), - ], - ] - ); - } -} diff --git a/src/php/cloud/class-cloud-api.php b/src/php/cloud/class-cloud-api.php deleted file mode 100644 index 788ce8cae..000000000 --- a/src/php/cloud/class-cloud-api.php +++ /dev/null @@ -1,527 +0,0 @@ -cached_cloud_links ) ) { - return $this->cached_cloud_links; - } - - // Fetch data from the stored transient, if available. - $transient_data = get_transient( self::CLOUD_MAP_TRANSIENT_KEY ); - if ( is_array( $transient_data ) ) { - $this->cached_cloud_links = $transient_data; - return $this->cached_cloud_links; - } - - // Otherwise, regenerate the local-to-cloud-map. - $this->cached_cloud_links = []; - - // Fetch and iterate through all local snippets to create the map. - foreach ( get_snippets() as $local_snippet ) { - // Skip snippets that are only stored locally. - if ( ! $local_snippet->cloud_id ) { - continue; - } - - $link = new Cloud_Link(); - $cloud_id_owner = $this->get_cloud_id_and_ownership( $local_snippet->cloud_id ); - $cloud_id_int = intval( $cloud_id_owner['cloud_id'] ); - $link->local_id = $local_snippet->id; - $link->cloud_id = $cloud_id_int; - $link->is_owner = $cloud_id_owner['is_owner']; - // Check if cloud id exists in cloud_id_rev array - this shows if the snippet is in the codevault. - $link->in_codevault = $cloud_id_rev[ $cloud_id_int ] ?? false; - - // Get the cloud snippet revision if in codevault get from cloud_id_rev array otherwise get from cloud. - if ( $link->in_codevault ) { - $cloud_snippet_revision = $cloud_id_rev[ $cloud_id_int ] ?? $this->get_cloud_snippet_revision( $local_snippet->cloud_id ); - $link->update_available = $local_snippet->revision < $cloud_snippet_revision; - } - - $this->cached_cloud_links[] = $link; - } - - set_transient( - self::CLOUD_MAP_TRANSIENT_KEY, - $this->cached_cloud_links, - DAY_IN_SECONDS * self::DAYS_TO_STORE_CS - ); - - return $this->cached_cloud_links; - } - - /** - * Get ownership and Cloud ID of a snippet. - * - * @param string $cloud_id Cloud ID. - * - * @return array - */ - public function get_cloud_id_and_ownership( string $cloud_id ): array { - $cloud_id_owner = explode( '_', $cloud_id ); - - return [ - 'cloud_id' => (int) $cloud_id_owner[0] ?? '', - 'is_owner' => isset( $cloud_id_owner[1] ) && $cloud_id_owner[1], - 'is_owner_string' => isset( $cloud_id_owner[1] ) && $cloud_id_owner[1] ? '1' : '0', - ]; - } - - /** - * Unpack JSON data from a request response. - * - * @param array|WP_Error $response Response from wp_request_*. - * - * @return array|null Associative array of JSON data on success, null on failure. - */ - private static function unpack_request_json( $response ): ?array { - $body = wp_remote_retrieve_body( $response ); - return $body ? json_decode( $body, true ) : null; - } - - /** - * Search Code Snippets Cloud -> Static Function - * - * @param string $search_method Search by name of codevault or keyword(s). - * @param string $search Search query. - * @param integer $page Search result page to retrieve. Defaults to '0'. - * - * @return Cloud_Snippets Result of search query. - */ - public static function fetch_search_results( string $search_method, string $search, int $page = 0 ): Cloud_Snippets { - $api_url = add_query_arg( - [ - 's_method' => $search_method, - 's' => $search, - 'page' => $page, - 'site_token' => self::get_local_token(), - 'site_host' => wp_parse_url( get_site_url(), PHP_URL_HOST ), - ], - self::get_cloud_api_url() . 'public/search' - ); - - $raw = self::unpack_request_json( wp_remote_get( $api_url ) ); - - $results = new Cloud_Snippets( $raw ); - $results->page = $page; - - return $results; - } - - /** - * Add a new link item to the local-to-cloud map. - * - * @param Cloud_Link $link Link to add. - * - * @return void - */ - public function add_cloud_link( Cloud_Link $link ) { - $local_to_cloud_map = get_transient( self::CLOUD_MAP_TRANSIENT_KEY ); - $local_to_cloud_map[] = $link; - - set_transient( - self::CLOUD_MAP_TRANSIENT_KEY, - $local_to_cloud_map, - DAY_IN_SECONDS * self::DAYS_TO_STORE_CS - ); - } - - /** - * Delete a snippet from local-to-cloud map. - * - * @param int $snippet_id Local snippet ID. - * - * @return void - */ - public function delete_snippet_from_transient_data( int $snippet_id ) { - if ( ! $this->cached_cloud_links ) { - $this->get_cloud_links(); - } - - foreach ( $this->cached_cloud_links as $link ) { - if ( $link->local_id === $snippet_id ) { - // Remove the link from the local_to_cloud_map. - $index = array_search( $link, $this->cached_cloud_links, true ); - unset( $this->cached_cloud_links[ $index ] ); - - // Update the transient data. - set_transient( - self::CLOUD_MAP_TRANSIENT_KEY, - $this->cached_cloud_links, - DAY_IN_SECONDS * self::DAYS_TO_STORE_CS - ); - } - } - } - - /** - * Retrieve a single cloud snippet from the API. - * - * @param int $cloud_id Remote cloud snippet ID. - * - * @return Cloud_Snippet Retrieved snippet. - */ - public static function get_single_snippet_from_cloud( int $cloud_id ): Cloud_Snippet { - $url = self::get_cloud_api_url() . sprintf( 'public/getsnippet/%s', $cloud_id ); - $response = wp_remote_get( $url ); - $cloud_snippet = self::unpack_request_json( $response ); - return new Cloud_Snippet( $cloud_snippet['snippet'] ); - } - - /** - * Get the current revision of a single cloud snippet. - * - * @param string $cloud_id Cloud snippet ID. - * - * @return string|null Revision number on success, null otherwise. - */ - public static function get_cloud_snippet_revision( string $cloud_id ): ?string { - $api_url = self::get_cloud_api_url() . sprintf( 'public/getsnippetrevision/%s', $cloud_id ); - $body = wp_remote_retrieve_body( wp_remote_get( $api_url ) ); - - if ( ! $body ) { - return null; - } - - $cloud_snippet_revision = json_decode( $body, true ); - return $cloud_snippet_revision['snippet_revision'] ?? null; - } - - /** - * Download a snippet from the cloud. - * - * @param int|string $cloud_id The cloud ID of the snippet as string from query args. - * @param string $source Unused in Core. - * @param string $action The action to be performed: 'download' or 'update'. - * - * @return array Result of operation: an array with `success` and `error_message` keys. - * - * @noinspection PhpUnusedParameterInspection - */ - public function download_or_update_snippet( int $cloud_id, string $source, string $action ): array { - $cloud_id = intval( $cloud_id ); - $snippet_to_store = $this->get_single_snippet_from_cloud( $cloud_id ); - - switch ( $action ) { - case 'download': - return $this->download_snippet_from_cloud( $snippet_to_store ); - case 'update': - return $this->update_snippet_from_cloud( $snippet_to_store ); - default: - return [ - 'success' => false, - 'error' => __( 'Invalid action.', 'code-snippets' ), - ]; - } - } - - /** - * Download a snippet from the cloud. - * - * @param Cloud_Snippet $snippet_to_store The snippet to be downloaded. - * - * @return array The result of the download. - */ - public function download_snippet_from_cloud( Cloud_Snippet $snippet_to_store ): array { - $snippet = new Snippet( $snippet_to_store ); - - // Set the snippet id to 0 to ensure that the snippet is saved as a new snippet. - $ownership = $snippet_to_store->is_owner ? '1' : '0'; - $snippet->id = 0; - $snippet->active = 0; - $snippet->cloud_id = $snippet_to_store->id . '_' . $ownership; - $snippet->desc = $snippet_to_store->description ? $snippet_to_store->description : ''; - - // Save the snippet to the database. - $new_snippet = save_snippet( $snippet ); - - $link = new Cloud_Link(); - $link->local_id = $new_snippet->id; - $link->cloud_id = $snippet_to_store->id; - $link->is_owner = $snippet_to_store->is_owner; - $link->in_codevault = false; - $link->update_available = false; - - $this->add_cloud_link( $link ); - - return [ - 'success' => true, - 'action' => 'Single Downloaded', - 'snippet_id' => $new_snippet->id, - 'link_id' => $link->cloud_id, - ]; - } - - /** - * Update a snippet from the cloud. - * - * @param Cloud_Snippet $snippet_to_store Snippet to be updated. - * - * @return array The result of the update. - */ - public function update_snippet_from_cloud( Cloud_Snippet $snippet_to_store ): array { - $cloud_id = $snippet_to_store->id . '_' . ( $snippet_to_store->is_owner ? '1' : '0' ); - - $local_snippet = get_snippet_by_cloud_id( sanitize_key( $cloud_id ) ); - - // Only update the code, active and revision fields. - $fields = [ - 'code' => $snippet_to_store->code, - 'active' => false, - 'revision' => $snippet_to_store->revision, - ]; - - update_snippet_fields( $local_snippet->id, $fields ); - $this->clear_caches(); - - return [ - 'success' => true, - 'action' => __( 'Updated', 'code-snippets' ), - ]; - } - - /** - * Find the cloud link for a given cloud snippet identifier. - * - * @param int $cloud_id Cloud ID. - * - * @return Cloud_Link|null - */ - public function get_link_for_cloud_id( int $cloud_id ): ?Cloud_Link { - $cloud_links = $this->get_cloud_links(); - - if ( $cloud_links ) { - foreach ( $cloud_links as $cloud_link ) { - if ( $cloud_link->cloud_id === $cloud_id ) { - return $cloud_link; - } - } - } - - return null; - } - - - /** - * Find the cloud link for a given cloud snippet. - * - * @param Cloud_Snippet $cloud_snippet Cloud snippet. - * - * @return Cloud_Link|null - */ - public function get_link_for_cloud_snippet( Cloud_Snippet $cloud_snippet ): ?Cloud_Link { - return $this->get_link_for_cloud_id( $cloud_snippet->id ); - } - - /** - * Translate a snippet scope to a type. - * - * @param string $scope The scope of the snippet. - * - * @return string The type of the snippet. - */ - public static function get_type_from_scope( string $scope ): string { - switch ( $scope ) { - case 'global': - return 'php'; - case 'site-css': - return 'css'; - case 'site-footer-js': - return 'js'; - case 'content': - return 'html'; - default: - return ''; - } - } - - /** - * Get the label for a given cloud status. - * - * @param int $status Cloud status code. - * - * @return string The label for the status. - */ - public static function get_status_label( int $status ): string { - $labels = [ - self::STATUS_PRIVATE => __( 'Private', 'code-snippets' ), - self::STATUS_PUBLIC => __( 'Public', 'code-snippets' ), - self::STATUS_UNVERIFIED => __( 'Unverified', 'code-snippets' ), - self::STATUS_AI_VERIFIED => __( 'AI Verified', 'code-snippets' ), - self::STATUS_PRO_VERIFIED => __( 'Pro Verified', 'code-snippets' ), - ]; - - return $labels[ $status ] ?? __( 'Unknown', 'code-snippets' ); - } - - /** - * Get the badge class for a given cloud status. - * - * @param int $status Cloud status code. - * - * @return string - */ - public static function get_status_badge( int $status ): string { - $badge_names = [ - self::STATUS_PRIVATE => 'private', - self::STATUS_PUBLIC => 'public', - self::STATUS_UNVERIFIED => 'failure', - self::STATUS_AI_VERIFIED => 'success', - self::STATUS_PRO_VERIFIED => 'info', - ]; - - return $badge_names[ $status ] ?? 'neutral'; - } - - /** - * Renders the html for the preview thickbox popup. - * - * @return void - */ - public static function render_cloud_snippet_thickbox() { - add_thickbox(); - ?> - - cached_cloud_links = null; - - delete_transient( self::CLOUD_MAP_TRANSIENT_KEY ); - } -} diff --git a/src/php/cloud/class-cloud-link.php b/src/php/cloud/class-cloud-link.php deleted file mode 100644 index 21671325c..000000000 --- a/src/php/cloud/class-cloud-link.php +++ /dev/null @@ -1,61 +0,0 @@ -|object $data Initial data fields. - */ - public function __construct( $data = null ) { - parent::__construct( - [ - 'local_id' => 0, - 'cloud_id' => 0, - 'is_owner' => false, - 'in_codevault' => false, - 'update_available' => false, - ], - $data - ); - } - - /** - * Prepare a value before it is stored. - * - * @param mixed $value Value to prepare. - * @param string $field Field name. - * - * @return mixed Value in the correct format. - */ - protected function prepare_field( $value, string $field ) { - switch ( $field ) { - case 'local_id': - case 'remote_id': - return absint( $value ); - - case 'is_owner': - case 'in_codevault': - case 'update_available': - return is_bool( $value ) ? $value : (bool) $value; - - default: - return $value; - } - } -} diff --git a/src/php/cloud/class-cloud-search-list-table.php b/src/php/cloud/class-cloud-search-list-table.php deleted file mode 100644 index 40cad9d40..000000000 --- a/src/php/cloud/class-cloud-search-list-table.php +++ /dev/null @@ -1,361 +0,0 @@ - 'cloud-snippet', - 'plural' => 'cloud-snippets', - 'ajax' => false, - ] - ); - - // Strip the result query arg from the URL. - $_SERVER['REQUEST_URI'] = remove_query_arg( [ 'result' ] ); - - $this->cloud_api = code_snippets()->cloud_api; - } - - /** - * Prepare items for the table. - * - * @return void - */ - public function prepare_items() { - $per_page = $this->get_items_per_page( 'snippets_per_page', 10 ); - $user_per_page = (int) get_user_option( 'snippets_per_page', get_current_user_id() ); - if ( $user_per_page > 0 ) { - $per_page = $user_per_page; - } - - // Fetch snippets, passing a 0-based page index to the Cloud API (WP list tables are 1-based). - $page_index = max( 0, $this->get_pagenum() - 1 ); - $this->cloud_snippets = $this->fetch_snippets( $per_page, $page_index ); - $this->items = $this->cloud_snippets->snippets; - - $this->process_actions(); - - $this->set_pagination_args( - [ - 'per_page' => $per_page, - 'total_items' => $this->cloud_snippets->total_snippets, - 'total_pages' => $this->cloud_snippets->total_pages, - ] - ); - } - - /** - * Process any actions that have been submitted, such as downloading cloud snippets to the local database. - * - * @return void - */ - public function process_actions() { - $_SERVER['REQUEST_URI'] = remove_query_arg( - [ 'action', 'snippet', '_wpnonce', 'source', 'cloud-bundle-run', 'cloud-bundle-show', 'bundle_share_name', 'cloud_bundles' ] - ); - - // Check request is coming from the cloud search page. - if ( ! isset( $_REQUEST['type'] ) || 'cloud_search' !== sanitize_key( wp_unslash( $_REQUEST['type'] ) ) ) { - return; - } - - if ( ! isset( $_REQUEST['action'], $_REQUEST['snippet'], $_REQUEST['source'] ) ) { - return; - } - - $action = sanitize_key( wp_unslash( $_REQUEST['action'] ) ); - $source = sanitize_key( wp_unslash( $_REQUEST['source'] ) ); - $snippet_id = absint( wp_unslash( $_REQUEST['snippet'] ) ); - - if ( ! in_array( $action, [ 'download', 'update' ], true ) ) { - return; - } - - if ( ! $snippet_id ) { - return; - } - - check_admin_referer( cloud_lts_get_snippet_action_nonce_action( $action, $snippet_id, $source ) ); - - cloud_lts_process_download_action( - $action, - $source, - (string) $snippet_id, - ); - } - - /** - * Output table rows. - * - * @return void - */ - public function display_rows() { - $status_descriptions = [ - Cloud_API::STATUS_PUBLIC => - __( 'Snippet has passed basic review.', 'code-snippets' ), - Cloud_API::STATUS_AI_VERIFIED => - __( 'Snippet has been tested by our AI bot.', 'code-snippets' ), - Cloud_API::STATUS_UNVERIFIED => - __( 'Snippet has not undergone any review yet.', 'code-snippets' ), - ]; - - /** - * The current table item. - * - * @var $item Cloud_Snippet - */ - foreach ( $this->items as $item ) { - ?> -
- -
-
-

- tags ) > 0 ? strtolower( esc_attr( $item->tags[0] ) ) : 'general'; - - printf( - '%s', - esc_url( "https://codesnippets.cloud/images/plugin-icons/$category-logo.png" ), - esc_attr( $category ) - ); - - $link = code_snippets()->cloud_api->get_link_for_cloud_snippet( $item ); - - if ( $link ) { - printf( '', esc_url( code_snippets()->get_snippet_edit_url( $link->local_id ) ) ); - } else { - printf( - '', - '#TB_inline?&width=700&height=500&inlineId=show-code-preview', - esc_attr__( 'Preview this snippet', 'code-snippets' ), - esc_attr( $item->id ), - esc_attr( Cloud_API::get_type_from_scope( $item->scope ) ) - ); - } - - echo esc_html( $item->name ); - - - - echo ''; - ?> -

-
    - -
-
-
-

process_description( $item->description ) ); ?>

-

- - %s', - esc_html__( 'Codevault:', 'code-snippets' ), - esc_url( sprintf( 'https://codesnippets.cloud/codevault/%s', $item->codevault ) ), - esc_html( $item->codevault ) - ); - ?> - -

-
-
-
-
-
-
- cloud_api->get_status_label( $item->status ) ); - - if ( isset( $status_descriptions[ $item->status ] ) ) { - echo ''; - printf( '
%s
', esc_html( $status_descriptions[ $item->status ] ) ); - } - ?> -
-
- -
- - - - -
- -
- - updated ) ) ) ); - ?> -
-
-
-
- 150 ? substr( $description, 0, 150 ) . '…' : $description; - } - - /** - * Text displayed when no snippet data is available. - * - * @return void - */ - public function no_items() { - if ( ! empty( $_REQUEST['cloud_search'] ) && count( $this->cloud_snippets->snippets ) < 1 ) { - echo '

', - esc_html__( 'No snippets or codevault could be found with that search term. Please try again.', 'code-snippets' ), - '

'; - } else { - echo '

', esc_html__( 'Please enter a term to start searching code snippets in the cloud.', 'code-snippets' ), '

'; - } - } - - /** - * Fetch the snippets used to populate the table. - * - * @return Cloud_Snippets - */ - public function fetch_snippets( int $per_page = 10, int $page_index = 0 ): Cloud_Snippets { - // Check if search term has been entered. - if ( isset( $_REQUEST['type'], $_REQUEST['cloud_search'], $_REQUEST['cloud_select'] ) && - 'cloud_search' === sanitize_key( wp_unslash( $_REQUEST['type'] ) ) - ) { - // If we have a search query, then send a search request to cloud server API search endpoint. - $search_query = sanitize_text_field( wp_unslash( $_REQUEST['cloud_search'] ) ); - $search_by = sanitize_text_field( wp_unslash( $_REQUEST['cloud_select'] ) ); - - // Pass the provided 0-based page index to the API. - return Cloud_API::fetch_search_results( $search_by, $search_query, $page_index ); - } - - // If no search results, then return empty object. - return new Cloud_Snippets(); - } - - /** - * Gets the current search result page number. - * - * @return integer - */ - public function get_pagenum(): int { - $page = isset( $_REQUEST['search_page'] ) ? absint( $_REQUEST['search_page'] ) : 0; - - if ( isset( $this->_pagination_args['total_pages'] ) && $page > $this->_pagination_args['total_pages'] ) { - $page = $this->_pagination_args['total_pages']; - } - - return max( 1, $page ); - } - - /** - * Display the table. - * - * @return void - */ - public function display() { - Cloud_API::render_cloud_snippet_thickbox(); - parent::display(); - } - - /** - * Displays the pagination. - * - * @param string $which Context where the pagination will be displayed. - * - * @return void - */ - protected function pagination( $which ) { - if ( empty( $this->_pagination_args ) ) { - return; - } - - $total_items = $this->_pagination_args['total_items'] ?? 0; - $total_pages = $this->_pagination_args['total_pages'] ?? 0; - // get_pagenum already returns a 1-based page number used for display. - $pagenum_display = $this->get_pagenum(); - - if ( 'top' === $which && $total_pages >= 1 ) { - $this->screen->render_screen_reader_content( 'heading_pagination' ); - } - - $paginate = cloud_lts_pagination( $which, 'search', $total_items, $total_pages, $pagenum_display ); - $page_class = $paginate['page_class']; - $output = $paginate['output']; - - $this->_pagination = "
$output
"; - - echo wp_kses_post( $this->_pagination ); - } -} diff --git a/src/php/cloud/class-cloud-snippet.php b/src/php/cloud/class-cloud-snippet.php deleted file mode 100644 index 326434901..000000000 --- a/src/php/cloud/class-cloud-snippet.php +++ /dev/null @@ -1,87 +0,0 @@ - $tags An array of the tags. - * @property string $scope The scope name. - * @property string $codevault Name of user codevault. - * @property string $total_votes The total number of votes. - * @property string $vote_count The number of actual votes. - * @property string $wp_tested Tested with WP version. - * @property string $status Snippet Status ID. - * @property string $created The date and time when the snippet data was first created, in ISO format. - * @property string $updated When the snippet was last updated, in ISO format. - * @property integer $revision The update revision number. - * @property bool $is_owner If user is owner or author of snippet. - */ -class Cloud_Snippet extends Data_Item { - - /** - * Constructor function. - * - * @param array|null $initial_data Initial snippet data. - */ - public function __construct( ?array $initial_data = null ) { - parent::__construct( - [ - 'id' => '', - 'cloud_id' => '', - 'name' => '', - 'description' => '', - 'code' => '', - 'tags' => [], - 'scope' => '', - 'status' => '', - 'codevault' => '', - 'total_votes' => '', - 'vote_count' => '', - 'wp_tested' => '', - 'created' => '', - 'updated' => '', - 'revision' => 0, - 'is_owner' => false, - 'shared_network' => false, - ], - $initial_data - ); - } - - /** - * Prepare a value before it is stored. - * - * @param mixed $value Value to prepare. - * @param string $field Field name. - * - * @return mixed Value in the correct format. - */ - protected function prepare_field( $value, string $field ) { - switch ( $field ) { - case 'id': - case 'revision': - return absint( $value ); - - case 'is_owner': - return (bool) $value; - case 'description': - return ( null === $value ) ? '' : $value; - case 'tags': - return code_snippets_build_tags_array( $value ); - - default: - return $value; - } - } -} diff --git a/src/php/cloud/class-cloud-snippets.php b/src/php/cloud/class-cloud-snippets.php deleted file mode 100644 index 43d62ce99..000000000 --- a/src/php/cloud/class-cloud-snippets.php +++ /dev/null @@ -1,107 +0,0 @@ - $initial_data Initial data. - */ - public function __construct( $initial_data = null ) { - $initial_data = $this->normalize_cloud_api( $initial_data ); - parent::__construct( - [ - 'snippets' => [], - 'total_snippets' => 0, - 'total_pages' => 0, - 'page' => 0, - 'cloud_id_rev' => [], - ], - $initial_data, - [ - 'items' => 'snippets', - 'total_items' => 'total_snippets', - 'page' => 'page', - 'cloud_id_rev' => 'cloud_id_rev', - ] - ); - } - - /** - * Prepare a value before it is stored. - * - * @param mixed $value Value to prepare. - * @param string $field Field name. - * - * @return mixed Value in the correct format. - */ - protected function prepare_field( $value, string $field ) { - switch ( $field ) { - case 'page': - case 'total_pages': - case 'total_snippets': - return absint( $value ); - - default: - return $value; - } - } - - /** - * Prepare the `snippets` field by ensuring it is a list of Cloud_Snippets objects. - * - * @param mixed $snippets The field as provided. - * - * @return Cloud_Snippets[] The field in the correct format. - */ - protected function prepare_snippets( $snippets ): array { - $result = []; - $snippets = is_array( $snippets ) ? $snippets : [ $snippets ]; - - foreach ( $snippets as $snippet ) { - $result[] = $snippet instanceof Cloud_Snippet ? $snippet : new Cloud_Snippet( $snippet ); - } - - return $result; - } - - /** - * Normalize payloads returned by the cloud API into the shape expected by this class. - * - * @param mixed $initial_data Raw data passed into the constructor. - * - * @return mixed Normalized data array or original value when no normalization is required. - */ - private function normalize_cloud_api( $initial_data ) { - // pagination metadata is nested under a 'meta' key. - if ( is_array( $initial_data ) && isset( $initial_data['meta'] ) ) { - $meta = $initial_data['meta']; - $normalized = []; - $normalized['snippets'] = $initial_data['snippets'] ?? $initial_data['data'] ?? []; - $normalized['total_snippets'] = isset( $meta['total'] ) ? (int) $meta['total'] : 0; - $normalized['total_pages'] = isset( $meta['total_pages'] ) ? (int) $meta['total_pages'] : 0; - $normalized['page'] = isset( $meta['page'] ) ? max( 0, (int) $meta['page'] - 1 ) : 0; - $normalized['cloud_id_rev'] = $initial_data['cloud_id_rev'] ?? []; - $initial_data = $normalized; - } - - return $initial_data; - } -} diff --git a/src/php/cloud/list-table-shared-ops.php b/src/php/cloud/list-table-shared-ops.php deleted file mode 100644 index 0bae9a1ba..000000000 --- a/src/php/cloud/list-table-shared-ops.php +++ /dev/null @@ -1,287 +0,0 @@ -', - esc_attr( $column_name ), - esc_attr( $snippet->id ), - esc_attr( $column_name ), - esc_attr( $snippet->$column_name ) - ); -} - -/** - * Display a hidden input field for a certain column and snippet value. - * - * @param string $column_name Column name. - * @param Cloud_Snippet $snippet Column item. - * - * @return string HTML - */ -function cloud_lts_build_column_hidden_input( string $column_name, Cloud_Snippet $snippet ): string { - return sprintf( - '', - esc_attr( $column_name ), - esc_attr( $snippet->id ), - esc_attr( $column_name ), - esc_attr( $snippet->$column_name ) - ); -} - -/** - * Process the download snippet action - * - * @param string $action Action - 'download' or 'update'. - * @param string $source Source - 'search' or 'cloud'. - * @param string $snippet Snippet ID. - * - * @return void - */ -function cloud_lts_process_download_action( string $action, string $source, string $snippet ) { - if ( 'download' === $action || 'update' === $action ) { - $result = code_snippets()->cloud_api->download_or_update_snippet( $snippet, $source, $action ); - - if ( $result['success'] ) { - $redirect_uri = $result['snippet_id'] ? - code_snippets()->get_snippet_edit_url( (int) $result['snippet_id'] ) : - add_query_arg( 'result', $result['action'] ); - - wp_safe_redirect( esc_url_raw( $redirect_uri ) ); - exit; - } - } -} - -/** - * Build action links for snippet. - * - * @param Cloud_Snippet $cloud_snippet Snippet/Column item. - * @param string $source Source - 'search' or 'codevault'. - * - * @return string Action link HTML. - */ -function cloud_lts_build_action_links( Cloud_Snippet $cloud_snippet, string $source ): string { - $lang = Cloud_API::get_type_from_scope( $cloud_snippet->scope ); - $link = code_snippets()->cloud_api->get_link_for_cloud_snippet( $cloud_snippet ); - $is_licensed = code_snippets()->licensing->is_licensed(); - $download = $is_licensed || ! in_array( $lang, [ 'css', 'js' ], true ); - $snippet_id = (int) $cloud_snippet->id; - - if ( $link ) { - if ( $is_licensed && $link->update_available ) { - $update_url = wp_nonce_url( - add_query_arg( - [ - 'action' => 'update', - 'snippet' => $snippet_id, - 'source' => $source, - ] - ), - cloud_lts_get_snippet_action_nonce_action( 'update', $snippet_id, $source ) - ); - return sprintf( - '
  • %s
  • ', - esc_url( $update_url ), - esc_html__( 'Update Available', 'code-snippets' ) - ); - } else { - return sprintf( - '
  • %s
  • ', - esc_url( code_snippets()->get_snippet_edit_url( $link->local_id ) ), - esc_html__( 'View', 'code-snippets' ) - ); - } - } - - if ( $download ) { - $download_query = [ - 'action' => 'download', - 'snippet' => $snippet_id, - 'source' => $source, - ]; - - // Preserve current cloud page if present so downstream handlers receive pagination context. - if ( isset( $_REQUEST['cloud_page'] ) ) { - $download_query['cloud_page'] = (int) wp_unslash( $_REQUEST['cloud_page'] ); - } - - $download_url = wp_nonce_url( - add_query_arg( $download_query ), - cloud_lts_get_snippet_action_nonce_action( 'download', $snippet_id, $source ) - ); - - $download_button = sprintf( - '
  • %s
  • ', - esc_url( $download_url ), - esc_html__( 'Download', 'code-snippets' ) - ); - } else { - $download_button = sprintf( - '
  • %s %s
  • ', - 'button button-primary button-disabled tooltip tooltip-block tooltip-end', - esc_html__( 'Download', 'code-snippets' ), - esc_html__( 'This snippet type is only available in Code Snippets Pro', 'code-snippets' ) - ); - } - - $preview_button = sprintf( - '
  • %s
  • ', - '#TB_inline?&width=700&height=500&inlineId=show-code-preview', - esc_attr( $cloud_snippet->name ), - 'cloud-snippet-preview thickbox button', - esc_attr( $cloud_snippet->id ), - esc_attr( $lang ), - esc_html__( 'Preview', 'code-snippets' ) - ); - - return $download_button . $preview_button; -} - -/** - * Build the pagination functionality - * - * @param string $which Context where the pagination will be displayed. - * @param string $source Source - 'search' or 'cloud'. - * @param int $total_items Total number of items. - * @param int $total_pages Total number of pages. - * @param int $pagenum Current page number. - * - * @return array - */ -function cloud_lts_pagination( string $which, string $source, int $total_items, int $total_pages, int $pagenum ): array { - /* translators: %s: Number of items. */ - $num = sprintf( _n( '%s item', '%s items', $total_items, 'code-snippets' ), number_format_i18n( $total_items ) ); - $output = '' . $num . ''; - - $param_key = $source . '_page'; - $current = isset( $_REQUEST[ $param_key ] ) ? (int) $_REQUEST[ $param_key ] : $pagenum; - $current_url = remove_query_arg( wp_removable_query_args() ) . '#' . $source; - - $page_links = array(); - - $html_current_page = ''; - $total_pages_before = ''; - $total_pages_after = ''; - - $disable_first = false; - $disable_last = false; - $disable_prev = false; - $disable_next = false; - - if ( 1 === $current ) { - $disable_first = true; - $disable_prev = true; - } - - if ( $total_pages === $current ) { - $disable_last = true; - $disable_next = true; - } - - if ( $disable_first ) { - $page_links[] = ''; - } else { - $page_links[] = sprintf( - '%s', - esc_url( remove_query_arg( $source . '_page', $current_url ) ), - esc_html__( 'First page', 'code-snippets' ) - ); - } - - if ( $disable_prev ) { - $page_links[] = ''; - } else { - $page_links[] = sprintf( - '%s', - esc_url( add_query_arg( $source . '_page', max( 1, $current - 1 ), $current_url ) ), - esc_html__( 'Previous page', 'code-snippets' ) - ); - } - - if ( 'bottom' === $which ) { - $html_current_page = $current; - $total_pages_before = sprintf( '%s', __( 'Current page', 'code-snippets' ) ); - } - - if ( 'top' === $which ) { - $html_current_page = sprintf( - '', - __( 'Current page', 'code-snippets' ), - $source, - $current, - strlen( $total_pages ) - ); - } - - $html_total_pages = sprintf( '%s', number_format_i18n( $total_pages ) ); - - /* translators: 1: Current page, 2: Total pages. */ - $current_html = _x( '%1$s of %2$s', 'paging', 'code-snippets' ); - $page_links[] = $total_pages_before . sprintf( $current_html, $html_current_page, $html_total_pages ) . $total_pages_after; - - if ( $disable_next ) { - $page_links[] = ''; - } else { - $page_links[] = sprintf( - '%s', - esc_url( add_query_arg( $source . '_page', min( $total_pages, $current + 1 ), $current_url ) ), - esc_html__( 'Next page', 'code-snippets' ), - '›' - ); - } - - if ( $disable_last ) { - $page_links[] = ''; - } else { - $page_links[] = sprintf( - '%s', - esc_url( add_query_arg( $source . '_page', $total_pages, $current_url ) ), - esc_html__( 'Last page', 'code-snippets' ), - '»' - ); - } - - $pagination_links_class = 'pagination-links'; - if ( ! empty( $infinite_scroll ) ) { - $pagination_links_class .= ' hide-if-js'; - } - - $output .= "\n" . implode( "\n", $page_links ) . ''; - - $page_class = $total_pages ? '' : ' no-pages'; - - return [ - 'output' => $output, - 'page_class' => $page_class, - ]; -} diff --git a/src/php/export/class-export-attachment.php b/src/php/export/class-export-attachment.php deleted file mode 100644 index fdb1c1535..000000000 --- a/src/php/export/class-export-attachment.php +++ /dev/null @@ -1,55 +0,0 @@ -build_filename( $language ) ) ); - header( sprintf( 'Content-Type: %s; charset=%s', sanitize_mime_type( $mime_type ), get_bloginfo( 'charset' ) ) ); - } - - /** - * Export snippets in JSON format as a downloadable file. - */ - public function download_snippets_json() { - $this->do_headers( 'json', 'application/json' ); - // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped - echo wp_json_encode( - $this->create_export_object(), - apply_filters( 'code_snippets/export/json_encode_options', 0 ) - ); - exit; - } - - /** - * Export snippets in their code file format. - */ - public function download_snippets_code() { - $lang = $this->snippets_list[0]->lang; - - $mime_types = [ - 'php' => 'text/php', - 'css' => 'text/css', - 'js' => 'text/javascript', - 'json' => 'application/json', - ]; - - $this->do_headers( $lang, $mime_types[ $lang ] ?? 'text/plain' ); - - // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped - echo $this->export_snippets_code( $this->snippets_list[0]->type ); - exit; - } -} diff --git a/src/php/export/class-export.php b/src/php/export/class-export.php deleted file mode 100644 index 49f466d37..000000000 --- a/src/php/export/class-export.php +++ /dev/null @@ -1,194 +0,0 @@ - $ids List of snippet IDs to export. - * @param boolean|null $network Whether to fetch snippets from local or network table. - */ - public function __construct( array $ids, ?bool $network = null ) { - $this->snippets_list = get_snippets( $ids, $network ); - } - - /** - * Build the export filename. - * - * @param string $format File format. Used for file extension. - * - * @return string - */ - public function build_filename( string $format ): string { - if ( 1 === count( $this->snippets_list ) ) { - // If there is only snippet to export, use its name instead of the site name. - $title = strtolower( $this->snippets_list[0]->name ); - } else { - // Otherwise, use the site name as set in Settings > General. - $title = strtolower( get_bloginfo( 'name' ) ); - } - - $filename = "$title.code-snippets.$format"; - return apply_filters( 'code_snippets/export/filename', $filename, $title, $this->snippets_list ); - } - - /** - * Bundle snippets together into JSON format. - * - * @return array Snippets as JSON object. - */ - public function create_export_object(): array { - $snippets = array(); - - foreach ( $this->snippets_list as $snippet ) { - $snippets[] = array_map( - function ( $value ) { - return is_string( $value ) ? - str_replace( "\r\n", "\n", $value ) : - $value; - }, - $snippet->get_modified_fields() - ); - } - - return array( - 'generator' => 'Code Snippets v' . code_snippets()->version, - 'date_created' => gmdate( 'Y-m-d H:i' ), - 'snippets' => $snippets, - ); - } - - /** - * Bundle a snippets into a PHP file. - */ - public function export_snippets_php(): string { - $result = "snippets_list as $snippet ) { - $code = trim( $snippet->code ); - - if ( ( 'php' !== $snippet->type && 'html' !== $snippet->type ) || ! $code ) { - continue; - } - - $result .= "\n/**\n * $snippet->display_name\n"; - - if ( ! empty( $snippet->desc ) ) { - // Convert description to PhpDoc. - $desc = wp_strip_all_tags( str_replace( "\n", "\n * ", $snippet->desc ) ); - $result .= " *\n * $desc\n"; - } - - $result .= " */\n"; - - if ( 'content' === $snippet->scope ) { - $shortcode_tag = apply_filters( 'code_snippets_export_shortcode_tag', "code_snippets_export_$snippet->id", $snippet ); - - $code = sprintf( - "add_shortcode( '%s', function () {\n\tob_start();\n\t?>\n\n\t%s\n\n\tsnippets_list as $snippet ) { - $condition_data = []; - - if ( ! $snippet->code || 'cond' !== $snippet->type ) { - continue; - } - - $rules = json_decode( $snippet->code, false ); - - if ( json_last_error() !== JSON_ERROR_NONE ) { - continue; - } - - foreach ( $fields_to_copy as $field ) { - if ( ! empty( $snippet->$field ) ) { - $condition_data[ $field ] = $snippet->$field; - } - } - - $condition_data['rules'] = $rules; - $conditions_data[] = $condition_data; - } - - return wp_json_encode( 1 === count( $conditions_data ) ? $conditions_data[0] : $conditions_data, JSON_PRETTY_PRINT ); - } - - /** - * Generate a downloadable CSS or JavaScript file from a list of snippets - * - * @phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped - * - * @param string|null $type Snippet type. Supports 'css' or 'js'. - */ - public function export_snippets_code( ?string $type = null ): string { - $result = ''; - - if ( ! $type ) { - $type = $this->snippets_list[0]->type; - } - - if ( 'php' === $type || 'html' === $type ) { - return $this->export_snippets_php(); - } - - if ( 'cond' === $type ) { - return $this->export_conditions_json(); - } - - foreach ( $this->snippets_list as $snippet ) { - $snippet = new Snippet( $snippet ); - - if ( $snippet->type !== $type ) { - continue; - } - - $result .= "\n/*\n"; - - if ( $snippet->name ) { - $result .= wp_strip_all_tags( $snippet->name ) . "\n\n"; - } - - if ( ! empty( $snippet->desc ) ) { - $result .= wp_strip_all_tags( $snippet->desc ) . "\n"; - } - - $result .= "*/\n\n$snippet->code\n\n"; - } - - return $result; - } -} diff --git a/src/php/flat-files/classes/class-config-repository.php b/src/php/flat-files/classes/class-config-repository.php deleted file mode 100644 index 07ab54523..000000000 --- a/src/php/flat-files/classes/class-config-repository.php +++ /dev/null @@ -1,55 +0,0 @@ -fs = $fs; - } - - public function load( string $base_dir ): array { - $config_file_path = trailingslashit( $base_dir ) . static::CONFIG_FILE_NAME; - - if ( is_file( $config_file_path ) ) { - if ( function_exists( 'opcache_invalidate' ) ) { - opcache_invalidate( $config_file_path, true ); - } - return require $config_file_path; - } - return []; - } - - public function save( string $base_dir, array $active_snippets ): void { - $config_file_path = trailingslashit( $base_dir ) . static::CONFIG_FILE_NAME; - - ksort( $active_snippets ); - - $file_content = "fs->put_contents( $config_file_path, $file_content, FS_CHMOD_FILE ); - - if ( is_file( $config_file_path ) ) { - if ( function_exists( 'opcache_invalidate' ) ) { - opcache_invalidate( $config_file_path, true ); - } - } - } - - public function update( string $base_dir, Snippet $snippet, ?bool $remove = false ): void { - $active_snippets = $this->load( $base_dir ); - - if ( $remove ) { - unset( $active_snippets[ $snippet->id ] ); - } else { - $active_snippets[ $snippet->id ] = $snippet->get_fields(); - } - - $this->save( $base_dir, $active_snippets ); - } -} diff --git a/src/php/flat-files/classes/class-file-system-adapter.php b/src/php/flat-files/classes/class-file-system-adapter.php deleted file mode 100644 index 62055253a..000000000 --- a/src/php/flat-files/classes/class-file-system-adapter.php +++ /dev/null @@ -1,47 +0,0 @@ -fs = $wp_filesystem; - } - - public function put_contents( string $path, string $contents, $chmod ) { - return $this->fs->put_contents( $path, $contents, $chmod ); - } - - public function exists( string $path ): bool { - return $this->fs->exists( $path ); - } - - public function delete( $file, $recursive = false, $type = false ): bool { - return $this->fs->delete( $file, $recursive, $type ); - } - - public function is_dir( string $path ): bool { - return $this->fs->is_dir( $path ); - } - - public function mkdir( string $path, $chmod ) { - return $this->fs->mkdir( $path, $chmod ); - } - - public function rmdir( string $path, bool $recursive = false ): bool { - return $this->fs->rmdir( $path, $recursive ); - } - - public function chmod( string $path, $chmod ): bool { - return $this->fs->chmod( $path, $chmod ); - } - - public function is_writable( string $path ): bool { - return $this->fs->is_writable( $path ); - } -} diff --git a/src/php/flat-files/handlers/html-snippet-handler.php b/src/php/flat-files/handlers/html-snippet-handler.php deleted file mode 100644 index d7a4446aa..000000000 --- a/src/php/flat-files/handlers/html-snippet-handler.php +++ /dev/null @@ -1,17 +0,0 @@ -\n\n" . $code; - } -} diff --git a/src/php/flat-files/handlers/php-snippet-handler.php b/src/php/flat-files/handlers/php-snippet-handler.php deleted file mode 100644 index aaa212f9b..000000000 --- a/src/php/flat-files/handlers/php-snippet-handler.php +++ /dev/null @@ -1,18 +0,0 @@ - $handler ) { - $this->register_handler( $type, $handler ); - } - } - - /** - * Registers a handler for a snippet type. - * - * @param string $type - * @param Snippet_Type_Handler_Interface $handler - * @return void - */ - public function register_handler( string $type, Snippet_Type_Handler_Interface $handler ): void { - $this->handlers[ $type ] = $handler; - } - - /** - * Gets the handler for a snippet type. - * - * @param string $type - * - * @return Snippet_Type_Handler_Interface|null - */ - public function get_handler( string $type ): ?Snippet_Type_Handler_Interface { - if ( ! isset( $this->handlers[ $type ] ) ) { - return null; - } - - return $this->handlers[ $type ]; - } -} diff --git a/src/php/migration/importers/files/file-upload-importer.php b/src/php/migration/importers/files/file-upload-importer.php deleted file mode 100644 index 84d82005e..000000000 --- a/src/php/migration/importers/files/file-upload-importer.php +++ /dev/null @@ -1,406 +0,0 @@ - WP_REST_Server::CREATABLE, - 'callback' => [ $this, 'parse_uploaded_files' ], - 'permission_callback' => function() { - return current_user_can( 'manage_options' ); - }, - ] ); - - register_rest_route( $namespace, 'file-upload/import', [ - 'methods' => WP_REST_Server::CREATABLE, - 'callback' => [ $this, 'import_selected_snippets' ], - 'permission_callback' => function() { - return current_user_can( 'manage_options' ); - }, - 'args' => [ - 'snippets' => [ - 'description' => __( 'Array of snippet data to import', 'code-snippets' ), - 'type' => 'array', - 'required' => true, - ], - 'duplicate_action' => [ - 'description' => __( 'Action to take when duplicate snippets are found', 'code-snippets' ), - 'type' => 'string', - 'enum' => [ 'ignore', 'replace', 'skip' ], - 'default' => 'ignore', - ], - 'network' => [ - 'description' => __( 'Whether to import to network table', 'code-snippets' ), - 'type' => 'boolean', - 'default' => false, - ], - ], - ] ); - } - - public function parse_uploaded_files( WP_REST_Request $request ) { - // Verify nonce for security - $nonce = $request->get_header( 'X-WP-Nonce' ); - if ( ! $nonce || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) { - return new WP_Error( - 'rest_cookie_invalid_nonce', - __( 'Cookie check failed', 'code-snippets' ), - [ 'status' => 403 ] - ); - } - - // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified above via REST API header - if ( empty( $_FILES['files'] ) ) { - return new WP_Error( - 'no_files', - __( 'No files were uploaded.', 'code-snippets' ), - [ 'status' => 400 ] - ); - } - - // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Nonce verified above, file data validated below - $files = $_FILES['files']; - - if ( ! isset( $files['name'], $files['type'], $files['tmp_name'], $files['error'] ) ) { - return new WP_Error( - 'invalid_file_data', - __( 'Invalid file upload data.', 'code-snippets' ), - [ 'status' => 400 ] - ); - } - - $all_snippets = []; - $errors = []; - - $file_count = is_array( $files['name'] ) ? count( $files['name'] ) : 1; - - for ( $i = 0; $i < $file_count; $i++ ) { - $file_name = is_array( $files['name'] ) ? $files['name'][ $i ] : $files['name']; - $file_type = is_array( $files['type'] ) ? $files['type'][ $i ] : $files['type']; - $file_tmp = is_array( $files['tmp_name'] ) ? $files['tmp_name'][ $i ] : $files['tmp_name']; - $file_error = is_array( $files['error'] ) ? $files['error'][ $i ] : $files['error']; - - if ( UPLOAD_ERR_OK !== $file_error ) { - $errors[] = sprintf( - /* translators: %1$s: file name, %2$s: error message */ - __( 'Upload error for file %1$s: %2$s', 'code-snippets' ), - $file_name, - $this->get_upload_error_message( $file_error ) - ); - continue; - } - - $file_info = pathinfo( $file_name ); - $extension = strtolower( $file_info['extension'] ?? '' ); - $mime_type = sanitize_mime_type( $file_type ); - - if ( ! $this->is_valid_file_type( $extension, $mime_type ) ) { - $errors[] = sprintf( - /* translators: %s: file name */ - __( 'Invalid file type for %s. Only JSON and XML files are allowed.', 'code-snippets' ), - $file_name, - ); - continue; - } - - $snippets = $this->parse_file_content( $file_tmp, $extension, $mime_type, $file_name ); - - if ( is_wp_error( $snippets ) ) { - $errors[] = sprintf( - /* translators: %1$s: file name, %2$s: error message */ - __( 'Error parsing %1$s: %2$s', 'code-snippets' ), - $file_name, - $snippets->get_error_message(), - ); - } else { - $all_snippets = array_merge( $all_snippets, $snippets ); - } - } - - if ( empty( $all_snippets ) ) { - return new WP_Error( - 'no_snippets_found', - __( 'No valid snippets found in the uploaded files.', 'code-snippets' ), - [ - 'status' => 400, - 'errors' => $errors, - ], - ); - } - - $response = [ - 'snippets' => $all_snippets, - 'total_count' => count( $all_snippets ), - 'message' => sprintf( - /* translators: %d: number of snippets */ - _n( - 'Found %d snippet ready for import.', - 'Found %d snippets ready for import.', - count( $all_snippets ), - 'code-snippets', - ), - count( $all_snippets ) - ), - ]; - - if ( ! empty( $errors ) ) { - $response['warnings'] = $errors; - } - - return rest_ensure_response( $response ); - } - - public function import_selected_snippets( WP_REST_Request $request ) { - $snippets_data = $request->get_param( 'snippets' ); - $duplicate_action = $request->get_param( 'duplicate_action' ) ?? 'ignore'; - $network = $request->get_param( 'network' ) ?? false; - - if ( empty( $snippets_data ) || ! is_array( $snippets_data ) ) { - return new WP_Error( - 'no_snippets', - __( 'No snippet data provided for import.', 'code-snippets' ), - [ 'status' => 400 ] - ); - } - - $snippets = []; - foreach ( $snippets_data as $snippet_data ) { - $snippet = new Snippet(); - $snippet->network = $network; - - $import_fields = [ - 'name', - 'desc', - 'description', - 'code', - 'tags', - 'scope', - 'priority', - 'shared_network', - 'modified', - 'cloud_id', - ]; - - foreach ( $import_fields as $field ) { - if ( isset( $snippet_data[ $field ] ) ) { - $snippet->set_field( $field, $snippet_data[ $field ] ); - } - } - - $snippets[] = $snippet; - } - - $imported = $this->save_snippets( $snippets, $duplicate_action, $network ); - - $response = [ - 'imported' => count( $imported ), - 'imported_ids' => $imported, - 'message' => sprintf( - /* translators: %d: number of snippets */ - _n( - 'Successfully imported %d snippet.', - 'Successfully imported %d snippets.', - count( $imported ), - 'code-snippets', - ), - count( $imported ) - ), - ]; - - return rest_ensure_response( $response ); - } - - private function parse_file_content( string $file_path, string $extension, string $mime_type, string $file_name ) { - if ( ! file_exists( $file_path ) || ! is_file( $file_path ) ) { - return new WP_Error( - 'file_not_found', - __( 'File not found or is not a valid file.', 'code-snippets' ) - ); - } - - if ( 'json' === $extension || 'application/json' === $mime_type ) { - return $this->parse_json_file( $file_path, $file_name ); - } elseif ( 'xml' === $extension || in_array( $mime_type, [ 'text/xml', 'application/xml' ], true ) ) { - return $this->parse_xml_file( $file_path, $file_name ); - } - - return new WP_Error( - 'unsupported_file_type', - __( 'Unsupported file type.', 'code-snippets' ) - ); - } - - private function parse_json_file( string $file_path, string $file_name ) { - $raw_data = file_get_contents( $file_path ); - $data = json_decode( $raw_data, true ); - - if ( json_last_error() !== JSON_ERROR_NONE ) { - return new WP_Error( - 'invalid_json', - sprintf( - /* translators: %1$s: file name, %2$s: error message */ - __( 'Invalid JSON in file %1$s: %2$s', 'code-snippets' ), - $file_name, - json_last_error_msg() - ) - ); - } - - if ( ! isset( $data['snippets'] ) || ! is_array( $data['snippets'] ) ) { - return new WP_Error( - 'no_snippets_in_file', - sprintf( - /* translators: %s: file name */ - __( 'No snippets found in file %s', 'code-snippets' ), - $file_name - ) - ); - } - - $snippets = []; - foreach ( $data['snippets'] as $snippet_data ) { - $snippet_data['source_file'] = $file_name; - - $snippet_data['table_data'] = [ - 'id' => $snippet_data['id'] ?? uniqid(), - 'title' => $snippet_data['name'] ?? __( 'Untitled Snippet', 'code-snippets' ), - 'scope' => $snippet_data['scope'] ?? 'global', - 'tags' => is_array( $snippet_data['tags'] ?? null ) ? implode( ', ', $snippet_data['tags'] ) : '', - 'description' => $snippet_data['desc'] ?? $snippet_data['description'] ?? '', - 'type' => Snippet::get_type_from_scope( $snippet_data['scope'] ?? 'global' ) - ]; - - $snippets[] = $snippet_data; - } - - return $snippets; - } - - private function parse_xml_file( string $file_path, string $file_name ) { - $dom = new \DOMDocument( '1.0', get_bloginfo( 'charset' ) ); - - if ( ! $dom->load( $file_path ) ) { - return new WP_Error( - 'invalid_xml', - sprintf( - /* translators: %s: file name */ - __( 'Invalid XML in file %s', 'code-snippets' ), - $file_name - ) - ); - } - - $snippets_xml = $dom->getElementsByTagName( 'snippet' ); - $fields = [ 'name', 'description', 'desc', 'code', 'tags', 'scope' ]; - - $snippets = []; - $index = 0; - - foreach ( $snippets_xml as $snippet_xml ) { - $snippet_data = []; - - foreach ( $fields as $field_name ) { - $field = $snippet_xml->getElementsByTagName( $field_name )->item( 0 ); - - if ( isset( $field->nodeValue ) ) { - $snippet_data[ $field_name ] = $field->nodeValue; - } - } - - $scope = $snippet_xml->getAttribute( 'scope' ); - if ( ! empty( $scope ) ) { - $snippet_data['scope'] = $scope; - } - - $snippet_data['source_file'] = $file_name; - - $snippet_data['table_data'] = [ - 'id' => ++$index, - 'title' => $snippet_data['name'] ?? __( 'Untitled Snippet', 'code-snippets' ), - 'scope' => $snippet_data['scope'] ?? 'global', - 'tags' => $snippet_data['tags'] ?? '', - 'description' => $snippet_data['desc'] ?? $snippet_data['description'] ?? '', - 'type' => Snippet::get_type_from_scope( $snippet_data['scope'] ?? 'global' ), - ]; - - $snippets[] = $snippet_data; - } - - return $snippets; - } - - private function save_snippets( array $snippets, string $duplicate_action, bool $network ): array { - $existing_snippets = []; - - if ( 'replace' === $duplicate_action || 'skip' === $duplicate_action ) { - $all_snippets = get_snippets( [], $network ); - - foreach ( $all_snippets as $snippet ) { - if ( $snippet->name ) { - $existing_snippets[ $snippet->name ] = $snippet->id; - } - } - } - - $imported = []; - - foreach ( $snippets as $snippet ) { - if ( 'ignore' !== $duplicate_action && isset( $existing_snippets[ $snippet->name ] ) ) { - if ( 'replace' === $duplicate_action ) { - $snippet->id = $existing_snippets[ $snippet->name ]; - } elseif ( 'skip' === $duplicate_action ) { - continue; - } - } - - $saved_snippet = save_snippet( $snippet ); - - $snippet_id = $saved_snippet->id; - - if ( $snippet_id ) { - $imported[] = $snippet_id; - } - } - - return $imported; - } - - private function is_valid_file_type( string $extension, string $mime_type ): bool { - $valid_extensions = [ 'json', 'xml' ]; - $valid_mime_types = [ 'application/json', 'text/xml', 'application/xml' ]; - - return in_array( $extension, $valid_extensions, true ) || - in_array( $mime_type, $valid_mime_types, true ); - } - - - - private function get_upload_error_message( int $error_code ): string { - $error_messages = [ - UPLOAD_ERR_INI_SIZE => __( 'File exceeds the upload_max_filesize directive.', 'code-snippets' ), - UPLOAD_ERR_FORM_SIZE => __( 'File exceeds the MAX_FILE_SIZE directive.', 'code-snippets' ), - UPLOAD_ERR_PARTIAL => __( 'File was only partially uploaded.', 'code-snippets' ), - UPLOAD_ERR_NO_FILE => __( 'No file was uploaded.', 'code-snippets' ), - UPLOAD_ERR_NO_TMP_DIR => __( 'Missing a temporary folder.', 'code-snippets' ), - UPLOAD_ERR_CANT_WRITE => __( 'Failed to write file to disk.', 'code-snippets' ), - UPLOAD_ERR_EXTENSION => __( 'A PHP extension stopped the file upload.', 'code-snippets' ), - ]; - - return $error_messages[ $error_code ] ?? __( 'Unknown upload error.', 'code-snippets' ); - } -} diff --git a/src/php/migration/importers/plugins/header-footer-code-manager.php b/src/php/migration/importers/plugins/header-footer-code-manager.php deleted file mode 100644 index 8503a142d..000000000 --- a/src/php/migration/importers/plugins/header-footer-code-manager.php +++ /dev/null @@ -1,149 +0,0 @@ - 'name', - 'snippet' => 'code', - 'location' => 'scope', - 'created' => 'modified', - ]; - - private const HTML_SCOPE_TRANSFORMATIONS = [ - '' => 'content', - 'header' => 'head-content', - 'footer' => 'footer-content', - ]; - - public function get_name() { - return 'header-footer-code-manager'; - } - - public function get_title() { - return esc_html__( 'Header Footer Code Manager', 'code-snippets' ); - } - - public static function is_active(): bool { - return is_plugin_active( 'header-footer-code-manager/99robots-header-footer-code-manager.php' ); - } - - public function get_data( array $ids_to_import = [] ) { - global $wpdb; - $nnr_hfcm_table_name = $wpdb->prefix . 'hfcm_scripts'; - $sql = "SELECT * FROM `{$nnr_hfcm_table_name}`"; - - if ( ! empty( $ids_to_import ) ) { - $sql .= ' WHERE script_id IN (' . implode( ',', $ids_to_import ) . ')'; - } - - $snippets = $wpdb->get_results( - $sql - ); - - foreach ( $snippets as $snippet ) { - $snippet->table_data = [ - 'id' => (int) $snippet->script_id, - 'title' => $snippet->name, - ]; - } - - return $snippets; - } - - public function create_snippet( $snippet_data, bool $multisite ): ?Snippet { - $code_type = $snippet_data->snippet_type ?? ''; - - $snippet = new Snippet(); - $snippet->network = $multisite; - - foreach ( self::FIELD_MAPPINGS as $source_field => $target_field ) { - if ( ! isset( $snippet_data->$source_field ) ) { - continue; - } - - $value = $this->transform_field_value( - $target_field, - $snippet_data->$source_field, - $snippet_data - ); - - $scope_not_supported = 'scope' === $target_field && null === $value; - if ( $scope_not_supported ) { - return null; - } - - $snippet->set_field( $target_field, $value ); - } - - return $snippet; - } - - private function transform_field_value( string $target_field, $value, $snippet_data ) { - if ( 'scope' === $target_field ) { - return $this->transform_scope_value( $value, $snippet_data ); - } - - if ( 'code' === $target_field ) { - return $this->transform_code_value( $value, $snippet_data ); - } - - return $value; - } - - private function transform_scope_value( $location_value, $snippet_data ): ?string { - if ( ! is_scalar( $location_value ) ) { - return null; - } - - $code_type = $snippet_data->snippet_type; - - switch ( $code_type ) { - case 'html': - $transformations = self::HTML_SCOPE_TRANSFORMATIONS; - break; - default: - return null; - } - - return $transformations[ $location_value ] ?? null; - } - - private function transform_code_value( $code_value, $snippet_data ): ?string { - $code = html_entity_decode( $code_value ); - $code_type = $snippet_data->snippet_type ?? ''; - - $code = $this->strip_wrapper_tags( $code, $code_type ); - $code = $this->apply_minification( $code, $code_type ); - - return trim( $code ); - } - - private function strip_wrapper_tags( string $code, string $code_type ): string { - switch ( $code_type ) { - case 'css': - return preg_replace( '/<\s*style[^>]*>|<\s*\/\s*style\s*>/i', '', $code ); - case 'js': - return preg_replace( '/<\s*script[^>]*>|<\s*\/\s*script\s*>/i', '', $code ); - default: - return $code; - } - } - - private function apply_minification( string $code, string $code_type ): string { - if ( ! in_array( $code_type, [ 'css', 'js' ], true ) ) { - return $code; - } - - $setting = Settings\get_setting( 'general', 'minify_output' ); - if ( ! is_array( $setting ) || ! in_array( $code_type, $setting, true ) ) { - return $code; - } - - $minifier = 'css' === $code_type ? new Minify\CSS( $code ) : new Minify\JS( $code ); - return $minifier->minify(); - } -} diff --git a/src/php/migration/importers/plugins/importer-base.php b/src/php/migration/importers/plugins/importer-base.php deleted file mode 100644 index c84733b18..000000000 --- a/src/php/migration/importers/plugins/importer-base.php +++ /dev/null @@ -1,126 +0,0 @@ -create_snippet( $snippet_item, $multisite ); - - if ( $snippet ) { - if ( $auto_add_tags && ! empty( $tag_value ) ) { - if ( ! empty( $snippet->tags ) ) { - $snippet->add_tag( $tag_value ); - } else { - $snippet->tags = [ $tag_value ]; - } - } - - $snippets[] = $snippet; - } - } - - return $snippets; - } - - public function import( $request ) { - $ids_to_import = $request->get_param( 'ids' ) ?? []; - $multisite = $request->get_param( 'network' ) ?? false; - $auto_add_tags = $request->get_param( 'auto_add_tags' ) ?? false; - $tag_value = $request->get_param( 'tag_value' ) ?? ''; - - $data = $this->get_data( $ids_to_import ); - - $snippets = $this->transform( $data, $multisite, $auto_add_tags, $tag_value ); - - $imported = $this->save_snippets( $snippets ); - - return [ - 'imported' => $imported, - ]; - } - - public function get_items( $request ) { - return $this->get_data(); - } - - protected function save_snippets( array $snippets ): array { - $imported = []; - - foreach ( $snippets as $snippet ) { - $saved_snippet = save_snippet( $snippet ); - - $snippet_id = $saved_snippet->id; - - if ( $snippet_id ) { - $imported[] = $snippet_id; - } - } - - return $imported; - } - - public function register_rest_routes() { - $namespace = REST_API_NAMESPACE . self::VERSION; - - register_rest_route( $namespace, $this->get_name(), [ - 'methods' => WP_REST_Server::READABLE, - 'callback' => [ $this, 'get_items' ], - 'permission_callback' => function() { - return current_user_can( 'manage_options' ); - }, - ] ); - - register_rest_route( $namespace, $this->get_name() . '/import', [ - 'methods' => WP_REST_Server::CREATABLE, - 'callback' => [ $this, 'import' ], - 'permission_callback' => function() { - return current_user_can( 'manage_options' ); - }, - 'args' => [ - 'ids' => [ - 'type' => 'array', - 'required' => false, - ], - 'network' => [ - 'type' => 'boolean', - 'required' => false, - ], - 'auto_add_tags' => [ - 'type' => 'boolean', - 'required' => false, - ], - 'tag_value' => [ - 'type' => 'string', - 'required' => false, - ], - ], - ] ); - } -} diff --git a/src/php/migration/importers/plugins/insert-headers-and-footers.php b/src/php/migration/importers/plugins/insert-headers-and-footers.php deleted file mode 100644 index 98f5fdf3d..000000000 --- a/src/php/migration/importers/plugins/insert-headers-and-footers.php +++ /dev/null @@ -1,138 +0,0 @@ - 'name', - 'note' => 'desc', - 'code' => 'code', - 'tags' => 'tags', - 'location' => 'scope', - 'priority' => 'priority', - 'modified' => 'modified', - ]; - - private const PHP_SCOPE_TRANSFORMATIONS = [ - 'everywhere' => 'global', - 'admin_only' => 'admin', - 'frontend_only' => 'front-end', - ]; - - private const HTML_SCOPE_TRANSFORMATIONS = [ - '' => 'content', - 'site_wide_header' => 'head-content', - 'site_wide_footer' => 'footer-content', - ]; - - public function get_name() { - return 'insert-headers-and-footers'; - } - - public function get_title() { - return esc_html__( 'WPCode (Insert Headers and Footers)', 'code-snippets' ); - } - - public static function is_active(): bool { - return is_plugin_active( 'insert-headers-and-footers/ihaf.php' ); - } - - public function get_data( array $ids_to_import = [] ) { - $query_args = [ - 'post_type' => 'wpcode', - 'post_status' => [ - 'publish', - 'draft', - ], - 'nopaging' => true, - ]; - - if ( ! empty( $ids_to_import ) ) { - $query_args['include'] = $ids_to_import; - } - - $data = []; - $snippets = get_posts( $query_args ); - - foreach ( $snippets as $snippet_item ) { - $snippet = new \WPCode_Snippet( $snippet_item ); - $snippet_data = $snippet->get_data_for_caching(); - $snippet_data['tags'] = $snippet->get_tags(); - $snippet_data['note'] = $snippet->get_note(); - $snippet_data['cloud_id'] = null; - $snippet_data['custom_shortcode'] = $snippet->get_custom_shortcode(); - $snippet_data['table_data'] = [ - 'id' => $snippet_item->ID, - 'title' => $snippet_item->post_title, - ]; - - $data[] = apply_filters( 'wpcode_export_snippet_data', $snippet_data, $snippet ); - } - - $data = array_reverse( $data ); - - return $data; - } - - public function create_snippet( $snippet_data, bool $multisite ): ?Snippet { - $code_type = $snippet_data['code_type'] ?? ''; - $is_supported_code_type = in_array( $code_type, [ 'php', 'css', 'html', 'js' ], true ); - if ( ! $is_supported_code_type ) { - return null; - } - - $snippet = new Snippet(); - $snippet->network = $multisite; - - foreach ( self::FIELD_MAPPINGS as $source_field => $target_field ) { - if ( ! isset( $snippet_data[ $source_field ] ) ) { - continue; - } - - $value = $this->transform_field_value( - $target_field, - $snippet_data[ $source_field ], - $snippet_data - ); - - $scope_not_supported = 'scope' === $target_field && null === $value; - if ( $scope_not_supported ) { - return null; - } - - $snippet->set_field( $target_field, $value ); - } - - return $snippet; - } - - private function transform_field_value( string $target_field, $value, array $snippet_data ) { - if ( 'scope' === $target_field ) { - return $this->transform_scope_value( $value, $snippet_data ); - } - - return $value; - } - - private function transform_scope_value( $location_value, array $snippet_data ): ?string { - if ( ! is_scalar( $location_value ) ) { - return null; - } - - $code_type = $snippet_data['code_type']; - - switch ( $code_type ) { - case 'html': - $transformations = self::HTML_SCOPE_TRANSFORMATIONS; - break; - case 'php': - $transformations = self::PHP_SCOPE_TRANSFORMATIONS; - break; - default: - return null; - } - - return $transformations[ $location_value ] ?? null; - } -} diff --git a/src/php/migration/importers/plugins/insert-php-code-snippet.php b/src/php/migration/importers/plugins/insert-php-code-snippet.php deleted file mode 100644 index 7e5bb5799..000000000 --- a/src/php/migration/importers/plugins/insert-php-code-snippet.php +++ /dev/null @@ -1,125 +0,0 @@ - 'name', - 'content' => 'code', - 'insertionLocationType' => 'scope', - ]; - - private const SCOPE_TRANSFORMATIONS = [ - 0 => 'single-use', - 2 => 'admin', - 3 => 'front-end', - ]; - - private const SHORTCODE_SCOPE_TRANSFORMATIONS = [ - 3 => 'content', - ]; - - public function get_name() { - return 'insert-php-code-snippet'; - } - - public function get_title() { - return esc_html__( 'Insert PHP Code Snippet', 'code-snippets' ); - } - - public static function is_active(): bool { - return is_plugin_active( 'insert-php-code-snippet/insert-php-code-snippet.php' ); - } - - public function get_data( array $ids_to_import = [] ) { - global $wpdb; - $table_name = $wpdb->prefix . 'xyz_ips_short_code'; - $sql = "SELECT * FROM `{$table_name}`"; - - if ( ! empty( $ids_to_import ) ) { - $sql .= " WHERE id IN (" . implode( ',', $ids_to_import ) . ")"; - } - - $snippets = $wpdb->get_results( - $sql - ); - - foreach ( $snippets as $snippet ) { - $snippet->table_data = [ - 'id' => (int) $snippet->id, - 'title' => $snippet->title, - ]; - } - - return $snippets; - } - - public function create_snippet( $snippet_data, bool $multisite ): ?Snippet { - $code_type = $snippet_data->snippet_type ?? ''; - - $snippet = new Snippet(); - $snippet->network = $multisite; - - foreach ( self::FIELD_MAPPINGS as $source_field => $target_field ) { - if ( ! isset( $snippet_data->$source_field ) ) { - continue; - } - - $value = $this->transform_field_value( - $target_field, - $snippet_data->$source_field, - $snippet_data - ); - - $scope_not_supported = 'scope' === $target_field && null === $value; - if ( $scope_not_supported ) { - return null; - } - - $snippet->set_field( $target_field, $value ); - } - - return $snippet; - } - - private function transform_field_value( string $target_field, $value, $snippet_data ) { - if ( 'scope' === $target_field ) { - return $this->transform_scope_value( $value, $snippet_data ); - } - - if ( 'code' === $target_field ) { - return $this->transform_code_value( $value, $snippet_data ); - } - - return $value; - } - - private function transform_scope_value( $location_value, $snippet_data ): ?string { - if ( ! is_scalar( $location_value ) ) { - return null; - } - - $transformations = self::SCOPE_TRANSFORMATIONS; - - if ( '2' === $snippet_data->insertionMethod ) { - $transformations = self::SHORTCODE_SCOPE_TRANSFORMATIONS; - } - - return $transformations[ $location_value ] ?? null; - } - - private function transform_code_value( $code_value, $snippet_data ): ?string { - $code = html_entity_decode( $code_value ); - - if ( '2' !== $snippet_data->insertionMethod ) { - $code = $this->strip_wrapper_tags( $code ); - } - - return trim( $code ); - } - - private function strip_wrapper_tags( string $code ): string { - return preg_replace( '/^\s*<\?\s*(php)?\s*|\?\>\s*$/i', '', $code ); - } -} diff --git a/src/php/migration/importers/plugins/manager.php b/src/php/migration/importers/plugins/manager.php deleted file mode 100644 index 013541d42..000000000 --- a/src/php/migration/importers/plugins/manager.php +++ /dev/null @@ -1,60 +0,0 @@ -init_plugin_importers(); - add_action( 'rest_api_init', [ $this, 'register_rest_routes' ] ); - } - - private function init_plugin_importers() { - $this->plugin_importers = [ - 'insert-headers-and-footers' => new Insert_Headers_And_Footers_Importer(), - 'header-footer-code-manager' => new Header_Footer_Code_Manager_Importer(), - 'insert-php-code-snippet' => new Insert_PHP_Code_Snippet_Importer(), - ]; - } - - public function get_importer( string $source ) { - return $this->plugin_importers[ $source ] ?? null; - } - - public function get_importers() { - if ( empty( $this->plugin_importers ) ) { - $this->init_plugin_importers(); - } - - $plugins_list = []; - - foreach ( $this->plugin_importers as $importer ) { - $plugins_list[] = [ - 'name' => $importer->get_name(), - 'title' => $importer->get_title(), - 'is_active' => $importer::is_active(), - ]; - } - - return $plugins_list; - } - - public function register_rest_routes() { - $namespace = REST_API_NAMESPACE . self::VERSION; - - register_rest_route( $namespace, 'importers', [ - 'methods' => WP_REST_Server::READABLE, - 'callback' => [ $this, 'get_importers' ], - 'permission_callback' => function() { - return current_user_can( 'manage_options' ); - }, - ] ); - } -} diff --git a/src/php/settings/class-version-switch.php b/src/php/settings/class-version-switch.php deleted file mode 100644 index b2486e1ca..000000000 --- a/src/php/settings/class-version-switch.php +++ /dev/null @@ -1,362 +0,0 @@ - $download_url ) { - if ( 'trunk' !== $version ) { - $versions[] = [ - 'version' => $version, - 'url' => $download_url, - ]; - } - } - - // Sort versions in descending order - usort( $versions, function( $a, $b ) { - return version_compare( $b['version'], $a['version'] ); - }); - - // Cache for configured duration - set_transient( VERSION_CACHE_KEY, $versions, VERSION_CACHE_DURATION ); - } - - return $versions; - } - - public static function get_current_version(): string { - return defined( 'CODE_SNIPPETS_VERSION' ) ? CODE_SNIPPETS_VERSION : '0.0.0'; - } - - public static function is_version_switch_in_progress(): bool { - return get_transient( PROGRESS_KEY ) !== false; - } - - public static function clear_version_caches(): void { - delete_transient( VERSION_CACHE_KEY ); - delete_transient( PROGRESS_KEY ); - } - - public static function validate_target_version( string $target_version, array $available_versions ): array { - if ( empty( $target_version ) ) { - return [ - 'success' => false, - 'message' => __( 'No target version specified.', 'code-snippets' ), - 'download_url' => '', - ]; - } - - foreach ( $available_versions as $version_info ) { - if ( $version_info['version'] === $target_version ) { - return [ - 'success' => true, - 'message' => '', - 'download_url' => $version_info['url'], - ]; - } - } - - return [ - 'success' => false, - 'message' => __( 'Invalid version specified.', 'code-snippets' ), - 'download_url' => '', - ]; - } - - public static function create_error_response( string $message, string $technical_details = '' ): array { - if ( ! empty( $technical_details ) ) { - if ( function_exists( 'error_log' ) ) { - error_log( sprintf( 'Code Snippets version switch error: %s. Details: %s', $message, $technical_details ) ); - } - } - - return [ - 'success' => false, - 'message' => $message, - ]; - } - - public static function perform_version_install( string $download_url ) { - if ( ! function_exists( 'wp_update_plugins' ) ) { - require_once ABSPATH . 'wp-admin/includes/update.php'; - } - if ( ! function_exists( 'show_message' ) ) { - require_once ABSPATH . 'wp-admin/includes/misc.php'; - } - if ( ! class_exists( 'Plugin_Upgrader' ) ) { - require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; - } - - $update_handler = new \WP_Ajax_Upgrader_Skin(); - $upgrader = new \Plugin_Upgrader( $update_handler ); - - global $code_snippets_last_update_handler, $code_snippets_last_upgrader; - $code_snippets_last_update_handler = $update_handler; - $code_snippets_last_upgrader = $upgrader; - - return $upgrader->install( $download_url, [ - 'overwrite_package' => true, - 'clear_update_cache' => true, - ] ); - } - - public static function extract_handler_messages( $update_handler, $upgrader ): string { - $handler_messages = ''; - - if ( isset( $update_handler ) ) { - if ( method_exists( $update_handler, 'get_errors' ) ) { - $errs = $update_handler->get_errors(); - if ( $errs instanceof \WP_Error && $errs->has_errors() ) { - $handler_messages .= implode( "\n", $errs->get_error_messages() ); - } - } - if ( method_exists( $update_handler, 'get_error_messages' ) ) { - $em = $update_handler->get_error_messages(); - if ( $em ) { - $handler_messages .= "\n" . $em; - } - } - if ( method_exists( $update_handler, 'get_upgrade_messages' ) ) { - $upgrade_msgs = $update_handler->get_upgrade_messages(); - if ( is_array( $upgrade_msgs ) ) { - $handler_messages .= "\n" . implode( "\n", $upgrade_msgs ); - } elseif ( $upgrade_msgs ) { - $handler_messages .= "\n" . (string) $upgrade_msgs; - } - } - } - - if ( empty( $handler_messages ) && isset( $upgrader->result ) ) { - if ( is_wp_error( $upgrader->result ) ) { - $handler_messages = implode( "\n", $upgrader->result->get_error_messages() ); - } else { - $handler_messages = is_scalar( $upgrader->result ) ? (string) $upgrader->result : print_r( $upgrader->result, true ); - } - } - - return trim( $handler_messages ); - } - - public static function log_version_switch_attempt( string $target_version, $result, string $details = '' ): void { - if ( function_exists( 'error_log' ) ) { - error_log( sprintf( 'Code Snippets version switch failed. target=%s, result=%s, details=%s', $target_version, var_export( $result, true ), $details ) ); - } - } - - public static function handle_installation_failure( string $target_version, string $download_url, $install_result ): array { - global $code_snippets_last_update_handler, $code_snippets_last_upgrader; - - $handler_messages = self::extract_handler_messages( $code_snippets_last_update_handler, $code_snippets_last_upgrader ); - self::log_version_switch_attempt( $target_version, $install_result, "URL: $download_url, Messages: $handler_messages" ); - - $fallback_message = __( 'Failed to switch versions. Please try again.', 'code-snippets' ); - if ( ! empty( $handler_messages ) ) { - $short = wp_trim_words( wp_strip_all_tags( $handler_messages ), 40, '...' ); - $fallback_message = sprintf( '%s %s', $fallback_message, $short ); - } - - return [ - 'success' => false, - 'message' => $fallback_message, - ]; - } - - public static function handle_version_switch( string $target_version ): array { - if ( ! current_user_can( 'update_plugins' ) ) { - return self::create_error_response( __( 'You do not have permission to update plugins.', 'code-snippets' ) ); - } - - $available_versions = self::get_available_versions(); - $validation = self::validate_target_version( $target_version, $available_versions ); - - if ( ! $validation['success'] ) { - return self::create_error_response( $validation['message'] ); - } - - if ( self::get_current_version() === $target_version ) { - return self::create_error_response( __( 'Already on the specified version.', 'code-snippets' ) ); - } - - set_transient( PROGRESS_KEY, $target_version, PROGRESS_TIMEOUT ); - - $install_result = self::perform_version_install( $validation['download_url'] ); - - delete_transient( PROGRESS_KEY ); - - if ( is_wp_error( $install_result ) ) { - return self::create_error_response( $install_result->get_error_message() ); - } - - if ( $install_result ) { - delete_transient( VERSION_CACHE_KEY ); - - return [ - 'success' => true, - 'message' => sprintf( __( 'Successfully switched to version %s. Please refresh the page to see changes.', 'code-snippets' ), $target_version ), - ]; - } - - return self::handle_installation_failure( $target_version, $validation['download_url'], $install_result ); - } - - public static function render_version_switch_field( array $args ): void { - $current_version = self::get_current_version(); - $available_versions = self::get_available_versions(); - $is_switching = self::is_version_switch_in_progress(); - - ?> -
    -

    - - -

    - - -
    -

    -
    - -

    - - -

    - -

    - -

    - - - -
    __( 'You do not have permission to update plugins.', 'code-snippets' ), - ] ); - } - - $target_version = sanitize_text_field( $_POST['target_version'] ?? '' ); - - if ( empty( $target_version ) ) { - wp_send_json_error( [ - 'message' => __( 'No target version specified.', 'code-snippets' ), - ] ); - } - - $result = self::handle_version_switch( $target_version ); - - if ( $result['success'] ) { - wp_send_json_success( $result ); - } else { - wp_send_json_error( $result ); - } - } - - public static function render_refresh_versions_field( array $args ): void { - ?> - -

    - -

    __( 'You do not have permission to manage options.', 'code-snippets' ), - ] ); - } - - delete_transient( VERSION_CACHE_KEY ); - self::get_available_versions(); - - wp_send_json_success( [ - 'message' => __( 'Available versions updated successfully.', 'code-snippets' ), - ] ); - } - - public static function render_version_switch_warning(): void { - ?> - - file ), - [ 'code-editor' ], - $plugin->version - ); - } - - // Enqueue the menu scripts. - wp_enqueue_script( - 'code-snippets-settings-menu', - plugins_url( 'dist/settings.js', $plugin->file ), - [ 'code-snippets-code-editor' ], - $plugin->version, - true - ); - - wp_set_script_translations( 'code-snippets-settings-menu', 'code-snippets' ); - - // Extract the CodeMirror-specific editor settings. - $setting_fields = get_settings_fields(); - $editor_fields = array(); - - foreach ( $setting_fields['editor'] as $name => $field ) { - if ( empty( $field['codemirror'] ) ) { - continue; - } - - $editor_fields[] = array( - 'name' => $name, - 'type' => $field['type'], - 'codemirror' => addslashes( $field['codemirror'] ), - ); - } - - // Pass the saved options to the external JavaScript file. - $inline_script = 'var code_snippets_editor_settings = ' . wp_json_encode( $editor_fields ) . ';'; - - wp_add_inline_script( 'code-snippets-settings-menu', $inline_script, 'before' ); - - // Provide configuration and simple i18n for the version switch JS module. - $version_switch = array( - 'ajaxurl' => admin_url( 'admin-ajax.php' ), - 'nonce_switch' => wp_create_nonce( 'code_snippets_version_switch' ), - 'nonce_refresh' => wp_create_nonce( 'code_snippets_refresh_versions' ), - ); - - $strings = array( - 'selectDifferent' => esc_html__( 'Please select a different version to switch to.', 'code-snippets' ), - 'switching' => esc_html__( 'Switching...', 'code-snippets' ), - 'processing' => esc_html__( 'Processing version switch. Please wait...', 'code-snippets' ), - 'error' => esc_html__( 'An error occurred.', 'code-snippets' ), - 'errorSwitch' => esc_html__( 'An error occurred while switching versions. Please try again.', 'code-snippets' ), - 'refreshing' => esc_html__( 'Refreshing...', 'code-snippets' ), - 'refreshed' => esc_html__( 'Refreshed!', 'code-snippets' ), - ); - - wp_add_inline_script( 'code-snippets-settings-menu', 'var code_snippets_version_switch = ' . wp_json_encode( $version_switch ) . '; var __code_snippets_i18n = ' . wp_json_encode( $strings ) . ';', 'before' ); -} - -/** - * Retrieve the list of code editor themes. - * - * @return array List of editor themes. - */ -function get_editor_theme_list(): array { - $themes = [ - 'default' => __( 'Default', 'code-snippets' ), - ]; - - foreach ( get_editor_themes() as $theme ) { - - // Skip mobile themes. - if ( '-mobile' === substr( $theme, -7 ) ) { - continue; - } - - $themes[ $theme ] = ucwords( str_replace( '-', ' ', $theme ) ); - } - - return $themes; -} - -/** - * Render the editor preview setting - */ -function render_editor_preview() { - $settings = get_settings_values(); - $settings = $settings['editor']; - - $indent_unit = absint( $settings['indent_unit'] ); - $tab_size = absint( $settings['tab_size'] ); - - $n_tabs = $settings['indent_with_tabs'] ? floor( $indent_unit / $tab_size ) : 0; - $n_spaces = $settings['indent_with_tabs'] ? $indent_unit % $tab_size : $indent_unit; - - $indent = str_repeat( "\t", $n_tabs ) . str_repeat( ' ', $n_spaces ); - - $code = "add_filter( 'admin_footer_text', function ( \$text ) {\n\n" . - $indent . "\$site_name = get_bloginfo( 'name' );\n\n" . - $indent . '$text = "Thank you for visiting $site_name.";' . "\n" . - $indent . 'return $text;' . "\n" . - "} );\n"; - - echo ''; -} diff --git a/src/php/settings/settings-fields.php b/src/php/settings/settings-fields.php deleted file mode 100644 index 72262cc77..000000000 --- a/src/php/settings/settings-fields.php +++ /dev/null @@ -1,263 +0,0 @@ -> - */ -function get_default_settings(): array { - static $defaults; - - if ( isset( $defaults ) ) { - return $defaults; - } - - $defaults = [ - 'general' => [ - 'activate_by_default' => true, - 'enable_tags' => true, - 'enable_description' => true, - 'visual_editor_rows' => 5, - 'list_order' => 'priority-asc', - 'disable_prism' => false, - 'hide_upgrade_menu' => false, - 'complete_uninstall' => false, - 'enable_flat_files' => false, - ], - 'editor' => [ - 'indent_with_tabs' => true, - 'tab_size' => 4, - 'indent_unit' => 4, - 'font_size' => 14, - 'wrap_lines' => true, - 'code_folding' => true, - 'line_numbers' => true, - 'auto_close_brackets' => true, - 'highlight_selection_matches' => true, - 'highlight_active_line' => true, - 'keymap' => 'default', - 'theme' => 'default', - ], - 'version-switch' => [ - 'selected_version' => '', - ], - 'debug' => [ - 'enable_version_change' => false, - ], - ]; - - $defaults = apply_filters( 'code_snippets_settings_defaults', $defaults ); - - return $defaults; -} - -/** - * Retrieve the settings fields - * - * @return array> - */ -function get_settings_fields(): array { - static $fields; - - if ( isset( $fields ) ) { - return $fields; - } - - $fields = []; - - $fields['debug'] = [ - 'database_update' => [ - 'name' => __( 'Database Table Upgrade', 'code-snippets' ), - 'type' => 'action', - 'label' => __( 'Upgrade Database Table', 'code-snippets' ), - 'desc' => __( 'Use this button to manually upgrade the Code Snippets database table. This action will only affect the snippets table and should be used only when necessary.', 'code-snippets' ), - ], - 'reset_caches' => [ - 'name' => __( 'Reset Caches', 'code-snippets' ), - 'type' => 'action', - 'desc' => __( 'Use this button to manually clear snippets caches.', 'code-snippets' ), - ], - 'enable_version_change' => [ - 'name' => __( 'Version Change', 'code-snippets' ), - 'type' => 'checkbox', - 'label' => __( 'Enable the ability to switch or rollback versions of the Code Snippets core plugin.', 'code-snippets' ), - ], - ]; - - $fields['version-switch'] = [ - 'version_switcher' => [ - 'name' => __( 'Switch Version', 'code-snippets' ), - 'type' => 'callback', - 'render_callback' => [ '\\Code_Snippets\\Settings\\Version_Switch', 'render_version_switch_field' ], - ], - 'refresh_versions' => [ - 'name' => __( 'Refresh Versions', 'code-snippets' ), - 'type' => 'callback', - 'render_callback' => [ '\\Code_Snippets\\Settings\\Version_Switch', 'render_refresh_versions_field' ], - ], - 'version_warning' => [ - 'name' => '', - 'type' => 'callback', - 'render_callback' => [ '\\Code_Snippets\\Settings\\Version_Switch', 'render_version_switch_warning' ], - ], - ]; - - $fields['general'] = [ - 'activate_by_default' => [ - 'name' => __( 'Activate by Default', 'code-snippets' ), - 'type' => 'checkbox', - 'label' => __( "Make the 'Save and Activate' button the default action when saving a snippet.", 'code-snippets' ), - ], - 'enable_tags' => [ - 'name' => __( 'Enable Snippet Tags', 'code-snippets' ), - 'type' => 'checkbox', - 'label' => __( 'Show snippet tags on admin pages.', 'code-snippets' ), - ], - 'enable_description' => [ - 'name' => __( 'Enable Snippet Descriptions', 'code-snippets' ), - 'type' => 'checkbox', - 'label' => __( 'Show snippet descriptions on admin pages.', 'code-snippets' ), - ], - 'visual_editor_rows' => [ - 'name' => __( 'Description Editor Height', 'code-snippets' ), - 'type' => 'number', - 'label' => _x( 'rows', 'unit', 'code-snippets' ), - 'min' => 0, - ], - 'list_order' => [ - 'name' => __( 'Snippets List Order', 'code-snippets' ), - 'type' => 'select', - 'desc' => __( 'Default way to order snippets on the All Snippets admin menu.', 'code-snippets' ), - 'options' => [ - 'priority-asc' => __( 'Priority', 'code-snippets' ), - 'name-asc' => __( 'Name (A-Z)', 'code-snippets' ), - 'name-desc' => __( 'Name (Z-A)', 'code-snippets' ), - 'modified-desc' => __( 'Modified (latest first)', 'code-snippets' ), - 'modified-asc' => __( 'Modified (oldest first)', 'code-snippets' ), - ], - ], - 'disable_prism' => [ - 'name' => __( 'Disable Syntax Highlighter', 'code-snippets' ), - 'type' => 'checkbox', - 'label' => __( 'Disable syntax highlighting when displaying snippet code on the front-end.', 'code-snippets' ), - ], - ]; - - if ( ! code_snippets()->licensing->is_licensed() ) { - $fields['general']['hide_upgrade_menu'] = [ - 'name' => __( 'Hide Upgrade Notices', 'code-snippets' ), - 'type' => 'checkbox', - 'label' => __( 'Hide notices inviting you to upgrade to Code Snippets Pro.', 'code-snippets' ), - ]; - } - - if ( ! is_multisite() || is_main_site() ) { - $fields['general']['complete_uninstall'] = [ - 'name' => __( 'Complete Uninstall', 'code-snippets' ), - 'type' => 'checkbox', - 'label' => __( 'When the plugin is deleted from the Plugins menu, also delete all snippets and plugin settings.', 'code-snippets' ), - ]; - } - - $fields['editor'] = [ - 'indent_with_tabs' => [ - 'name' => __( 'Indent With Tabs', 'code-snippets' ), - 'type' => 'checkbox', - 'label' => __( 'Use hard tabs instead of spaces for indentation.', 'code-snippets' ), - 'codemirror' => 'indentWithTabs', - ], - 'tab_size' => [ - 'name' => __( 'Tab Size', 'code-snippets' ), - 'type' => 'number', - 'desc' => __( 'The width of a tab character.', 'code-snippets' ), - 'label' => _x( 'spaces', 'unit', 'code-snippets' ), - 'codemirror' => 'tabSize', - 'min' => 0, - ], - 'indent_unit' => [ - 'name' => __( 'Indent Unit', 'code-snippets' ), - 'type' => 'number', - 'desc' => __( 'The number of spaces to indent a block.', 'code-snippets' ), - 'label' => _x( 'spaces', 'unit', 'code-snippets' ), - 'codemirror' => 'indentUnit', - 'min' => 0, - ], - 'font_size' => [ - 'name' => __( 'Font Size', 'code-snippets' ), - 'type' => 'number', - 'label' => _x( 'px', 'unit', 'code-snippets' ), - 'codemirror' => 'fontSize', - 'min' => 8, - 'max' => 28, - ], - 'wrap_lines' => [ - 'name' => __( 'Wrap Lines', 'code-snippets' ), - 'type' => 'checkbox', - 'label' => __( 'Soft-wrap long lines of code instead of horizontally scrolling.', 'code-snippets' ), - 'codemirror' => 'lineWrapping', - ], - - 'code_folding' => [ - 'name' => __( 'Code Folding', 'code-snippets' ), - 'type' => 'checkbox', - 'label' => __( 'Allow folding functions or other blocks into a single line.', 'code-snippets' ), - 'codemirror' => 'foldGutter', - ], - 'line_numbers' => [ - 'name' => __( 'Line Numbers', 'code-snippets' ), - 'type' => 'checkbox', - 'label' => __( 'Show line numbers to the left of the editor.', 'code-snippets' ), - 'codemirror' => 'lineNumbers', - ], - 'auto_close_brackets' => [ - 'name' => __( 'Auto Close Brackets', 'code-snippets' ), - 'type' => 'checkbox', - 'label' => __( 'Auto-close brackets and quotes when typed.', 'code-snippets' ), - 'codemirror' => 'autoCloseBrackets', - ], - 'highlight_selection_matches' => [ - 'name' => __( 'Highlight Selection Matches', 'code-snippets' ), - 'label' => __( 'Highlight all instances of a currently selected word.', 'code-snippets' ), - 'type' => 'checkbox', - 'codemirror' => 'highlightSelectionMatches', - ], - 'highlight_active_line' => [ - 'name' => __( 'Highlight Active Line', 'code-snippets' ), - 'label' => __( 'Highlight the line that is currently being edited.', 'code-snippets' ), - 'type' => 'checkbox', - 'codemirror' => 'styleActiveLine', - ], - 'keymap' => [ - 'name' => __( 'Keymap', 'code-snippets' ), - 'type' => 'select', - 'desc' => __( 'The set of keyboard shortcuts to use in the code editor.', 'code-snippets' ), - 'options' => [ - 'default' => __( 'Default', 'code-snippets' ), - 'vim' => __( 'Vim', 'code-snippets' ), - 'emacs' => __( 'Emacs', 'code-snippets' ), - 'sublime' => __( 'Sublime Text', 'code-snippets' ), - ], - 'codemirror' => 'keyMap', - ], - 'theme' => [ - 'name' => __( 'Theme', 'code-snippets' ), - 'type' => 'select', - 'options' => get_editor_theme_list(), - 'codemirror' => 'theme', - ], - ]; - - $fields = apply_filters( 'code_snippets_settings_fields', $fields ); - - return $fields; -} diff --git a/src/php/snippet-ops.php b/src/php/snippet-ops.php index cfd661914..a4c6b0afe 100644 --- a/src/php/snippet-ops.php +++ b/src/php/snippet-ops.php @@ -7,9 +7,51 @@ namespace Code_Snippets; -use ParseError; -use function Code_Snippets\Settings\get_self_option; -use function Code_Snippets\Settings\update_self_option; +use Code_Snippets\Core\DB; +use Code_Snippets\Flat_Files\Snippet_Files; +use Exception; +use Code_Snippets\Model\Snippet; +use Code_Snippets\Utils\Validator; +use Throwable; +use function Code_Snippets\Utils\get_self_option; +use function Code_Snippets\Utils\update_self_option; + +/** + * Get the locked status for a snippet from wp_options. + * + * @param int $snippet_id Snippet ID. + * @param bool|null $network Whether the snippet is network-wide (true) or site-wide (false). + * + * @return bool Whether the snippet is locked. + */ +function is_snippet_locked( int $snippet_id, ?bool $network = null ): bool { + $network = DB::validate_network_param( $network ); + $locked_snippets = get_self_option( $network, 'code_snippets_locked', [] ); + + return isset( $locked_snippets[ $snippet_id ] ) && $locked_snippets[ $snippet_id ]; +} + +/** + * Set the locked status for a snippet in wp_options. + * + * @param int $snippet_id Snippet ID. + * @param bool $locked Whether the snippet should be locked. + * @param bool|null $network Whether the snippet is network-wide (true) or site-wide (false). + * + * @return void + */ +function set_snippet_locked( int $snippet_id, bool $locked, ?bool $network = null ): void { + $network = DB::validate_network_param( $network ); + $locked_snippets = get_self_option( $network, 'code_snippets_locked', [] ); + + if ( $locked ) { + $locked_snippets[ $snippet_id ] = true; + } else { + unset( $locked_snippets[ $snippet_id ] ); + } + + update_self_option( $network, 'code_snippets_locked', $locked_snippets ); +} /** * Clean the cache where active snippets are stored. @@ -20,11 +62,13 @@ * @return void */ function clean_active_snippets_cache( string $table_name, $scopes = false ) { - $scope_groups = $scopes ? [ $scopes ] : [ - [ 'head-content', 'footer-content' ], - [ 'global', 'single-use', 'front-end' ], - [ 'global', 'single-use', 'admin' ], - ]; + $scope_groups = $scopes + ? [ $scopes ] + : [ + [ 'head-content', 'body-content', 'footer-content' ], + [ 'global', 'single-use', 'front-end' ], + [ 'global', 'single-use', 'admin' ], + ]; foreach ( $scope_groups as $scopes ) { wp_cache_delete( sprintf( 'active_snippets_%s_%s', sanitize_key( join( '_', $scopes ) ), $table_name ), CACHE_GROUP ); @@ -51,17 +95,17 @@ function clean_snippets_cache( string $table_name ) { * @param array $ids The IDs of the snippets to fetch. * @param bool|null $network Retrieve multisite-wide snippets (true) or site-wide snippets (false). * - * @return array List of Snippet objects. + * @return Snippet[] List of Snippet objects. * * @since 2.0 */ -function get_snippets( array $ids = array(), ?bool $network = null ): array { +function get_snippets( array $ids = [], ?bool $network = null ): array { global $wpdb; // If only one ID has been passed in, defer to the get_snippet() function. $ids_count = count( $ids ); if ( 1 === $ids_count ) { - return array( get_snippet( $ids[0], $network ) ); + return [ get_snippet( $ids[0], $network ) ]; } $network = DB::validate_network_param( $network ); @@ -73,15 +117,20 @@ function get_snippets( array $ids = array(), ?bool $network = null ): array { if ( ! is_array( $snippets ) ) { $results = $wpdb->get_results( "SELECT * FROM $table_name", ARRAY_A ); - $snippets = $results ? - array_map( + $snippets = $results + ? array_map( function ( $snippet_data ) use ( $network ) { $snippet_data['network'] = $network; - return new Snippet( $snippet_data ); + $snippet = new Snippet( $snippet_data ); + // Load locked from wp_options. + if ( $snippet->id > 0 ) { + $snippet->locked = is_snippet_locked( $snippet->id, $network ); + } + return $snippet; }, $results - ) : - array(); + ) + : []; $snippets = apply_filters( 'code_snippets/get_snippets', $snippets, $network ); @@ -173,11 +222,11 @@ function code_snippets_build_tags_array( $tags ): array { * @param int $id The ID of the snippet to retrieve. 0 to build a new snippet. * @param bool|null $network Retrieve a multisite-wide snippet (true) or site-wide snippet (false). * - * @return Snippet A single snippet object. + * @return ?Snippet A single snippet object. * * @since 2.0.0 */ -function get_snippet( int $id = 0, ?bool $network = null ): Snippet { +function get_snippet( int $id = 0, ?bool $network = null ): ?Snippet { global $wpdb; $id = absint( $id ); @@ -207,19 +256,25 @@ function get_snippet( int $id = 0, ?bool $network = null ): Snippet { } $snippet->network = $network; + + // Load locked from wp_options if snippet has an ID. + if ( $snippet->id > 0 ) { + $snippet->locked = is_snippet_locked( $snippet->id, $network ); + } + return apply_filters( 'code_snippets/get_snippet', $snippet, $id, $network ); } /** - * Ensure the list of shared network snippets is correct if one has been recently activated or deactivated. + * Ensure the list of shared network snippets is correct if one has been recently active or deactivated. * Write operation. * * @access private * * @param Snippet[] $snippets Snippets that was recently updated. * - * @return boolean Whether an update was performed. + * @return bool Whether an update was performed. */ function update_shared_network_snippets( array $snippets ): bool { $shared_ids = []; @@ -296,8 +351,8 @@ function activate_snippet( int $id, ?bool $network = null ) { // translators: %d: snippet identifier. return sprintf( __( 'Could not locate snippet with ID %d.', 'code-snippets' ), $id ); } - - if('php' == $snippet->type ){ + + if ( 'php' === $snippet->type ) { $validator = new Validator( $snippet->code ); if ( $validator->validate() ) { return __( 'Could not activate snippet: code did not pass validation.', 'code-snippets' ); @@ -326,8 +381,8 @@ function activate_snippet( int $id, ?bool $network = null ) { * Activates multiple snippets. * Write operation. * - * @param array $ids The IDs of the snippets to activate. - * @param bool|null $network Whether the snippets are multisite-wide (true) or site-wide (false). + * @param array $ids The IDs of the snippets to activate. + * @param bool|null $network Whether the snippets are multisite-wide (true) or site-wide (false). * * @return Snippet[]|null Snippets which were successfully activated, or null on failure. * @@ -410,8 +465,8 @@ function deactivate_snippet( int $id, ?bool $network = null ): ?Snippet { // Update the recently active list. $snippet = get_snippet( $id ); - $recently_active = [ $id => time() ] + get_self_option( $network, 'recently_activated_snippets', [] ); - update_self_option( $network, 'recently_activated_snippets', $recently_active ); + $recently_active = [ $id => time() ] + get_self_option( $network, 'recently_active_snippets', [] ); + update_self_option( $network, 'recently_active_snippets', $recently_active ); update_shared_network_snippets( [ $snippet ] ); do_action( 'code_snippets/deactivate_snippet', $id, $network ); @@ -438,6 +493,11 @@ function delete_snippet( int $id, ?bool $network = null ): bool { $snippet = get_snippet( $id, $network ); + // Prevent deletion of locked snippets. + if ( $snippet->locked ) { + return false; + } + $result = $wpdb->delete( $table, array( 'id' => $id ), @@ -447,7 +507,13 @@ function delete_snippet( int $id, ?bool $network = null ): bool { if ( $result ) { do_action( 'code_snippets/delete_snippet', $snippet, $network ); clean_snippets_cache( $table ); - code_snippets()->cloud_api->delete_snippet_from_transient_data( $id ); + + $recently_active = get_self_option( $network, 'recently_active_snippets', [] ); + + if ( isset( $recently_active[ $id ] ) ) { + unset( $recently_active[ $id ] ); + update_self_option( $network, 'recently_active_snippets', $recently_active ); + } } return (bool) $result; @@ -471,20 +537,17 @@ function trash_snippet( int $id, ?bool $network = null ): bool { $snippet = get_snippet( $id, $network ); - $result = $wpdb->update( - $table, - array( 'active' => '-1' ), - array( 'id' => $id ), - array( '%d' ) - ); - - if ( $result ) { - do_action( 'code_snippets/trash_snippet', $snippet, $network ); - clean_snippets_cache( $table ); - code_snippets()->cloud_api->delete_snippet_from_transient_data( $id ); + // Prevent trashing of locked snippets. + if ( $snippet->locked ) { + return false; } - return (bool) $result; + $wpdb->update( $table, [ 'active' => '-1' ], [ 'id' => $id ], [ '%d' ] ); + + do_action( 'code_snippets/trash_snippet', $snippet, $network ); + clean_snippets_cache( $table ); + + return true; } /** @@ -503,12 +566,7 @@ function restore_snippet( int $id, ?bool $network = null ): bool { $network = DB::validate_network_param( $network ); $table = code_snippets()->db->get_table_name( $network ); - $result = $wpdb->update( - $table, - array( 'active' => '0' ), - array( 'id' => $id ), - array( '%d' ) - ); + $result = $wpdb->update( $table, [ 'active' => '0' ], [ 'id' => $id ], [ '%d' ] ); if ( $result ) { do_action( 'code_snippets/restore_snippet', $id, $network ); @@ -525,6 +583,7 @@ function restore_snippet( int $id, ?bool $network = null ): bool { */ function test_snippet_code( Snippet $snippet ) { $snippet->code_error = null; + $snippet->code_error_trace = null; if ( 'php' !== $snippet->type ) { return; @@ -535,16 +594,18 @@ function test_snippet_code( Snippet $snippet ) { if ( $result ) { $snippet->code_error = [ $result['message'], $result['line'] ]; + $snippet->code_error_trace = ( new Exception() )->getTraceAsString(); } if ( ! $snippet->code_error && 'single-use' !== $snippet->scope ) { $result = execute_snippet( $snippet->code, $snippet->id, true ); - if ( $result instanceof ParseError ) { + if ( $result instanceof Throwable ) { $snippet->code_error = [ ucfirst( rtrim( $result->getMessage(), '.' ) ) . '.', $result->getLine(), ]; + $snippet->code_error_trace = $result->getTraceAsString(); } } } @@ -559,7 +620,7 @@ function test_snippet_code( Snippet $snippet ) { * * @since 2.0.0 */ -function save_snippet( $snippet ) { +function save_snippet( $snippet ): ?Snippet { global $wpdb; $table = code_snippets()->db->get_table_name( $snippet->network ); @@ -567,6 +628,18 @@ function save_snippet( $snippet ) { $snippet = new Snippet( $snippet ); } + // Prevent modification of locked snippets (allow unlocking itself). + if ( 0 !== $snippet->id ) { + $old_snippet = get_snippet( $snippet->id, $snippet->network ); + + if ( $old_snippet->locked && $snippet->locked ) { + // If it was locked and the new request still wants it locked, + // prevent changes to sensitive fields (code and name). + $snippet->code = $old_snippet->code; + $snippet->name = $old_snippet->name; + } + } + // Update the last modification date if necessary. $snippet->update_modified(); @@ -590,10 +663,15 @@ function save_snippet( $snippet ) { $snippet->increment_revision(); } + // Increment the revision number unless revision = 1 or revision is not set. + if ( $snippet->revision && $snippet->revision > 1 ) { + $snippet->increment_revision(); + } + // Shared network snippets are always considered inactive. $snippet->active = $snippet->active && ! $snippet->shared_network; - // Build the list of data to insert. + // Build the list of data to insert (excluding locked, which is stored in wp_options). $data = [ 'name' => $snippet->name, 'description' => $snippet->desc, @@ -605,7 +683,7 @@ function save_snippet( $snippet ) { 'active' => intval( $snippet->active ), 'modified' => $snippet->modified, 'revision' => $snippet->revision, - 'cloud_id' => $snippet->cloud_id ? $snippet->cloud_id : null, + 'cloud_id' => $snippet->cloud_id_owner ? $snippet->cloud_id_owner : null, ]; // Create a new snippet if the ID is not set. @@ -616,21 +694,43 @@ function save_snippet( $snippet ) { } $snippet->id = $wpdb->insert_id; - do_action( 'code_snippets/create_snippet', $snippet, $table ); - } else { + $updated = get_snippet( $snippet->id, $snippet->network ); + $updated->code_error = $snippet->code_error; + $updated->code_error_trace = $snippet->code_error_trace; + do_action( 'code_snippets/create_snippet', $updated, $table ); - // Otherwise, update the snippet data. - $result = $wpdb->update( $table, $data, [ 'id' => $snippet->id ], null, [ '%d' ] ); - if ( false === $result ) { - return null; + if ( $updated->id > 0 ) { + set_snippet_locked( $updated->id, $updated->locked, $updated->network ); } + } else { + // Otherwise, update the snippet data. + $existing = get_snippet( $snippet->id, $snippet->network ); + + set_snippet_locked( $snippet->id, $snippet->locked, $snippet->network ); + $wpdb->update( $table, $data, [ 'id' => $snippet->id ], null, [ '%d' ] ); + + $updated = get_snippet( $snippet->id, $snippet->network ); + $updated->code_error = $snippet->code_error; + $updated->code_error_trace = $snippet->code_error_trace; - do_action( 'code_snippets/update_snippet', $snippet, $table ); + do_action( 'code_snippets/update_snippet', $updated, $table, $existing, $snippet ); + + if ( ! $updated->active && $existing->active ) { + $recently_active = [ $updated->id => time() ] + get_self_option( $updated->network, 'recently_active_snippets', [] ); + update_self_option( $updated->network, 'recently_active_snippets', $recently_active ); + } elseif ( ! $updated->active ) { + $recently_active = get_self_option( $updated->network, 'recently_active_snippets', [] ); + + if ( isset( $recently_active[ $updated->id ] ) ) { + unset( $recently_active[ $updated->id ] ); + update_self_option( $updated->network, 'recently_active_snippets', $recently_active ); + } + } } - update_shared_network_snippets( [ $snippet ] ); + update_shared_network_snippets( [ $updated ] ); clean_snippets_cache( $table ); - return $snippet; + return $updated; } /** @@ -639,13 +739,16 @@ function save_snippet( $snippet ) { * * Code must NOT be escaped, as it will be executed directly. * - * @param string $code Snippet code to execute. - * @param integer $id Snippet ID. - * @param boolean $force Force snippet execution, even if save mode is active. + * @param string $code Snippet code to execute. + * @param int $id Snippet ID. + * @param bool $force Force snippet execution, even if save mode is active. * - * @return ParseError|mixed Code error if encountered during execution, or result of snippet execution otherwise. + * @return Throwable|mixed Code error if encountered during execution, or result of snippet execution otherwise. * - * @since 2.0.0 + * @since 2.0.0 + * @noinspection PhpUndefinedConstantInspection + * + * phpcs:disable Squiz.PHP.Eval.Discouraged */ function execute_snippet( string $code, int $id = 0, bool $force = false ) { /** @@ -661,8 +764,8 @@ function execute_snippet( string $code, int $id = 0, bool $force = false ) { try { $result = eval( $code ); - } catch ( ParseError $parse_error ) { - $result = $parse_error; + } catch ( Throwable $throwable ) { + $result = $throwable; } ob_end_clean(); @@ -676,8 +779,8 @@ function execute_snippet( string $code, int $id = 0, bool $force = false ) { * * Read operation. * - * @param string $cloud_id The Cloud ID of the snippet to retrieve. - * @param boolean|null $multisite Retrieve a multisite-wide snippet (true) or site-wide snippet (false). + * @param string $cloud_id The Cloud ID of the snippet to retrieve. + * @param bool|null $multisite Retrieve a multisite-wide snippet (true) or site-wide snippet (false). * * @return Snippet|null A single snippet object or null if no snippet was found. * @@ -704,6 +807,12 @@ function get_snippet_by_cloud_id( string $cloud_id, ?bool $multisite = null ): ? $snippet_data = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $table_name WHERE cloud_id = %s", $cloud_id ) ); // cache pass, db call ok. $snippet = $snippet_data ? new Snippet( $snippet_data ) : null; + // Load locked from wp_options if snippet exists. + if ( $snippet && $snippet->id > 0 ) { + $snippet->network = $multisite; + $snippet->locked = is_snippet_locked( $snippet->id, $multisite ); + } + return apply_filters( 'code_snippets/get_snippet_by_cloud_id', $snippet, $cloud_id, $multisite ); } @@ -718,6 +827,7 @@ function get_snippet_by_cloud_id( string $cloud_id, ?bool $multisite = null ): ? function update_snippet_fields( int $snippet_id, array $fields, ?bool $network = null ) { global $wpdb; + $network = DB::validate_network_param( $network ); $table = code_snippets()->db->get_table_name( $network ); // Build a new snippet object for the validation. @@ -726,26 +836,56 @@ function update_snippet_fields( int $snippet_id, array $fields, ?bool $network = // Validate fields through the snippet class and copy them into a clean array. $clean_fields = array(); + $locked_value = null; foreach ( $fields as $field => $value ) { + // Handle locked separately (stored in wp_options). + if ( 'locked' === $field ) { + if ( $snippet->set_field( $field, $value ) ) { + $locked_value = $snippet->$field; + } + continue; + } if ( $snippet->set_field( $field, $value ) ) { $clean_fields[ $field ] = $snippet->$field; } } - // Update the snippet in the database. - $wpdb->update( $table, $clean_fields, array( 'id' => $snippet->id ), null, array( '%d' ) ); + // Update the snippet in the database (excluding locked). + if ( ! empty( $clean_fields ) ) { + $wpdb->update( $table, $clean_fields, array( 'id' => $snippet->id ), null, array( '%d' ) ); + } + + // Save locked to wp_options if it was provided. + if ( null !== $locked_value ) { + set_snippet_locked( $snippet->id, $locked_value, $network ); + } - do_action( 'code_snippets/update_snippet', $snippet->id, $table ); clean_snippets_cache( $table ); + $updated = get_snippet( $snippet->id, $network ); + if ( $updated->id ) { + do_action( 'code_snippets/update_snippet', $updated, $table ); + } } -function execute_snippet_from_flat_file( $code, $file, int $id = 0, bool $force = false ) { +/** + * Evaluate a snippet by loading it from the filesystem. + * + * @param string $code Snippet code. + * @param string $file Snippet filename. + * @param int $id Snippet ID. + * @param bool $force Force snippet execution, even if save mode is active. + * + * @return bool|Exception|Throwable|null Code error if encountered during execution, or result of snippet execution otherwise. + */ +function execute_snippet_from_flat_file( string $code, string $file, int $id = 0, bool $force = false ) { if ( ! is_file( $file ) ) { - return execute_snippet( $code, $id, $force ); + execute_snippet( $code, $id, $force ); + return true; } + /* @noinspection PhpUndefinedConstantInspection */ if ( ! $force && defined( 'CODE_SNIPPETS_SAFE_MODE' ) && CODE_SNIPPETS_SAFE_MODE ) { return false; } @@ -755,10 +895,6 @@ function execute_snippet_from_flat_file( $code, $file, int $id = 0, bool $force try { require_once $file; $result = null; - } catch ( ParseError $parse_error ) { - $result = $parse_error; - } catch ( Error $error ) { - $result = $error; } catch ( Throwable $throwable ) { $result = $throwable; } diff --git a/src/php/uninstall.php b/src/php/uninstall.php deleted file mode 100644 index 5afaefb69..000000000 --- a/src/php/uninstall.php +++ /dev/null @@ -1,110 +0,0 @@ -query( "DROP TABLE IF EXISTS {$wpdb->prefix}snippets" ); - - delete_option( 'code_snippets_version' ); - delete_option( 'recently_activated_snippets' ); - delete_option( 'code_snippets_settings' ); - - delete_option( 'code_snippets_cloud_settings' ); - delete_transient( 'cs_codevault_snippets' ); - delete_transient( 'cs_local_to_cloud_map' ); -} - -/** - * Clean up data created by this plugin on multisite. - * - * phpcs:disable WordPress.DB.DirectDatabaseQuery.SchemaChange - */ -function uninstall_multisite() { - global $wpdb; - - // Loop through sites. - $blog_ids = get_sites( [ 'fields' => 'ids' ] ); - - foreach ( $blog_ids as $site_id ) { - switch_to_blog( $site_id ); - uninstall_current_site(); - } - - restore_current_blog(); - - // Remove network snippets table. - $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}ms_snippets" ); - - // Remove saved options. - delete_site_option( 'code_snippets_version' ); - delete_site_option( 'recently_activated_snippets' ); -} - -function delete_flat_files_directory() { - $flat_files_dir = WP_CONTENT_DIR . '/code-snippets'; - - if ( ! is_dir( $flat_files_dir ) ) { - return; - } - - if ( ! function_exists( 'request_filesystem_credentials' ) ) { - require_once ABSPATH . 'wp-admin/includes/file.php'; - } - - global $wp_filesystem; - WP_Filesystem(); - - if ( $wp_filesystem && $wp_filesystem->is_dir( $flat_files_dir ) ) { - $wp_filesystem->delete( $flat_files_dir, true ); - } -} - -/** - * Uninstall the Code Snippets plugin. - * - * @return void - */ -function uninstall_plugin() { - if ( complete_uninstall_enabled() ) { - - if ( is_multisite() ) { - uninstall_multisite(); - } else { - uninstall_current_site(); - } - - delete_flat_files_directory(); - } -} diff --git a/src/php/views/import.php b/src/php/views/import.php deleted file mode 100644 index 11f372d81..000000000 --- a/src/php/views/import.php +++ /dev/null @@ -1,37 +0,0 @@ - -
    -

    - is_compact_menu() ) { - $this->render_page_title_actions( [ 'manage', 'add', 'settings' ] ); - } - - ?> -

    - -
    -
    diff --git a/src/php/views/manage.php b/src/php/views/manage.php deleted file mode 100644 index 5b0b71942..000000000 --- a/src/php/views/manage.php +++ /dev/null @@ -1,129 +0,0 @@ - __( 'All Snippets', 'code-snippets' ) ], Plugin::get_types() ); -$current_type = $this->get_current_type(); - -if ( false !== strpos( code_snippets()->version, 'beta' ) ) { - echo '

    '; - echo wp_kses( - __( 'Thank you for testing this beta version of Code Snippets. We would love to hear your thoughts.', 'code-snippets' ), - [ 'span' => [ 'class' => [ 'highlight-yellow' ] ] ] - ); - - printf( - ' %s', - esc_url( __( 'https://codesnippets.pro/beta-testing/feedback/', 'code-snippets' ) ), - esc_html__( 'Share feedback', 'code-snippets' ) - ); - echo '

    '; -} - -?> - -
    -

    - render_page_title_actions( code_snippets()->is_compact_menu() ? [ 'add', 'import', 'settings' ] : [ 'add', 'import' ] ); - - $this->list_table->search_notice(); - ?> -

    - - print_messages(); ?> - - - - [ - __( 'Function snippets are run on your site as if there were in a plugin or theme functions.php file.', 'code-snippets' ), - __( 'Learn more about function snippets →', 'code-snippets' ), - 'https://codesnippets.pro/learn-php/', - ], - 'html' => [ - __( 'Content snippets are bits of reusable PHP and HTML content that can be inserted into posts and pages.', 'code-snippets' ), - __( 'Learn more about content snippets →', 'code-snippets' ), - 'https://codesnippets.pro/learn-html/', - ], - 'css' => [ - __( 'Style snippets are written in CSS and loaded in the admin area or on the site front-end, just like the theme style.css.', 'code-snippets' ), - __( 'Learn more about style snippets →', 'code-snippets' ), - 'https://codesnippets.pro/learn-css/', - ], - 'js' => [ - __( 'Script snippets are loaded on the site front-end in a JavaScript file, either in the head or body sections.', 'code-snippets' ), - __( 'Learn more about javascript snippets →', 'code-snippets' ), - 'https://codesnippets.pro/learn-js/', - ], - 'cloud' => [ - __( 'See all your public and private snippets that are stored in your Code Snippet Cloud codevault.', 'code-snippets' ), - __( 'Learn more about Code Snippets Cloud →', 'code-snippets' ), - 'https://codesnippets.cloud/getstarted/', - ], - ]; - - - if ( isset( $type_info[ $current_type ] ) ) { - $info = $type_info[ $current_type ]; - - printf( - '

    %s %s

    ', - esc_html( $info[0] ), - esc_url( $info[2] ), - esc_html( $info[1] ) - ); - } - - do_action( 'code_snippets/admin/manage/before_list_table' ); - $this->list_table->views(); - - switch ( $current_type ) { - case 'cloud_search': - include_once 'partials/cloud-search.php'; - break; - - default: - include_once 'partials/list-table.php'; - break; - } - - do_action( 'code_snippets/admin/manage', $current_type ); - - ?> -
    diff --git a/src/php/views/partials/cloud-search.php b/src/php/views/partials/cloud-search.php deleted file mode 100644 index 078399ed1..000000000 --- a/src/php/views/partials/cloud-search.php +++ /dev/null @@ -1,76 +0,0 @@ - - -

    - - - - - -

    - -
    - - - ', esc_attr( sanitize_text_field( wp_unslash( $_REQUEST['type'] ) ) ) ); - } - ?> -
    -

    - -

    -
    -
    - - - - -
    -
    -
    - - cloud_search_list_table->display(); - } - - ?> -
    diff --git a/src/php/views/partials/list-table-notices.php b/src/php/views/partials/list-table-notices.php deleted file mode 100644 index ff6b16eab..000000000 --- a/src/php/views/partials/list-table-notices.php +++ /dev/null @@ -1,112 +0,0 @@ - -
    -

    - - CODE_SNIPPETS_SAFE_MODE', 'wp-config.php' ); - ?> - - - - -

    -
    - __( 'Snippet executed.', 'code-snippets' ), - 'activated' => __( 'Snippet activated.', 'code-snippets' ), - 'activated-multi' => __( 'Selected snippets activated.', 'code-snippets' ), - 'deactivated' => __( 'Snippet deactivated.', 'code-snippets' ), - 'deactivated-multi' => __( 'Selected snippets deactivated.', 'code-snippets' ), - 'deleted' => __( 'Snippet trashed.', 'code-snippets' ), - 'deleted-multi' => __( 'Selected snippets trashed.', 'code-snippets' ), - 'deleted_permanently' => __( 'Snippet permanently deleted.', 'code-snippets' ), - 'deleted-permanently-multi' => __( 'Selected snippets permanently deleted.', 'code-snippets' ), - 'restored' => __( 'Snippet restored.', 'code-snippets' ), - 'restored-multi' => __( 'Selected snippets restored.', 'code-snippets' ), - 'cloned' => __( 'Snippet cloned.', 'code-snippets' ), - 'cloned-multi' => __( 'Selected snippets cloned.', 'code-snippets' ), - 'cloud-refreshed' => __( 'Synced cloud data has been successfully refreshed.', 'code-snippets' ), -]; - -// Add undo link for single snippet trash action -if ( 'deleted' === $result && ! empty( $_REQUEST['ids'] ) ) { - $deleted_ids = sanitize_text_field( $_REQUEST['ids'] ); - $undo_url = wp_nonce_url( - add_query_arg( - [ - 'action' => 'restore', - 'ids' => $deleted_ids, - ] - ), - 'bulk-snippets' - ); - - // translators: %s: Undo URL. - $undo_message = __( 'Snippet trashed. Undo', 'code-snippets' ); - $result_messages['deleted'] = sprintf( $undo_message, esc_url( $undo_url ) ); -} - -// Add undo link for bulk snippet trash action -if ( 'deleted-multi' === $result && ! empty( $_REQUEST['ids'] ) ) { - $deleted_ids = sanitize_text_field( $_REQUEST['ids'] ); - $undo_url = wp_nonce_url( - add_query_arg( array( - 'action' => 'restore', - 'ids' => $deleted_ids, - ) ), - 'bulk-snippets' - ); - - // translators: %s: Undo URL. - $undo_message = __( 'Selected snippets trashed. Undo', 'code-snippets' ); - $result_messages['deleted-multi'] = sprintf( $undo_message, esc_url( $undo_url ) ); -} - -$result_messages = apply_filters( 'code_snippets/manage/result_messages', $result_messages ); - -if ( isset( $result_messages[ $result ] ) ) { - $result_kses = [ - 'strong' => [], - 'a' => [ - 'href' => [], - ], - ]; - - printf( - '

    %s

    ', - wp_kses( $result_messages[ $result ], $result_kses ) - ); -} diff --git a/src/php/views/partials/list-table.php b/src/php/views/partials/list-table.php deleted file mode 100644 index a9569386e..000000000 --- a/src/php/views/partials/list-table.php +++ /dev/null @@ -1,33 +0,0 @@ - - -
    - list_table->search_box( __( 'Search Snippets', 'code-snippets' ), 'search_id' ); - ?> -
    - -
    - - list_table->display(); - ?> -
    diff --git a/src/php/views/welcome.php b/src/php/views/welcome.php deleted file mode 100644 index 053161345..000000000 --- a/src/php/views/welcome.php +++ /dev/null @@ -1,200 +0,0 @@ -api->get_hero_item(); - -$changelog_sections = [ - 'Added' => [ - 'title' => __( 'New features', 'code-snippets' ), - 'icon' => 'lightbulb', - ], - 'Improved' => [ - 'title' => __( 'Improvements', 'code-snippets' ), - 'icon' => 'chart-line', - ], - 'Fixed' => [ - 'title' => __( 'Bug fixes', 'code-snippets' ), - 'icon' => 'buddicons-replies', - ], - 'Other' => [ - 'title' => __( 'Other', 'code-snippets' ), - 'icon' => 'open-folder', - ], -]; - -$plugin_types = [ - 'core' => __( 'Core', 'code-snippets' ), - 'pro' => __( 'Pro', 'code-snippets' ), -]; - -?> - - - - diff --git a/src/readme.txt b/src/readme.txt index 9ab3e2419..7f42f997b 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -5,7 +5,9 @@ Tags: code, snippets, multisite, php, css License: GPL-2.0-or-later License URI: license.txt Stable tag: 3.9.6 -Tested up to: 6.9 +Requires at least: 5.5 +Tested up to: 7.0 +Requires PHP: 7.4 An easy, clean, and simple way to enhance your site with code snippets. @@ -40,7 +42,7 @@ https://youtu.be/uzND-wdSCMQ == Installation == -= Automatic installation = += Automatic Installation = 1. Log into your WordPress admin 2. Click __Plugins__ @@ -52,7 +54,7 @@ https://youtu.be/uzND-wdSCMQ 5. Click __Install Now__ under "Code Snippets" 6. Activate the plugin -= Manual installation = += Manual Installation = 1. Download the plugin 2. Extract the contents of the zip file @@ -104,58 +106,81 @@ You can report security bugs found in the source code of this plugin through the == Changelog == -= 3.9.6 (2026-04-28) = += 3.10.0 (UPCOMING) = -__Changed__ +__Added__ -* tweak: improve snippets rest api +* New admin interface for managing snippets, with a cleaner layout, faster interactions, and a more consistent experience across plugin screens. +* Card view for browsing snippets, with a view switcher on the snippets table and Community Cloud. +* Snippet preview modal for viewing snippet code from the snippets table without opening the editor. +* Automatic hiding of unrelated admin notices from other plugins on Code Snippets screens. +* Admin bar snippet drawer, based on the Deckerweb Snippets workflow, for quick access to snippets and Safe Mode from the WordPress admin bar. +* Snippet locking to help prevent accidental edits or deletion of important snippets. Props to https://github.com/mgiannopoulos24. +* Improved screen options on the main snippets table, including controls for visible columns and truncating long snippet names or descriptions. +* Bulk actions and bulk code download support in the redesigned snippets table. +* Featured snippets and improved browsing in Community Cloud. +* WordPress modern theme admin styling compatibility. +* Clearer accessibility labels, headings, tab markup, table checkboxes, sort buttons, copy buttons, and drag-and-drop upload controls. -__Removed__ +__Changed__ -* remove redundant comments +* Redesigned the main snippets table with improved search, filtering, sorting, pagination, row actions, and bulk selection. +* Improved the snippet import and migration experience, including clearer file upload handling and third-party plugin migration flows. +* Improved snippet error handling so activation failures, validation errors, and stack traces are easier to understand. +* Improved Community Cloud search and filtering, including server-side filters, better result loading, and clearer empty states. +* Updated the welcome screen, toolbar, import screen, and cloud screens to match the new admin experience. +* Updated internal plugin architecture to a cleaner PSR-4 structure for better long-term maintainability. +* Improved accessibility across the snippets table, import screen, migration flow, Community Cloud, welcome screen, toolbar, dialogs, tooltips, and code editor. +* Improved colour contrast and reduced-motion support across admin screens. __Fixed__ -* site admin cannot toggle shared network snippets status +* Fixed REST API server error responses on missing snippets. +* Fixed redundant frontend logic, improving overall performance. +* Fixed Community Cloud search results and pagination to respect WordPress screen options. +* Fixed snippet saving and activation feedback to improve validation and runtime error display. +* Fixed downloaded Community Cloud snippets appearing as not downloaded after a page reload. +* Fixed network snippet lookups using the wrong database table on multisite. +* Fixed the inactive snippets count including trashed snippets. +* Fixed featured Community Cloud snippets failing to load with some cloud API responses. -= 3.9.5 (2026-02-05) = += 3.9.6 (2026-04-28) = -__Added__ +__Fixed__ -* Confirmed WordPress 6.9 compatability +* Improved permissions handling with snippets REST API. +* Site admin cannot toggle shared network snippets status. -__Changed__ += 3.9.5 (2026-02-05) = -* Improved nonce handling for cloud snippet download and update actions to for enhanced security +__Fixed__ + +* Improved security when handling actions for downloading and updating cloud snippets. = 3.9.4 (2026-01-14) = __Added__ -* New import functionality to migrate snippets from file uploads with drag-and-drop interface -* Support for importing snippets from other popular plugins (Header Footer Code Manager, Insert Headers and Footers, Insert PHP Code Snippet) -* Enhanced file based execution support with improved multisite mode compatibility - -__Changed__ - -* Updated links to more recent documentation pages +* New import functionality to migrate snippets from file uploads with drag-and-drop interface. +* Support for importing snippets from other popular plugins (Header Footer Code Manager, Insert Headers and Footers, Insert PHP Code Snippet). +* Enhanced file based execution support with improved multisite mode compatibility. __Fixed__ -* Fixed multisite capability checks in Plugin class -* Fixed snippet execution logic for multisite support by centralizing trashed snippet handling -* Fixed multisite snippet handling to ensure local snippets use correct table and filter out trashed snippets +* Fixed multisite capability checks in Plugin class. +* Fixed snippet execution logic for multisite support by centralizing trashed snippet handling. +* Fixed multisite snippet handling to ensure local snippets use correct table and filter out trashed snippets. = 3.9.3 (2025-12-03) = __Added__ -* Enhanced end-to-end tests to verify the toggle visual state in the snippets list page, improving UI verification and test reliability +* End-to-end tests to verify the toggle visual state in the snippets list page, improving UI verification and test reliability. __Fixed__ -* Fix missing import of common/direction in src/css/manage.scss to restore correct styling and direction-aware layout -* Fix toggle activation check to ensure the correct transformation value is used when detecting active/inactive state +* Restored missing styles styling and direction-aware layout from Manage menu. +* Ensure correct transformation value is used when detecting state of activation toggle. = 3.9.2 (2025-11-17) = @@ -282,186 +307,4 @@ __Fixed__ * Fixed errors in bundle iteration by adding a check for the bundles array before iterating. -= 3.6.8 (2025-02-14) = - -__Added__ - -* `code_snippets/hide_welcome_banner` filter hook for hiding welcome banner in dashboard. - -__Changed__ - -* Updated Freemius SDK to the latest version. (PRO) - -__Removed__ - -* Functionality allowing `[code_snippet]` shortcodes to be embedded recursively – it will be re-added in a future version. - -__Fixed__ - -* Shortcodes embedded within `[code_snippet]` shortcodes not evaluating correctly. -* Translation functions being called too early in some instances when loading plugin settings. -* 'Generate' button not appearing on some sites. (PRO) -* Incorrect arrow entity used in cloud list table (props to [brandonjp]). -* Removed reference to missing plugins.css file in core plugin version. - -= 3.6.7 (2025-01-24) = - -__Added__ - -* Generated snippet shortcode tags will include the snippet name, for easier identification. -* Admin notices will dismiss automatically after five seconds. ([#208](https://github.com/codesnippetspro/code-snippets/issues/208)) - -__Changed__ - -* Updated CSS to use latest Sass features. -* Moved theme selector to just above editor preview on settings page (thanks to brandonjp). ([#206](https://github.com/codesnippetspro/code-snippets/issues/206)) -* `[code_snippet]` shortcodes can now be nested within each other. ([#198](https://github.com/codesnippetspro/code-snippets/issues/198)) - -__Fixed__ - -* Save buttons above editor did not follow usual validation process in Pro. (PRO) ([#197](https://github.com/codesnippetspro/code-snippets/issues/197)) -* Minor inconsistencies in consistent UI elements between Core and Pro. -* Tags input not allowing input. ([#211](https://github.com/codesnippetspro/code-snippets/issues/211)) -* Issue with Elementor source code widget. (PRO) ([#205](https://github.com/codesnippetspro/code-snippets/issues/205)) -* Snippet descriptions not visible when viewing cloud search results. -* Snippet import page not displaying number of successfully imported snippets. -* Use UTC time when deciding when to display campaign notices. - -= 3.6.6.1 (2024-11-27) = - -__Fixed__ - -* Redeployment of v3.6.6 to overcome issue with initial build. -* Type issue when caching cloud links. (PRO) - -= 3.6.6 (2024-11-27) = - -__Changed__ - -* Improved compatability with modern versions of PHP. -* Extended welcome API to include admin notices. - -__Fixed__ - -* Memory issue from checking aggregate posts while loading front-end syntax highlighter. -* Translation functions being called too early on upgrade, resulting in localisation loading errors. -* Bug preventing the 'share on network' status of network snippets from correctly updating. -* Incorrect logic controlling when to display 'Save Changes' or 'Save Changes and Activate' buttons. -* Old notices persisting when switching between editing and creating snippets. - -= 3.6.5.1 (2024-05-24) = - -* Redeployment of v3.6.5 to overcome issue with initial build. - -= 3.6.5 (2024-05-24) = - -__Added__ - -* New admin menu providing useful resources and updates on the Code Snippets plugin and community. - -= 3.6.4 (2024-03-15) = - -__Added__ - -* AI generation for all snippet types: HTML, CSS, JS. (PRO) -* Button to create a cloud connection directly from the Snippets menu when disconnected. (PRO) - -__Changed__ - -* Increment the revision number of CSS and JS snippet when using the 'Reset Caches' debug action. (PRO) -* UX in generate dialog, such as allowing 'Enter' to submit the form. (PRO) - -__Fixed__ - -* Minor type compatability issue with newer versions of PHP. -* Undefined array key issue when initiating cloud sync. (PRO) -* Bug preventing downloading a single snippet from a bundle. (PRO) -* Translations not loading for strings in JavaScript files. - -= 3.6.3 (2023-11-13) = - -__Added__ - -* Added debug action for resetting snippets caches. - -__Fixed__ - -* Import error when initialising cloud sync configuration. (PRO) - -= 3.6.2 (2023-11-11) = - -__Removed__ - -* Removed automatic encoding of code content. - -__Fixed__ - -* Error when attempting to save shared network snippets marked as active. -* Type error when rendering checkbox fields without a stored or default value. -* Label for snippet sharing input incorrectly linked to input field. -* Error when attempting to download export files from Edit menu. -* Issue loading Freemius string overrides too early. (PRO) -* Fix redirect URL when connecting with OAuth on subdirectory or HTTPS sites. (PRO) -* Import error when attempting to completely uninstall the plugin. - -= 3.6.1 (2023-11-07) = - -__Fixed__ - -* Issue accessing fields on Snippets class. - -= 3.6.0 (2023-11-07) = - -__Added__ - -* Ability to authenticate with Code Snippets Cloud using OAuth. (PRO) -* Integration with GPT AI for generating snippets. (PRO) -* Ability to generate line-by-line descriptions of snippet code with GPT AI. (PRO) -* Ability to generate tags and description text from existing snippet code with GPT AI. (PRO) -* Added debug settings menu for manually performing problem-solving actions. -* Filter to disable scroll-into-view functionality for edit page notices. - -__Changed__ - -* Updated minimum PHP requirement to 7.4. -* Ensure that the URL of the edit snippet page changes when adding a new snippet. -* Snippet tags will automatically be added when focus is lost on the tags field. - -__Fixed__ - -* Moved active status border on edit name field to left-hand side. -* New notices will not scroll if already at top of page. -* Potential CSRF vulnerability allowing an authenticated user to reset settings. - -= 3.5.1 (2023-09-15) = - -__Fixed__ - -* Undefined array key error when accessing plugin settings page. (PRO) -* Issue registering API endpoints affecting edit post screen. (PRO) -* Snippet ID instead of snippet object being passed to `code_snippets/update_snippet` action hook. - -= 3.5.0 (2023-09-13) = - -__Added__ - -* Support for the Code Snippets Cloud API. -* Search and download public snippets. -* Codevault back-up and synchronisation. (PRO) -* Synchronised local snippets are automatically updated in Cloud. (PRO) -* Bulk actions - 'update' and 'download'. -* Download snippets from public and private codevaults. (PRO) -* Search and download any publicly viewable snippet in Code Snippet Cloud by keyword or name of codevault. (PRO) -* Deploy snippets to plugin from Code Snippets Cloud app. (PRO) -* Bundles of Joy! Search and download Snippet Bundles in one go direct from Code Snippets Cloud. (PRO) - -__Changed__ - -* Redirect to snippets table when deleting snippet from the edit menu. -* Scroll new notices into view on edit menu. - -__Fixed__ - -* Error when attempting to update network shared snippets after saving. [[#](https://wordpress.org/support/topic/activating-snippets-breaks-on-wordpress-6-3/)] - **[The full changelog is available on GitHub](https://github.com/codesnippetspro/code-snippets/blob/core/CHANGELOG.md)** diff --git a/src/uninstall.php b/src/uninstall.php old mode 100644 new mode 100755 index 74f1d4b92..9d70f4bec --- a/src/uninstall.php +++ b/src/uninstall.php @@ -6,13 +6,16 @@ * @since 2.0.0 */ -namespace Code_Snippets\Uninstall; +namespace Code_Snippets; + +use Code_Snippets\Core\Uninstaller; // Ensure this plugin is actually being uninstalled. if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) || ( defined( 'CODE_SNIPPETS_PRO' ) && CODE_SNIPPETS_PRO ) ) { return; } -require_once __DIR__ . '/php/uninstall.php'; +require_once __DIR__ . '/php/Core/Uninstaller.php'; -uninstall_plugin(); +$uninstaller = new Uninstaller(); +$uninstaller->uninstall_plugin(); diff --git a/test-playwright.sh b/test-playwright.sh old mode 100755 new mode 100644 diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 000000000..d1b51e497 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,197 @@ +# PHPUnit Testing Setup + +## Quick Start + +### 1. Install WordPress Test Suite (recommended) + +Run the setup script (downloads WordPress + the WP test suite into the repo, and creates the test DB if needed): + +```bash +npm run test:setup:php +``` + +Defaults used by `test:setup:php`: + +- **DB Name**: `code_snippets_phpunit` +- **DB User**: `root` +- **DB Password**: *(empty)* +- **DB Host**: `127.0.0.1` +- **WP Version**: `latest` + +Override defaults via env vars (example): + +```bash +WP_PHPUNIT_DB_NAME=wp_phpunit_test \ +WP_PHPUNIT_DB_USER=root \ +WP_PHPUNIT_DB_PASS=root \ +WP_PHPUNIT_DB_HOST=127.0.0.1 \ +WP_PHPUNIT_WP_VERSION=latest \ +npm run test:setup:php +``` + +### 2. Run Tests + +Run all tests: + +```bash +npm run test:php +``` + +Run tests with detailed output: + +```bash +npm run test:php:watch +``` + +Or run PHPUnit directly: + +```bash +WP_TESTS_DIR=./.wp-tests-lib src/vendor/bin/phpunit -c phpunit.xml +``` + +## What Gets Installed + +The `test:setup:php` script will: + +1. Download WordPress core to `./.wp-core/` +2. Download the WordPress test library to `./.wp-tests-lib/` +3. Create a test database (if it doesn't exist) +4. Generate `./.wp-tests-lib/wp-tests-config.php` + +## Troubleshooting + +### "Could not find includes/functions.php" + +Run `npm run test:setup:php` to download the WordPress test suite. + +### Database connection errors + +Verify your database credentials and that MySQL is running. + +### Permission errors + +Make sure the installation script is executable: + +```bash +chmod +x scripts/install-wp-tests.sh +``` + +### Missing `svn` + +The WordPress test suite download uses `svn export`. Install Subversion if you don't already have it. + +## Writing Tests + +Tests should be placed in `tests/unit/` using roughly the same PSR-4 namespace structure as the PHP source files. + +Example test: + +```php +assertTrue( true ); + } +} +``` + +### Guidelines / caveats + +- Keep tests isolated: create your own fixtures and clean up after each test where possible. +- Prefer plugin APIs (`save_snippet`, `delete_snippet`, etc.) over direct SQL so behavior matches runtime (and keeps + flat-file mode in sync). +- Avoid depending on UI strings/markup in PHPUnit tests—assert on behavior, data, and registered WP objects (e.g. + `WP_Admin_Bar` nodes). +- Ideally try to maintain a direct mapping between a source class and its testing class. If a testing class is becoming + too large, consider whether the source class could be broken up into smaller concerns. + +--- + +# Playwright E2E Testing + +## Setup + +Prerequisites: + +- Docker (required for `wp-env`) +- Node.js/npm + +Install JavaScript dependencies: + +```bash +npm ci +``` + +Install Playwright browsers (once): + +```bash +npx playwright install +``` + +Start the WordPress environment: + +```bash +npm run wp-env:start +``` + +Optional (recommended when switching branches / after failures): reset the WP env: + +```bash +npm run wp-env:clean +npm run wp-env:start +``` + +Prepare the environment for E2E (cleans stale flat-file artifacts, ensures plugin active, etc.): + +```bash +npm run test:setup:playwright +``` + +## Run tests + +Run everything: + +```bash +npm run test:playwright +``` + +Run a single project: + +```bash +npm run test:playwright -- --project=chromium-db-snippets +``` + +Run the file-based snippets project (includes flat-file setup): + +```bash +npm run test:playwright -- --project=chromium-file-based-snippets +``` + +Run with HTML reporter but don’t auto-open the report: + +```bash +PW_TEST_HTML_REPORT_OPEN=never npm run test:playwright +``` + +## Debugging failures + +- Traces are saved under `test-results/` on failures. View one with: + +```bash +npx playwright show-trace test-results/**/trace.zip +``` + +## Writing Playwright tests + +Guidelines / caveats: + +- Prefer resilient locators (`getByRole`, `getByLabel`, stable ids) over fragile CSS selectors. +- Use `wpCli()` for setup/fixtures when possible (fast + deterministic). +- Always clean up created snippets/pages (prefer the helper methods so file-based mode stays in sync). +- Avoid leaking global state between tests (e.g. Safe Mode, mu-plugins, settings toggles). +- Keep per-test timeouts explicit only when needed (and use constants rather than magic numbers). diff --git a/tests/bootstrap.php b/tests/bootstrap.php index e0f5446ef..6c2d8f7f9 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,14 +1,48 @@ \n" ); } + +require_once $_tests_dir . '/includes/functions.php'; + +tests_add_filter( + 'muplugins_loaded', + function () { + require dirname( __DIR__ ) . '/src/code-snippets.php'; + } +); + +require $_tests_dir . '/includes/bootstrap.php'; +require __DIR__ . '/unit/UnitTestCase.php'; +require __DIR__ . '/unit/AdminUnitTestCase.php'; diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 2333b60ca..81e930827 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -58,4 +58,4 @@ npm run wp-env:stop && npm run wp-env:start ```bash npm run test:playwright:debug curl http://localhost:8888/wp-admin/ # Check WordPress -``` \ No newline at end of file +``` diff --git a/tests/e2e/auth.setup.ts b/tests/e2e/auth.setup.ts index 59a5c39c0..50e9fd7bd 100644 --- a/tests/e2e/auth.setup.ts +++ b/tests/e2e/auth.setup.ts @@ -1,19 +1,69 @@ import { join } from 'path' import { expect, test as setup } from '@playwright/test' +import { wpCli } from './helpers/wpCli' const authFile = join(__dirname, '.auth/user.json') +const AUTH_SETUP_TIMEOUT_MS = 120000 setup('authenticate', async ({ page }) => { + setup.setTimeout(AUTH_SETUP_TIMEOUT_MS) + + // Ensure a clean environment across local runs / retries. + // If Safe Mode is enabled via `wp-config.php` it disables snippet execution and can + // break unrelated tests (e.g., those expecting snippets to run). + try { + await wpCli(['config', 'delete', 'CODE_SNIPPETS_SAFE_MODE']) + } catch { + // Ignore if the constant isn't present. + } + + // CI sometimes boots with WordPress already installed (so the workflow's + // `wp core install --admin_password=...` step is skipped). Ensure the admin + // credentials are set to the expected values before logging in via UI. + try { + await wpCli(['user', 'update', 'admin', '--user_pass=password']) + } catch { + // If the user doesn't exist, create it (local/wp-env + CI both support this). + await wpCli([ + 'user', + 'create', + 'admin', + 'admin@example.org', + '--user_pass=password', + '--role=administrator' + ]) + } + await page.goto('/wp-login.php') await page.waitForSelector('#user_login') await page.fill('#user_login', 'admin') await page.fill('#user_pass', 'password') - await page.click('#wp-submit') + await Promise.all([ + page.waitForLoadState('domcontentloaded'), + page.click('#wp-submit') + ]) + + // If WordPress shows the DB upgrade interstitial it includes a link to + // `upgrade.php`. In that case navigate back to `/wp-admin` (the upgrade + // process is handled by the environment) and then wait for the admin UI. + const upgradeLink = page.locator('a[href*="upgrade.php"]') + if (0 < await upgradeLink.count()) { + // Click the upgrade link to reach the upgrade interstitial page. + await upgradeLink.first().click() - await page.waitForURL(/wp-admin/) - await page.waitForSelector('#wpbody-content, #adminmenu') + // If the interstitial shows an "Update WordPress Database" action, click it. + const updateBtn = page.locator('a:has-text("Update WordPress Database")') + if (0 < await updateBtn.count()) { + await updateBtn.first().click() + } + // Give the upgrade process more time to complete and the admin UI to load. + await page.waitForSelector('#wpbody-content, #adminmenu', { timeout: 120000 }) + } else { + // Normal path: wait for admin UI. + await page.waitForSelector('#wpbody-content, #adminmenu', { timeout: 60000 }) + } await expect(page.locator('#adminmenu')).toBeVisible() diff --git a/tests/e2e/badge-contrast.spec.ts b/tests/e2e/badge-contrast.spec.ts new file mode 100644 index 000000000..4064240d2 --- /dev/null +++ b/tests/e2e/badge-contrast.spec.ts @@ -0,0 +1,94 @@ +import { readFileSync } from 'fs' +import { join } from 'path' +import { expect, test } from '@playwright/test' + +// WCAG AA contrast coverage for snippet type badges (12px bold = normal text, +// 4.5:1 minimum). Reads the palette straight from the SCSS theme so palette +// edits cannot silently regress accessibility; runs without a browser page. + +const SNIPPET_TYPES = ['php', 'html', 'css', 'js', 'cond'] +const AA_NORMAL_TEXT = 4.5 +const AA_GRAPHICAL = 3 +const DEFAULT_TEXT_COLOR = '#fff' + +const themeScss = readFileSync( + join(__dirname, '..', '..', 'src', 'css', 'common', '_theme.scss'), + 'utf8' +) + +const scssVariable = (name: string): string => { + const match = new RegExp(`^\\$${name}:\\s*(#[0-9a-fA-F]{3,8});`, 'm').exec(themeScss) + if (!match) { + throw new Error(`could not resolve $${name} in _theme.scss`) + } + return match[1] +} + +const badgeColors = (name: string): [string, string] => { + const badgesMap = /\$badges:\s*\((?[\s\S]*?)\);/.exec(themeScss)?.groups?.entries + const entry = badgesMap + ? new RegExp(`^\\s*${name}:\\s*([^,\\n]+)`, 'm').exec(badgesMap)?.[1].trim() + : undefined + if (!entry) { + throw new Error(`no $badges entry for ${name}`) + } + const [background, text = DEFAULT_TEXT_COLOR] = entry + .split(/\s+/) + .map(value => value.startsWith('$') ? scssVariable(value.slice(1)) : value) + return [background, text] +} + +const luminance = (hex: string): number => { + const digits = hex.replace('#', '') + const expanded = 3 === digits.length ? digits.replace(/./g, c => c + c) : digits + const [r, g, b] = [0, 2, 4] + .map(i => parseInt(expanded.slice(i, i + 2), 16) / 255) + .map(v => 0.03928 >= v ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4) + return 0.2126 * r + 0.7152 * g + 0.0722 * b +} + +const contrastRatio = (a: string, b: string): number => { + const [lighter, darker] = [luminance(a), luminance(b)].sort((x, y) => y - x) + return (lighter + 0.05) / (darker + 0.05) +} + +test.describe('badge contrast', () => { + for (const type of SNIPPET_TYPES) { + test(`${type} badge meets WCAG AA for normal text`, () => { + const [background, text] = badgeColors(type) + expect(contrastRatio(background, text)).toBeGreaterThanOrEqual(AA_NORMAL_TEXT) + }) + } +}) + +// The unlicensed snippet type picker renders through the `.inverted-badges +// .badge` override in _badges.scss rather than the theme palette, so read +// that consumer rule directly — this test must fail if the override regresses +// even while every $badges entry passes. +test.describe('locked-state badge contrast', () => { + const badgesScss = readFileSync( + join(__dirname, '..', '..', 'src', 'css', 'common', '_badges.scss'), + 'utf8' + ) + + const lockedRule = + /\.inverted-badges \.badge \{\s*color:\s*(?#[0-9a-fA-F]{3,8});\s*background-color:\s*(?#[0-9a-fA-F]{3,8});/ + .exec(badgesScss)?.groups + + const lockedIcon = /\.inverted-badges \.badge \{[\s\S]*?\.dashicons \{\s*color:\s*(?#[0-9a-fA-F]{3,8});/ + .exec(badgesScss)?.groups + + test('locked badge text meets WCAG AA for normal text', () => { + if (!lockedRule) { + throw new Error('could not resolve .inverted-badges .badge colors in _badges.scss') + } + expect(contrastRatio(lockedRule.background, lockedRule.text)).toBeGreaterThanOrEqual(AA_NORMAL_TEXT) + }) + + test('locked badge icon meets WCAG AA for graphical objects', () => { + if (!lockedRule || !lockedIcon) { + throw new Error('could not resolve .inverted-badges .badge dashicons color in _badges.scss') + } + expect(contrastRatio(lockedRule.background, lockedIcon.icon)).toBeGreaterThanOrEqual(AA_GRAPHICAL) + }) +}) diff --git a/tests/e2e/cloud-download-eligibility.spec.ts b/tests/e2e/cloud-download-eligibility.spec.ts new file mode 100644 index 000000000..8d9eecc0d --- /dev/null +++ b/tests/e2e/cloud-download-eligibility.spec.ts @@ -0,0 +1,153 @@ +import { expect, test } from '@playwright/test' +import { CloudStatus } from '../../src/js/types/schema/CloudSnippetSchema' +import { URLS } from './helpers/constants' +import type { CloudSnippetSchema } from '../../src/js/types/schema/CloudSnippetSchema' +import type { Page } from '@playwright/test' + +const cloudSnippet = (fields: Partial & Pick): CloudSnippetSchema => ({ + slug: `snippet-${fields.id}`, + description: '', + code: 'phpinfo();', + tags: [], + scope: 'global', + codevault: 'testvault', + total_votes: 0, + vote_count: 0, + wp_tested: '6.7', + status: CloudStatus.Public, + created: '2026-01-01 00:00:00', + updated: '2026-01-01 00:00:00', + revision: 1, + is_owner: false, + local_id: null, + update_available: false, + ...fields +}) + +const ELIGIBLE = cloudSnippet({ id: 101, name: 'Eligible Alpha' }) +const LINKED = cloudSnippet({ id: 102, name: 'Linked Beta', local_id: 42 }) +const PRO_LOCKED = cloudSnippet({ id: 103, name: 'Pro Gamma', scope: 'site-css' }) +const ELIGIBLE_OTHER = cloudSnippet({ id: 104, name: 'Eligible Delta' }) + +interface CloudRoutesState { + snippets: CloudSnippetSchema[] + downloads: number[] +} + +const forceLicenseState = (page: Page, isLicensed: boolean) => + page.addInitScript(licensed => { + let value: { isLicensed?: boolean } | undefined + + Object.defineProperty(window, 'CODE_SNIPPETS', { + configurable: true, + get: () => value, + set: (incoming: { isLicensed?: boolean } | undefined) => { + value = incoming ? { ...incoming, isLicensed: licensed } : incoming + } + }) + }, isLicensed) + +const routeCloudSnippets = (page: Page, state: CloudRoutesState) => + page.route(url => decodeURIComponent(url.href).includes('cloud/snippets'), async route => { + const url = decodeURIComponent(route.request().url()) + const download = /cloud\/snippets\/(?\d+)\/download/.exec(url) + + if (download && 'POST' === route.request().method()) { + state.downloads.push(Number(download.groups?.id)) + await route.fulfill({ json: { snippet: null } }) + } else { + await route.fulfill({ + json: { + snippets: state.snippets, + page: 1, + total_pages: 1, + total_snippets: state.snippets.length + } + }) + } + }) + +const openCommunityCloud = async (page: Page, view: 'table' | 'card') => { + await page.goto(URLS.COMMUNITY_CLOUD) + await expect(page.locator('.cloud-search')).toBeVisible() + await page.getByTitle(`Switch to ${view} view`).click() +} + +const applyBulkDownload = async (page: Page) => { + await page.locator('#bulk-action-selector-top').selectOption('download') + await page.locator('#doaction').click() +} + +test.describe('Cloud bulk download eligibility', () => { + test('unlicensed table view only offers and downloads eligible snippets', async ({ page }) => { + const state: CloudRoutesState = { snippets: [ELIGIBLE, LINKED, PRO_LOCKED], downloads: [] } + await forceLicenseState(page, false) + await routeCloudSnippets(page, state) + await openCommunityCloud(page, 'table') + + const table = page.locator('.cloud-snippets-table') + await expect(table.getByRole('checkbox', { name: 'Select Eligible Alpha' })).toBeVisible() + await expect(table.getByRole('checkbox', { name: 'Select Linked Beta' })).toHaveCount(0) + await expect(table.getByRole('checkbox', { name: 'Select Pro Gamma' })).toHaveCount(0) + + await table.getByRole('checkbox', { name: 'Select all snippets' }).check() + await expect(table.getByRole('checkbox', { name: 'Select Eligible Alpha' })).toBeChecked() + + await applyBulkDownload(page) + await expect.poll(() => state.downloads).toEqual([ELIGIBLE.id]) + }) + + test('unlicensed card view select-all only downloads eligible snippets', async ({ page }) => { + const state: CloudRoutesState = { snippets: [ELIGIBLE, LINKED, PRO_LOCKED], downloads: [] } + await forceLicenseState(page, false) + await routeCloudSnippets(page, state) + await openCommunityCloud(page, 'card') + + const cards = page.locator('.cloud-search-results') + await expect(cards.getByRole('checkbox', { name: 'Select Eligible Alpha' })).toBeVisible() + await expect(cards.getByRole('checkbox', { name: 'Select Linked Beta' })).toHaveCount(0) + await expect(cards.getByRole('checkbox', { name: 'Select Pro Gamma' })).toHaveCount(0) + + await page.getByRole('checkbox', { name: 'Select all items' }).check() + await expect(cards.getByRole('checkbox', { name: 'Select Eligible Alpha' })).toBeChecked() + + await applyBulkDownload(page) + await expect.poll(() => state.downloads).toEqual([ELIGIBLE.id]) + }) + + test('licensed table view select-all includes Pro snippets but not linked ones', async ({ page }) => { + const state: CloudRoutesState = { snippets: [ELIGIBLE, LINKED, PRO_LOCKED], downloads: [] } + await forceLicenseState(page, true) + await routeCloudSnippets(page, state) + await openCommunityCloud(page, 'table') + + const table = page.locator('.cloud-snippets-table') + await expect(table.getByRole('checkbox', { name: 'Select Pro Gamma' })).toBeVisible() + await expect(table.getByRole('checkbox', { name: 'Select Linked Beta' })).toHaveCount(0) + + await table.getByRole('checkbox', { name: 'Select all snippets' }).check() + await applyBulkDownload(page) + await expect.poll(() => [...state.downloads].sort((a, b) => a - b)).toEqual([ELIGIBLE.id, PRO_LOCKED.id]) + }) + + test('selections hidden by a new search are not downloaded', async ({ page }) => { + const state: CloudRoutesState = { snippets: [ELIGIBLE, LINKED, PRO_LOCKED], downloads: [] } + await forceLicenseState(page, false) + await routeCloudSnippets(page, state) + await openCommunityCloud(page, 'table') + + const table = page.locator('.cloud-snippets-table') + await table.getByRole('checkbox', { name: 'Select Eligible Alpha' }).check() + + state.snippets = [ELIGIBLE_OTHER] + await page.locator('#cloud-search-query').fill('delta') + await page.locator('.cloud-search-form').getByRole('button', { name: /Search Cloud Library/i }).click() + + const otherCheckbox = table.getByRole('checkbox', { name: 'Select Eligible Delta' }) + await expect(otherCheckbox).toBeVisible() + await otherCheckbox.check() + + await applyBulkDownload(page) + await expect.poll(() => state.downloads).toEqual([ELIGIBLE_OTHER.id]) + }) +}) diff --git a/tests/e2e/code-snippets-community-featured.spec.ts b/tests/e2e/code-snippets-community-featured.spec.ts new file mode 100644 index 000000000..3274cad40 --- /dev/null +++ b/tests/e2e/code-snippets-community-featured.spec.ts @@ -0,0 +1,279 @@ +import { expect, test } from '@playwright/test' +import { TIMEOUTS, URLS } from './helpers/constants' +import { wpCli } from './helpers/wpCli' +import type { Page } from '@playwright/test' + +const REFRESH_DELAY = 3000 + +const switchSnippetView = async (page: Page, view: 'Card view' | 'Table view') => { + const saved = page + .waitForResponse( + response => response.url().includes('/snippet-view') && 'GET' !== response.request().method(), + { timeout: TIMEOUTS.SHORT } + ) + .catch(() => undefined) + await page.getByRole('button', { name: view }).click() + await saved +} + +const closePreviewIfOpen = async (page: Page) => { + const closeButton = page.getByRole('button', { name: 'Close' }) + + if (await closeButton.isVisible()) { + await closeButton.click() + } +} + +const openCommunityCloud = async (page: Page) => { + await page.goto(URLS.COMMUNITY_CLOUD) + await page.waitForLoadState('domcontentloaded') +} + +const isFeaturedRequest = (url: URL): boolean => + url.pathname.includes('/cloud/snippets/featured') || + true === url.searchParams.get('rest_route')?.includes('/cloud/snippets/featured') + +const isSnippetDownloadRequest = (url: URL): boolean => + url.pathname.includes('/cloud/snippets/501/download') || + true === url.searchParams.get('rest_route')?.includes('/cloud/snippets/501/download') + +const makeCloudSnippet = (id: number, name: string, localId: number | null = null) => ({ + id, + slug: `mock-cloud-snippet-${id}`, + name, + description: 'Mock description', + code: ' ({ + snippets, + page: 1, + total_pages: 1, + total_snippets: snippets.length, + available_filters: {} +}) + +test.describe('Community Cloud Featured Snippets', () => { + const jsErrors: string[] = [] + + test.beforeEach(({ page }) => { + jsErrors.length = 0 + + page.on('pageerror', error => { + jsErrors.push(error.message) + }) + }) + + // Restore the stored view rather than clicking the toolbar back: a failed + // request removes the results, and the view toggle along with them. + test.afterEach(async () => { + await wpCli(['eval', "delete_option( 'code_snippets_snippet_view' );"]) + }) + + test('Page loads without JavaScript errors', async ({ page }) => { + await openCommunityCloud(page) + + // Wait for the cloud search form to render, confirming the React app mounted. + await expect(page.locator('.cloud-search')).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + + expect(jsErrors).toHaveLength(0) + }) + + test('Featured heading appears or graceful empty state', async ({ page }) => { + await openCommunityCloud(page) + + // Wait for the search form — this confirms the page rendered. + await expect(page.locator('.cloud-search')).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + + // Wait for the loading spinner to disappear, indicating the featured request completed. + await page.locator('.cloud-search .components-spinner') + .waitFor({ state: 'hidden', timeout: TIMEOUTS.DEFAULT }) + .catch(() => undefined) + + const featuredHeading = page.locator('.cloud-snippets-heading', { hasText: 'Featured Snippets' }) + const headingVisible = await featuredHeading + .waitFor({ state: 'visible', timeout: TIMEOUTS.SHORT }) + .then(() => true) + .catch(() => false) + + if (headingVisible) { + await expect(featuredHeading).toContainText('Featured Snippets') + } else { + // Cloud API unreachable — verify no crash: the search form is still functional. + await expect(page.locator('.cloud-search')).toBeVisible() + await expect(page.locator('#cloud-search-query')).toBeVisible() + } + }) + + test('Search overrides featured heading', async ({ page }) => { + await openCommunityCloud(page) + + // Wait for the page to be ready. + await expect(page.locator('.cloud-search')).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + + // Type a search term. + const searchInput = page.locator('#cloud-search-query') + await expect(searchInput).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + await searchInput.fill('disable comments') + + // Submit the search form. + await page.locator('.cloud-search-form').getByRole('button', { name: /Search Cloud Library/i }).click() + + // Wait for the search to complete (spinner appears then disappears). + await page.locator('.cloud-search .components-spinner') + .waitFor({ state: 'hidden', timeout: TIMEOUTS.DEFAULT }) + .catch(() => undefined) + + // The "Featured Snippets" heading should no longer be visible (search-mode heading replaces it). + await expect(page.locator('.cloud-snippets-heading', { hasText: 'Featured Snippets' })).not.toBeVisible() + }) + + test('Announces loading and error states while featured snippets resolve', async ({ page }) => { + let releaseRequest: VoidFunction = () => undefined + const requestPending = new Promise(resolve => { + releaseRequest = () => resolve() + }) + + await page.route(isFeaturedRequest, async route => { + await requestPending + return route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ message: 'Cloud unavailable' }) + }) + }) + await openCommunityCloud(page) + + const loadingNotice = page.getByRole('status', { name: 'Community snippets status' }) + await expect(loadingNotice).toHaveClass(/code-snippets-notice/) + await expect(loadingNotice).toContainText('Loading community snippets…') + + releaseRequest() + + const errorNotice = page.getByRole('alert', { name: 'Community snippets status' }) + await expect(errorNotice).toHaveClass(/code-snippets-notice/) + await expect(errorNotice) + .toContainText('An error occurred while fetching search results. Please try again.') + }) + + test('Shares download state between the card and its preview', async ({ page }) => { + let releaseDownload: VoidFunction = () => undefined + const downloadPending = new Promise(resolve => { + releaseDownload = () => resolve() + }) + let featuredRequests = 0 + + // Every search result reports the snippet as not downloaded, including the + // refresh that follows the download, so only the state shared between the two + // mounts can show it as downloaded. + await page.route(isFeaturedRequest, async route => { + featuredRequests += 1 + + // Hold the refresh back so the card can be checked before it arrives. + if (1 < featuredRequests) { + await new Promise(resolve => setTimeout(resolve, REFRESH_DELAY)) + } + + return route.fulfill({ + contentType: 'application/json', + body: JSON.stringify(makeFeaturedResponse([ + makeCloudSnippet(501, 'Downloadable Cloud Snippet'), + makeCloudSnippet(502, 'Installed Cloud Snippet', 42) + ])) + }) + }) + await page.route(isSnippetDownloadRequest, async route => { + await downloadPending + return route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ success: true, snippet_id: 42, link_id: 501 }) + }) + }) + await openCommunityCloud(page) + + await switchSnippetView(page, 'Card view') + + try { + const preview = page.locator('.code-snippets-preview-modal') + + // A snippet that is already installed offers editing rather than downloading. + await page.getByRole('button', { name: 'Installed Cloud Snippet' }).click() + await expect(preview.getByRole('link', { name: 'Edit' })).toBeVisible() + await page.getByRole('button', { name: 'Close' }).click() + + const card = page.locator('.cloud-search-result', { hasText: 'Downloadable Cloud Snippet' }) + const cardActions = card.locator('.snippet-card-footer-actions') + await card.getByRole('button', { name: 'Downloadable Cloud Snippet' }).click() + await preview.getByRole('button', { name: 'Download' }).click() + + // Both mounts show the download as pending before the request resolves. + await expect(preview.getByRole('button', { name: 'Download' })).toBeDisabled() + await expect(cardActions.getByRole( + 'button', + { name: 'Download', exact: true, includeHidden: true } + )).toBeDisabled() + + const downloaded = page.waitForResponse(response => isSnippetDownloadRequest(new URL(response.url()))) + releaseDownload() + await downloaded + + // The card offers editing as soon as the download resolves, before the + // refresh that follows it has returned. + await expect(cardActions.getByRole( + 'link', + { name: 'Edit', exact: true, includeHidden: true } + )).toHaveCount(1) + expect(featuredRequests).toBeLessThan(3) + + // The refresh still reports the snippet as not downloaded, and the card + // keeps offering editing regardless. + await expect.poll(() => featuredRequests).toBeGreaterThan(1) + await expect(cardActions.getByRole( + 'link', + { name: 'Edit', exact: true, includeHidden: true } + )).toHaveCount(1) + } finally { + releaseDownload() + await closePreviewIfOpen(page) + } + }) + + test('Table checkboxes share the cloud selection state', async ({ page }) => { + await page.route(isFeaturedRequest, route => route.fulfill({ + contentType: 'application/json', + body: JSON.stringify(makeFeaturedResponse()) + })) + await openCommunityCloud(page) + await switchSnippetView(page, 'Table view') + + const table = page.locator('.cloud-snippets-table') + await expect(table).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + + const headerCheckbox = table.locator('thead').getByRole('checkbox', { name: 'Select all snippets' }) + const rowCheckbox = table.locator('tbody').getByRole('checkbox', { name: 'Select Mock Cloud Snippet' }) + + // The table owns the only select-all control; the toolbar checkbox is + // reserved for the card view. + await expect(page.getByRole('checkbox', { name: 'Select all items' })).toHaveCount(0) + + await rowCheckbox.check() + await expect(rowCheckbox).toBeChecked() + await expect(headerCheckbox).toBeChecked() + + await headerCheckbox.uncheck() + await expect(rowCheckbox).not.toBeChecked() + }) +}) diff --git a/tests/e2e/code-snippets-edit.spec.ts b/tests/e2e/code-snippets-edit.spec.ts index de1204b2c..d4b3d7a3e 100644 --- a/tests/e2e/code-snippets-edit.spec.ts +++ b/tests/e2e/code-snippets-edit.spec.ts @@ -1,14 +1,13 @@ -import { test } from '@playwright/test' -import { SnippetsTestHelper } from './helpers/SnippetsTestHelper' -import { MESSAGES } from './helpers/constants' - -const TEST_SNIPPET_NAME = 'E2E Test Snippet' +import { expect, test } from '@playwright/test' +import { DEFAULT_E2E_SNIPPET_BASE_NAME, SnippetsTestHelper } from './helpers/SnippetsTestHelper' +import { MESSAGES, SELECTORS, TIMEOUTS } from './helpers/constants' test.describe('Code Snippets Admin', () => { let helper: SnippetsTestHelper test.beforeEach(async ({ page }) => { helper = new SnippetsTestHelper(page) + await SnippetsTestHelper.cleanupSnippetsByPrefix(DEFAULT_E2E_SNIPPET_BASE_NAME) await helper.navigateToSnippetsAdmin() }) @@ -17,25 +16,172 @@ test.describe('Code Snippets Admin', () => { }) test('Can add a new snippet', async () => { + const snippetName = SnippetsTestHelper.makeUniqueSnippetName() await helper.createSnippet({ - name: TEST_SNIPPET_NAME, + name: snippetName, code: "add_filter('show_admin_bar', '__return_false');" }) }) test('Can activate and deactivate a snippet', async () => { - await helper.openSnippet(TEST_SNIPPET_NAME) + const snippetName = SnippetsTestHelper.makeUniqueSnippetName() + await helper.createSnippet({ + name: snippetName, + code: "add_filter('show_admin_bar', '__return_false');" + }) + await helper.openSnippet(snippetName) + + // Activate it. await helper.saveSnippet('save_and_activate') - await helper.expectSuccessMessageInParagraph(MESSAGES.SNIPPET_UPDATED_AND_ACTIVATED) + await helper.expectSuccessMessage(MESSAGES.SNIPPET_UPDATED_AND_ACTIVATED) + // Deactivate it (Status toggle + save in the new UI). await helper.saveSnippet('save_and_deactivate') - await helper.expectSuccessMessageInParagraph(MESSAGES.SNIPPET_UPDATED_AND_DEACTIVATED) + await helper.expectSuccessMessage(MESSAGES.SNIPPET_UPDATED_AND_DEACTIVATED) + }) + + test('Can activate a new snippet on the first save attempt', async ({ page }) => { + const snippetName = SnippetsTestHelper.makeUniqueSnippetName() + await helper.clickAddNewSnippet() + await helper.fillSnippetForm({ + name: snippetName, + code: "add_filter('show_admin_bar', '__return_false');" + }) + + await helper.saveSnippet('save_and_activate') + await helper.expectSuccessMessage(MESSAGES.SNIPPET_CREATED_AND_ACTIVATED) + + await helper.navigateToSnippetsAdmin() + + const snippetRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`) + .first() + + await expect(snippetRow).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + await expect(snippetRow.locator(SELECTORS.SNIPPET_TOGGLE).first()).toBeChecked({ timeout: TIMEOUTS.DEFAULT }) + + await helper.cleanupSnippet(snippetName) + }) + + test('Edit menu shortcut keeps operable button semantics', async ({ page }) => { + const snippetName = SnippetsTestHelper.makeUniqueSnippetName() + await helper.createSnippet({ + name: snippetName, + code: "add_filter('show_admin_bar', '__return_false');" + }) + + await helper.openSnippet(snippetName) + + const editMenuLink = page.locator('#adminmenu a.code-snippets-edit-menu-link').first() + + await expect(editMenuLink).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + await expect(editMenuLink).toHaveAttribute('role', 'button') + await expect(editMenuLink).toHaveAttribute('tabindex', '0') + await expect(editMenuLink).not.toHaveAttribute('aria-disabled', /true/) + + await helper.cleanupSnippet(snippetName) + }) + + test('Back navigation confirms before discarding unsaved changes', async ({ page }) => { + const snippetName = SnippetsTestHelper.makeUniqueSnippetName() + await helper.clickAddNewSnippet() + await helper.fillSnippetForm({ + name: snippetName, + code: "add_filter('show_admin_bar', '__return_false');" + }) + await helper.saveSnippet() + await expect(page).toHaveURL(/page=edit-snippet/) + + const editedName = `${snippetName} edited` + await page.locator('#title').fill(editedName) + // Leaving the editor is confirmed either through the unsaved-changes prompt or + // the browser's own unload prompt, depending on how the editor was reached, so + // the prompt is answered without asserting which of the two it is. + page.once('dialog', dialog => dialog.dismiss()) + await page.evaluate(() => window.history.back()) + + await expect(page).toHaveURL(/page=edit-snippet/) + await expect(page.locator('#title')).toHaveValue(editedName) + + page.once('dialog', dialog => dialog.accept()) + await page.evaluate(() => window.history.back()) + await expect(page).not.toHaveURL(/page=edit-snippet/) + + await helper.cleanupSnippet(snippetName) + }) + + test('Accepted in-page Back navigation shows one confirmation', async ({ page }) => { + const snippetName = SnippetsTestHelper.makeUniqueSnippetName() + await helper.createSnippet({ + name: snippetName, + code: "add_filter('show_admin_bar', '__return_false');" + }) + await helper.openSnippet(snippetName) + + await page.locator('a.page-title-action').filter({ hasText: 'Add New' }).click() + await expect(page).toHaveURL(/page=add-snippet/) + await page.locator('#title').fill(`${snippetName} draft`) + + const dialogs: { message: string, type: string }[] = [] + page.on('dialog', async dialog => { + dialogs.push({ message: dialog.message(), type: dialog.type() }) + await dialog.accept() + }) + + await page.evaluate(() => window.history.back()) + await expect(page).toHaveURL(/page=edit-snippet/) + await expect(page.locator('#title')).toHaveValue(snippetName) + + expect(dialogs).toHaveLength(1) + expect(dialogs[0].type).toBe('confirm') + expect(dialogs[0].message).toContain('unsaved changes') + + await helper.cleanupSnippet(snippetName) + }) + + test('Shows an error notice when activation fails after saving', async ({ page }) => { + const snippetName = SnippetsTestHelper.makeUniqueSnippetName() + await helper.clickAddNewSnippet() + await helper.fillSnippetForm({ + name: snippetName, + code: 'missing_runtime_function_call();' + }) + + await helper.saveSnippet('save_and_activate') + + const errorNotice = page.locator('.wrap > .notice.error').first() + await expect(errorNotice).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + await expect(errorNotice).toContainText('Snippet could not be activated.') + await expect(errorNotice).toContainText('Call to undefined function missing_runtime_function_call()') + await expect(errorNotice).toContainText('The snippet was saved, but remains inactive due to this error:') + + const traceDetails = errorNotice.locator('details').first() + await expect(traceDetails).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + await expect(traceDetails.locator('summary')).toContainText('View stack trace') + + await helper.navigateToSnippetsAdmin() + + const snippetRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`) + .first() + + await expect(snippetRow).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + await expect(snippetRow.locator(SELECTORS.SNIPPET_TOGGLE).first()).not.toBeChecked({ timeout: TIMEOUTS.DEFAULT }) + + await helper.cleanupSnippet(snippetName) }) test('Can delete a snippet', async () => { - await helper.openSnippet(TEST_SNIPPET_NAME) + const snippetName = SnippetsTestHelper.makeUniqueSnippetName() + await helper.createSnippet({ + name: snippetName, + code: "add_filter('show_admin_bar', '__return_false');" + }) + + await helper.openSnippet(snippetName) await helper.deleteSnippet() - await helper.expectTextNotVisible(TEST_SNIPPET_NAME) + await helper.deleteSnippetFromList(snippetName) + await helper.expectElementCount(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`, 0) }) }) diff --git a/tests/e2e/code-snippets-evaluation.spec.ts b/tests/e2e/code-snippets-evaluation.spec.ts index 3bccb5884..8e08eb066 100644 --- a/tests/e2e/code-snippets-evaluation.spec.ts +++ b/tests/e2e/code-snippets-evaluation.spec.ts @@ -1,10 +1,10 @@ import { expect, test } from '@playwright/test' -import { SnippetsTestHelper } from './helpers/SnippetsTestHelper' +import { DEFAULT_E2E_SNIPPET_BASE_NAME, SnippetsTestHelper } from './helpers/SnippetsTestHelper' import { SELECTORS } from './helpers/constants' import { wpCli } from './helpers/wpCli' import type { Page } from '@playwright/test' -const TEST_SNIPPET_NAME = 'E2E Snippet Test' +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const BODY_CLASS_TEST_CODE = ` add_filter('admin_body_class', function($classes) { @@ -32,8 +32,8 @@ const verifyShortcodeRendersCorrectly = async ( await helper.expectTextVisible('Page content after shortcode.') } -const createPageWithShortcode = async (snippetId: string): Promise => { - const shortcode = `[code_snippet id=${snippetId} format name="${TEST_SNIPPET_NAME}"]` +const createPageWithShortcode = async (snippetId: string, snippetName: string): Promise => { + const shortcode = `[code_snippet id=${snippetId} format name="${snippetName}"]` const pageContent = `

    Page content before shortcode.

    \n\n${shortcode}\n\n

    Page content after shortcode.

    ` try { @@ -47,51 +47,68 @@ const createPageWithShortcode = async (snippetId: string): Promise => { '--porcelain' ])).trim() - const pageUrl = (await wpCli(['post', 'url', pageId])).trim() - return pageUrl + return (await wpCli(['post', 'url', pageId])).trim() } catch (error) { - console.error('Failed to create page via WP-CLI:', error) + console.error('Failed to create page via WP-CLI.', error) + // The suite depends on WP-CLI in local/wp-env mode; keep failures explicit to avoid + // silently exercising a different creation path. throw error } } -const createHtmlSnippetForEditor = async (helper: SnippetsTestHelper, page: Page): Promise => { +const createHtmlSnippetForEditor = async ( + helper: SnippetsTestHelper, + page: Page, + snippetName: string +): Promise => { await helper.createAndActivateSnippet({ - name: TEST_SNIPPET_NAME, + name: snippetName, code: '
    ' + '

    Custom HTML Content

    This content was inserted via shortcode!

    ', type: 'HTML', location: 'IN_EDITOR' }) - const currentUrl = page.url() - const urlMatch = /[?&]id=(?\d+)/.exec(currentUrl) + // `createAndActivateSnippet` ends on the list screen; pull the ID from the edit link. + await helper.navigateToSnippetsAdmin() + const row = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`) + .first() + await expect(row).toBeVisible() + + const nameLink = row.getByRole('link', { name: new RegExp(escapeRegExp(snippetName)) }).first() + const editHref = await nameLink.evaluate(el => el.getAttribute('href') ?? '') + + const urlMatch = /[?&]id=(?\d+)/.exec(editHref) expect(urlMatch).toBeTruthy() return urlMatch?.groups?.id ?? '0' } test.describe('Code Snippets Evaluation', () => { let helper: SnippetsTestHelper + let snippetName: string test.beforeEach(async ({ page }) => { helper = new SnippetsTestHelper(page) + snippetName = SnippetsTestHelper.makeUniqueSnippetName() + + await SnippetsTestHelper.cleanupSnippetsByPrefix(DEFAULT_E2E_SNIPPET_BASE_NAME) await helper.navigateToSnippetsAdmin() }) test('PHP snippet is evaluating correctly', async () => { await helper.createAndActivateSnippet({ - name: TEST_SNIPPET_NAME, + name: snippetName, code: "add_filter('show_admin_bar', '__return_false');" }) await helper.navigateToFrontend() - await helper.expectElementNotVisible(SELECTORS.ADMIN_BAR) await helper.expectElementCount(SELECTORS.ADMIN_BAR, 0) }) test('PHP Snippet runs everywhere', async ({ page }) => { await helper.createAndActivateSnippet({ - name: TEST_SNIPPET_NAME, + name: snippetName, location: 'EVERYWHERE', code: BODY_CLASS_TEST_CODE }) @@ -105,7 +122,7 @@ test.describe('Code Snippets Evaluation', () => { test('PHP Snippet runs only in Admin', async ({ page }) => { await helper.createAndActivateSnippet({ - name: TEST_SNIPPET_NAME, + name: snippetName, location: 'ADMIN_ONLY', code: BODY_CLASS_TEST_CODE }) @@ -119,7 +136,7 @@ test.describe('Code Snippets Evaluation', () => { test('PHP Snippet runs only in Frontend', async ({ page }) => { await helper.createAndActivateSnippet({ - name: TEST_SNIPPET_NAME, + name: snippetName, location: 'FRONTEND_ONLY', code: BODY_CLASS_TEST_CODE }) @@ -133,7 +150,7 @@ test.describe('Code Snippets Evaluation', () => { test('HTML snippet is evaluating correctly in footer', async () => { await helper.createAndActivateSnippet({ - name: TEST_SNIPPET_NAME, + name: snippetName, code: '

    Hello World HTML snippet in footer!

    ', type: 'HTML', location: 'SITE_FOOTER' @@ -146,7 +163,7 @@ test.describe('Code Snippets Evaluation', () => { test('HTML snippet is evaluating correctly in header', async () => { await helper.createAndActivateSnippet({ - name: TEST_SNIPPET_NAME, + name: snippetName, code: '

    Hello World HTML snippet in header!

    ', type: 'HTML', location: 'SITE_HEADER' @@ -157,14 +174,42 @@ test.describe('Code Snippets Evaluation', () => { await helper.expectElementCount('text=Hello World HTML snippet in header!', 1) }) + test('HTML snippet is evaluating correctly at body start', async () => { + await helper.createAndActivateSnippet({ + name: snippetName, + code: '

    Hello World HTML snippet in body start!

    ', + type: 'HTML', + location: 'SITE_BODY' + }) + + await helper.navigateToFrontend() + await helper.expectTextVisible('Hello World HTML snippet in body start!') + await helper.expectElementCount('text=Hello World HTML snippet in body start!', 1) + await helper.expectTextBeforeElement('Hello World HTML snippet in body start!', SELECTORS.THEME_MAIN_WRAPPER) + }) + + test('HTML snippet is evaluating correctly at body end', async () => { + await helper.createAndActivateSnippet({ + name: snippetName, + code: '

    Hello World HTML snippet in body end!

    ', + type: 'HTML', + location: 'SITE_FOOTER' + }) + + await helper.navigateToFrontend() + await helper.expectTextVisible('Hello World HTML snippet in body end!') + await helper.expectElementCount('text=Hello World HTML snippet in body end!', 1) + await helper.expectTextAfterElement('Hello World HTML snippet in body end!', SELECTORS.THEME_MAIN_WRAPPER) + }) + test('HTML snippet works with shortcode in editor', async ({ page }) => { - const snippetId = await createHtmlSnippetForEditor(helper, page) - const pageUrl = await createPageWithShortcode(snippetId) + const snippetId = await createHtmlSnippetForEditor(helper, page, snippetName) + const pageUrl = await createPageWithShortcode(snippetId, snippetName) await verifyShortcodeRendersCorrectly(helper, page, pageUrl) }) test.afterEach(async () => { - await helper.cleanupSnippet(TEST_SNIPPET_NAME) + await helper.cleanupSnippet(snippetName) }) }) diff --git a/tests/e2e/code-snippets-list.spec.ts b/tests/e2e/code-snippets-list.spec.ts index f42496b43..0be20b30c 100644 --- a/tests/e2e/code-snippets-list.spec.ts +++ b/tests/e2e/code-snippets-list.spec.ts @@ -1,102 +1,587 @@ +import { readFileSync } from 'fs' import { expect, test } from '@playwright/test' -import { SnippetsTestHelper } from './helpers/SnippetsTestHelper' +import { DEFAULT_E2E_SNIPPET_BASE_NAME, SnippetsTestHelper } from './helpers/SnippetsTestHelper' +import { expectCanonicalCheckbox } from './helpers/checkbox' import { SELECTORS } from './helpers/constants' +import type { Page, Route } from '@playwright/test' -const TEST_SNIPPET_NAME = 'E2E List Test Snippet' +// The view preference saves through an optimistic background request, so wait +// for it to persist before navigating or ending the test. +const switchSnippetView = async (page: Page, view: 'Card view' | 'Table view') => { + const saved = page + .waitForResponse(response => response.url().includes('/snippet-view') && 'GET' !== response.request().method(), { timeout: 5000 }) + .catch(() => undefined) + await page.getByRole('button', { name: view }).click() + await saved +} + +const MAXIMUM_COLUMN_ALIGNMENT_OFFSET = 0.5 test.describe('Code Snippets List Page Actions', () => { let helper: SnippetsTestHelper + let snippetName: string + const EXPORT_TEST_TIMEOUT_MS = 60000 test.beforeEach(async ({ page }) => { helper = new SnippetsTestHelper(page) + snippetName = SnippetsTestHelper.makeUniqueSnippetName() + await SnippetsTestHelper.cleanupSnippetsByPrefix(DEFAULT_E2E_SNIPPET_BASE_NAME) await helper.navigateToSnippetsAdmin() await helper.createAndActivateSnippet({ - name: TEST_SNIPPET_NAME, + name: snippetName, code: "add_filter('show_admin_bar', '__return_false');" }) await helper.navigateToSnippetsAdmin() }) test.afterEach(async () => { - await helper.cleanupSnippet(TEST_SNIPPET_NAME) + await helper.cleanupSnippet(snippetName) }) - test('Can toggle snippet activation from list page', async ({ page }) => { - const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) - const toggleSwitch = snippetRow.locator('a.snippet-activation-switch') + test('Filters snippets as the search query changes without a submit control', async ({ page }) => { + const search = page.getByRole('search') + const searchInput = search.getByRole('searchbox', { name: 'Search Snippets:' }) + const snippetRow = page.getByRole('row', { name: new RegExp(snippetName) }) + + await searchInput.fill(snippetName) + + await expect(snippetRow).toBeVisible() + await searchInput.fill(`${snippetName}-does-not-exist`) + await expect(snippetRow).toBeHidden() + await expect(search.getByRole('button', { name: 'Search' })).toHaveCount(0) + }) + + test('Presents table rows with aligned columns, checkboxes and actions', async ({ page }) => { + await switchSnippetView(page, 'Table view') + + const table = page.locator('.snippets-list-view .wp-list-table:not(.cloud-snippets-table)') + const snippetRow = table.locator('tbody tr').filter({ hasText: snippetName }).first() + + for (const column of ['name', 'type', 'desc', 'tags', 'date', 'priority']) { + const headerCell = table.locator(`thead .column-${column}`).first() + const bodyCell = snippetRow.locator(`.column-${column}`) + const headerInlineStart = await headerCell.evaluate(element => { + const sortableTitle = element.querySelector('.sortable-column-title') + + if (sortableTitle) { + return sortableTitle.getBoundingClientRect().left + } - await expect(toggleSwitch).toHaveAttribute('title', 'Deactivate') + const textNode = Array.from(element.childNodes) + .find(node => Node.TEXT_NODE === node.nodeType && node.textContent?.trim()) + const range = document.createRange() + range.selectNode(textNode ?? element) + return range.getBoundingClientRect().left + }) + const bodyInlineStart = await bodyCell.evaluate(element => { + const styles = getComputedStyle(element) + return element.getBoundingClientRect().left + Number.parseFloat(styles.paddingInlineStart) + }) - await toggleSwitch.click() - await page.waitForLoadState('networkidle') + expect(Math.abs(headerInlineStart - bodyInlineStart)) + .toBeLessThanOrEqual(MAXIMUM_COLUMN_ALIGNMENT_OFFSET) + } - const updatedRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) - const updatedToggle = updatedRow.locator('a.snippet-activation-switch') - await expect(updatedToggle).toHaveAttribute('title', 'Activate') + // The plugin restyles native checkboxes, which only holds while the rules + // out-weigh the WordPress defaults. + await expectCanonicalCheckbox(snippetRow.locator('.check-column input[type="checkbox"]')) + const rowActions = snippetRow.locator('.row-actions') - await updatedToggle.click() - await page.waitForLoadState('networkidle') + for (const action of [ + rowActions.getByRole('link', { name: 'Edit', exact: true }), + rowActions.getByRole('button', { name: 'Preview', exact: true }), + rowActions.getByRole('button', { name: 'Clone', exact: true }), + rowActions.getByRole('button', { name: 'Export', exact: true }) + ]) { + await expect(action).toHaveCSS('color', 'rgb(34, 113, 177)') + } - const reactivatedRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) - const reactivatedToggle = reactivatedRow.locator('a.snippet-activation-switch') - await expect(reactivatedToggle).toHaveAttribute('title', 'Deactivate') + await expect(rowActions.getByRole('button', { name: 'Trash', exact: true })) + .toHaveCSS('color', 'rgb(179, 45, 46)') + + // Type badges are wrapped in links that inherit a transparent outline, so + // assert that some focus indicator is drawn rather than a specific one. + const badgeLink = snippetRow.locator('.column-type a').first() + await badgeLink.focus() + + await expect(badgeLink).toBeFocused() + expect(await badgeLink.evaluate(element => getComputedStyle(element).boxShadow)).not.toBe('none') + }) + + test('Card action popovers let keyboard focus continue through the document', async ({ page }) => { + await switchSnippetView(page, 'Card view') + + try { + const card = page.locator('.snippets-card-grid .code-snippets-card').filter({ hasText: snippetName }) + const trigger = card.getByRole('button', { name: `Actions for ${snippetName}` }) + const popover = card.locator('.kebab-menu-popover') + + await expect(card).toBeVisible() + await trigger.click() + await expect(popover).toBeVisible() + await popover.getByRole('button').last().focus() + await page.keyboard.press('Tab') + + await expect(page.locator('#bulk-action-selector-bottom')).toBeFocused() + await expect(popover).toHaveCount(0) + + await trigger.click() + await expect(popover).toBeVisible() + await popover.getByRole('button').first().focus() + await page.keyboard.press('Shift+Tab') + + await expect(trigger).toBeFocused() + await expect(popover).toBeVisible() + await page.keyboard.press('Shift+Tab') + + await expect(card.getByRole('link', { name: 'Edit' })).toBeFocused() + await expect(popover).toHaveCount(0) + } finally { + await switchSnippetView(page, 'Table view').catch(() => undefined) + } + }) + + test('Can toggle snippet activation from list page', async ({ page }) => { + const snippetRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`) + .first() + + const toggleCell = snippetRow.locator('td').first() + const toggleSwitch = toggleCell.getByRole('switch').first() + await expect(toggleSwitch).toBeVisible() + + // Active rows draw an accent border on the checkbox cell, so the same width + // is reserved on every other row: without it, rows jump as they are toggled. + const rowCheckbox = snippetRow.locator('.check-column input[type="checkbox"]') + const checkboxInlineStart = async () => (await rowCheckbox.boundingBox())?.x ?? 0 + const initialInlineStart = await checkboxInlineStart() + + const initialChecked = await toggleSwitch.isChecked() + await expect(toggleSwitch).toHaveAccessibleName(initialChecked ? /Deactivate/i : /Activate/i) + + await toggleSwitch.click({ force: true }) + if (initialChecked) { + await expect(toggleSwitch).not.toBeChecked() + } else { + await expect(toggleSwitch).toBeChecked() + } + await expect(toggleSwitch).toHaveAccessibleName(!initialChecked ? /Deactivate/i : /Activate/i) + expect(Math.abs(await checkboxInlineStart() - initialInlineStart)) + .toBeLessThanOrEqual(MAXIMUM_COLUMN_ALIGNMENT_OFFSET) + + await toggleSwitch.click({ force: true }) + if (initialChecked) { + await expect(toggleSwitch).toBeChecked() + } else { + await expect(toggleSwitch).not.toBeChecked() + } + await expect(toggleSwitch).toHaveAccessibleName(initialChecked ? /Deactivate/i : /Activate/i) }) test('Can access edit from list page', async ({ page }) => { - const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) + const snippetRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`) + .first() - await snippetRow.locator(SELECTORS.EDIT_ACTION).click() + await snippetRow.locator(SELECTORS.SNIPPET_NAME_LINK).first().click() await expect(page).toHaveURL(/page=edit-snippet/) - await expect(page.locator('#title')).toHaveValue(TEST_SNIPPET_NAME) + await expect(page.locator('#title')).toHaveValue(snippetName) }) test('Can clone snippet from list page', async ({ page }) => { - const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) + const snippetRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`) + .first() await snippetRow.locator(SELECTORS.CLONE_ACTION).click() - await page.waitForLoadState('networkidle') await expect(page).toHaveURL(/page=snippets/) + await expect(page.locator(SELECTORS.SNIPPETS_TABLE)).toBeVisible() + + // Verify that a cloned snippet exists in the table (use table-scoped check to avoid admin bar matches) + const clonedRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName} [CLONE]"))`) + .first() + await expect(clonedRow).toBeVisible() + + // Clean up the clone by trashing it + await clonedRow.locator(SELECTORS.DELETE_ACTION).click() + await expect(page.locator(SELECTORS.SNIPPETS_TABLE)).toBeVisible() + }) - await helper.expectTextVisible(`${TEST_SNIPPET_NAME} [CLONE]`) + test('Can clone a snippet once from the preview modal', async ({ page }) => { + let createRequests = 0 - const clonedRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME} [CLONE]")`) + const trackCreateRequest = async (route: Route) => { + const request = route.request() + const requestUrl = new URL(request.url()) + const restRoute = requestUrl.searchParams.get('rest_route') + const isCreateRequest = 'POST' === request.method() && ( + requestUrl.pathname.endsWith('/code-snippets/v1/snippets') || + '/code-snippets/v1/snippets' === restRoute + ) - page.on('dialog', async dialog => { - expect(dialog.type()).toBe('confirm') - await dialog.accept() - }) + if (isCreateRequest) { + createRequests += 1 + await new Promise(resolve => setTimeout(resolve, 500)) + } - await clonedRow.locator(SELECTORS.DELETE_ACTION).click() - await page.waitForLoadState('networkidle') + await route.continue() + } + + await page.route('**/wp-json/code-snippets/v1/snippets*', trackCreateRequest) + await page.route(/\/index\.php\?rest_route=/, trackCreateRequest) + + const snippetRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`) + .first() + await snippetRow.getByRole('button', { name: 'Preview' }).click() + + const previewModal = page.getByRole('dialog', { name: snippetName }) + const cloneButton = previewModal.getByRole('button', { name: 'Clone' }) + await cloneButton.click() + await expect(cloneButton).toBeDisabled() + await cloneButton.click({ force: true }) + + await expect(previewModal).toBeHidden() + expect(createRequests).toBe(1) + await helper.cleanupSnippet(`${snippetName} [CLONE]`) }) - test('Can delete snippet from list page', async ({ page }) => { - const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) + test('Can trash a snippet from the preview modal', async ({ page }) => { + const snippetRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`) + .first() + await snippetRow.getByRole('button', { name: 'Preview' }).click() - page.on('dialog', async dialog => { - expect(dialog.type()).toBe('confirm') - await dialog.accept() - }) + const previewModal = page.getByRole('dialog', { name: snippetName }) + await previewModal.getByRole('button', { name: 'Trash' }).click() + + const confirmDialog = page.getByRole('dialog', { name: 'Are you sure?' }) + await confirmDialog.getByRole('button', { name: 'Trash' }).click() + await expect(previewModal).toBeHidden() + + await page.locator('a[href*="status=trashed"]').first().click() + await expect(page).toHaveURL(/status=trashed/) + await expect(page.locator(`${SELECTORS.SNIPPET_ROW}:has-text("${snippetName}")`).first()).toBeVisible() + }) + + test('Can delete snippet from list page', async ({ page }) => { + const snippetRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`) + .first() + // Click "Trash" in row actions — in the new React UI, this moves to trash immediately (no dialog) await snippetRow.locator(SELECTORS.DELETE_ACTION).click() - await page.waitForLoadState('networkidle') + + // Some implementations show a confirmation modal that must be dismissed. + const confirmDialog = page.locator('[role="dialog"]').filter({ hasText: /Are you sure\\?/i }) + const dialogVisible = await confirmDialog + .waitFor({ state: 'visible', timeout: 2000 }) + .then(() => true) + .catch(() => false) + + if (dialogVisible) { + await confirmDialog.locator('button:has-text("Trash"), button:has-text("Delete")').first().click() + await confirmDialog.waitFor({ state: 'hidden', timeout: 30000 }).catch(() => undefined) + } await expect(page).toHaveURL(/page=snippets/) - await helper.expectElementCount(`tr:has-text("${TEST_SNIPPET_NAME}")`, 0) + await expect(page.locator(SELECTORS.SNIPPETS_TABLE)).toBeVisible() + + // Navigate to the trash view using the new filter link format + const trashedLink = page.locator('a[href*="status=trashed"]').first() + await expect(trashedLink).toBeVisible() + await trashedLink.click() + + await expect(page).toHaveURL(/status=trashed/, { timeout: 30000 }) + await expect(page.locator(SELECTORS.SNIPPETS_TABLE)).toBeVisible() + + const trashedRow = page.locator(`${SELECTORS.SNIPPET_ROW}:has-text("${snippetName}")`).first() + await expect(trashedRow).toBeVisible({ timeout: 30000 }) + await expect(trashedRow).toContainText(/Restore/i) }) test('Can export snippet from list page', async ({ page }) => { - const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) + test.setTimeout(EXPORT_TEST_TIMEOUT_MS) + const snippetRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`) + .first() - const downloadPromise = page.waitForEvent('download') + const download = await Promise.all([ + page.waitForEvent('download'), + snippetRow.locator(SELECTORS.EXPORT_ACTION).click() + ]).then(([downloadEvent]) => downloadEvent) - await snippetRow.locator(SELECTORS.EXPORT_ACTION).click() - - const download = await downloadPromise expect(download.suggestedFilename()).toMatch(/\.json$/) }) + + test('Can export multiple snippets from bulk actions', async ({ page }) => { + test.setTimeout(EXPORT_TEST_TIMEOUT_MS) + const secondSnippetName = SnippetsTestHelper.makeUniqueSnippetName() + + await helper.createAndActivateSnippet({ + name: secondSnippetName, + code: "add_filter('show_admin_bar', '__return_false');" + }) + await helper.navigateToSnippetsAdmin() + + const firstRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`) + .first() + const secondRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${secondSnippetName}"))`) + .first() + + await firstRow.locator('input[name="checked[]"]').check({ force: true }) + await secondRow.locator('input[name="checked[]"]').check({ force: true }) + await page.locator('select[name="action"]').first().selectOption({ label: 'Export' }) + + const download = await Promise.all([ + page.waitForEvent('download'), + page.locator('#doaction').click() + ]).then(([downloadEvent]) => downloadEvent) + + expect(download.suggestedFilename()).toBe('snippets.code-snippets.json') + + await helper.cleanupSnippet(secondSnippetName) + }) + + test('Can download a single snippet from bulk actions', async ({ page }) => { + test.setTimeout(EXPORT_TEST_TIMEOUT_MS) + const snippetRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`) + .first() + + await snippetRow.locator('input[name="checked[]"]').check({ force: true }) + await page.locator('select[name="action"]').first().selectOption({ label: 'Download' }) + + const download = await Promise.all([ + page.waitForEvent('download'), + page.locator('#doaction').click() + ]).then(([downloadEvent]) => downloadEvent) + + expect(download.suggestedFilename()).toMatch(/\.code-snippets\.php$/) + }) + + test('Can download multiple snippets from bulk actions as a zip archive', async ({ page }) => { + test.setTimeout(EXPORT_TEST_TIMEOUT_MS) + const secondSnippetName = SnippetsTestHelper.makeUniqueSnippetName('E2E Download CSS') + + await SnippetsTestHelper.createSnippetViaCli({ + name: secondSnippetName, + active: false, + type: 'css' + }) + await helper.navigateToSnippetsAdmin() + + const firstRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`) + .first() + const secondRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${secondSnippetName}"))`) + .first() + + await firstRow.locator('input[name="checked[]"]').check({ force: true }) + await secondRow.locator('input[name="checked[]"]').check({ force: true }) + await page.locator('select[name="action"]').first().selectOption({ label: 'Download' }) + + const download = await Promise.all([ + page.waitForEvent('download'), + page.locator('#doaction').click() + ]).then(([downloadEvent]) => downloadEvent) + + expect(download.suggestedFilename()).toMatch(/^code-snippets-\d+\.zip$/) + + await helper.cleanupSnippet(secondSnippetName) + }) + + test('Bulk download stays scoped to the current page selection', async ({ page }) => { + test.setTimeout(EXPORT_TEST_TIMEOUT_MS) + const bulkScopeBaseName = 'E2E Bulk Scope' + const firstScopedSnippetName = SnippetsTestHelper.makeUniqueSnippetName(bulkScopeBaseName) + const secondScopedSnippetName = SnippetsTestHelper.makeUniqueSnippetName(bulkScopeBaseName) + + await SnippetsTestHelper.setSnippetsPerPage(1) + + try { + await helper.createAndActivateSnippet({ + name: firstScopedSnippetName, + code: "add_filter('show_admin_bar', '__return_false');" + }) + await helper.createAndActivateSnippet({ + name: secondScopedSnippetName, + code: "add_filter('show_admin_bar', '__return_false');" + }) + await helper.navigateToSnippetsAdmin() + + await page.locator('#snippets_search').fill(bulkScopeBaseName) + + const firstPageRow = page.locator(SELECTORS.SNIPPET_ROW).first() + await expect(firstPageRow).toBeVisible() + await firstPageRow.locator('input[name="checked[]"]').check({ force: true }) + + await page.locator('.next-page').first().click() + + const secondPageRow = page.locator(SELECTORS.SNIPPET_ROW).first() + await expect(secondPageRow).toBeVisible() + await secondPageRow.locator('input[name="checked[]"]').check({ force: true }) + await page.locator('select[name="action"]').first().selectOption({ label: 'Download' }) + + const download = await Promise.all([ + page.waitForEvent('download'), + page.locator('#doaction').click() + ]).then(([downloadEvent]) => downloadEvent) + + expect(download.suggestedFilename()).toMatch(/\.code-snippets\.php$/) + } finally { + await SnippetsTestHelper.resetSnippetsPerPage() + await helper.cleanupSnippet(firstScopedSnippetName) + await helper.cleanupSnippet(secondScopedSnippetName) + } + }) + + test('Bulk export stays scoped to the current page selection', async ({ page }) => { + test.setTimeout(EXPORT_TEST_TIMEOUT_MS) + const bulkScopeBaseName = 'E2E Bulk Scope Export' + const firstScopedSnippetName = SnippetsTestHelper.makeUniqueSnippetName(bulkScopeBaseName) + const secondScopedSnippetName = SnippetsTestHelper.makeUniqueSnippetName(bulkScopeBaseName) + + await SnippetsTestHelper.setSnippetsPerPage(1) + + try { + await helper.createAndActivateSnippet({ + name: firstScopedSnippetName, + code: "add_filter('show_admin_bar', '__return_false');" + }) + await helper.createAndActivateSnippet({ + name: secondScopedSnippetName, + code: "add_filter('show_admin_bar', '__return_false');" + }) + await helper.navigateToSnippetsAdmin() + + await page.locator('#snippets_search').fill(bulkScopeBaseName) + + // Select a row on page 1. + const firstPageRow = page.locator(SELECTORS.SNIPPET_ROW).first() + await expect(firstPageRow).toBeVisible() + await firstPageRow.locator('input[name="checked[]"]').check({ force: true }) + + // Navigate to page 2 - the page-1 selection should be cleared. + await page.locator('.next-page').first().click() + + // Select the row on page 2 and export. + const secondPageRow = page.locator(SELECTORS.SNIPPET_ROW).first() + await expect(secondPageRow).toBeVisible() + await secondPageRow.locator('input[name="checked[]"]').check({ force: true }) + await page.locator('select[name="action"]').first().selectOption({ label: 'Export' }) + + const download = await Promise.all([ + page.waitForEvent('download'), + page.locator('#doaction').click() + ]).then(([downloadEvent]) => downloadEvent) + + // A single-snippet export (not a multi-snippet archive) confirms only the page-2 + // snippet — not both — was included in the selection. + expect(download.suggestedFilename()).toMatch(/\.code-snippets\.json$/) + const downloadPath = await download.path() + if (!downloadPath) { + throw new Error('Download did not produce a local file path') + } + const parsed = <{ snippets: { name: string }[] }>JSON.parse(readFileSync(downloadPath, 'utf-8')) + expect(parsed.snippets).toHaveLength(1) + } finally { + await SnippetsTestHelper.resetSnippetsPerPage() + await helper.cleanupSnippet(firstScopedSnippetName) + await helper.cleanupSnippet(secondScopedSnippetName) + } + }) +}) + +test.describe('Manage table Screen Options', () => { + let helper: SnippetsTestHelper + let snippetName: string + + test.beforeEach(async ({ page }) => { + helper = new SnippetsTestHelper(page) + snippetName = SnippetsTestHelper.makeUniqueSnippetName('E2E Screen Options') + await SnippetsTestHelper.cleanupSnippetsByPrefix(DEFAULT_E2E_SNIPPET_BASE_NAME) + await helper.createAndActivateSnippet({ + name: snippetName, + code: "add_filter('show_admin_bar', '__return_false');" + }) + await helper.navigateToSnippetsAdmin() + }) + + test.afterEach(async () => { + await helper.cleanupSnippet(snippetName) + }) + + const openScreenOptions = async (page: Page) => { + const panel = page.locator('#adv-settings') + const isVisible = await panel.isVisible().catch(() => false) + + if (!isVisible) { + await page.locator('#show-settings-link').click() + await expect(panel).toBeVisible() + } + } + + test('Card pagination initializes from the page URL', async ({ page }) => { + await SnippetsTestHelper.setSnippetsPerPage(1) + + try { + await helper.navigateToSnippetsAdmin() + await switchSnippetView(page, 'Card view') + await expect(page.locator('.snippets-card-grid')).toBeVisible() + + await page.goto('/wp-admin/admin.php?page=snippets&paged=2') + await expect(page.locator('.tablenav.top .current-page')).toHaveValue('2') + await expect(page.locator('.snippets-card-grid .code-snippets-card')).toHaveCount(1) + } finally { + await switchSnippetView(page, 'Table view').catch(() => undefined) + await SnippetsTestHelper.resetSnippetsPerPage() + } + }) + + test('Column visibility toggle hides and shows columns in real time', async ({ page }) => { + await openScreenOptions(page) + + const descToggle = page.locator('#adv-settings input.hide-column-tog[value="desc"]') + await expect(descToggle).toBeVisible() + + // Ensure Description column is initially visible. + await descToggle.check() + await expect(page.locator('.wp-list-table th.column-desc').first()).not.toHaveClass(/\bhidden\b/) + + // Uncheck — column should disappear in real time. + await descToggle.uncheck() + await expect(page.locator('.wp-list-table th.column-desc').first()).toHaveClass(/\bhidden\b/) + + // Re-check — column should reappear in real time. + await descToggle.check() + await expect(page.locator('.wp-list-table th.column-desc').first()).not.toHaveClass(/\bhidden\b/) + }) + + test('Truncation toggle applies and removes the truncation class in real time', async ({ page }) => { + await openScreenOptions(page) + + const truncationToggle = page.locator('#snippets-table-truncate-row-values') + await expect(truncationToggle).toBeVisible() + + // Enable truncation and verify the CSS class is applied. + await truncationToggle.check() + await expect(page.locator('.wp-list-table.truncate-row-values')).toBeVisible() + + // Disable truncation and verify the CSS class is removed. + await truncationToggle.uncheck() + await expect(page.locator('.wp-list-table.truncate-row-values')).toBeHidden() + + // Re-enable and verify restoration. + await truncationToggle.check() + await expect(page.locator('.wp-list-table.truncate-row-values')).toBeVisible() + }) }) diff --git a/tests/e2e/code-snippets-list.spec.ts-snapshots/snippet-row-active-darwin.png b/tests/e2e/code-snippets-list.spec.ts-snapshots/snippet-row-active-darwin.png new file mode 100644 index 000000000..ac04337d6 Binary files /dev/null and b/tests/e2e/code-snippets-list.spec.ts-snapshots/snippet-row-active-darwin.png differ diff --git a/tests/e2e/code-snippets-list.spec.ts-snapshots/snippet-row-inactive-darwin.png b/tests/e2e/code-snippets-list.spec.ts-snapshots/snippet-row-inactive-darwin.png new file mode 100644 index 000000000..18de391be Binary files /dev/null and b/tests/e2e/code-snippets-list.spec.ts-snapshots/snippet-row-inactive-darwin.png differ diff --git a/tests/e2e/code-snippets-notice-filter.spec.ts b/tests/e2e/code-snippets-notice-filter.spec.ts new file mode 100644 index 000000000..5843dde45 --- /dev/null +++ b/tests/e2e/code-snippets-notice-filter.spec.ts @@ -0,0 +1,71 @@ +import { expect, test } from '@playwright/test' +import { expectCanonicalCheckbox } from './helpers/checkbox' +import { URLS } from './helpers/constants' + +test.describe('Code Snippets admin notice filtering', () => { + test('Hides foreign notices injected into the manage root', async ({ page }) => { + await page.goto(URLS.SNIPPETS_ADMIN) + + const container = page.locator('#manage-snippets-container') + await expect(container).toBeVisible() + await container.evaluate(element => { + const notice = document.createElement('div') + notice.className = 'notice notice-warning' + notice.dataset.testid = 'foreign-notice' + notice.textContent = 'Foreign notice' + element.prepend(notice) + }) + + const foreignNotice = container.locator(':scope > [data-testid="foreign-notice"]') + await expect(foreignNotice).toHaveCount(1) + await expect(foreignNotice).toHaveCSS('display', 'none') + }) + + test('Keeps direct plugin notices visible', async ({ page }) => { + await page.goto(`${URLS.SNIPPETS_ADMIN}&result=deleted`) + + const pluginNotice = page.locator('#manage-snippets-container > .notice') + .filter({ hasText: 'Snippet deleted.' }) + + await expect(pluginNotice).toHaveClass(/code-snippets-notice/) + await expect(pluginNotice).toBeVisible() + }) + + test('Filters foreign notices from the settings page', async ({ page }) => { + await page.goto(URLS.SETTINGS_ADMIN) + + const settingsPage = page.locator('#wpbody-content > .wrap') + .filter({ has: page.locator('.settings-type-nav') }) + await expect(settingsPage).toBeVisible() + await settingsPage.evaluate(element => { + const foreignNotice = document.createElement('div') + foreignNotice.className = 'notice notice-warning' + foreignNotice.dataset.testid = 'foreign-notice' + foreignNotice.textContent = 'Foreign settings notice' + element.prepend(foreignNotice) + + for (const [type, text] of [ + ['updated', 'Settings saved.'], + ['notice-error', 'Settings could not be saved.'] + ]) { + const pluginNotice = document.createElement('div') + pluginNotice.className = `notice ${type} settings-error` + pluginNotice.textContent = text + element.prepend(pluginNotice) + } + }) + + const foreignNotice = settingsPage.locator(':scope > [data-testid="foreign-notice"]') + await expect(foreignNotice).toHaveCount(1) + await expect(foreignNotice).toHaveCSS('display', 'none') + await expect(settingsPage.locator(':scope > .settings-error')).toHaveCount(2) + + for (const pluginNotice of await settingsPage.locator(':scope > .settings-error').all()) { + await expect(pluginNotice).toBeVisible() + } + + const checkbox = page.locator('.settings-section:visible input[type="checkbox"]:not(.switch)').first() + await expect(checkbox).toBeVisible() + await expectCanonicalCheckbox(checkbox) + }) +}) diff --git a/tests/e2e/code-snippets-preview.spec.ts b/tests/e2e/code-snippets-preview.spec.ts new file mode 100644 index 000000000..68b6fe3d4 --- /dev/null +++ b/tests/e2e/code-snippets-preview.spec.ts @@ -0,0 +1,189 @@ +import { expect, test } from '@playwright/test' +import { DEFAULT_E2E_SNIPPET_BASE_NAME, SnippetsTestHelper } from './helpers/SnippetsTestHelper' +import { SELECTORS, TIMEOUTS } from './helpers/constants' +import { wpCli } from './helpers/wpCli' +import type { Locator, Page } from '@playwright/test' + +const MAXIMUM_FOCUS_ATTEMPTS = 10 +const MAXIMUM_BADGE_ALIGNMENT_OFFSET = 4 +const PREVIEW_VIEWPORT_WIDTHS = [1280, 640] +const CONTROL_HEIGHT = 38 + +// Disabling the admin's "Syntax Highlighting" preference makes +// wp_enqueue_code_editor() a no-op, so window.wp.codeEditor is undefined when +// the preview modal opens. The modal must fall back to the read-only textarea +// instead of throwing. +const setSyntaxHighlighting = (enabled: boolean): Promise => { + const value = enabled ? "'true'" : "'false'" + const php = ` + $user = get_user_by('login', 'admin'); + if ($user) { + update_user_meta($user->ID, 'syntax_highlighting', ${value}); + } + ` + + return wpCli(['eval', php]) +} + +test.describe('Code Snippets Preview Modal', () => { + let helper: SnippetsTestHelper + let snippetName: string + const openPreviewEditor = async (page: Page): Promise => { + await setSyntaxHighlighting(true) + await helper.navigateToSnippetsAdmin() + await helper.filterSnippetsByName(snippetName) + + const row = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`) + .first() + await expect(row).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + + await row.hover() + await row.locator(SELECTORS.PREVIEW_ACTION).first().click() + + const editor = page.locator('.code-snippets-preview-modal .CodeMirror') + await expect(editor).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + return editor + } + const focusPreviewEditor = async (page: Page, editor: Locator): Promise => { + for (let attempt = 0; attempt < MAXIMUM_FOCUS_ATTEMPTS; attempt++) { + if (await editor.evaluate(element => element.classList.contains('CodeMirror-focused'))) { + break + } + + await page.keyboard.press('Tab') + } + + await expect(editor).toHaveClass(/CodeMirror-focused/) + } + // The CodeMirror input differs by inputStyle ('textarea' or 'contenteditable' + // depending on the WordPress version), so read the document and selection + // through the editor instance instead of locating the input element. + const readPreviewEditor = (editor: Locator, method: 'getSelection' | 'getValue'): Promise => + editor.evaluate((element, editorMethod) => { + const codeMirror = ( string>> + }>element).CodeMirror + + return codeMirror?.[editorMethod]?.() ?? '' + }, method) + + test.beforeEach(async ({ page }) => { + helper = new SnippetsTestHelper(page) + snippetName = SnippetsTestHelper.makeUniqueSnippetName() + await SnippetsTestHelper.cleanupSnippetsByPrefix(DEFAULT_E2E_SNIPPET_BASE_NAME) + await setSyntaxHighlighting(false) + await SnippetsTestHelper.createSnippetViaCli({ name: snippetName, type: 'php', active: false }) + }) + + test.afterEach(async () => { + await setSyntaxHighlighting(true) + await wpCli(['eval', "delete_option( 'code_snippets_snippet_view' );"]) + await helper.cleanupSnippet(snippetName) + }) + + test('Preview falls back to a readable textarea when the code editor is unavailable', + async ({ page }) => { + const pageErrors: string[] = [] + page.on('pageerror', error => pageErrors.push(error.message)) + + await wpCli(['eval', "update_option( 'code_snippets_snippet_view', 'table' );"]) + await helper.navigateToSnippetsAdmin() + await helper.filterSnippetsByName(snippetName) + + const row = page + .locator( + `${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))` + ) + .first() + await expect(row).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + + await row.hover() + await row.locator(SELECTORS.PREVIEW_ACTION).first().click() + + const preview = page.locator('.code-snippets-preview-modal') + await expect(preview).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + + const codeArea = preview.getByLabel('Snippet code preview') + await expect(codeArea).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + await expect(codeArea).toHaveValue(new RegExp(snippetName)) + + await expect(preview.locator('.code-snippets-preview-modal__badge .badge')) + .toBeVisible() + + expect(pageErrors).toEqual([]) + }) + + test('Preview code can be selected with the keyboard without being changed', async ({ page }) => { + const editor = await openPreviewEditor(page) + const initialValue = await readPreviewEditor(editor, 'getValue') + expect(initialValue).not.toBe('') + await focusPreviewEditor(page, editor) + await page.keyboard.press('Shift+ArrowRight') + + await expect.poll(() => readPreviewEditor(editor, 'getSelection')).not.toBe('') + await expect.poll(() => readPreviewEditor(editor, 'getValue')).toBe(initialValue) + }) + + test('Preview editor exposes its accessible label', async ({ page }) => { + const editor = await openPreviewEditor(page) + + await expect(editor.locator('[aria-label="Snippet code preview"]')).toBeAttached() + }) + + test('Preview type badge sits in the header before the close button', async ({ page }) => { + await openPreviewEditor(page) + + const modal = page.locator('.code-snippets-preview-modal') + const header = modal.locator('.components-modal__header') + const heading = header.locator('.components-modal__header-heading-container') + const badge = header.locator('.code-snippets-preview-modal__badge .badge') + const closeButton = header.getByRole('button', { name: 'Close' }) + + for (const width of PREVIEW_VIEWPORT_WIDTHS) { + await page.setViewportSize({ width, height: 800 }) + await expect(heading).toBeVisible() + await expect(badge).toBeVisible() + await expect(closeButton).toBeVisible() + await expect(async () => { + const [headingBox, badgeBox, closeButtonBox] = + await Promise.all([heading.boundingBox(), badge.boundingBox(), closeButton.boundingBox()]) + + expect(headingBox).not.toBeNull() + expect(badgeBox).not.toBeNull() + expect(closeButtonBox).not.toBeNull() + + if (headingBox && badgeBox && closeButtonBox) { + const badgeCenter = badgeBox.y + badgeBox.height / 2 + const closeButtonCenter = closeButtonBox.y + closeButtonBox.height / 2 + + expect(headingBox.x + headingBox.width).toBeLessThanOrEqual(badgeBox.x) + expect(badgeBox.x + badgeBox.width).toBeLessThanOrEqual(closeButtonBox.x) + expect(Math.abs(badgeCenter - closeButtonCenter)).toBeLessThanOrEqual( + MAXIMUM_BADGE_ALIGNMENT_OFFSET + ) + } + }).toPass({ timeout: TIMEOUTS.SHORT }) + } + + // Modals render through a portal outside the page wrapper, so they only pick + // up the plugin control styling while the dialog is covered by those rules. + // The dialog animates in, so poll until the height settles. + await expect.poll(() => modal.getByRole('button', { name: 'Clone' }) + .evaluate(element => element.getBoundingClientRect().height)) + .toBeCloseTo(CONTROL_HEIGHT, 0) + }) + + for (const keypress of ['Tab', 'Shift+Tab']) { + test(`${keypress} leaves the preview editor`, async ({ page }) => { + const editor = await openPreviewEditor(page) + await focusPreviewEditor(page, editor) + + await page.keyboard.press(keypress) + + await expect.poll( + () => editor.evaluate(element => element.contains(document.activeElement)) + ).toBe(false) + }) + } +}) diff --git a/tests/e2e/code-snippets-quicknav-admin-bar.spec.ts b/tests/e2e/code-snippets-quicknav-admin-bar.spec.ts new file mode 100644 index 000000000..0363cf9b2 --- /dev/null +++ b/tests/e2e/code-snippets-quicknav-admin-bar.spec.ts @@ -0,0 +1,242 @@ +import { expect, test } from '@playwright/test' +import { SnippetsTestHelper } from './helpers/SnippetsTestHelper' +import { wpCli } from './helpers/wpCli' +import type { Page } from '@playwright/test' + +const QUICKNAV_PREFIX = 'E2E QuickNav' +const QUICKNAV_PER_PAGE = 2 +const QUICKNAV_TEST_TIMEOUT_MS = 180000 + +test.describe('Admin Bar Snippets QuickNav', () => { + let activeA: string + let activeB: string + let activeC: string + let inactiveB: string + let inactiveC: string + let inactiveA: string + + test.beforeAll(async () => { + test.setTimeout(QUICKNAV_TEST_TIMEOUT_MS) + await SnippetsTestHelper.setAdminBarQuickNavSettings({ enabled: true, perPage: QUICKNAV_PER_PAGE }) + await SnippetsTestHelper.cleanupSnippetsByPrefix(QUICKNAV_PREFIX) + + activeA = `${QUICKNAV_PREFIX} Active A` + activeB = `${QUICKNAV_PREFIX} Active B` + activeC = `${QUICKNAV_PREFIX} Active C` + inactiveA = `${QUICKNAV_PREFIX} Inactive A` + inactiveB = `${QUICKNAV_PREFIX} Inactive B` + inactiveC = `${QUICKNAV_PREFIX} Inactive Z HTML` + + await SnippetsTestHelper.createSnippetViaCli({ name: activeA, active: true, type: 'php' }) + await SnippetsTestHelper.createSnippetViaCli({ name: activeB, active: true, type: 'php' }) + await SnippetsTestHelper.createSnippetViaCli({ name: activeC, active: true, type: 'php' }) + await SnippetsTestHelper.createSnippetViaCli({ name: inactiveA, active: false, type: 'php' }) + await SnippetsTestHelper.createSnippetViaCli({ name: inactiveB, active: false, type: 'php' }) + await SnippetsTestHelper.createSnippetViaCli({ name: inactiveC, active: false, type: 'html' }) + }) + + test.afterAll(async () => { + await SnippetsTestHelper.cleanupSnippetsByPrefix(QUICKNAV_PREFIX) + await SnippetsTestHelper.setAdminBarQuickNavSettings({ enabled: true, perPage: QUICKNAV_PER_PAGE }) + }) + + const openListing = async (page: Page, query: string) => { + await page.goto(`/wp-admin/admin.php?page=snippets${query}`) + + const root = page.locator('#wp-admin-bar-code-snippets') + await expect(root).toBeVisible() + await root.hover() + } + + const getTotalPagesForListing = async (page: Page, status: 'active' | 'inactive') => { + const node = page.locator(`#wp-admin-bar-code-snippets-${status}-snippets`) + await node.hover() + + const controls = node.locator(`.code-snippets-pagination-controls[data-status="${status}"]`).first() + const totalPagesAttr = await controls.getAttribute('data-total-pages').catch(() => null) + const parsed = totalPagesAttr ? Number(totalPagesAttr) : NaN + return Number.isFinite(parsed) && 0 < parsed ? parsed : 1 + } + + const expectSnippetVisibleInListingPages = async ( + page: Page, + options: { status: 'active' | 'inactive'; queryArg: string; snippetName: string } + ) => { + await openListing(page, '') + + const totalPages = await getTotalPagesForListing(page, options.status) + + for (let pageNo = 1; pageNo <= totalPages; pageNo++) { + await openListing(page, `&${options.queryArg}=${pageNo}`) + + const node = page.locator(`#wp-admin-bar-code-snippets-${options.status}-snippets`) + await node.hover() + + const items = node.locator('li.code-snippets-snippet-item a') + await items.first().waitFor({ state: 'visible', timeout: 5000 }).catch(() => null) + + const match = items.filter({ hasText: options.snippetName }).first() + if (await match.isVisible().catch(() => false)) { + await expect(match).toBeVisible({ timeout: 30000 }) + return + } + } + + throw new Error(`Snippet not found in ${options.status} listing after checking ${totalPages} page(s): ${options.snippetName}`) + } + + test('Menu structure, gating, and pagination work', async ({ page }) => { + test.setTimeout(QUICKNAV_TEST_TIMEOUT_MS) + + const helper = new SnippetsTestHelper(page) + await helper.navigateToSnippetsAdmin() + + const root = page.locator('#wp-admin-bar-code-snippets') + await expect(root).toBeVisible() + await root.hover() + + await expect(page.locator('#wp-admin-bar-code-snippets-manage')).toBeVisible() + await expect(page.locator('#wp-admin-bar-code-snippets-add')).toBeVisible() + await expect(page.locator('#wp-admin-bar-code-snippets-import')).toBeVisible() + await expect(page.locator('#wp-admin-bar-code-snippets-settings')).toBeVisible() + await expect(page.locator('#wp-admin-bar-code-snippets-active-snippets')).toBeVisible() + await expect(page.locator('#wp-admin-bar-code-snippets-inactive-snippets')).toBeVisible() + await expect(page.locator('#wp-admin-bar-code-snippets-safe-mode-doc')).toBeVisible() + + const safeModeDocLink = page.locator('#wp-admin-bar-code-snippets-safe-mode-doc a').first() + await expect(safeModeDocLink).toHaveAttribute('href', 'https://snipco.de/safe-mode') + await expect(safeModeDocLink).toHaveAttribute('target', '_blank') + + // Free vs Pro gating: CSS/JS/COND lead to upgrade when unlicensed, otherwise to the type's add screen. + const proLicensed = await SnippetsTestHelper.isProLicensed() + + for (const type of ['css', 'js', 'cond']) { + const node = page.locator(`#wp-admin-bar-code-snippets-add-${type}`) + + if (proLicensed) { + await expect(node).not.toHaveClass(/code-snippets-disabled/) + await expect(node.locator('a')).toHaveAttribute('href', new RegExp(`type=${type}`)) + } else { + await expect(node).toHaveClass(/code-snippets-disabled/) + await expect(node.locator('a')).toHaveAttribute('href', /page=code_snippets_upgrade/) + } + } + + // Pagination: perPage=2 and we created 3 active snippets. + const activeNode = page.locator('#wp-admin-bar-code-snippets-active-snippets') + await activeNode.hover() + + const activeControls = activeNode.locator('.code-snippets-pagination-controls[data-status="active"]') + await expect(activeControls).toBeVisible() + + const activeItems = activeNode.locator('li.code-snippets-snippet-item a') + await expect(activeItems.filter({ hasText: activeA })).toBeVisible() + await expect(activeItems.filter({ hasText: activeB })).toBeVisible() + await expect(activeItems.filter({ hasText: activeC })).not.toBeVisible() + + await expectSnippetVisibleInListingPages(page, { status: 'active', queryArg: 'code_snippets_ab_active_page', snippetName: activeC }) + + // Ensure titles are type-prefixed. + await expect(activeItems.filter({ hasText: activeC })).toContainText('(PHP)') + + // Inactive list exists and includes our inactive snippet. + const inactiveNode = page.locator('#wp-admin-bar-code-snippets-inactive-snippets') + await inactiveNode.hover() + const inactiveControls = inactiveNode.locator('.code-snippets-pagination-controls[data-status="inactive"]') + await expect(inactiveControls).toBeVisible() + + const inactiveItems = inactiveNode.locator('li.code-snippets-snippet-item a') + await expect(inactiveItems.first()).toBeVisible({ timeout: 30000 }) + + await expectSnippetVisibleInListingPages(page, { + status: 'inactive', + queryArg: 'code_snippets_ab_inactive_page', + snippetName: inactiveA + }) + await expectSnippetVisibleInListingPages(page, { + status: 'inactive', + queryArg: 'code_snippets_ab_inactive_page', + snippetName: inactiveC + }) + const inactiveCLink = page + .locator('#wp-admin-bar-code-snippets-inactive-snippets li.code-snippets-snippet-item a') + .filter({ hasText: inactiveC }) + .first() + await expect(inactiveCLink).toContainText('(HTML)') + }) + + test('Manage submenu contains status quick links', async ({ page }) => { + test.setTimeout(QUICKNAV_TEST_TIMEOUT_MS) + + const helper = new SnippetsTestHelper(page) + await helper.navigateToSnippetsAdmin() + + const root = page.locator('#wp-admin-bar-code-snippets') + await expect(root).toBeVisible() + await root.hover() + + const manageNode = page.locator('#wp-admin-bar-code-snippets-manage') + await manageNode.hover() + + await expect(page.locator('#wp-admin-bar-code-snippets-status-all a')).toHaveAttribute('href', /page=snippets&status=all/) + await expect(page.locator('#wp-admin-bar-code-snippets-status-active a')).toHaveAttribute('href', /page=snippets&status=active/) + await expect(page.locator('#wp-admin-bar-code-snippets-status-inactive a')).toHaveAttribute('href', /page=snippets&status=inactive/) + }) + + test('QuickNav menu can be disabled via setting', async ({ page }) => { + test.setTimeout(QUICKNAV_TEST_TIMEOUT_MS) + + await SnippetsTestHelper.setAdminBarQuickNavSettings({ enabled: false, perPage: QUICKNAV_PER_PAGE }) + + const helper = new SnippetsTestHelper(page) + await helper.navigateToSnippetsAdmin() + + await expect(page.locator('#wp-admin-bar-code-snippets')).toHaveCount(0) + + await SnippetsTestHelper.setAdminBarQuickNavSettings({ enabled: true, perPage: QUICKNAV_PER_PAGE }) + await helper.navigateToSnippetsAdmin() + await expect(page.locator('#wp-admin-bar-code-snippets')).toBeVisible() + }) + + test('Safe Mode indicator appears only when Safe Mode is active', async ({ page }) => { + test.setTimeout(QUICKNAV_TEST_TIMEOUT_MS) + const safeModeMuPluginPath = 'wp-content/mu-plugins/code-snippets-e2e-safe-mode.php' + + const removeMuPlugin = async () => { + await wpCli(['eval', `@unlink( ABSPATH . ${JSON.stringify(safeModeMuPluginPath)} );`]) + } + + await removeMuPlugin() + + await page.goto('/wp-admin/admin.php?page=snippets') + await expect(page.locator('#wp-admin-bar-code-snippets-safe-mode')).toHaveCount(0) + + try { + // Enable safe mode via a temporary mu-plugin so we don't rely on mutating wp-config.php. + await wpCli([ + 'eval', + ` + $path = ABSPATH . ${JSON.stringify(safeModeMuPluginPath)}; + wp_mkdir_p( dirname( $path ) ); + file_put_contents( + $path, + " { await page.goto(`${wpAdminbase}/admin.php?page=snippets-settings`) await page.waitForSelector('#wpbody-content') - await page.waitForSelector('form') + // Await page.waitForSelector('form') const flatFilesCheckbox = page.locator('input[name="code_snippets_settings[general][enable_flat_files]"]') await expect(flatFilesCheckbox).toBeVisible() @@ -17,10 +17,17 @@ setup('enable flat files', async ({ page }) => { await flatFilesCheckbox.check() } - await page.click('input[type="submit"][name="submit"]') + // Await page.click('input[type="submit"][name="submit"]') - await page.waitForSelector('.notice-success', { timeout: 10000 }) - await expect(page.locator('.notice-success')).toContainText('Settings saved') + // await page.waitForSelector('.notice-success', { timeout: 10000 }) + // await expect(page.locator('.notice-success')).toContainText('Settings saved') + const saveButton = page.getByRole('button', { name: 'Save Changes' }) + + await Promise.all([ + page.waitForURL(/settings-updated=true/, { timeout: 10000 }), + saveButton.click() + ]) + await page.reload() await page.waitForSelector('input[name="code_snippets_settings[general][enable_flat_files]"]') diff --git a/tests/e2e/helpers/SnippetsTestHelper.ts b/tests/e2e/helpers/SnippetsTestHelper.ts index d67b5e18c..f2588d95a 100644 --- a/tests/e2e/helpers/SnippetsTestHelper.ts +++ b/tests/e2e/helpers/SnippetsTestHelper.ts @@ -1,32 +1,255 @@ import { expect } from '@playwright/test' -import { - BUTTONS, - MESSAGES, - SELECTORS, - SNIPPET_LOCATIONS, - SNIPPET_TYPES, - TIMEOUTS, - URLS -} from './constants' -import type { Page} from '@playwright/test' +import { BUTTONS, MESSAGES, SELECTORS, SNIPPET_LOCATIONS, SNIPPET_TYPES, TIMEOUTS, URLS } from './constants' +import { wpCli } from './wpCli' +import type { Page } from '@playwright/test' + +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +const META_OR_CONTROL_A = 'darwin' === process.platform ? 'Meta+A' : 'Control+A' + +const RANDOM_RADIX = 36 +const RANDOM_SLICE_START = 2 +const RANDOM_SLICE_END = 7 +const CLICK_RETRIES = 3 +const SAVE_CONFIRM_RETRIES = 3 +const AT_LEAST_ONE = 1 + +const getErrorMessage = (error: unknown): string => { + if (error instanceof Error) { + return error.message + } + return String(error) +} export interface SnippetFormOptions { + name: string + code: string + type?: keyof typeof SNIPPET_TYPES + location?: keyof typeof SNIPPET_LOCATIONS +} + +export interface CreateSnippetCliOptions { name: string; - code: string; - type?: keyof typeof SNIPPET_TYPES; - location?: keyof typeof SNIPPET_LOCATIONS; + active: boolean; + type?: 'php' | 'html' | 'css' | 'js' | 'cond'; } +export const DEFAULT_E2E_SNIPPET_BASE_NAME = 'E2E Snippet Test' + export class SnippetsTestHelper { - constructor(private page: Page) {} + constructor(private page: Page) { } + + static makeUniqueSnippetName(baseName: string = DEFAULT_E2E_SNIPPET_BASE_NAME): string { + return `${baseName} ${Date.now()}-${Math.random().toString(RANDOM_RADIX).slice(RANDOM_SLICE_START, RANDOM_SLICE_END)}` + } + + static async setAdminBarQuickNavSettings(options: { enabled: boolean; perPage: number }): Promise { + const php = ` + \\Code_Snippets\\Settings\\update_setting('general', 'enable_admin_bar', ${options.enabled ? 'true' : 'false'}); + \\Code_Snippets\\Settings\\update_setting('general', 'admin_bar_snippet_limit', ${options.perPage}); + ` + + await wpCli(['eval', php]) + } + + static async setSnippetsPerPage(perPage: number): Promise { + const php = ` + $user = get_user_by('login', 'admin'); + $user_id = $user ? $user->ID : 1; + update_user_option($user_id, 'snippets_per_page', ${perPage}); + ` + + await wpCli(['eval', php]) + } + + static async resetSnippetsPerPage(): Promise { + const php = ` + $user = get_user_by('login', 'admin'); + $user_id = $user ? $user->ID : 1; + delete_user_option($user_id, 'snippets_per_page'); + ` + + await wpCli(['eval', php]) + } + + static async createSnippetViaCli(options: CreateSnippetCliOptions): Promise { + const type = options.type ?? 'php' + let scope = 'global' + switch (type) { + case 'html': + scope = 'content' + break + case 'css': + scope = 'site-css' + break + case 'js': + scope = 'site-footer-js' + break + case 'cond': + scope = 'condition' + break + } + + const code = 'html' === type ? `

    ${options.name}

    \n` : `// ${options.name}\n` + + const php = ` + $snippet = new \\Code_Snippets\\Model\\Snippet([ + 'name' => ${JSON.stringify(options.name)}, + 'desc' => '', + 'code' => ${JSON.stringify(code)}, + 'scope' => ${JSON.stringify(scope)}, + 'active' => ${options.active ? 'true' : 'false'}, + 'tags' => [], + ]); + \\Code_Snippets\\save_snippet($snippet); + ` + + await wpCli(['eval', php]) + } + + static async cleanupSnippetsByPrefix(prefix: string): Promise { + const php = ` + global $wpdb; + $prefix = ${JSON.stringify(prefix)}; + $like = $wpdb->esc_like( $prefix ) . '%'; + $targets = [ [ false, \\Code_Snippets\\code_snippets()->db->get_table_name( false ) ] ]; + + if ( is_multisite() ) { + $targets[] = [ true, \\Code_Snippets\\code_snippets()->db->get_table_name( true ) ]; + } + + foreach ( $targets as $target ) { + [ $network, $table ] = $target; + $ids = $wpdb->get_col( $wpdb->prepare( "SELECT id FROM {$table} WHERE name LIKE %s", $like ) ); + foreach ( $ids as $id ) { + \\Code_Snippets\\delete_snippet( intval( $id ), (bool) $network ); + } + } + ` + + await wpCli(['eval', php]) + } + + static async isProLicensed(): Promise { + try { + const output = await wpCli(['snippet', 'license-status', '--format=json']) + const status = <{ is_licensed?: string }>JSON.parse(output) + return 'Yes' === status.is_licensed + } catch { + return false + } + } + + private async clickButton(name: RegExp, options: { force?: boolean } = {}): Promise { + const force = options.force ?? true + + for (let attempt = 0; CLICK_RETRIES > attempt; attempt++) { + try { + const buttons = this.page.getByRole('button', { name }) + const count = await buttons.count() + + for (let i = 0; i < Math.max(count, AT_LEAST_ONE); i++) { + const candidate = 0 === count ? buttons.first() : buttons.nth(i) + const visible = await candidate.isVisible().catch(() => false) + if (!visible && 0 !== count) { + continue + } + await candidate.click({ timeout: TIMEOUTS.DEFAULT, force }) + return + } + + // Fallback: attempt to click the first match even if not considered "visible". + await buttons.first().click({ timeout: TIMEOUTS.DEFAULT, force }) + return + } catch (error: unknown) { + const message = getErrorMessage(error) + if (!message.includes('not attached to the DOM') && !message.includes('Target closed')) { + throw error + } + } + } + + throw new Error(`Failed to click button: ${name}`) + } + + private async setCodeMirrorValue(value: string): Promise { + const didSetViaApi = await this.page + .evaluate(newValue => { + const wrapper = document.querySelector('.CodeMirror') + const cm = (<{ CodeMirror?: unknown }>wrapper).CodeMirror + + if (!cm || 'object' !== typeof cm) { + return false + } + + const { setValue, refresh } = <{ setValue?: unknown; refresh?: unknown }>cm + + if ('function' !== typeof setValue) { + return false + } + + setValue.call(cm, newValue) + + if ('function' === typeof refresh) { + refresh.call(cm) + } + + return true + }, value) + .catch(() => false) + + if (didSetViaApi) { + return + } + + const editor = this.page.locator('.CodeMirror').first() + await expect(editor).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + await editor.click() + await this.page.keyboard.press(META_OR_CONTROL_A) + await this.page.keyboard.type(value) + } + + private async selectSnippetLocation(location: keyof typeof SNIPPET_LOCATIONS): Promise { + const locationLabel = SNIPPET_LOCATIONS[location] + + const locationSelect = this.page.locator(SELECTORS.LOCATION_SELECT) + await expect(locationSelect).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + await locationSelect.click() + + const listbox = this.page.getByRole('listbox').first() + await expect(listbox).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + await listbox + .getByRole('option', { name: new RegExp(escapeRegExp(locationLabel), 'i') }) + .click() + + await expect(this.page.locator(SELECTORS.LOCATION_SELECT)).toContainText(locationLabel) + } /** - * Navigate to the Code Snippets admin page - */ + * Navigate to the Code Snippets admin page. + * + * The snippet view preference persists server-side per user, so an earlier + * card-view test can leave the manage page rendering cards. Callers of this + * helper expect the table, so switch back whenever cards are active. + */ async navigateToSnippetsAdmin(): Promise { await this.page.goto(URLS.SNIPPETS_ADMIN) - await this.page.waitForLoadState('networkidle') - await this.page.waitForSelector(SELECTORS.WPBODY_CONTENT, { timeout: TIMEOUTS.DEFAULT }) + + const viewToggle = this.page.getByRole('button', { name: 'Table view' }) + await viewToggle.waitFor({ timeout: TIMEOUTS.DEFAULT }) + + if (0 === await this.page.locator(SELECTORS.SNIPPETS_TABLE).count()) { + await viewToggle.click() + } + + await this.page.waitForSelector(SELECTORS.SNIPPETS_TABLE, { timeout: TIMEOUTS.DEFAULT }) + } + + /** + * Filter the snippets table to a specific snippet name. + */ + async filterSnippetsByName(snippetName: string): Promise { + await this.page.fill(SELECTORS.SNIPPET_SEARCH_INPUT, snippetName) } /** @@ -34,168 +257,417 @@ export class SnippetsTestHelper { */ async navigateToFrontend(): Promise { await this.page.goto(URLS.FRONTEND) - await this.page.waitForLoadState('networkidle') + await this.page.waitForSelector('body', { timeout: TIMEOUTS.DEFAULT }) } /** - * Click the "Add New" button to start creating a snippet - */ + * Click the "Add New" button to start creating a snippet + */ async clickAddNewSnippet(): Promise { - await this.page.waitForSelector(SELECTORS.PAGE_TITLE, { timeout: TIMEOUTS.DEFAULT }) - await this.page.click(SELECTORS.ADD_NEW_BUTTON) - await this.page.waitForLoadState('networkidle') + await this.page.goto(URLS.ADD_SNIPPET_ADMIN) + await this.page.waitForSelector(SELECTORS.TITLE_INPUT, { timeout: TIMEOUTS.DEFAULT }) } /** - * Fill the snippet form with the provided options - */ + * Fill the snippet form with the provided options + */ async fillSnippetForm(options: SnippetFormOptions): Promise { await this.page.waitForSelector(SELECTORS.TITLE_INPUT) await this.page.fill(SELECTORS.TITLE_INPUT, options.name) if (options.type && 'PHP' !== options.type) { - await this.page.click(SELECTORS.SNIPPET_TYPE_SELECT) - await this.page.click(`text=${SNIPPET_TYPES[options.type]}`) + const snippetTypeSelect = this.page.locator(SELECTORS.SNIPPET_TYPE_SELECT) + await snippetTypeSelect.click() + + // React Select renders options in a listbox; scope the click to options to avoid matching + // other UI strings like "Skip to main content". + const listbox = this.page.getByRole('listbox') + const optionLabel = SNIPPET_TYPES[options.type] + + await listbox.getByRole('option', { name: new RegExp(escapeRegExp(optionLabel), 'i') }).click() } await this.page.waitForSelector(SELECTORS.CODE_MIRROR_TEXTAREA) - await this.page.fill(SELECTORS.CODE_MIRROR_TEXTAREA, options.code) + await this.setCodeMirrorValue(options.code) if (options.location) { - await this.page.waitForSelector(SELECTORS.LOCATION_SELECT, { timeout: TIMEOUTS.SHORT }) - await this.page.click(SELECTORS.LOCATION_SELECT) - - await this.page.waitForSelector(`text=${SNIPPET_LOCATIONS[options.location]}`, { timeout: TIMEOUTS.SHORT }) - await this.page.click(`text=${SNIPPET_LOCATIONS[options.location]}`, { force: true }) + await this.selectSnippetLocation(options.location) } } /** - * Save the snippet with the specified action - */ + * Save the snippet with the specified action + */ async saveSnippet(action: 'save' | 'save_and_activate' | 'save_and_deactivate' = 'save'): Promise { - const buttonMap = { - save: BUTTONS.SAVE, - save_and_activate: BUTTONS.SAVE_AND_ACTIVATE, - save_and_deactivate: BUTTONS.SAVE_AND_DEACTIVATE, + if ('save_and_activate' === action) { + const activateButton = this.page.locator(BUTTONS.SAVE_AND_ACTIVATE).first() + if (await activateButton.isVisible().catch(() => false)) { + await this.clickSaveAndConfirm(/^Save and Activate$/i) + return + } + + // Fallback: toggle status to active and save. + const inactiveToggle = this.page.getByRole('checkbox', { name: /^Inactive$/ }).first() + if (await inactiveToggle.isVisible().catch(() => false)) { + await inactiveToggle.click({ timeout: TIMEOUTS.DEFAULT, force: true }) + } + await this.clickSaveAndConfirm(/^Save Snippet$/i) + return } - await this.page.click(buttonMap[action]) + if ('save_and_deactivate' === action) { + // New UI deactivates via Status toggle + "Save Snippet". + const activeToggle = this.page.getByRole('checkbox', { name: /^Active$/ }).first() + if (await activeToggle.isVisible().catch(() => false)) { + await activeToggle.click({ timeout: TIMEOUTS.DEFAULT, force: true }) + } else { + const statusToggle = this.page.getByRole('checkbox', { name: /Active|Inactive/ }).first() + if (await statusToggle.isVisible().catch(() => false)) { + await statusToggle.click({ timeout: TIMEOUTS.DEFAULT, force: true }) + } + } + await this.clickSaveAndConfirm(/^Save Snippet$/i) + return + } + + await this.clickSaveAndConfirm(/^Save Snippet$/i) } - /** - * Expect a success message with the specified text - */ - async expectSuccessMessage(expectedMessage: string): Promise { - await expect(this.page.locator(SELECTORS.SUCCESS_MESSAGE)).toContainText(expectedMessage) + private async clickSaveAndConfirm(name: RegExp): Promise { + for (let attempt = 0; SAVE_CONFIRM_RETRIES > attempt; attempt++) { + await this.clickButton(name) + + const settled = await this.page.locator(SELECTORS.SAVE_SETTLED_NOTICE).first() + .waitFor({ state: 'visible', timeout: TIMEOUTS.DEFAULT }) + .then(() => true) + .catch(() => false) + + if (settled) { + return + } + + const buttonStillPresent = await this.page.getByRole('button', { name }).first() + .isVisible() + .catch(() => false) + + if (!buttonStillPresent) { + return + } + } } /** - * Expect a success message in paragraph element + * Expect a success message with the specified text */ - async expectSuccessMessageInParagraph(expectedMessage: string): Promise { - await expect(this.page.locator(SELECTORS.SUCCESS_MESSAGE_P)).toContainText(expectedMessage) + async expectSuccessMessage(expectedMessage: string | RegExp): Promise { + await expect(this.page.locator(SELECTORS.SUCCESS_MESSAGE)).toContainText(expectedMessage) } /** - * Open an existing snippet by name - */ + * Open an existing snippet by name + */ async openSnippet(snippetName: string): Promise { - await this.page.waitForSelector(`text=${snippetName}`) - await this.page.click(`text=${snippetName}`) - await this.page.waitForLoadState('networkidle') + await this.page.goto(URLS.SNIPPETS_ADMIN) + await this.page.waitForSelector(SELECTORS.SNIPPETS_TABLE, { timeout: TIMEOUTS.DEFAULT }) + await this.filterSnippetsByName(snippetName) + + const row = this.page.locator(SELECTORS.SNIPPET_ROW).filter({ hasText: snippetName }).first() + await expect(row).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + + await row.locator(SELECTORS.SNIPPET_NAME_LINK).click() + await this.page.waitForSelector(SELECTORS.TITLE_INPUT, { timeout: TIMEOUTS.DEFAULT }) } /** - * Delete a snippet (assumes you're already on the snippet edit page) - */ + * Delete a snippet (assumes you're already on the snippet edit page) + */ async deleteSnippet(): Promise { - await this.page.click(BUTTONS.DELETE) - await this.page.click(SELECTORS.DELETE_CONFIRM_BUTTON) + await this.page.locator(BUTTONS.DELETE).first().click() + + // Some UIs show a React dialog, others navigate immediately. + const dialog = this.page.locator('[role="dialog"]').filter({ hasText: /Are you sure\?/i }) + const dialogVisible = await dialog + .waitFor({ state: 'visible', timeout: TIMEOUTS.SHORT }) + .then(() => true) + .catch(() => false) + + if (dialogVisible) { + await Promise.all([ + this.page.waitForURL(/page=snippets/, { timeout: TIMEOUTS.DEFAULT }), + dialog.locator('button:has-text("Trash"), button:has-text("Delete")').first().click() + ]) + } else { + await this.page.waitForURL(/page=snippets/, { timeout: TIMEOUTS.DEFAULT }) + } + + await expect(this.page).toHaveURL(/page=snippets/) + await expect(this.page.locator(SELECTORS.SNIPPETS_TABLE)).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) } /** - * Check if a snippet exists on the snippets list page + * Delete a snippet by name from the snippets list page. */ - async snippetExists(snippetName: string): Promise { - const count = await this.page.locator(`text=${snippetName}`).count() - return 0 < count + async deleteSnippetFromList(snippetName: string): Promise { + await this.navigateToSnippetsAdmin() + await this.filterSnippetsByName(snippetName) + + const row = this.page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`) + .first() + + const rowVisible = await row + .waitFor({ state: 'visible', timeout: TIMEOUTS.SHORT }) + .then(() => true) + .catch(() => false) + + if (!rowVisible) { + return + } + + await row.locator(SELECTORS.DELETE_ACTION).first().click() + + // After trashing, it may still show depending on current filter; navigate to trash to ensure it's gone. + const trashedLink = this.page.locator('a[href*="status=trashed"]').first() + const trashedLinkVisible = await trashedLink + .waitFor({ state: 'visible', timeout: TIMEOUTS.SHORT }) + .then(() => true) + .catch(() => false) + + if (!trashedLinkVisible) { + return + } + + await trashedLink.click() + await expect(this.page).toHaveURL(/status=trashed/, { timeout: TIMEOUTS.DEFAULT }) + + const trashedRow = this.page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`) + .first() + + const trashedVisible = await trashedRow + .waitFor({ state: 'visible', timeout: TIMEOUTS.SHORT }) + .then(() => true) + .catch(() => false) + + if (!trashedVisible) { + return + } + + await trashedRow.locator('button:has-text("Delete Permanently")').click() + + const dialog = this.page.locator('[role="dialog"]').filter({ hasText: /Are you sure\?/i }) + const dialogVisible = await dialog + .waitFor({ state: 'visible', timeout: TIMEOUTS.SHORT }) + .then(() => true) + .catch(() => false) + + if (dialogVisible) { + await dialog.locator('button:has-text("Delete")').click() + } + + await expect(this.page.locator(SELECTORS.SNIPPETS_TABLE)).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) } /** - * Clean up a snippet by name (navigate to admin, find snippet, delete it) + * Clean up all snippets by name (navigate to admin, find snippets, delete them) */ async cleanupSnippet(snippetName: string): Promise { - await this.navigateToSnippetsAdmin() - - if (await this.snippetExists(snippetName)) { - await this.openSnippet(snippetName) - await this.deleteSnippet() + // Prefer WP-CLI cleanup for speed and determinism. Use plugin operations so + // file-based execution stays in sync (flat files update via hooks). + try { + await SnippetsTestHelper.cleanupSnippetsByPrefix(snippetName) + } catch { + // Cleanup should never fail the test run. } } /** - * Verify the current URL contains the snippets admin page - */ + * Verify the current URL contains the snippets admin page + */ async expectToBeOnSnippetsAdminPage(): Promise { const currentUrl = this.page.url() expect(currentUrl).toContain('page=snippets') - await expect(this.page.locator(SELECTORS.PAGE_TITLE)).toBeVisible() + await expect(this.page.locator(SELECTORS.SNIPPETS_TABLE)).toBeVisible() } /** - * Expect an element to be visible - */ - async expectElementVisible(selector: string): Promise { - await expect(this.page.locator(selector)).toBeVisible() - } - - /** - * Expect an element to not be visible - */ - async expectElementNotVisible(selector: string): Promise { - await expect(this.page.locator(selector)).not.toBeVisible() - } - - /** - * Expect an element to have a specific count - */ + * Expect an element to have a specific count + */ async expectElementCount(selector: string, expectedCount: number): Promise { const count = await this.page.locator(selector).count() expect(count).toBe(expectedCount) } /** - * Expect text to be visible on the page - */ + * Expect text to be visible on the page + */ async expectTextVisible(text: string): Promise { await expect(this.page.locator(`text=${text}`)).toBeVisible() } /** - * Expect text to not be visible on the page - */ + * Expect text to not be visible on the page + */ async expectTextNotVisible(text: string): Promise { await expect(this.page.locator('body')).not.toContainText(text) } + async expectTextBeforeElement(text: string, selector: string): Promise { + const precedes = await this.page.evaluate( + ({ text, selector }) => { + const node = document.evaluate( + `//p[contains(text(),"${text}")]`, + document, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + null + ).singleNodeValue + + const reference = document.querySelector(selector) + + if (!node || !reference) { + return null + } + + return !!(reference.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_PRECEDING) + }, + { text, selector } + ) + + expect(precedes).toBe(true) + } + + async expectTextAfterElement(text: string, selector: string): Promise { + const follows = await this.page.evaluate( + ({ text, selector }) => { + const node = document.evaluate( + `//p[contains(text(),"${text}")]`, + document, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + null + ).singleNodeValue + + const reference = document.querySelector(selector) + + if (!node || !reference) { + return null + } + + return !!(reference.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING) + }, + { text, selector } + ) + + expect(follows).toBe(true) + } + /** - * Create a complete snippet with save and activate - */ + * Create a complete snippet with save and activate + */ async createAndActivateSnippet(options: SnippetFormOptions): Promise { await this.clickAddNewSnippet() await this.fillSnippetForm(options) await this.saveSnippet('save_and_activate') await this.expectSuccessMessage(MESSAGES.SNIPPET_CREATED_AND_ACTIVATED) + + // Ensure activation is actually persisted by toggling from the list screen. + await this.navigateToSnippetsAdmin() + await this.filterSnippetsByName(options.name) + const row = this.page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${options.name}"))`) + .first() + await expect(row).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + + const toggleCell = row.locator('td').first() + const toggleSwitch = toggleCell.getByRole('switch').first() + await expect(toggleSwitch).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + + const isChecked = await toggleSwitch.isChecked().catch(() => false) + if (!isChecked) { + await toggleSwitch.click({ timeout: TIMEOUTS.DEFAULT, force: true }) + await expect(toggleSwitch).toBeChecked({ timeout: TIMEOUTS.DEFAULT }) + } + + await expect(toggleSwitch).toHaveAccessibleName(/Deactivate/i, { timeout: TIMEOUTS.DEFAULT }) } /** - * Create a snippet without activating - */ + * Create a snippet without activating + */ async createSnippet(options: SnippetFormOptions): Promise { await this.clickAddNewSnippet() await this.fillSnippetForm(options) await this.saveSnippet('save') await this.expectSuccessMessage(MESSAGES.SNIPPET_CREATED) } + + // CSS Testing Helpers + + /** + * Create a test DOM element for CSS testing + */ + async createTestElement(className: string, textContent = 'Test Element'): Promise { + await this.page.evaluate(({ className, textContent }: { className: string; textContent: string }) => { + const testElement = document.createElement('div') + testElement.className = className + testElement.textContent = textContent + document.body.appendChild(testElement) + }, { className, textContent }) + } + + /** + * Get computed CSS style property from an element + */ + async getComputedStyle(selector: string, property: keyof CSSStyleDeclaration) { + return await this.page.locator(selector).evaluate( + (element, prop) => window.getComputedStyle(element)[prop], + property + ) + } + + /** + * Verify that CSS styles are applied to an element + */ + async verifyStylesApplied(selector: string, expectedStyles: Partial): Promise { + for (const [property, expectedValue] of Object.entries(expectedStyles)) { + const actualValue = await this.getComputedStyle(selector, property) + expect(actualValue).toBe(expectedValue) + } + } + + /** + * Verify that CSS styles are NOT applied to an element + */ + async verifyStylesNotApplied(selector: string, unexpectedStyles: Partial): Promise { + for (const [property, unexpectedValue] of Object.entries(unexpectedStyles)) { + const actualValue = await this.getComputedStyle(selector, property) + expect(actualValue).not.toBe(unexpectedValue) + } + } + + // JavaScript Testing Helpers + + /** + * Verify that a global variable has the expected value + */ + async verifyGlobalVariable(variableName: keyof Window, expectedValue: unknown): Promise { + const actualValue = await this.page.evaluate( + varName => window[varName], + variableName + ) + expect(actualValue).toBe(expectedValue) + } + + /** + * Verify that a global function returns the expected result + */ + async verifyGlobalFunction(functionName: keyof Window, expectedResult: unknown): Promise { + const result = await this.page.evaluate( + funcName => (<(() => unknown) | undefined> window[funcName])?.(), + functionName + ) + + expect(result).toBe(expectedResult) + } } diff --git a/tests/e2e/helpers/checkbox.ts b/tests/e2e/helpers/checkbox.ts new file mode 100644 index 000000000..b125c1f47 --- /dev/null +++ b/tests/e2e/helpers/checkbox.ts @@ -0,0 +1,16 @@ +import { expect } from '@playwright/test' +import type { Locator } from '@playwright/test' + +/** + * Assert that a checkbox uses the plugin styling rather than the WordPress + * default, which is the only way these rules can regress: the size proves the + * shared rules applied at all, and the border colour proves the checked state + * still resolves. + */ +export const expectCanonicalCheckbox = async (checkbox: Locator): Promise => { + await expect(checkbox).toHaveCSS('width', '20px') + await expect(checkbox).toHaveCSS( + 'border-top-color', + await checkbox.isChecked() ? 'rgb(34, 113, 177)' : 'rgb(195, 196, 199)' + ) +} diff --git a/tests/e2e/helpers/constants.ts b/tests/e2e/helpers/constants.ts index 1686accf3..be716a87c 100644 --- a/tests/e2e/helpers/constants.ts +++ b/tests/e2e/helpers/constants.ts @@ -1,56 +1,60 @@ export const SELECTORS = { - WPBODY_CONTENT: '#wpbody-content, .wrap, #wpcontent', - PAGE_TITLE: 'h1, .page-title', - ADD_NEW_BUTTON: '.page-title-action', - TITLE_INPUT: '#title', CODE_MIRROR_TEXTAREA: '.CodeMirror textarea', - SNIPPET_TYPE_SELECT: '#snippet-type-select-input', + SNIPPET_TYPE_SELECT: '.snippet-type-container .code-snippets-select', LOCATION_SELECT: '.code-snippets-select-location', - SUCCESS_MESSAGE: '#message.notice', - SUCCESS_MESSAGE_P: '#message.notice p', - - DELETE_CONFIRM_BUTTON: 'button.components-button.is-destructive.is-primary', + SUCCESS_MESSAGE: '.snippet-editor-sidebar .notice.updated', + SAVE_SETTLED_NOTICE: '.snippet-editor-sidebar .notice.updated, .code-snippets-notice.error', SNIPPETS_TABLE: '.wp-list-table', SNIPPET_ROW: '.wp-list-table tbody tr', - SNIPPET_TOGGLE: '.snippet-activation-switch input[type="checkbox"]', - SNIPPET_NAME_LINK: '.row-title', + SNIPPET_TOGGLE: 'input.switch', + SNIPPET_NAME_LINK: '.snippet-name', + SNIPPET_SEARCH_INPUT: '#snippets_search', - EDIT_ACTION: '.row-actions .edit a', - CLONE_ACTION: '.row-actions .clone a', - DELETE_ACTION: '.row-actions .delete a', - EXPORT_ACTION: '.row-actions .export a', + PREVIEW_ACTION: '.row-actions button:has-text("Preview")', + CLONE_ACTION: '.row-actions button:has-text("Clone")', + DELETE_ACTION: '.row-actions button:has-text("Trash")', + EXPORT_ACTION: '.row-actions button:has-text("Export")', - ADMIN_BAR: '#wpadminbar' + ADMIN_BAR: '#wpadminbar', + THEME_MAIN_WRAPPER: '.wp-site-blocks' } export const TIMEOUTS = { - DEFAULT: 10000, + DEFAULT: 30000, SHORT: 5000 } export const URLS = { SNIPPETS_ADMIN: '/wp-admin/admin.php?page=snippets', + COMMUNITY_CLOUD_ADMIN: '/wp-admin/admin.php?page=snippets&subpage=cloud-community', + ADD_SNIPPET_ADMIN: '/wp-admin/admin.php?page=add-snippet', + IMPORT_SNIPPETS_ADMIN: '/wp-admin/admin.php?page=import-code-snippets', + SETTINGS_ADMIN: '/wp-admin/admin.php?page=snippets-settings', + WELCOME_SCREEN_ADMIN: '/wp-admin/admin.php?page=code-snippets-welcome', + ADD_SNIPPET: '/wp-admin/admin.php?page=add-snippet', + COMMUNITY_CLOUD: '/wp-admin/admin.php?page=snippets&subpage=cloud-community', FRONTEND: '/' } export const MESSAGES = { - SNIPPET_CREATED: 'Snippet created', - SNIPPET_CREATED_AND_ACTIVATED: 'Snippet created and activated', - SNIPPET_UPDATED_AND_ACTIVATED: 'Snippet updated and activated', - SNIPPET_UPDATED_AND_DEACTIVATED: 'Snippet updated and deactivated' + SNIPPET_CREATED: /Snippet (?:created|updated)/i, + SNIPPET_CREATED_AND_ACTIVATED: /Snippet (?:created|updated)(?: and activated)?/i, + SNIPPET_UPDATED_AND_ACTIVATED: /Snippet updated/i, + SNIPPET_UPDATED_AND_DEACTIVATED: /Snippet updated/i } export const SNIPPET_TYPES = { - PHP: 'PHP', - HTML: 'HTML' + PHP: 'Functions', + HTML: 'Content' } export const SNIPPET_LOCATIONS = { - SITE_FOOTER: 'In site footer', - SITE_HEADER: 'In site section', + SITE_HEADER: 'In site header ( section)', + SITE_BODY: 'In site content (start of )', + SITE_FOOTER: 'In site footer (end of )', IN_EDITOR: 'Where inserted in editor', ADMIN_ONLY: 'Only run in administration area', FRONTEND_ONLY: 'Only run on site front-end', @@ -58,8 +62,7 @@ export const SNIPPET_LOCATIONS = { } export const BUTTONS = { - SAVE: 'text=Save Snippet', - SAVE_AND_ACTIVATE: 'text=Save and Activate', - SAVE_AND_DEACTIVATE: 'text=Save and Deactivate', - DELETE: 'text=Delete' + SAVE: 'role=button[name="Save Snippet"]', + SAVE_AND_ACTIVATE: 'role=button[name="Save and Activate"]', + DELETE: 'button:has-text("Trash")' } diff --git a/tests/e2e/settings-tabs.spec.ts b/tests/e2e/settings-tabs.spec.ts new file mode 100644 index 000000000..d4821f793 --- /dev/null +++ b/tests/e2e/settings-tabs.spec.ts @@ -0,0 +1,35 @@ +import { expect, test } from '@playwright/test' + +const SETTINGS_URL = '/wp-admin/admin.php?page=snippets-settings§ion=general' +const TABS = '#settings-sections-tabs' + +test.describe('Settings tabs', () => { + test('switch between rendered sections in place', async ({ page }) => { + await page.goto(SETTINGS_URL) + + const wrap = page.locator('.wrap[data-active-tab]') + await expect(wrap).toHaveAttribute('data-active-tab', 'general') + + // Mark the document so a full navigation would be detectable below. + await page.evaluate(() => { + (> window).csSameDocument = true + }) + + await page.locator(`${TABS} [data-section="editor"]`).click() + + await expect(wrap).toHaveAttribute('data-active-tab', 'editor') + await expect(page.locator(`${TABS} [data-section="editor"]`)).toHaveClass(/active-type/) + await expect(page).toHaveURL(/section=editor/) + + // Redirections after saving must lead back to the selected tab. + await expect(page.locator('input[name=_wp_http_referer]')).toHaveValue(/section=editor/) + + // The swap happens without reloading the page. + expect(await page.evaluate(() => + (> window).csSameDocument)).toBe(true) + + await page.locator(`${TABS} [data-section="general"]`).click() + await expect(wrap).toHaveAttribute('data-active-tab', 'general') + await expect(page.locator(`${TABS} [data-section="general"]`)).toHaveClass(/active-type/) + }) +}) diff --git a/tests/e2e/text-utils.spec.ts b/tests/e2e/text-utils.spec.ts new file mode 100644 index 000000000..40b7f2336 --- /dev/null +++ b/tests/e2e/text-utils.spec.ts @@ -0,0 +1,123 @@ +import { expect, test } from '@playwright/test' +import { stripTags } from '../../src/js/utils/text' +import type { Page } from '@playwright/test' + +const stripTagsInPage = (page: Page, text: string): Promise => + page.evaluate(stripTags, text) + +test.describe('stripTags', () => { + test('preserves separation between block elements', async ({ page }) => { + expect(await stripTagsInPage(page, '

    First

    Second

    ')).toBe('First Second') + expect(await stripTagsInPage(page, 'Line one
    Line two')).toBe('Line one Line two') + expect(await stripTagsInPage(page, '
    • One
    • Two
    ')).toBe('One Two') + }) + + test('preserves separation between blockquotes', async ({ page }) => { + expect(await stripTagsInPage(page, '
    First
    Second
    ')) + .toBe('First Second') + }) + + test('preserves separation between other block elements', async ({ page }) => { + expect(await stripTagsInPage(page, '
    First
    Second
    ')) + .toBe('First Second') + expect(await stripTagsInPage(page, '
    Third
    ')) + .toBe('First Second Third') + }) + + test('handles ">" inside quoted attribute values', async ({ page }) => { + expect(await stripTagsInPage(page, '

    First

    Second

    ')).toBe('First Second') + expect(await stripTagsInPage(page, "inline")).toBe('inline') + }) + + test('handles malformed tags with unbalanced quotes', async ({ page }) => { + expect(await stripTagsInPage(page, '

    inline text')).toBe('') + }) + + test('uses native parser semantics for malformed comparison text', async ({ page }) => { + expect(await stripTagsInPage(page, 'x { + expect(await stripTagsInPage(page, 'Visible

    { + expect(await stripTagsInPage(page, ' y')).toBe('y') + }) + + test('handles long malformed comparison text within a sanity bound', async ({ page }) => { + const input = 'if (a { + expect(await stripTagsInPage(page, 'Code snippets')).toBe('Code snippets') + expect(await stripTagsInPage(page, 'link')).toBe('link') + }) + + test('keeps inline markup inside a word joined', async ({ page }) => { + expect(await stripTagsInPage(page, 'inline')).toBe('inline') + }) + + test('strips comments and PHP blocks', async ({ page }) => { + expect(await stripTagsInPage(page, 'AB')).toBe('AB') + expect(await stripTagsInPage(page, 'C')).toBe('C') + expect(await stripTagsInPage(page, 'Ac')).toBe('ac') + }) + + test('does not include script or style content', async ({ page }) => { + expect(await stripTagsInPage( + page, + '

    Visible

    Text

    ' + )).toBe('Visible Text') + }) + + test('handles repeated unmatched comment and PHP openers within a sanity bound', async ({ page }) => { + const timeStrip = async (opener: string, repeats: number): Promise => { + const input = opener.repeat(repeats) + const started = performance.now() + expect(await stripTagsInPage(page, input)).toBe('') + + return performance.now() - started + } + + for (const opener of ['