Compare commits

...
Sign in to create a new pull request.

15 commits

Author SHA1 Message Date
rodolfomartinez
3d00d084ea Update LICENSE 2026-02-07 15:27:53 +01:00
Sebastian Martinez
48f5e2b8e8 ‘GUI-fixes’ 2026-02-06 14:14:06 -05:00
Sebastian Martinez
1b7103622d ‘GUI-fixes’ 2026-02-06 11:58:57 -05:00
5f2426c401 added more maple apps 2026-02-05 19:12:58 -05:00
rodolfomartinez
423b9a25fb initial commit 2026-02-02 14:17:16 -05:00
rodolfomartinez
e468202f95 added maple performance caching plugin 2026-02-02 12:35:28 -05:00
rodolfomartinez
c44c49a836 debugging fixes 2026-02-02 11:37:40 -05:00
rodolfomartinez
fda7bd1d12 font handling fix 2026-02-02 11:04:00 -05:00
rodolfomartinez
d5ecb31dad font management fixes 2026-02-02 08:31:36 -05:00
rodolfomartinez
847ed92c23 v1-pre 2026-02-02 00:13:36 -05:00
rodolfomartinez
572552ff13 Update LICENSE 2026-02-01 21:07:13 +01:00
rodolfomartinez
f3127a246e Update LICENSE 2026-02-01 20:50:35 +01:00
rodolfomartinez
ca42d7d387 Update LICENSE 2026-02-01 20:42:01 +01:00
rodolfomartinez
e6f71e3706 initial commit 2026-01-30 22:33:40 -05:00
rodolfomartinez
d066133bd4 Update LICENSE 2025-12-18 20:14:10 +01:00
14366 changed files with 1911992 additions and 527 deletions

689
LICENSE
View file

@ -1,642 +1,277 @@
MAPLE OPEN TECH LICENSE (MOTL) VERSION 1.0 # Maple Open Tech License (MOTL) Version 1.0
Source-Available. Time-Released. Fair. **Source-Available · Time-Released · Ethical Use License**
Copyright 2025 Maple Open Tech Copyright © 2025 Maple Open Tech
================================================================================ ---
LICENSE PARAMETERS ## License Parameters
Licensed Work: Maple Open Tech Monorepo - **Licensed Work:** [SOFTWARE NAME]
Version: 1.0 - **Version:** [VERSION NUMBER]
Release Date: 2025-12-05 - **Release Date:** [YYYY-MM-DD]
Change Date: Four (4) years from the Release Date - **Change Date:** Four (4) years from the Release Date, or [YYYY-MM-DD]
Change License: MIT License (https://opensource.org/licenses/MIT) - **Change Effect:** Removal of commercial and revenue-centered restrictions
Licensor: Maple Open Tech (https://mapleopentech.ca) - **Licensor:** Maple Open Tech (https://mapleopentech.ca)
================================================================================ ---
EU Users: This license is supplemented by the EU Compliance Addendum which ## Plain-English Summary (Non-Binding)
provides additional consumer protections required under EU law.
AI Features: Use of AI-powered features is governed by the AI Terms Addendum. ### Before the Change Date
Data Processing: Where GDPR applies, a Data Processing Agreement is available. - ✅ View, audit, and study the source code
- ✅ Personal, academic, evaluation, and non-production use
- ✅ Internal testing and proof-of-concept
- ✅ Submit contributions
- ✅ Ethical restrictions in Section 4A apply
================================================================================ - ❌ Production or business use without a Commercial License
- ❌ Hosting or SaaS use without a Commercial License
- ❌ Competing commercial services
PLAIN ENGLISH SUMMARY ### After the Change Date
Before the Change Date: - ✅ Production, commercial, and unrestricted use is permitted
- ⚠️ Ethical restrictions in Section 4A continue to apply
YES - View and Audit: Read the source code, verify security, study how ---
it works
YES - Non-Production Use: Development, testing, evaluation, academic ## 1. Definitions
research
YES - Personal Use: Run it for yourself, your family, your personal **“Licensed Work”** means the software, source code, object code, documentation, and related materials identified above.
projects
YES - Contribute: Submit bug reports, patches, improvements **“Licensor”** means Maple Open Tech.
PAID - Production Use: Requires a Commercial License from Maple Open Tech **“You”** or **“Licensee”** means the individual or entity exercising rights under this License.
PAID - Hosting/SaaS: Requires a Commercial License from Maple Open Tech **“Production Use”** means use of the Licensed Work in a live or operational environment that provides value to You or to third parties, including business operations, commercial services, or processing real (non-test) data.
NO - Compete: Cannot use to build a competing commercial service **“Service Offering”** means providing the functionality of the Licensed Work to third parties as a hosted, managed, cloud, or software-as-a-service offering.
After the Change Date: **“Non-Production Use”** means personal, academic, research, evaluation, development, testing, or proof-of-concept use.
YES - Everything: This version becomes MIT-licensed - do whatever you want **“Companion Animals”** means animals commonly kept for companionship, assistance, service, or protection, including dogs, cats, and similar domesticated animals, and excludes animals raised primarily for food, fiber, or agricultural production.
================================================================================ ---
LICENSE TERMS ## 2. Grant of Rights
-------------------------------------------------------------------------------- Subject to the terms of this License, the Licensor grants You a limited, worldwide, non-exclusive, non-transferable license to:
SECTION 1. DEFINITIONS
--------------------------------------------------------------------------------
"Licensed Work" means the source code, object code, documentation, and 1. Copy, view, and study the Licensed Work;
associated files identified above and distributed under this License. 2. Modify the Licensed Work for Non-Production Use;
3. Create derivative works for Non-Production Use;
4. Submit contributions to the Licensed Work.
"Maple Open Tech" or "Licensor" means the copyright holder(s) of the Production Use or Service Offerings prior to the Change Date require a separate Commercial License.
Licensed Work.
"You" or "Licensee" means the individual or entity exercising rights under ---
this License.
"Release Date" means the date the Licensed Work was first distributed under ## 3. Permitted Uses Without a Commercial License
this License, as specified above.
"Change Date" means the date specified above, after which the Licensed Work
becomes available under the Change License.
"Change License" means the MIT License, or such other open source license as
specified above.
"Production Use" means use of the Licensed Work in a live environment where
the Licensed Work or output derived from it provides value to You or third
parties, including but not limited to:
- Deploying as part of a business application
- Processing real (non-test) data
- Serving end users or customers
- Internal business operations beyond evaluation
"Service Offering" means providing the functionality of the Licensed Work to
third parties as a hosted, managed, cloud, or software-as-a-service offering,
whether paid or unpaid.
"Competing Service" means a Service Offering whose primary purpose
substantially overlaps with the primary purpose of the Licensed Work.
"Non-Production Use" means use solely for:
- Development and testing
- Personal, non-commercial purposes
- Academic research and education
- Evaluation and proof-of-concept
- Contributing improvements back to the Licensed Work
--------------------------------------------------------------------------------
SECTION 2. GRANT OF RIGHTS
--------------------------------------------------------------------------------
Subject to the terms of this License, Maple Open Tech grants You a limited,
worldwide, non-exclusive, non-transferable license to:
(a) Source Access - Copy, view, study, and audit the source code of the
Licensed Work.
(b) Non-Production Use - Use, modify, and create derivative works of the
Licensed Work for Non-Production Use, at no cost.
(c) Contributions - Submit bug reports, patches, documentation improvements,
and other contributions to the Licensed Work.
(d) Production Use (with Commercial License) - Use the Licensed Work in
Production Use if You have obtained a Commercial License from Maple
Open Tech.
--------------------------------------------------------------------------------
SECTION 3. ADDITIONAL USE GRANT
--------------------------------------------------------------------------------
The following uses are permitted without a Commercial License: The following uses are permitted without a Commercial License:
(a) Personal Use - Individuals using the Licensed Work for personal, - Personal, non-commercial use;
non-commercial purposes. - Internal evaluation for a reasonable period not exceeding ninety (90) days;
- Academic teaching or non-commercial research;
- Use by registered non-profit or charitable organizations for non-commercial purposes;
- Government use for **civilian, non-coercive public-service purposes**, such as education, health, or infrastructure administration, **excluding military, intelligence, immigration enforcement, or mass-surveillance uses**.
(b) Internal Evaluation - Businesses evaluating the Licensed Work for ---
potential adoption, for a reasonable evaluation period not exceeding
ninety (90) days.
(c) Academic and Research - Accredited educational institutions and ## 4. Restrictions
non-commercial research organizations using the Licensed Work for
teaching or research.
(d) Registered Non-Profits - Organizations registered as non-profit or Before the Change Date, You may not:
charitable under applicable law, using the Licensed Work for their
charitable purposes.
(e) Government Use - Government entities using the Licensed Work for public 1. Use the Licensed Work in Production Use without a Commercial License;
services (not for resale or Service Offerings). 2. Offer the Licensed Work as a Service Offering without a Commercial License;
3. Use the Licensed Work to create a competing commercial product or service;
4. Remove or obscure copyright or proprietary notices;
5. Use Licensor trademarks without written permission;
6. Sublicense, sell, rent, lease, or transfer the Licensed Work.
-------------------------------------------------------------------------------- These restrictions apply to modified and derivative works.
SECTION 4. RESTRICTIONS
--------------------------------------------------------------------------------
4.1 Before the Change Date, You may NOT: ---
(a) Use the Licensed Work in Production Use without a Commercial License. ## 4A. Prohibited Uses (Ethical Restrictions)
(b) Offer the Licensed Work as a Service Offering without a Commercial License. Notwithstanding any other provision of this License or any Commercial License, You may not use the Licensed Work, directly or indirectly, for any of the following purposes:
(c) Offer a Competing Service using the Licensed Work, with or without ### 4A.1 Weapons or Lethal Systems
modification. The development, operation, support, or optimization of weapons, weapon systems, or systems designed primarily to cause physical injury or death to humans.
(d) Remove, alter, or obscure any copyright, trademark, or other proprietary ### 4A.2 Lethal, Extrajudicial, or Discriminatory Harm
notices. Any activity intended to facilitate or materially contribute to:
- extrajudicial killing;
- collective punishment;
- targeting of individuals or groups based on protected characteristics; or
- treatment of people as threats or “undesirables” outside lawful due process.
(e) Use Maple Open Tech's name, logo, or trademarks without prior written ### 4A.3 Mass Surveillance or Population Control
consent. Mass or bulk surveillance of civilian populations, or systems designed primarily for population control, suppression, or intimidation rather than individualized, lawful investigation.
(f) Sublicense, sell, rent, lease, or otherwise transfer the Licensed Work. ### 4A.4 Immigration Enforcement and Removal
4.2 These restrictions apply to: Use of the Licensed Work for immigration enforcement, deportation, detention, or population removal is prohibited where such use is primarily coercive, punitive, discriminatory, or designed for mass identification, profiling, targeting, detention, or expulsion of individuals based on immigration status.
- The Licensed Work in its original form This prohibition includes use by agencies, programs, or contractors whose primary mandate is large-scale immigration control, population screening, or forced removal, and applies regardless of whether such use is described as civil, administrative, or national-security related.
- Any modified versions or derivative works
- Any work incorporating any portion of the Licensed Work
-------------------------------------------------------------------------------- #### Lawful Individualized Removal Exception
SECTION 5. TIME-RELEASE PROVISION
--------------------------------------------------------------------------------
5.1 Automatic License Change Notwithstanding the above, use of the Licensed Work is permitted **solely** to support **individualized, lawful removal actions** where **all** of the following conditions are met:
Effective on the Change Date, this License automatically and irrevocably a. The removal is based on a final, individualized decision issued by a competent court or tribunal with jurisdiction, following due process and an opportunity to be heard;
converts to the Change License (MIT License) for the specific version of the b. The Licensed Work is not used to identify, profile, select, prioritize, or generate targets for removal, but only to support administrative or logistical functions after the decision has been made;
Licensed Work identified above. c. The use involves meaningful human oversight and decision-making at all stages, and does not rely on automated or semi-automated systems to recommend, optimize, or scale removal actions;
d. The Licensed Work is not used to conduct mass operations, sweeps, raids, predictive enforcement, or population-level screening; and
e. The use complies with applicable law, oversight, and accountability mechanisms in the jurisdiction in which it occurs.
5.2 Effect of Change Any use that exceeds these conditions, or that materially contributes to mass, automated, or coercive immigration enforcement, constitutes a prohibited use under this License.
On and after the Change Date: #### Humanitarian Border Assistance Exception
- All restrictions in Section 4 cease to apply to this version Use of the Licensed Work is permitted for humanitarian border assistance or life-safety operations, provided that:
- You receive all rights granted by the Change License
- No action by Maple Open Tech is required for this change to take effect
- This change is irrevocable
5.3 Version-Specific a. The primary purpose is to prevent loss of life or provide emergency assistance, medical aid, shelter, search and rescue, or safe transport;
b. The Licensed Work is not used to identify, track, profile, detain, deport, or otherwise enforce immigration status;
c. Any data collected is limited to what is necessary for immediate humanitarian assistance and is not retained, repurposed, or shared for immigration enforcement; and
d. The use complies with applicable law and oversight in the jurisdiction in which it occurs.
This time-release applies only to the specific version identified in the Humanitarian assistance shall not be deemed valid if it is incidental to, or a pretext for, immigration enforcement activities.
License Parameters. Newer versions of the Licensed Work may have their own
Change Dates.
5.4 Earlier Release
Maple Open Tech may, at its sole discretion, make the Change License effective ### 4A.5 Military, Intelligence, or Paramilitary Operations
earlier than the Change Date by publishing notice at Use by or on behalf of armed forces, intelligence agencies, private military contractors, or similar organizations.
https://mapleopentech.ca/license.
-------------------------------------------------------------------------------- #### Humanitarian and Disaster Relief Exception
SECTION 6. COMMERCIAL LICENSING
--------------------------------------------------------------------------------
6.1 When Required Use by military or governmental entities is permitted **solely** for humanitarian assistance or disaster relief, provided that the Licensed Work is not used for combat, surveillance, targeting, law enforcement, population control, or security enforcement.
You must obtain a Commercial License if You: ### 4A.6 Human Rights Violations
Activities that materially contribute to persecution, discrimination, segregation, or deprivation of civil or human rights as recognized under international law.
- Use the Licensed Work in Production Use (and do not qualify under ### 4A.7 Biological or Environmental Harm
Section 3) Systems intended to cause large-scale harm to living organisms or ecosystems.
- Offer the Licensed Work as a Service Offering
- Wish to redistribute the Licensed Work before the Change Date
6.2 Obtaining a Commercial License #### Ethical Resource Extraction and Environmental Management Exception
Commercial Licenses are available at: https://mapleopentech.ca/license Use is permitted for **lawful, regulated, and necessary resource extraction or environmental management**, provided that:
Contact: licensing@mapleopentech.ca - The activity relates to energy, minerals, forestry, water, agriculture, infrastructure, or remediation;
- It complies with applicable environmental laws or governance frameworks, including those exercised by **First Nations** pursuant to treaty rights, self-government agreements, or First Nations law;
- Environmental harm is not the primary purpose and is not unnecessary or clearly disproportionate;
- The Licensed Work is not used for deliberate ecological destruction or irreversible damage beyond what is inherent to the lawful activity.
6.3 Commercial License Benefits ### Agriculture and Food Systems Exception
Commercial Licenses may include: Use is permitted for lawful agriculture, aquaculture, livestock, veterinary, or food-production purposes, including lawful cultivation, processing, and distribution of cannabis, provided that:
- Legal right to Production Use and Service Offerings - The use is regulated and legitimate;
- Priority support and SLA options - The Licensed Work is not used to cause unnecessary suffering or mass extermination;
- Input on feature roadmap - Companion Animals and non-agricultural wildlife are not targeted.
- Redistribution rights (negotiable)
- Custom terms for enterprise deployments
- Private cloud deployment rights
6.4 Pricing Transparency ### 4A.8 Lawful Civilian Law Enforcement (Permitted Use)
Current pricing is published at: https://mapleopentech.ca/pricing Use by civilian law-enforcement agencies is permitted only where it supports lawful, individualized investigations, is subject to due process, and does not involve population control, mass surveillance, or prohibited purposes.
Maple Open Tech commits to reasonable and fair commercial terms. ### 4A.9 Automated Targeting and Autonomous Harm
-------------------------------------------------------------------------------- Use of the Licensed Work to develop or operate systems that autonomously or semi-autonomously identify, select, track, or target people for physical harm, detention, coercion, or lethal action is prohibited, regardless of human involvement.
SECTION 7. CONTRIBUTOR TERMS
--------------------------------------------------------------------------------
If You submit code, documentation, or other contributions to the Licensed Work: ---
(a) License Grant - You grant Maple Open Tech a perpetual, worldwide, ## 5. Time-Release Provision
royalty-free, non-exclusive license to use, modify, sublicense, and
distribute Your contribution under this License, the Change License,
and any Commercial Licenses.
(b) Representation - You represent that You have the legal right to grant On the Change Date, all commercial and revenue-centered restrictions of this License are permanently removed for the specified version of the Licensed Work.
such license and that Your contribution does not infringe third-party
rights.
(c) No Obligation - Maple Open Tech is under no obligation to accept, use, After the Change Date:
or acknowledge Your contribution. - Production Use and Service Offerings are permitted without a Commercial License; and
- All other terms of this License, including attribution, disclaimers, patent provisions, and **Section 4A ethical restrictions**, remain in full force and effect.
(d) Attribution - Accepted contributions will be acknowledged in accordance This time-release applies only to the identified version. New versions may have different Change Dates or licensing terms.
with project practices.
-------------------------------------------------------------------------------- ---
SECTION 8. PATENT GRANT
--------------------------------------------------------------------------------
8.1 Grant ## 6. Commercial Licensing
Subject to the terms of this License, each contributor to the Licensed Work Prior to the Change Date, Production Use, Service Offerings, or redistribution require a Commercial License issued by Maple Open Tech.
grants You a perpetual, worldwide, non-exclusive, royalty-free patent license
to make, have made, use, sell, offer for sale, import, and otherwise transfer
the Licensed Work, to the extent such license is necessary to exercise the
rights granted under this License.
8.2 Defensive Termination Commercial Licenses may not authorize any use prohibited under Section 4A.
If You (or any entity on Your behalf) initiate patent litigation against any ---
entity alleging that the Licensed Work constitutes patent infringement:
- All patent licenses granted to You under this License terminate ## 7. Contributions
immediately
- All other licenses granted under this License also terminate
- This termination is automatic and requires no action by Maple Open Tech
-------------------------------------------------------------------------------- By submitting a contribution, You grant the Licensor a perpetual, worldwide, royalty-free license to use, modify, sublicense, and distribute the contribution under this License.
SECTION 9. ACCEPTANCE AND ACKNOWLEDGMENT
--------------------------------------------------------------------------------
BY DOWNLOADING, ACCESSING, COPYING, OR OTHERWISE USING THE LICENSED WORK, YOU: You represent that You have the legal right to grant this license.
- Acknowledge that You have read and understood this License ---
- Agree to be bound by all terms and conditions
- Represent that You have the legal capacity to enter this agreement
- Affirm that if accepting on behalf of an organization, You have
authority to bind that organization
- Confirm that You will use the Licensed Work only as permitted by
this License
IF YOU DO NOT AGREE TO THESE TERMS, DO NOT DOWNLOAD, ACCESS, OR USE THE ## 8. Patent Grant
LICENSED WORK.
-------------------------------------------------------------------------------- Each contributor grants You a non-exclusive, worldwide, royalty-free patent license to use the Licensed Work as permitted under this License.
SECTION 10. NO WARRANTY
--------------------------------------------------------------------------------
10.1 AS-IS PROVISION If You initiate patent litigation alleging infringement by the Licensed Work, all rights granted under this License terminate automatically.
THE LICENSED WORK IS PROVIDED "AS IS" AND "AS AVAILABLE", WITHOUT WARRANTY ---
OF ANY KIND, EXPRESS, IMPLIED, OR STATUTORY.
10.2 DISCLAIMER OF WARRANTIES ## 9. Acceptance
MAPLE OPEN TECH EXPRESSLY DISCLAIMS ALL WARRANTIES, INCLUDING BUT NOT By using the Licensed Work, You agree to be bound by this License.
LIMITED TO: If You do not agree, do not use the Licensed Work.
- MERCHANTABILITY ---
- FITNESS FOR A PARTICULAR PURPOSE
- NON-INFRINGEMENT
- ACCURACY OR COMPLETENESS
- QUIET ENJOYMENT
- COMPATIBILITY WITH ANY SYSTEM OR SOFTWARE
- THAT THE LICENSED WORK WILL MEET YOUR REQUIREMENTS
- THAT OPERATION WILL BE UNINTERRUPTED OR ERROR-FREE
- THAT DEFECTS WILL BE CORRECTED
10.3 RISK ASSUMPTION ## 10. No Warranty
YOU ACKNOWLEDGE THAT: THE LICENSED WORK IS PROVIDED **“AS IS”**, WITHOUT WARRANTY OF ANY KIND.
- You use the Licensed Work entirely at Your own risk ---
- You are solely responsible for determining suitability for Your
intended use
- You are responsible for all data backup and recovery
- No advice or information obtained from Maple Open Tech creates any
warranty
-------------------------------------------------------------------------------- ## 11. Limitation of Liability
SECTION 11. LIMITATION OF LIABILITY
--------------------------------------------------------------------------------
11.1 EXCLUSION OF DAMAGES To the maximum extent permitted by law, the Licensors total aggregate liability shall not exceed **CAD $100**.
TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT SHALL MAPLE ---
OPEN TECH OR ITS DIRECTORS, OFFICERS, EMPLOYEES, AGENTS, CONTRIBUTORS, OR
LICENSORS BE LIABLE FOR ANY:
- DIRECT, INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE ## 12. Governing Law and Jurisdiction
DAMAGES
- LOSS OF PROFITS, REVENUE, DATA, GOODWILL, OR BUSINESS OPPORTUNITY
- COST OF PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES
- BUSINESS INTERRUPTION OR LOSS OF USE
- DAMAGES ARISING FROM YOUR USE OR INABILITY TO USE THE LICENSED WORK
- DAMAGES ARISING FROM ANY CONTENT OBTAINED THROUGH THE LICENSED WORK
- ANY OTHER PECUNIARY LOSS
WHETHER BASED ON WARRANTY, CONTRACT, TORT (INCLUDING NEGLIGENCE), STRICT This License is governed by the laws of the Province of Ontario and the federal laws of Canada.
LIABILITY, OR ANY OTHER LEGAL THEORY, AND WHETHER OR NOT MAPLE OPEN TECH
HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
11.2 AGGREGATE LIABILITY CAP ---
NOTWITHSTANDING THE FOREGOING, MAPLE OPEN TECH'S TOTAL AGGREGATE LIABILITY ## 13. No Consumer Offering
FOR ALL CLAIMS ARISING OUT OF OR RELATING TO THIS LICENSE OR THE LICENSED
WORK SHALL NOT EXCEED ONE HUNDRED CANADIAN DOLLARS (CAD $100.00).
This cap applies: The Licensed Work is intended for professional, developer, academic, and organizational use only.
- Regardless of the number of claims ---
- Regardless of the form of action
- To all versions of the Licensed Work collectively
- To all incidents and causes of action in aggregate
11.3 ESSENTIAL BASIS ## 14. Termination
YOU ACKNOWLEDGE THAT: This License terminates automatically upon breach.
**Termination for violation of Section 4A applies both before and after the Change Date.**
- The limitations in this Section 11 are fundamental elements of the ---
bargain between You and Maple Open Tech
- Maple Open Tech would not provide the Licensed Work without these
limitations
- These limitations shall apply even if any limited remedy fails of its
essential purpose
11.4 JURISDICTIONAL VARIATIONS ## 15. General Provisions
Some jurisdictions do not allow the exclusion of certain warranties or the If any provision is held unenforceable, the remainder shall remain in effect.
limitation of certain damages. In such jurisdictions, the exclusions and
limitations herein shall apply to the maximum extent permitted by applicable
law.
-------------------------------------------------------------------------------- This License constitutes the entire agreement regarding the Licensed Work.
SECTION 12. DISPUTE RESOLUTION AND ARBITRATION
--------------------------------------------------------------------------------
12.1 MANDATORY ARBITRATION ---
Any dispute, controversy, or claim arising out of or relating to this License ## Contact
or the Licensed Work, including the formation, interpretation, breach, or
termination thereof, shall be resolved by binding arbitration rather than
in court.
12.2 ARBITRATION RULES - **Licensing:** licensing@mapleopentech.ca
- **Website:** https://mapleopentech.ca/license
Arbitration shall be conducted: ---
- Under the ADR Institute of Canada National Arbitration Rules *Maple Open Tech License (MOTL) v1.0*
- By a single arbitrator mutually agreed upon, or appointed under the Rules
- In Toronto, Ontario, Canada (or virtually, at either party's request)
- In the English language
- Under the laws of Ontario, Canada
12.3 CLASS ACTION WAIVER
YOU AGREE THAT ANY ARBITRATION OR PROCEEDING SHALL BE CONDUCTED ONLY ON AN
INDIVIDUAL BASIS AND NOT AS A CLASS, CONSOLIDATED, OR REPRESENTATIVE ACTION.
You waive any right to:
- Participate in a class action lawsuit or class-wide arbitration
- Participate in any consolidated or representative proceeding
- Have any dispute decided by a judge or jury
12.4 EXCEPTIONS TO ARBITRATION
Either party may seek:
- Injunctive or other equitable relief in any court of competent
jurisdiction to protect intellectual property rights
- Relief in small claims court for disputes within that court's
jurisdiction
12.5 EU CONSUMER EXEMPTION
For EU consumers, see the EU Compliance Addendum which exempts consumers
from mandatory arbitration.
--------------------------------------------------------------------------------
SECTION 13. GOVERNING LAW AND JURISDICTION
--------------------------------------------------------------------------------
13.1 GOVERNING LAW
This License is governed by the laws of the Province of Ontario, Canada and
the federal laws of Canada, without regard to conflict of law principles.
13.2 EXCLUSIVE JURISDICTION
Subject to arbitration (Section 12), legal actions shall be brought
exclusively in courts located in Toronto, Ontario, Canada. You consent to
personal jurisdiction and venue in such courts.
13.3 INTERNATIONAL USERS
If You are outside Canada:
- This License is governed by Ontario/Canadian law
- You submit to Ontario jurisdiction (subject to arbitration)
- If provisions are unenforceable in Your jurisdiction, they shall be
modified to the minimum extent necessary
--------------------------------------------------------------------------------
SECTION 14. TERMINATION
--------------------------------------------------------------------------------
14.1 Automatic Termination
Your rights under this License terminate automatically and immediately if
You breach any term, without notice from Maple Open Tech.
14.2 Effect of Termination
Upon termination, You must:
- Immediately cease all use of the Licensed Work
- Delete all copies in Your possession or control
- Certify deletion in writing if requested
14.3 Survival
Sections 8, 10, 11, 12, 13, 15, and 17 survive termination.
14.4 No Effect on Change License
Termination of this License does not affect any rights You have acquired
under the Change License after the Change Date.
--------------------------------------------------------------------------------
SECTION 15. DATA PROTECTION
--------------------------------------------------------------------------------
15.1 Your Responsibility
If the Licensed Work processes personal data, You are responsible for
compliance with applicable data protection laws.
15.2 GDPR
For users subject to GDPR:
- A Data Processing Agreement is available at: dpa@mapleopentech.ca
- Privacy Policy: https://mapleopentech.ca/privacy
- Sub-processor list: https://mapleopentech.ca/subprocessors
15.3 Data Sovereignty
Where the Licensed Work is offered as a hosted service by Maple Open Tech,
data residency options (including Canadian-only storage) are available.
Contact: licensing@mapleopentech.ca
--------------------------------------------------------------------------------
SECTION 16. EXPORT COMPLIANCE
--------------------------------------------------------------------------------
You are responsible for compliance with applicable export control laws. The
Licensed Work may be subject to Canadian and other export restrictions.
--------------------------------------------------------------------------------
SECTION 17. GENERAL PROVISIONS
--------------------------------------------------------------------------------
17.1 SEVERABILITY
If any provision is held invalid or unenforceable:
- It shall be modified to the minimum extent necessary
- If modification is not possible, it shall be severed
- Remaining provisions continue in full force
17.2 ENTIRE AGREEMENT
This License constitutes the entire agreement regarding the Licensed Work
and supersedes all prior agreements.
17.3 WAIVER
No waiver is effective unless in writing. No failure to exercise any right
operates as a waiver.
17.4 ASSIGNMENT
You may not assign this License without Maple Open Tech's written consent.
Maple Open Tech may assign without restriction.
17.5 NOTICES
Notices shall be sent to licensing@mapleopentech.ca and are deemed received
when actually received.
17.6 INTERPRETATION
- Headings are for convenience only
- "Including" means "including without limitation"
- "Days" means calendar days
- Currency is Canadian dollars unless specified
17.7 RELATIONSHIP
This License does not create any partnership, joint venture, employment, or
agency relationship.
--------------------------------------------------------------------------------
SECTION 18. CONTACT
--------------------------------------------------------------------------------
Commercial Licensing: licensing@mapleopentech.ca
https://mapleopentech.ca/license
General Inquiries: hello@mapleopentech.ca
Security Issues: security@mapleopentech.ca
EU Compliance: eu-compliance@mapleopentech.ca
GDPR / Data Protection: dpa@mapleopentech.ca
AI Compliance: ai-compliance@mapleopentech.ca
================================================================================
HOW TO APPLY THIS LICENSE
1. Include this file as LICENSE.txt in your project root
2. Fill in the License Parameters at the top (Software name, version, dates)
3. Add the following header to source files:
Copyright [YEAR] Maple Open Tech
Licensed under the Maple Open Tech License (MOTL) v1.0
https://mapleopentech.ca/license
This software is source-available. Production use requires a commercial
license. After [CHANGE DATE], this version becomes MIT-licensed.
================================================================================
FREQUENTLY ASKED QUESTIONS
Q: Is this open source?
A: Not yet. This is "source-available" until the Change Date. After that,
each version becomes MIT-licensed, which is OSI-approved open source.
Q: Can I view and audit the source code?
A: Yes, absolutely. Transparency and security auditing are explicitly
permitted.
Q: Can I use this for development and testing?
A: Yes. Non-Production Use (development, testing, evaluation, research)
is free.
Q: When do I need a Commercial License?
A: When you use it in production, offer it as a service, or want to
redistribute before the Change Date.
Q: What happens after the Change Date?
A: That specific version becomes MIT-licensed. You can do anything MIT
permits. Newer versions may still be under MOTL with their own Change
Dates.
Q: Can AWS/cloud providers take this and compete with you?
A: Not before the Change Date. Competing services require a Commercial
License.
Q: Can I self-host for my company?
A: Production self-hosting requires a Commercial License. We offer private
cloud licensing.
Q: I'm in the EU. Does the arbitration apply to me?
A: If you're an EU consumer, no. See the EU Compliance Addendum.
Q: Can I contribute improvements?
A: Yes! Contributions are welcome. By contributing, you grant us license
to use your contribution.
Q: What if my use case isn't clear?
A: Contact licensing@mapleopentech.ca - we're happy to clarify.
================================================================================
Maple Open Tech License (MOTL) v1.0
Last Updated: November 2025
https://mapleopentech.ca/license

Binary file not shown.

View file

@ -0,0 +1,19 @@
{
"permissions": {
"allow": [
"Bash(mkdir:*)",
"Bash(for po in *.po)",
"Bash(do mo=\"$po%.po.mo\")",
"Bash(msgfmt:*)",
"Bash(echo:*)",
"Bash(done)",
"Bash(ls:*)",
"Bash(xargs:*)",
"Bash(chmod:*)",
"Bash(composer install:*)",
"Bash(php:*)",
"Bash(docker --version:*)",
"Bash(brew list:*)"
]
}
}

View file

@ -0,0 +1,2 @@
<?php
// Silence is golden.

View file

@ -0,0 +1,191 @@
name: CI
on:
push:
branches: [main, develop]
paths:
- 'native/wordpress/maple-fonts-wp/**'
pull_request:
branches: [main, develop]
paths:
- 'native/wordpress/maple-fonts-wp/**'
defaults:
run:
working-directory: native/wordpress/maple-fonts-wp
jobs:
phpcs:
name: PHP Coding Standards
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
tools: composer, cs2pr
coverage: none
- name: Get Composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: ${{ runner.os }}-composer-
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Run PHP_CodeSniffer
run: ./vendor/bin/phpcs --report=checkstyle | cs2pr
continue-on-error: true
phpcompat:
name: PHP Compatibility
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
tools: composer
coverage: none
- name: Get Composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: ${{ runner.os }}-composer-
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Run PHPCompatibility
run: ./vendor/bin/phpcs -p --standard=PHPCompatibilityWP --runtime-set testVersion 7.4- --extensions=php --ignore=vendor,tests .
test:
name: PHP ${{ matrix.php }} / WP ${{ matrix.wordpress }}
runs-on: ubuntu-latest
needs: [phpcs, phpcompat]
strategy:
fail-fast: false
matrix:
php: ['7.4', '8.0', '8.1', '8.2', '8.3']
wordpress: ['6.5', '6.6', 'latest']
exclude:
# PHP 8.3 not fully compatible with older WP versions
- php: '8.3'
wordpress: '6.5'
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=5
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_mysql, mysqli, gd, exif, intl
tools: composer
coverage: none
- name: Get Composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache Composer dependencies
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-php${{ matrix.php }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: ${{ runner.os }}-php${{ matrix.php }}-composer-
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Install WordPress test suite
run: bash bin/install-wp-tests.sh wordpress_test root root 127.0.0.1 ${{ matrix.wordpress }} true
- name: Run PHPUnit
run: ./vendor/bin/phpunit --configuration phpunit.xml.dist
build:
name: Build Plugin
runs-on: ubuntu-latest
needs: test
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
tools: composer
coverage: none
- name: Install dependencies (production)
run: composer install --prefer-dist --no-dev --optimize-autoloader --no-progress
- name: Create build directory
run: mkdir -p build
- name: Build plugin zip
run: |
zip -r build/maple-local-fonts.zip . \
-x ".git/*" \
-x ".github/*" \
-x "tests/*" \
-x "bin/*" \
-x "build/*" \
-x "vendor/bin/*" \
-x "*.xml" \
-x "*.xml.dist" \
-x "composer.*" \
-x "phpcs.xml*" \
-x ".editorconfig" \
-x ".gitignore" \
-x "CLAUDE.md" \
-x "SECURITY.md" \
-x "GOOGLE_FONTS_API.md" \
-x "WORDPRESS_COMPATIBILITY.md"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: maple-local-fonts
path: native/wordpress/maple-fonts-wp/build/maple-local-fonts.zip
retention-days: 30

View file

@ -0,0 +1,2 @@
<?php
// Silence is golden.

View file

@ -0,0 +1,53 @@
when:
- event: [push, pull_request]
path: 'native/wordpress/maple-fonts-wp/**'
steps:
install:
image: composer:2
commands:
- cd native/wordpress/maple-fonts-wp
- composer install --prefer-dist --no-interaction
phpcs:
image: php:8.2-cli
commands:
- cd native/wordpress/maple-fonts-wp
- ./vendor/bin/phpcs --standard=phpcs.xml.dist --report=summary || true
depends_on:
- install
phpcompat:
image: php:8.2-cli
commands:
- cd native/wordpress/maple-fonts-wp
- ./vendor/bin/phpcs -p --standard=PHPCompatibilityWP --runtime-set testVersion 7.4- --extensions=php --ignore=vendor,tests . || true
depends_on:
- install
test-php74:
image: wordpressdevelop/phpunit:9-php-7.4-fpm
commands:
- cd native/wordpress/maple-fonts-wp
- composer install --prefer-dist --no-interaction
- bash bin/install-wp-tests.sh wordpress_test root root database latest true
- ./vendor/bin/phpunit
depends_on:
- install
test-php82:
image: wordpressdevelop/phpunit:9-php-8.2-fpm
commands:
- cd native/wordpress/maple-fonts-wp
- composer install --prefer-dist --no-interaction
- bash bin/install-wp-tests.sh wordpress_test root root database latest true
- ./vendor/bin/phpunit
depends_on:
- install
services:
database:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: wordpress_test

View file

@ -0,0 +1,264 @@
# CLAUDE.md — Maple Local Fonts WordPress Plugin
## Quick Reference
| Document | When to Reference |
|----------|-------------------|
| **CLAUDE.md** (this file) | Architecture, file structure, build order |
| **SECURITY.md** | Writing ANY PHP code |
| **GOOGLE_FONTS_API.md** | Building the font downloader class |
| **WORDPRESS_COMPATIBILITY.md** | Building the font registry, FSE integration |
---
## Project Overview
Build a WordPress plugin called **Maple Local Fonts** that:
1. Imports Google Fonts to local storage (one-time download)
2. Registers them with WordPress's native Font Library API
3. Fonts appear in FSE typography dropdowns alongside theme fonts
**Key principle:** Work WITH WordPress, not around it. We're a font importer — WordPress handles everything else.
---
## Requirements
- **Minimum PHP:** 7.4
- **Minimum WordPress:** 6.5 (required for Font Library API)
- **License:** GPL-2.0-or-later
---
## User Flow
```
Admin enters "Open Sans" → selects weights (400, 700) → selects styles (normal, italic)
Plugin hits Google Fonts CSS2 API (ONE TIME)
Downloads WOFF2 files to wp-content/fonts/
Registers font via WP Font Library API
Font appears in Appearance → Editor → Styles → Typography dropdown
User applies font using standard WordPress FSE controls
GOOGLE NEVER CONTACTED AGAIN — fonts served locally
```
---
## File Structure
```
maple-local-fonts/
├── maple-local-fonts.php # Main plugin file
├── index.php # Silence is golden
├── uninstall.php # Clean removal
├── readme.txt # WordPress.org readme
├── includes/
│ ├── index.php # Silence is golden
│ ├── class-mlf-font-downloader.php
│ ├── class-mlf-font-registry.php
│ ├── class-mlf-admin-page.php
│ └── class-mlf-ajax-handler.php
├── assets/
│ ├── index.php # Silence is golden
│ ├── admin.css
│ └── admin.js
└── languages/
├── index.php # Silence is golden
└── maple-local-fonts.pot
```
---
## Class Responsibilities
### MLF_Font_Downloader
- Build Google Fonts CSS2 URL
- Fetch CSS (with correct user-agent for WOFF2)
- Parse CSS to extract font face data
- Download WOFF2 files to wp-content/fonts/
- **Reference:** GOOGLE_FONTS_API.md
### MLF_Font_Registry
- Register fonts with WP Font Library (wp_font_family, wp_font_face post types)
- Delete fonts (remove posts and files)
- List imported fonts
- **Reference:** WORDPRESS_COMPATIBILITY.md
### MLF_Admin_Page
- Render settings page under Appearance menu
- Font name input, weight checkboxes, style checkboxes
- Display installed fonts with delete buttons
- **Reference:** SECURITY.md for output escaping
### MLF_Ajax_Handler
- Handle download requests
- Handle delete requests
- **Reference:** SECURITY.md for nonce/capability checks
---
## Admin Page UI
```
┌─────────────────────────────────────────────────────────┐
│ Maple Local Fonts │
├─────────────────────────────────────────────────────────┤
│ IMPORT FROM GOOGLE FONTS │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Font Name: [Open Sans_________________________] │ │
│ │ │ │
│ │ Weights: │ │
│ │ ☑ 400 (Regular) ☐ 300 (Light) ☐ 500 (Medium) │ │
│ │ ☑ 700 (Bold) ☐ 600 (Semi) ☐ 800 (Extra) │ │
│ │ │ │
│ │ Styles: │ │
│ │ ☑ Normal ☑ Italic │ │
│ │ │ │
│ │ Files to download: 4 │ │
│ │ [Download & Install] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ INSTALLED FONTS │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Open Sans │ │
│ │ 400 normal, 400 italic, 700 normal, 700 italic │ │
│ │ [Delete] │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ Roboto │ │
│ │ 400 normal, 500 normal, 700 normal │ │
│ │ [Delete] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
Use Appearance → Editor → Styles → Typography to │
│ apply fonts to your site. │
└─────────────────────────────────────────────────────────┘
```
---
## Build Order
### Phase 1: Foundation
1. `maple-local-fonts.php` — Plugin header, constants, autoloader, activation hook
2. `index.php` files in all directories (silence is golden)
3. `includes/class-mlf-ajax-handler.php` — With full security checks (nonce, capability, validation)
### Phase 2: Core Functionality
4. `includes/class-mlf-font-downloader.php` — Google Fonts fetching/parsing/downloading
5. `includes/class-mlf-font-registry.php` — WP Font Library integration
### Phase 3: Admin Interface
6. `includes/class-mlf-admin-page.php` — Settings page render
7. `assets/admin.css` — Admin page styles
8. `assets/admin.js` — AJAX handling for download/delete
### Phase 4: Cleanup & Polish
9. `uninstall.php` — Clean removal of fonts and data
10. `readme.txt` — WordPress.org readme with privacy section
11. Testing against all compatibility targets
---
## Critical Reminders
### Security (see SECURITY.md)
- ABSPATH check on EVERY PHP file
- index.php in EVERY directory
- Nonce verification FIRST in every AJAX handler
- Capability check SECOND
- Input validation THIRD
- Escape ALL output
### Performance
- Zero JavaScript on frontend
- Zero CSS on frontend
- All plugin code is admin-side only (except lightweight font registration)
- Timeout handling on all external requests
- Maximum limits to prevent infinite loops
### GDPR
- No user data collected
- External requests only during admin import
- Fonts served locally after import
- Document in readme.txt
### Compatibility (see WORDPRESS_COMPATIBILITY.md)
- Declare WooCommerce HPOS compatibility
- Use wp_get_font_dir() not hardcoded paths
- Use Font Library API (post types) not theme.json filtering
- Don't touch frontend, let Global Styles handle everything
---
## Constants
```php
define('MLF_VERSION', '1.0.0');
define('MLF_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('MLF_PLUGIN_URL', plugin_dir_url(__FILE__));
define('MLF_PLUGIN_BASENAME', plugin_basename(__FILE__));
// Limits
define('MLF_MAX_FONTS_PER_REQUEST', 10);
define('MLF_MAX_WEIGHTS_PER_FONT', 9);
define('MLF_REQUEST_TIMEOUT', 30);
```
---
## WordPress Hooks Used
```php
// Activation
register_activation_hook(__FILE__, 'mlf_activate');
// Admin menu
add_action('admin_menu', 'mlf_register_menu');
// Admin assets (our page only)
add_action('admin_enqueue_scripts', 'mlf_enqueue_admin_assets');
// AJAX (admin only, no nopriv)
add_action('wp_ajax_mlf_download_font', 'mlf_ajax_download');
add_action('wp_ajax_mlf_delete_font', 'mlf_ajax_delete');
// WooCommerce HPOS
add_action('before_woocommerce_init', 'mlf_declare_hpos_compatibility');
```
---
## Testing Checklist
### Functional
- [ ] Import Open Sans 400, 700 normal + italic → 4 files created
- [ ] Font appears in FSE typography dropdown
- [ ] Apply font in Global Styles → frontend shows local font
- [ ] Delete font → files and posts removed
- [ ] Re-import same font → appropriate error message
### Security
- [ ] AJAX without nonce → 403
- [ ] AJAX as subscriber → 403
- [ ] Path traversal in font name → rejected
- [ ] XSS in font name → sanitized
- [ ] Direct PHP file access → blank/exit
### Performance
- [ ] No frontend network requests from plugin
- [ ] Import 5 fonts → completes without timeout
- [ ] Admin page load < 500ms added time
### Compatibility
- [ ] WordPress 6.5, 6.6+
- [ ] Twenty Twenty-Five theme
- [ ] WooCommerce (HPOS enabled)
- [ ] Wordfence (enabled)
- [ ] LearnDash
- [ ] WPForms

View file

@ -0,0 +1,707 @@
# GOOGLE_FONTS_API.md — Google Fonts Retrieval Guide
## Overview
This document covers how to retrieve fonts from Google Fonts CSS2 API. This is a **one-time retrieval** during admin import — after download, fonts are served locally and Google is never contacted again.
---
## The Retrieval Flow
```
1. Admin enters "Open Sans" + selects weights/styles
2. Plugin constructs Google Fonts CSS2 URL
3. Plugin fetches CSS (contains @font-face rules with WOFF2 URLs)
4. Plugin parses CSS to extract WOFF2 URLs
5. Plugin downloads each WOFF2 file to wp-content/fonts/
6. Plugin registers font with WordPress Font Library
7. DONE - Google never contacted again
```
---
## Google Fonts CSS2 API
### Base URL
```
https://fonts.googleapis.com/css2
```
### URL Construction
**Pattern:**
```
https://fonts.googleapis.com/css2?family={Font+Name}:ital,wght@{variations}&display=swap
```
**Variations format:**
```
ital,wght@{italic},{weight};{italic},{weight};...
```
Where:
- `ital` = 0 (normal) or 1 (italic)
- `wght` = weight (100-900)
### Examples
**Open Sans 400 normal only:**
```
https://fonts.googleapis.com/css2?family=Open+Sans:wght@400&display=swap
```
**Open Sans 400 + 700 normal:**
```
https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;700&display=swap
```
**Open Sans 400 + 700, both normal and italic:**
```
https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,400;0,700;1,400;1,700&display=swap
```
**Breakdown of `ital,wght@0,400;0,700;1,400;1,700`:**
- `0,400` = normal 400
- `0,700` = normal 700
- `1,400` = italic 400
- `1,700` = italic 700
### URL Builder Function
```php
/**
* Build Google Fonts CSS2 API URL.
*
* @param string $font_name Font family name (e.g., "Open Sans")
* @param array $weights Array of weights (e.g., [400, 700])
* @param array $styles Array of styles (e.g., ['normal', 'italic'])
* @return string Google Fonts CSS2 URL
*/
function mlf_build_google_fonts_url($font_name, $weights, $styles) {
// URL-encode font name (spaces become +)
$family = str_replace(' ', '+', $font_name);
// Build variations
$variations = [];
// Sort for consistent URLs
sort($weights);
sort($styles);
$has_italic = in_array('italic', $styles, true);
$has_normal = in_array('normal', $styles, true);
foreach ($weights as $weight) {
if ($has_normal) {
$variations[] = "0,{$weight}";
}
if ($has_italic) {
$variations[] = "1,{$weight}";
}
}
// If only normal styles, simpler format
if ($has_normal && !$has_italic) {
$wght = implode(';', $weights);
return "https://fonts.googleapis.com/css2?family={$family}:wght@{$wght}&display=swap";
}
// Full format with ital axis
$variation_string = implode(';', $variations);
return "https://fonts.googleapis.com/css2?family={$family}:ital,wght@{$variation_string}&display=swap";
}
```
---
## CRITICAL: User-Agent Requirement
Google Fonts returns **different file formats** based on user-agent:
| User-Agent | Format Returned |
|------------|-----------------|
| Old browser | TTF or WOFF |
| Modern browser | WOFF2 |
| curl (no UA) | TTF |
**We need WOFF2** (smallest, best compression, modern browser support).
### Required User-Agent
Use a modern Chrome user-agent:
```php
$user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
```
### Fetch Function
```php
/**
* Fetch CSS from Google Fonts API.
*
* @param string $font_name Font family name
* @param array $weights Weights to fetch
* @param array $styles Styles to fetch
* @return string|WP_Error CSS content or error
*/
function mlf_fetch_google_css($font_name, $weights, $styles) {
$url = mlf_build_google_fonts_url($font_name, $weights, $styles);
// Validate URL
if (!mlf_is_valid_google_fonts_url($url)) {
return new WP_Error('invalid_url', 'Invalid Google Fonts URL');
}
// CRITICAL: Must use modern browser user-agent to get WOFF2
$response = wp_remote_get($url, [
'timeout' => 15,
'sslverify' => true,
'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
]);
if (is_wp_error($response)) {
return new WP_Error(
'request_failed',
'Could not connect to Google Fonts: ' . $response->get_error_message()
);
}
$status = wp_remote_retrieve_response_code($response);
if ($status === 400) {
return new WP_Error('font_not_found', 'Font not found on Google Fonts');
}
if ($status !== 200) {
return new WP_Error('http_error', 'Google Fonts returned HTTP ' . $status);
}
$css = wp_remote_retrieve_body($response);
if (empty($css)) {
return new WP_Error('empty_response', 'Empty response from Google Fonts');
}
// Verify we got WOFF2 (sanity check)
if (strpos($css, '.woff2)') === false) {
return new WP_Error('wrong_format', 'Did not receive WOFF2 format - check user-agent');
}
return $css;
}
```
---
## Parsing the CSS Response
### Sample Response
Google returns CSS like this:
```css
/* latin-ext */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v40/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsg-1x4gaVI.woff2) format('woff2');
unicode-range: U+0100-02AF, U+0304, U+0308, ...;
}
/* latin */
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url(https://fonts.gstatic.com/s/opensans/v40/memSYaGs126MiZpBA-UvWbX2vVnXBbObj2OVZyOOSr4dVJWUgsjZ0B4gaVI.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, ...;
}
```
### Key Observations
1. **Multiple @font-face blocks per weight** — Google splits fonts by unicode subset (latin, latin-ext, cyrillic, etc.)
2. **We want the "latin" subset** — It's the base and covers most use cases
3. **Each block has:** font-family, font-style, font-weight, src URL, unicode-range
### Unicode Subset Strategy
**Option A: Download only latin (simpler, smaller)**
- Parse CSS, identify latin blocks, download only those
- Good for most sites
**Option B: Download all subsets (more complete)**
- Download all WOFF2 files
- Larger but supports more languages
**Recommended: Option A** — Start with latin subset. Can add option for more subsets later.
### Parsing Function
```php
/**
* Parse Google Fonts CSS and extract font face data.
*
* @param string $css CSS content from Google Fonts
* @param string $font_name Expected font family name
* @return array|WP_Error Array of font face data or error
*/
function mlf_parse_google_css($css, $font_name) {
$font_faces = [];
// Match all @font-face blocks
$pattern = '/@font-face\s*\{([^}]+)\}/s';
if (!preg_match_all($pattern, $css, $matches)) {
return new WP_Error('parse_failed', 'Could not parse CSS - no @font-face rules found');
}
foreach ($matches[1] as $block) {
$face_data = mlf_parse_font_face_block($block);
if (is_wp_error($face_data)) {
continue; // Skip malformed blocks
}
// Verify font family matches (security)
if (strcasecmp($face_data['family'], $font_name) !== 0) {
continue;
}
// Create unique key for weight+style combo
$key = $face_data['weight'] . '-' . $face_data['style'];
// Prefer latin subset (usually comes after latin-ext)
// Check if this is a latin block by unicode-range
$is_latin = mlf_is_latin_subset($face_data['unicode_range']);
// Only store if:
// 1. We don't have this weight/style yet, OR
// 2. This is latin and replaces non-latin
if (!isset($font_faces[$key]) || $is_latin) {
$font_faces[$key] = $face_data;
}
}
if (empty($font_faces)) {
return new WP_Error('no_fonts', 'No valid font faces found in CSS');
}
return array_values($font_faces);
}
/**
* Parse a single @font-face block.
*
* @param string $block Content inside @font-face { }
* @return array|WP_Error Parsed data or error
*/
function mlf_parse_font_face_block($block) {
$data = [];
// Extract font-family
if (preg_match('/font-family:\s*[\'"]?([^;\'"]+)[\'"]?;/i', $block, $m)) {
$data['family'] = trim($m[1]);
} else {
return new WP_Error('missing_family', 'Missing font-family');
}
// Extract font-weight
if (preg_match('/font-weight:\s*(\d+);/i', $block, $m)) {
$data['weight'] = $m[1];
} else {
return new WP_Error('missing_weight', 'Missing font-weight');
}
// Extract font-style
if (preg_match('/font-style:\s*(\w+);/i', $block, $m)) {
$data['style'] = $m[1];
} else {
$data['style'] = 'normal'; // Default
}
// Extract src URL - MUST be fonts.gstatic.com
if (preg_match('/src:\s*url\((https:\/\/fonts\.gstatic\.com\/[^)]+\.woff2)\)/i', $block, $m)) {
$data['url'] = $m[1];
} else {
return new WP_Error('missing_src', 'Missing or invalid src URL');
}
// Extract unicode-range (optional, for subset detection)
if (preg_match('/unicode-range:\s*([^;]+);/i', $block, $m)) {
$data['unicode_range'] = trim($m[1]);
} else {
$data['unicode_range'] = '';
}
return $data;
}
/**
* Check if unicode-range indicates latin subset.
* Latin typically starts with U+0000-00FF.
*
* @param string $range Unicode range string
* @return bool True if appears to be latin subset
*/
function mlf_is_latin_subset($range) {
// Latin subset typically includes basic ASCII range
// and does NOT include extended Latin (U+0100+) as primary
if (empty($range)) {
return true; // Assume latin if no range specified
}
// Latin subset usually starts with U+0000 and includes U+00FF
// Latin-ext starts with U+0100
if (preg_match('/U\+0000/', $range) && !preg_match('/^U\+0100/', $range)) {
return true;
}
return false;
}
```
---
## Downloading WOFF2 Files
### Download Function
```php
/**
* Download a single WOFF2 file from Google Fonts.
*
* @param string $url Google Fonts static URL
* @param string $font_slug Font slug (e.g., "open-sans")
* @param string $weight Font weight (e.g., "400")
* @param string $style Font style (e.g., "normal")
* @return string|WP_Error Local file path or error
*/
function mlf_download_font_file($url, $font_slug, $weight, $style) {
// Validate URL is from Google
if (!mlf_is_valid_google_fonts_url($url)) {
return new WP_Error('invalid_url', 'URL is not from Google Fonts');
}
// Build local filename
$filename = sprintf('%s_%s_%s.woff2', $font_slug, $style, $weight);
$filename = sanitize_file_name($filename);
// Get destination path
$font_dir = wp_get_font_dir();
$destination = trailingslashit($font_dir['path']) . $filename;
// Validate destination path
if (!mlf_validate_font_path($destination)) {
return new WP_Error('invalid_path', 'Invalid destination path');
}
// Ensure directory exists
if (!wp_mkdir_p($font_dir['path'])) {
return new WP_Error('mkdir_failed', 'Could not create fonts directory');
}
// Download file
$response = wp_remote_get($url, [
'timeout' => 30,
'sslverify' => true,
'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
]);
if (is_wp_error($response)) {
return new WP_Error(
'download_failed',
'Failed to download font file: ' . $response->get_error_message()
);
}
$status = wp_remote_retrieve_response_code($response);
if ($status !== 200) {
return new WP_Error('http_error', 'Font download returned HTTP ' . $status);
}
$content = wp_remote_retrieve_body($response);
if (empty($content)) {
return new WP_Error('empty_file', 'Downloaded font file is empty');
}
// Verify it looks like a WOFF2 file (magic bytes: wOF2)
if (substr($content, 0, 4) !== 'wOF2') {
return new WP_Error('invalid_format', 'Downloaded file is not valid WOFF2');
}
// Write file using WP Filesystem
global $wp_filesystem;
if (empty($wp_filesystem)) {
require_once ABSPATH . 'wp-admin/includes/file.php';
WP_Filesystem();
}
if (!$wp_filesystem->put_contents($destination, $content, FS_CHMOD_FILE)) {
return new WP_Error('write_failed', 'Could not write font file');
}
return $destination;
}
```
### Batch Download Function
```php
/**
* Download all font files for a font family.
*
* @param string $font_name Font family name
* @param array $weights Weights to download
* @param array $styles Styles to download
* @return array|WP_Error Array of downloaded files or error
*/
function mlf_download_font_family($font_name, $weights, $styles) {
// Fetch CSS from Google
$css = mlf_fetch_google_css($font_name, $weights, $styles);
if (is_wp_error($css)) {
return $css;
}
// Parse CSS to get font face data
$font_faces = mlf_parse_google_css($css, $font_name);
if (is_wp_error($font_faces)) {
return $font_faces;
}
// Generate slug from font name
$font_slug = sanitize_title($font_name);
// Download each font file
$downloaded = [];
$errors = [];
foreach ($font_faces as $face) {
$result = mlf_download_font_file(
$face['url'],
$font_slug,
$face['weight'],
$face['style']
);
if (is_wp_error($result)) {
$errors[] = $result->get_error_message();
continue;
}
$downloaded[] = [
'path' => $result,
'weight' => $face['weight'],
'style' => $face['style'],
];
}
// If no files downloaded, return error
if (empty($downloaded)) {
return new WP_Error(
'download_failed',
'Could not download any font files: ' . implode(', ', $errors)
);
}
return [
'font_name' => $font_name,
'font_slug' => $font_slug,
'files' => $downloaded,
];
}
```
---
## Error Handling
### Common Errors
| Error | Cause | User Message |
|-------|-------|--------------|
| Font not found | Typo in font name | "Font not found on Google Fonts. Check the spelling." |
| Network timeout | Slow connection | "Could not connect to Google Fonts. Please try again." |
| Invalid format | Wrong user-agent | Internal error - should not happen |
| Write failed | Permissions | "Could not save font files. Check directory permissions." |
### Error Messages (User-Friendly)
```php
/**
* Convert internal error codes to user-friendly messages.
*
* @param WP_Error $error The error object
* @return string User-friendly message
*/
function mlf_get_user_error_message($error) {
$code = $error->get_error_code();
$messages = [
'font_not_found' => 'Font not found on Google Fonts. Please check the spelling and try again.',
'request_failed' => 'Could not connect to Google Fonts. Please check your internet connection and try again.',
'http_error' => 'Google Fonts returned an error. Please try again later.',
'parse_failed' => 'Could not process the font data. The font may not be available.',
'download_failed' => 'Could not download the font files. Please try again.',
'write_failed' => 'Could not save font files. Please check that wp-content/fonts is writable.',
'mkdir_failed' => 'Could not create fonts directory. Please check file permissions.',
'invalid_path' => 'Invalid file path. Please contact support.',
'invalid_url' => 'Invalid font URL. Please contact support.',
];
return $messages[$code] ?? 'An unexpected error occurred. Please try again.';
}
```
---
## Complete Downloader Class
```php
<?php
if (!defined('ABSPATH')) {
exit;
}
class MLF_Font_Downloader {
private $user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
/**
* Download a font from Google Fonts.
*
* @param string $font_name Font family name
* @param array $weights Weights to download
* @param array $styles Styles to download
* @return array|WP_Error Download result or error
*/
public function download($font_name, $weights, $styles) {
// Validate inputs
if (empty($font_name) || !preg_match('/^[a-zA-Z0-9\s\-]+$/', $font_name)) {
return new WP_Error('invalid_name', 'Invalid font name');
}
$weights = array_intersect(array_map('absint', $weights), [100,200,300,400,500,600,700,800,900]);
if (empty($weights)) {
return new WP_Error('invalid_weights', 'No valid weights specified');
}
$styles = array_intersect($styles, ['normal', 'italic']);
if (empty($styles)) {
return new WP_Error('invalid_styles', 'No valid styles specified');
}
// Fetch CSS
$css = $this->fetch_css($font_name, $weights, $styles);
if (is_wp_error($css)) {
return $css;
}
// Parse CSS
$font_faces = $this->parse_css($css, $font_name);
if (is_wp_error($font_faces)) {
return $font_faces;
}
// Download files
$font_slug = sanitize_title($font_name);
$downloaded = $this->download_files($font_faces, $font_slug);
if (is_wp_error($downloaded)) {
return $downloaded;
}
return [
'font_name' => $font_name,
'font_slug' => $font_slug,
'files' => $downloaded,
];
}
private function build_url($font_name, $weights, $styles) {
$family = str_replace(' ', '+', $font_name);
sort($weights);
$has_italic = in_array('italic', $styles, true);
$has_normal = in_array('normal', $styles, true);
if ($has_normal && !$has_italic) {
$wght = implode(';', $weights);
return "https://fonts.googleapis.com/css2?family={$family}:wght@{$wght}&display=swap";
}
$variations = [];
foreach ($weights as $weight) {
if ($has_normal) {
$variations[] = "0,{$weight}";
}
if ($has_italic) {
$variations[] = "1,{$weight}";
}
}
$variation_string = implode(';', $variations);
return "https://fonts.googleapis.com/css2?family={$family}:ital,wght@{$variation_string}&display=swap";
}
private function fetch_css($font_name, $weights, $styles) {
$url = $this->build_url($font_name, $weights, $styles);
$response = wp_remote_get($url, [
'timeout' => 15,
'sslverify' => true,
'user-agent' => $this->user_agent,
]);
if (is_wp_error($response)) {
return new WP_Error('request_failed', $response->get_error_message());
}
$status = wp_remote_retrieve_response_code($response);
if ($status === 400) {
return new WP_Error('font_not_found', 'Font not found');
}
if ($status !== 200) {
return new WP_Error('http_error', 'HTTP ' . $status);
}
$css = wp_remote_retrieve_body($response);
if (empty($css) || strpos($css, '.woff2)') === false) {
return new WP_Error('invalid_response', 'Invalid CSS response');
}
return $css;
}
private function parse_css($css, $font_name) {
// Implementation as shown above
// Returns array of font face data
}
private function download_files($font_faces, $font_slug) {
// Implementation as shown above
// Returns array of downloaded file info
}
}
```
---
## Testing Checklist
- [ ] Valid font name (Open Sans) returns CSS with WOFF2 URLs
- [ ] Invalid font name returns appropriate error
- [ ] Multiple weights are all downloaded
- [ ] Italic styles are handled correctly
- [ ] Files are saved to correct location
- [ ] Files have correct WOFF2 magic bytes
- [ ] Timeout handling works (test with slow connection)
- [ ] User-agent produces WOFF2 (not TTF/WOFF)

View file

@ -0,0 +1,621 @@
# SECURITY.md — Maple Local Fonts Security Requirements
## Overview
This document covers all security requirements for the Maple Local Fonts plugin. Reference this when writing ANY PHP code.
---
## ABSPATH Check (Every PHP File)
Every PHP file MUST start with this check. No exceptions.
```php
<?php
if (!defined('ABSPATH')) {
exit;
}
```
---
## Silence is Golden Files
Create `index.php` in EVERY directory:
```php
<?php
// Silence is golden.
if (!defined('ABSPATH')) {
exit;
}
```
**Required locations:**
- `/maple-local-fonts/index.php`
- `/maple-local-fonts/includes/index.php`
- `/maple-local-fonts/assets/index.php`
- `/maple-local-fonts/languages/index.php`
---
## OWASP Compliance
### A1 - Injection Prevention
**SQL Injection:**
```php
// NEVER do this
$wpdb->query("SELECT * FROM table WHERE id = " . $_POST['id']);
// ALWAYS do this
$wpdb->get_results($wpdb->prepare(
"SELECT * FROM %i WHERE id = %d",
$table_name,
absint($_POST['id'])
));
```
**Note:** This plugin should rarely need direct SQL. Use WordPress APIs (`get_posts`, `wp_insert_post`, etc.) which handle escaping internally.
### A2 - Authentication
All admin actions require capability check:
```php
if (!current_user_can('edit_theme_options')) {
wp_die('Unauthorized', 'Error', ['response' => 403]);
}
```
### A3 - Sensitive Data
- No API keys (Google Fonts CSS2 API is public)
- No user credentials stored
- No PII collected
### A5 - Broken Access Control
**Order of checks for ALL AJAX handlers:**
```php
public function handle_ajax_action() {
// 1. Nonce verification FIRST
if (!check_ajax_referer('mlf_action_name', 'nonce', false)) {
wp_send_json_error(['message' => 'Security check failed'], 403);
}
// 2. Capability check SECOND
if (!current_user_can('edit_theme_options')) {
wp_send_json_error(['message' => 'Unauthorized'], 403);
}
// 3. Input validation THIRD
// ... validate all inputs ...
// 4. Process request
// ... actual logic ...
}
```
### A7 - Cross-Site Scripting (XSS)
**Escape ALL output:**
```php
// HTML content
echo esc_html($font_name);
// HTML attributes
echo '<input value="' . esc_attr($font_name) . '">';
// URLs
echo '<a href="' . esc_url($url) . '">';
// JavaScript data
wp_localize_script('mlf-admin', 'mlfData', [
'fontName' => esc_js($font_name), // Or let wp_localize_script handle it
]);
// Translatable strings with variables
printf(
esc_html__('Installed: %s', 'maple-local-fonts'),
esc_html($font_name)
);
```
**Never trust input for output:**
```php
// WRONG - XSS vulnerability
echo '<div>' . $_POST['font_name'] . '</div>';
// RIGHT - sanitize input, escape output
$font_name = sanitize_text_field($_POST['font_name']);
echo '<div>' . esc_html($font_name) . '</div>';
```
### A8 - Insecure Deserialization
```php
// NEVER use unserialize() on external data
$data = unserialize($_POST['data']); // DANGEROUS
// Use JSON instead
$data = json_decode(sanitize_text_field($_POST['data']), true);
if (json_last_error() !== JSON_ERROR_NONE) {
wp_send_json_error(['message' => 'Invalid data format']);
}
```
### A9 - Vulnerable Components
- No external PHP libraries
- Use only WordPress core functions
- Keep dependencies to zero
---
## Nonce Implementation
### Creating Nonces
**In admin page form:**
```php
wp_nonce_field('mlf_download_font', 'mlf_nonce');
```
**For AJAX (via wp_localize_script):**
```php
wp_localize_script('mlf-admin', 'mlfData', [
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('mlf_download_font'),
]);
```
### Verifying Nonces
**AJAX handler:**
```php
// Returns false on failure, doesn't die (we handle response ourselves)
if (!check_ajax_referer('mlf_download_font', 'nonce', false)) {
wp_send_json_error(['message' => 'Security check failed'], 403);
}
```
**Form submission:**
```php
if (!wp_verify_nonce($_POST['mlf_nonce'], 'mlf_download_font')) {
wp_die('Security check failed');
}
```
### Nonce Names
Use consistent, descriptive nonce action names:
| Action | Nonce Name |
|--------|------------|
| Download font | `mlf_download_font` |
| Delete font | `mlf_delete_font` |
| Update settings | `mlf_update_settings` |
---
## Input Validation
### Font Name Validation
```php
$font_name = isset($_POST['font_name']) ? sanitize_text_field($_POST['font_name']) : '';
// Strict allowlist pattern - alphanumeric, spaces, hyphens only
if (!preg_match('/^[a-zA-Z0-9\s\-]+$/', $font_name)) {
wp_send_json_error(['message' => 'Invalid font name: only letters, numbers, spaces, and hyphens allowed']);
}
// Length limit
if (strlen($font_name) > 100) {
wp_send_json_error(['message' => 'Font name too long']);
}
// Not empty
if (empty($font_name)) {
wp_send_json_error(['message' => 'Font name required']);
}
```
### Weight Validation
```php
$weights = isset($_POST['weights']) ? (array) $_POST['weights'] : [];
// Convert to integers
$weights = array_map('absint', $weights);
// Strict allowlist
$allowed_weights = [100, 200, 300, 400, 500, 600, 700, 800, 900];
$weights = array_intersect($weights, $allowed_weights);
// Must have at least one
if (empty($weights)) {
wp_send_json_error(['message' => 'At least one weight required']);
}
```
### Style Validation
```php
$styles = isset($_POST['styles']) ? (array) $_POST['styles'] : [];
// Strict allowlist - only these two values ever
$allowed_styles = ['normal', 'italic'];
$styles = array_filter($styles, function($style) use ($allowed_styles) {
return in_array($style, $allowed_styles, true);
});
// Must have at least one
if (empty($styles)) {
wp_send_json_error(['message' => 'At least one style required']);
}
```
### Font Family ID Validation (for delete)
```php
$font_id = isset($_POST['font_id']) ? absint($_POST['font_id']) : 0;
if ($font_id < 1) {
wp_send_json_error(['message' => 'Invalid font ID']);
}
// Verify it exists and is a font family
$font = get_post($font_id);
if (!$font || $font->post_type !== 'wp_font_family') {
wp_send_json_error(['message' => 'Font not found']);
}
// Verify it's one we imported (not a theme font)
if (get_post_meta($font_id, '_mlf_imported', true) !== '1') {
wp_send_json_error(['message' => 'Cannot delete theme fonts']);
}
```
---
## File Operation Security
### Path Traversal Prevention
```php
/**
* Validate that a path is within the WordPress fonts directory.
* Prevents path traversal attacks.
*
* @param string $path Full path to validate
* @return bool True if path is safe, false otherwise
*/
function mlf_validate_font_path($path) {
$font_dir = wp_get_font_dir();
$fonts_path = wp_normalize_path(trailingslashit($font_dir['path']));
// Resolve to real path (handles ../ etc)
$real_path = realpath($path);
// If realpath fails, file doesn't exist yet - validate the directory
if ($real_path === false) {
$dir = dirname($path);
$real_dir = realpath($dir);
if ($real_dir === false) {
return false;
}
$real_path = wp_normalize_path($real_dir . '/' . basename($path));
} else {
$real_path = wp_normalize_path($real_path);
}
// Must be within fonts directory
return strpos($real_path, $fonts_path) === 0;
}
```
### Filename Sanitization
```php
/**
* Sanitize and validate a font filename.
*
* @param string $filename The filename to validate
* @return string|false Sanitized filename or false if invalid
*/
function mlf_sanitize_font_filename($filename) {
// WordPress sanitization first
$filename = sanitize_file_name($filename);
// Must have .woff2 extension
if (pathinfo($filename, PATHINFO_EXTENSION) !== 'woff2') {
return false;
}
// No path components
if ($filename !== basename($filename)) {
return false;
}
// Reasonable length
if (strlen($filename) > 200) {
return false;
}
return $filename;
}
```
### Safe File Writing
```php
/**
* Safely write a font file to the fonts directory.
*
* @param string $filename Sanitized filename
* @param string $content File content
* @return string|WP_Error File path on success, WP_Error on failure
*/
function mlf_write_font_file($filename, $content) {
// Validate filename
$safe_filename = mlf_sanitize_font_filename($filename);
if ($safe_filename === false) {
return new WP_Error('invalid_filename', 'Invalid filename');
}
// Get fonts directory
$font_dir = wp_get_font_dir();
$destination = trailingslashit($font_dir['path']) . $safe_filename;
// Validate path
if (!mlf_validate_font_path($destination)) {
return new WP_Error('invalid_path', 'Invalid file path');
}
// Ensure directory exists
if (!wp_mkdir_p($font_dir['path'])) {
return new WP_Error('mkdir_failed', 'Could not create fonts directory');
}
// Write file
global $wp_filesystem;
if (empty($wp_filesystem)) {
require_once ABSPATH . 'wp-admin/includes/file.php';
WP_Filesystem();
}
if (!$wp_filesystem->put_contents($destination, $content, FS_CHMOD_FILE)) {
return new WP_Error('write_failed', 'Could not write font file');
}
return $destination;
}
```
### Safe File Deletion
```php
/**
* Safely delete a font file.
*
* @param string $path Full path to the file
* @return bool True on success, false on failure
*/
function mlf_delete_font_file($path) {
// Validate path is within fonts directory
if (!mlf_validate_font_path($path)) {
return false;
}
// Must be a .woff2 file
if (pathinfo($path, PATHINFO_EXTENSION) !== 'woff2') {
return false;
}
// File must exist
if (!file_exists($path)) {
return true; // Already gone, that's fine
}
return wp_delete_file($path);
}
```
---
## HTTP Request Security
### Outbound Requests (Google Fonts)
```php
$response = wp_remote_get($url, [
'timeout' => 15,
'sslverify' => true, // Always verify SSL
'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
]);
// Check for errors
if (is_wp_error($response)) {
// Log error, return gracefully
error_log('MLF: Google Fonts request failed - ' . $response->get_error_message());
return new WP_Error('request_failed', 'Could not connect to Google Fonts');
}
// Check HTTP status
$status = wp_remote_retrieve_response_code($response);
if ($status !== 200) {
return new WP_Error('http_error', 'Google Fonts returned status ' . $status);
}
// Get body
$body = wp_remote_retrieve_body($response);
if (empty($body)) {
return new WP_Error('empty_response', 'Empty response from Google Fonts');
}
```
### URL Validation (Google Fonts only)
```php
/**
* Validate that a URL is a legitimate Google Fonts URL.
*
* @param string $url URL to validate
* @return bool True if valid Google Fonts URL
*/
function mlf_is_valid_google_fonts_url($url) {
$parsed = wp_parse_url($url);
if (!$parsed || !isset($parsed['host'])) {
return false;
}
// Only allow Google Fonts domains
$allowed_hosts = [
'fonts.googleapis.com',
'fonts.gstatic.com',
];
return in_array($parsed['host'], $allowed_hosts, true);
}
```
---
## AJAX Handler Complete Template
```php
<?php
if (!defined('ABSPATH')) {
exit;
}
class MLF_Ajax_Handler {
public function __construct() {
add_action('wp_ajax_mlf_download_font', [$this, 'handle_download']);
add_action('wp_ajax_mlf_delete_font', [$this, 'handle_delete']);
// NEVER add wp_ajax_nopriv_ - admin only functionality
}
/**
* Handle font download AJAX request.
*/
public function handle_download() {
// 1. NONCE CHECK
if (!check_ajax_referer('mlf_download_font', 'nonce', false)) {
wp_send_json_error(['message' => 'Security check failed'], 403);
}
// 2. CAPABILITY CHECK
if (!current_user_can('edit_theme_options')) {
wp_send_json_error(['message' => 'Unauthorized'], 403);
}
// 3. INPUT VALIDATION
$font_name = isset($_POST['font_name']) ? sanitize_text_field($_POST['font_name']) : '';
if (empty($font_name) || !preg_match('/^[a-zA-Z0-9\s\-]+$/', $font_name) || strlen($font_name) > 100) {
wp_send_json_error(['message' => 'Invalid font name']);
}
$weights = isset($_POST['weights']) ? array_map('absint', (array) $_POST['weights']) : [];
$weights = array_intersect($weights, [100, 200, 300, 400, 500, 600, 700, 800, 900]);
if (empty($weights)) {
wp_send_json_error(['message' => 'At least one weight required']);
}
$styles = isset($_POST['styles']) ? (array) $_POST['styles'] : [];
$styles = array_intersect($styles, ['normal', 'italic']);
if (empty($styles)) {
wp_send_json_error(['message' => 'At least one style required']);
}
// 4. PROCESS REQUEST
try {
$downloader = new MLF_Font_Downloader();
$result = $downloader->download($font_name, $weights, $styles);
if (is_wp_error($result)) {
wp_send_json_error(['message' => $result->get_error_message()]);
}
wp_send_json_success([
'message' => sprintf('Successfully installed %s', esc_html($font_name)),
'font_id' => $result,
]);
} catch (Exception $e) {
error_log('MLF Download Error: ' . $e->getMessage());
wp_send_json_error(['message' => 'An unexpected error occurred']);
}
}
/**
* Handle font deletion AJAX request.
*/
public function handle_delete() {
// 1. NONCE CHECK
if (!check_ajax_referer('mlf_delete_font', 'nonce', false)) {
wp_send_json_error(['message' => 'Security check failed'], 403);
}
// 2. CAPABILITY CHECK
if (!current_user_can('edit_theme_options')) {
wp_send_json_error(['message' => 'Unauthorized'], 403);
}
// 3. INPUT VALIDATION
$font_id = isset($_POST['font_id']) ? absint($_POST['font_id']) : 0;
if ($font_id < 1) {
wp_send_json_error(['message' => 'Invalid font ID']);
}
// Verify font exists and is ours
$font = get_post($font_id);
if (!$font || $font->post_type !== 'wp_font_family') {
wp_send_json_error(['message' => 'Font not found']);
}
if (get_post_meta($font_id, '_mlf_imported', true) !== '1') {
wp_send_json_error(['message' => 'Cannot delete theme fonts']);
}
// 4. PROCESS REQUEST
try {
$registry = new MLF_Font_Registry();
$result = $registry->delete_font($font_id);
if (is_wp_error($result)) {
wp_send_json_error(['message' => $result->get_error_message()]);
}
wp_send_json_success(['message' => 'Font deleted successfully']);
} catch (Exception $e) {
error_log('MLF Delete Error: ' . $e->getMessage());
wp_send_json_error(['message' => 'An unexpected error occurred']);
}
}
}
```
---
## Security Checklist
Before committing any code:
- [ ] ABSPATH check at top of every PHP file
- [ ] index.php exists in every directory
- [ ] All AJAX handlers verify nonce first
- [ ] All AJAX handlers check capability second
- [ ] All user input sanitized with appropriate function
- [ ] All user input validated against allowlists where applicable
- [ ] All output escaped with appropriate function
- [ ] File paths validated to prevent traversal
- [ ] No direct SQL queries (use WordPress APIs)
- [ ] No `unserialize()` on user input
- [ ] No `eval()` or similar dynamic execution
- [ ] External URLs validated before use
- [ ] Error messages don't expose sensitive info

View file

@ -0,0 +1,560 @@
# WORDPRESS_COMPATIBILITY.md — WordPress & Plugin Compatibility
## Overview
This document covers compatibility requirements for WordPress core systems and popular plugins. Reference this when building the font registry class and integration points.
---
## WordPress Version Requirements
**Minimum: WordPress 6.5**
WordPress 6.5 introduced the Font Library API which this plugin depends on. Earlier versions will not work.
```php
// Check on activation
register_activation_hook(__FILE__, function() {
if (version_compare(get_bloginfo('version'), '6.5', '<')) {
deactivate_plugins(plugin_basename(__FILE__));
wp_die(
'Maple Local Fonts requires WordPress 6.5 or higher for Font Library support.',
'Plugin Activation Error',
['back_link' => true]
);
}
});
// Also check on admin init (in case WP was downgraded)
add_action('admin_init', function() {
if (version_compare(get_bloginfo('version'), '6.5', '<')) {
deactivate_plugins(plugin_basename(__FILE__));
add_action('admin_notices', function() {
echo '<div class="error"><p>Maple Local Fonts has been deactivated. It requires WordPress 6.5 or higher.</p></div>';
});
}
});
```
---
## WordPress Font Library API
### How It Works
WordPress 6.5+ stores fonts using custom post types:
| Post Type | Purpose |
|-----------|---------|
| `wp_font_family` | Font family (e.g., "Open Sans") |
| `wp_font_face` | Individual weight/style variant (child of family) |
Fonts are stored in `wp-content/fonts/` by default.
### Getting the Fonts Directory
```php
// ALWAYS use this function, never hardcode paths
$font_dir = wp_get_font_dir();
// Returns:
[
'path' => '/var/www/html/wp-content/fonts',
'url' => 'https://example.com/wp-content/fonts',
'subdir' => '',
'basedir' => '/var/www/html/wp-content/fonts',
'baseurl' => 'https://example.com/wp-content/fonts',
]
```
### Registering a Font Family
```php
/**
* Register a font family with WordPress Font Library.
*
* @param string $font_name Display name (e.g., "Open Sans")
* @param string $font_slug Slug (e.g., "open-sans")
* @param array $files Array of downloaded file data
* @return int|WP_Error Font family post ID or error
*/
function mlf_register_font_family($font_name, $font_slug, $files) {
// Check if font already exists
$existing = get_posts([
'post_type' => 'wp_font_family',
'name' => $font_slug,
'posts_per_page' => 1,
'post_status' => 'publish',
]);
if (!empty($existing)) {
return new WP_Error('font_exists', 'Font family already installed');
}
// Build font family settings
$font_family_settings = [
'name' => $font_name,
'slug' => $font_slug,
'fontFamily' => sprintf('"%s", sans-serif', $font_name),
'fontFace' => [],
];
// Add each font face
foreach ($files as $file) {
$font_dir = wp_get_font_dir();
$relative_path = str_replace($font_dir['path'], '', $file['path']);
$font_family_settings['fontFace'][] = [
'fontFamily' => $font_name,
'fontWeight' => $file['weight'],
'fontStyle' => $file['style'],
'src' => 'file:.' . $font_dir['basedir'] . $relative_path,
];
}
// Create font family post
$family_id = wp_insert_post([
'post_type' => 'wp_font_family',
'post_title' => $font_name,
'post_name' => $font_slug,
'post_status' => 'publish',
'post_content' => wp_json_encode($font_family_settings),
]);
if (is_wp_error($family_id)) {
return $family_id;
}
// Mark as imported by our plugin (for identification)
update_post_meta($family_id, '_mlf_imported', '1');
update_post_meta($family_id, '_mlf_import_date', current_time('mysql'));
// Create font face posts (children)
foreach ($files as $file) {
$font_dir = wp_get_font_dir();
$face_settings = [
'fontFamily' => $font_name,
'fontWeight' => $file['weight'],
'fontStyle' => $file['style'],
'src' => 'file:.' . $font_dir['baseurl'] . '/' . basename($file['path']),
];
wp_insert_post([
'post_type' => 'wp_font_face',
'post_parent' => $family_id,
'post_status' => 'publish',
'post_content' => wp_json_encode($face_settings),
]);
}
// Clear font caches
delete_transient('wp_font_library_fonts');
return $family_id;
}
```
### Deleting a Font Family
```php
/**
* Delete a font family and its files.
*
* @param int $family_id Font family post ID
* @return bool|WP_Error True on success, error on failure
*/
function mlf_delete_font_family($family_id) {
$family = get_post($family_id);
if (!$family || $family->post_type !== 'wp_font_family') {
return new WP_Error('not_found', 'Font family not found');
}
// Verify it's one we imported
if (get_post_meta($family_id, '_mlf_imported', true) !== '1') {
return new WP_Error('not_ours', 'Cannot delete fonts not imported by this plugin');
}
// Get font faces
$faces = get_children([
'post_parent' => $family_id,
'post_type' => 'wp_font_face',
]);
$font_dir = wp_get_font_dir();
// Delete font face files and posts
foreach ($faces as $face) {
$settings = json_decode($face->post_content, true);
if (isset($settings['src'])) {
// Convert file:. URL to path
$src = $settings['src'];
$src = str_replace('file:.', '', $src);
// Handle both URL and path formats
if (strpos($src, $font_dir['baseurl']) !== false) {
$file_path = str_replace($font_dir['baseurl'], $font_dir['path'], $src);
} else {
$file_path = $font_dir['path'] . '/' . basename($src);
}
// Validate path before deletion
if (mlf_validate_font_path($file_path) && file_exists($file_path)) {
wp_delete_file($file_path);
}
}
wp_delete_post($face->ID, true);
}
// Delete family post
wp_delete_post($family_id, true);
// Clear caches
delete_transient('wp_font_library_fonts');
return true;
}
```
### Listing Installed Fonts
```php
/**
* Get all fonts imported by this plugin.
*
* @return array Array of font data
*/
function mlf_get_imported_fonts() {
$fonts = get_posts([
'post_type' => 'wp_font_family',
'posts_per_page' => 100,
'post_status' => 'publish',
'meta_key' => '_mlf_imported',
'meta_value' => '1',
]);
$result = [];
foreach ($fonts as $font) {
$settings = json_decode($font->post_content, true);
// Get variants
$faces = get_children([
'post_parent' => $font->ID,
'post_type' => 'wp_font_face',
]);
$variants = [];
foreach ($faces as $face) {
$face_settings = json_decode($face->post_content, true);
$variants[] = [
'weight' => $face_settings['fontWeight'] ?? '400',
'style' => $face_settings['fontStyle'] ?? 'normal',
];
}
$result[] = [
'id' => $font->ID,
'name' => $settings['name'] ?? $font->post_title,
'slug' => $settings['slug'] ?? $font->post_name,
'variants' => $variants,
'import_date' => get_post_meta($font->ID, '_mlf_import_date', true),
];
}
return $result;
}
```
---
## Gutenberg FSE Integration
### How Fonts Appear in the Editor
Once registered via the Font Library API, fonts automatically appear in:
1. **Global Styles** → Typography → Font dropdown
2. **Block settings** → Typography → Font dropdown (when per-block typography is enabled)
No additional integration code is needed — WordPress handles this automatically.
### Theme.json Compatibility
**DO NOT:**
- Directly modify theme.json
- Filter `wp_theme_json_data_theme` to inject fonts (let Font Library handle it)
- Override global styles CSS directly
**DO:**
- Use the Font Library API (post types)
- Let WordPress generate CSS custom properties
- Trust the system
### CSS Custom Properties
When a font is applied in Global Styles, WordPress generates:
```css
body {
--wp--preset--font-family--open-sans: "Open Sans", sans-serif;
}
```
And applies it:
```css
body {
font-family: var(--wp--preset--font-family--open-sans);
}
```
Our plugin doesn't need to touch this — it's automatic.
---
## WooCommerce Compatibility
### HPOS (High-Performance Order Storage)
WooCommerce's HPOS moves order data from post meta to custom tables. We must declare compatibility.
```php
// Declare HPOS compatibility
add_action('before_woocommerce_init', function() {
if (class_exists(\Automattic\WooCommerce\Utilities\FeaturesUtil::class)) {
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility(
'custom_order_tables',
__FILE__,
true
);
}
});
```
### Why We're Compatible
Our plugin:
- Does NOT interact with orders at all
- Does NOT query wp_posts for order data
- Does NOT use wp_postmeta for order data
- Only uses wp_font_family and wp_font_face post types
We're inherently compatible because we don't touch WooCommerce data.
### Frontend Considerations
**DO NOT:**
- Override `.woocommerce` class styles
- Override `.wc-block-*` styles
- Target cart/checkout elements specifically
**DO:**
- Let WooCommerce elements inherit from body/heading fonts
- Let global styles cascade naturally
WooCommerce product titles, descriptions, and other text will naturally inherit the fonts set via Global Styles. No special handling needed.
---
## Wordfence Compatibility
### Potential Concerns
1. **Outbound requests** to Google Fonts during import
2. **AJAX endpoints** for admin actions
3. **File operations** in wp-content
### Why We're Compatible
**Outbound Requests:**
- Only occur during admin import (user-initiated action)
- Target well-known domains (fonts.googleapis.com, fonts.gstatic.com)
- Use standard `wp_remote_get()` which Wordfence allows
- No runtime external requests on frontend
**AJAX Endpoints:**
- Use standard `admin-ajax.php` (not custom endpoints)
- Include proper nonces
- Follow WordPress patterns that Wordfence expects
**File Operations:**
- Write only to `wp-content/fonts/` (WordPress default directory)
- Use WordPress Filesystem API
- Don't create executable files
### Testing with Wordfence
Test these scenarios with Wordfence active:
- [ ] Learning Mode: Import should succeed
- [ ] Enabled Mode: Import should succeed
- [ ] Rate Limiting: Admin AJAX not blocked
- [ ] Firewall: No false positives on font download
---
## LearnDash Compatibility
### Overview
LearnDash is a WordPress LMS that uses:
- Custom post types (courses, lessons, topics, quizzes)
- Custom templates
- Focus Mode (distraction-free learning)
### Why We're Compatible
Our plugin:
- Doesn't touch LearnDash post types
- Doesn't modify LearnDash templates
- Doesn't inject CSS on frontend
- Lets Global Styles cascade to LearnDash content
LearnDash course content, lesson text, and quiz questions will inherit the fonts set in Global Styles automatically.
### Focus Mode Consideration
LearnDash Focus Mode uses its own template. Fonts set via Global Styles will apply because:
- Focus Mode still loads theme.json styles
- CSS custom properties cascade to all content
- No special handling needed
**DO NOT:**
- Target `.learndash-*` classes specifically
- Override Focus Mode styles
- Inject custom CSS for LearnDash
---
## WPForms Compatibility
### Overview
WPForms renders forms via shortcodes and blocks. Form styling is handled by WPForms.
### Why We're Compatible
- Form labels and text inherit from body font
- We don't override `.wpforms-*` classes
- No JavaScript conflicts (we have no frontend JS)
### Consideration
If a user wants form text in a different font, they should use WPForms' built-in styling options or custom CSS — not expect our plugin to handle it.
---
## General Best Practices
### What We Hook Into
```php
// Admin menu
add_action('admin_menu', [$this, 'register_menu']);
// Admin assets (only on our page)
add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_assets']);
// AJAX handlers
add_action('wp_ajax_mlf_download_font', [$this, 'handle_download']);
add_action('wp_ajax_mlf_delete_font', [$this, 'handle_delete']);
// WooCommerce HPOS compatibility
add_action('before_woocommerce_init', [$this, 'declare_hpos_compatibility']);
```
### What We DON'T Hook Into
```php
// NO frontend hooks
// add_action('wp_enqueue_scripts', ...); // DON'T DO THIS
// add_action('wp_head', ...); // DON'T DO THIS
// add_action('wp_footer', ...); // DON'T DO THIS
// NO theme modification hooks
// add_filter('wp_theme_json_data_theme', ...); // Let Font Library handle it
// NO WooCommerce hooks
// add_action('woocommerce_*', ...); // DON'T DO THIS
// NO content filters
// add_filter('the_content', ...); // DON'T DO THIS
```
---
## Conflict Debugging
If a user reports a conflict, check:
### 1. Plugin Load Order
Our plugin should load with default priority. Check if another plugin is:
- Modifying the Font Library
- Overriding font CSS
- Filtering theme.json
### 2. CSS Specificity
If fonts aren't applying:
- Check browser DevTools for CSS cascade
- Look for more specific selectors overriding global styles
- Check for `!important` declarations
### 3. Cache Issues
Font changes not appearing:
- Clear browser cache
- Clear any caching plugins (WP Rocket, W3TC, etc.)
- Clear CDN cache if applicable
- WordPress transients: `delete_transient('wp_font_library_fonts')`
### 4. JavaScript Errors
If admin page isn't working:
- Check browser console for JS errors
- Look for conflicts with other admin scripts
- Verify jQuery isn't being dequeued
---
## Compatibility Checklist
Before releasing:
### WordPress Core
- [ ] Works on WordPress 6.5
- [ ] Works on WordPress 6.6+
- [ ] Font Library API integration works
- [ ] Fonts appear in Global Styles
- [ ] Fonts apply correctly on frontend
### WooCommerce
- [ ] HPOS compatibility declared
- [ ] No errors in WooCommerce status page
- [ ] Product pages render correctly with custom fonts
- [ ] Cart/Checkout not affected
### Wordfence
- [ ] Import works with firewall enabled
- [ ] No blocked requests
- [ ] No false positive security alerts
### LearnDash
- [ ] Course content inherits fonts
- [ ] Focus Mode renders correctly
- [ ] No JavaScript conflicts
### WPForms
- [ ] Forms render correctly
- [ ] No styling conflicts
### Other
- [ ] No PHP errors in debug.log
- [ ] No JavaScript errors in console
- [ ] Admin page loads correctly
- [ ] No memory issues during import

View file

@ -0,0 +1,575 @@
/**
* Maple Local Fonts - Admin Styles
*
* @package Maple_Local_Fonts
*/
/* Container */
.mlf-wrap {
max-width: 800px;
}
.mlf-wrap > h1 {
margin-bottom: 0;
}
.mlf-description {
color: #646970;
margin-top: 8px;
margin-bottom: 20px;
}
.mlf-container {
margin-top: 20px;
}
/* Sections */
.mlf-section {
background: #fff;
border: 1px solid #c3c4c7;
border-radius: 4px;
padding: 20px 24px;
margin-bottom: 20px;
}
.mlf-section h2 {
margin-top: 0;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #e0e0e0;
font-size: 1.1em;
}
/* Form */
.mlf-form-row {
margin-bottom: 20px;
}
.mlf-form-row:last-of-type {
margin-bottom: 0;
}
.mlf-form-row > label {
display: block;
font-weight: 600;
margin-bottom: 8px;
}
.mlf-form-row input[type="text"] {
width: 100%;
max-width: 400px;
padding: 8px 12px;
font-size: 14px;
}
.mlf-form-row .description {
margin-top: 8px;
color: #646970;
font-style: italic;
font-size: 13px;
}
/* Search Input */
.mlf-search-wrapper {
display: flex;
align-items: center;
gap: 8px;
max-width: 500px;
}
.mlf-search-input {
flex: 1;
padding: 6px 12px !important;
font-size: 14px;
}
.mlf-search-btn {
flex-shrink: 0;
height: 36px;
padding: 0 16px !important;
}
.mlf-search-spinner {
flex-shrink: 0;
float: none !important;
margin: 0 !important;
}
/* Search Results */
.mlf-search-results {
margin-top: 12px;
border: 1px solid #c3c4c7;
border-radius: 4px;
max-height: 400px;
overflow-y: auto;
background: #fff;
}
.mlf-results-list {
padding: 0;
}
.mlf-result-item {
padding: 16px;
border-bottom: 1px solid #e0e0e0;
cursor: pointer;
transition: background-color 0.15s ease;
}
.mlf-result-item:last-child {
border-bottom: none;
}
.mlf-result-item:hover {
background: #f0f6fc;
}
.mlf-result-info {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.mlf-result-name {
font-weight: 600;
font-size: 15px;
color: #1d2327;
}
.mlf-result-category {
font-size: 12px;
color: #646970;
padding: 2px 8px;
background: #f0f0f1;
border-radius: 3px;
}
.mlf-result-badges {
display: flex;
gap: 6px;
}
.mlf-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 3px;
font-weight: 500;
}
.mlf-badge-variable {
background: #d4edda;
color: #155724;
}
.mlf-result-preview {
font-size: 22px;
color: #50575e;
line-height: 1.3;
padding-top: 4px;
}
.mlf-no-results {
padding: 24px;
text-align: center;
color: #646970;
font-style: italic;
}
.mlf-search-error {
padding: 16px 24px;
text-align: center;
color: #8b6914;
background: #fcf9e8;
border-bottom: 1px solid #f0e6c8;
}
/* Selected Font */
.mlf-selected-font {
margin-top: 16px;
padding: 16px;
background: #f0f6fc;
border: 1px solid #c5d9ed;
border-radius: 4px;
}
.mlf-selected-font-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #c5d9ed;
}
.mlf-selected-label {
color: #646970;
font-size: 13px;
}
.mlf-selected-name {
font-weight: 600;
font-size: 16px;
color: #1d2327;
flex: 1;
}
.mlf-change-font {
background: none;
border: none;
color: #2271b1;
cursor: pointer;
font-size: 13px;
padding: 4px 8px;
}
.mlf-change-font:hover {
color: #135e96;
text-decoration: underline;
}
.mlf-italic-row {
margin-bottom: 16px;
}
/* Italic Toggle */
.mlf-italic-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: #fff;
border: 1px solid #dcdcde;
border-radius: 4px;
cursor: pointer;
font-weight: normal !important;
transition: background-color 0.15s ease;
}
.mlf-italic-toggle:hover {
background: #f6f7f7;
}
.mlf-italic-toggle input[type="checkbox"] {
margin: 0;
}
/* Submit Row */
.mlf-form-row-submit {
display: flex;
align-items: center;
gap: 12px;
margin-top: 0;
padding-top: 0;
border-top: none;
}
.mlf-selected-font .mlf-form-row-submit {
margin-top: 0;
}
.mlf-form-row-submit .spinner {
float: none;
margin: 0;
}
/* Messages */
.mlf-message {
padding: 12px 16px;
border-radius: 4px;
margin-top: 16px;
}
.mlf-message-success {
background: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
}
.mlf-message-error {
background: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
}
/* Info Note */
.mlf-info-note {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 20px;
padding: 12px 16px;
background: #f0f6fc;
border: 1px solid #c5d9ed;
border-radius: 4px;
font-size: 13px;
color: #1d2327;
}
.mlf-info-note .dashicons {
color: #2271b1;
font-size: 18px;
width: 18px;
height: 18px;
margin-top: 1px;
}
/* Section Header */
.mlf-section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #e0e0e0;
}
.mlf-section-header h2 {
margin: 0;
padding: 0;
border: none;
}
.mlf-check-updates-btn {
flex-shrink: 0;
}
/* Font List */
.mlf-font-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.mlf-font-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: #f6f7f7;
border: 1px solid #dcdcde;
border-radius: 4px;
}
.mlf-font-info {
flex: 1;
}
.mlf-font-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 4px;
}
.mlf-font-name {
margin: 0;
font-size: 1.1em;
}
.mlf-update-badge {
display: inline-block;
padding: 2px 8px;
background: #fcf9e8;
border: 1px solid #d4b106;
border-radius: 3px;
color: #8b6914;
font-size: 11px;
font-weight: 500;
}
.mlf-font-variants {
margin: 0 0 4px 0;
color: #646970;
font-size: 0.9em;
}
.mlf-font-meta {
margin: 0;
color: #8c8f94;
font-size: 0.8em;
display: flex;
gap: 12px;
}
.mlf-font-version {
font-family: monospace;
background: #f0f0f1;
padding: 1px 6px;
border-radius: 3px;
}
.mlf-update-btn {
color: #2271b1;
border-color: #2271b1;
}
.mlf-update-btn:hover {
background: #2271b1;
color: #fff;
border-color: #2271b1;
}
.mlf-font-actions {
margin-left: 20px;
}
.mlf-delete-btn {
color: #b32d2e;
border-color: #b32d2e;
}
.mlf-delete-btn:hover {
background: #b32d2e;
color: #fff;
border-color: #b32d2e;
}
.mlf-delete-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* No Fonts Message */
.mlf-no-fonts {
color: #646970;
font-style: italic;
padding: 24px;
text-align: center;
background: #f6f7f7;
border-radius: 4px;
margin: 0;
}
/* Info Box */
.mlf-info-box {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
background: #f0f6fc;
border: 1px solid #c5d9ed;
border-radius: 4px;
}
.mlf-info-box .dashicons {
color: #2271b1;
font-size: 20px;
width: 20px;
height: 20px;
margin-top: 2px;
}
.mlf-info-box p {
margin: 0 0 8px 0;
}
.mlf-info-box p:last-child {
margin-bottom: 0;
}
.mlf-info-box a {
color: #2271b1;
text-decoration: none;
}
.mlf-info-box a:hover {
text-decoration: underline;
}
/* Classic Theme Info Box */
.mlf-info-box-classic {
flex-direction: row;
align-items: flex-start;
background: #fcf9e8;
border-color: #d4b106;
}
.mlf-info-box-classic .dashicons {
color: #9d7e05;
}
.mlf-classic-theme-info {
flex: 1;
}
.mlf-classic-theme-info p {
margin: 0 0 12px 0;
}
.mlf-classic-theme-info p:last-child {
margin-bottom: 0;
}
.mlf-classic-theme-info .description {
color: #646970;
font-style: italic;
margin-top: 12px;
}
.mlf-code-example {
background: #1d2327;
color: #f0f0f1;
padding: 12px 16px;
border-radius: 4px;
font-family: Consolas, Monaco, monospace;
font-size: 12px;
line-height: 1.6;
overflow-x: auto;
white-space: pre;
margin: 12px 0;
}
/* Settings Toggle */
.mlf-setting-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: #fff;
border: 1px solid #dcdcde;
border-radius: 4px;
cursor: pointer;
font-weight: normal !important;
transition: background-color 0.15s ease;
}
.mlf-setting-toggle:hover {
background: #f6f7f7;
}
.mlf-setting-toggle input[type="checkbox"] {
margin: 0;
}
.mlf-settings-section .description {
margin-top: 8px;
max-width: 600px;
}
/* Loading State */
.mlf-loading {
opacity: 0.6;
pointer-events: none;
}
/* Responsive */
@media screen and (max-width: 782px) {
.mlf-font-item {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.mlf-font-actions {
margin-left: 0;
}
.mlf-result-info {
flex-wrap: wrap;
}
.mlf-selected-font-header {
flex-wrap: wrap;
}
}

View file

@ -0,0 +1,668 @@
/**
* Maple Local Fonts - Admin JavaScript
*
* @package Maple_Local_Fonts
*/
(function($) {
'use strict';
var MLF = {
/**
* Search debounce timer.
*/
searchTimer: null,
/**
* Currently selected font.
*/
selectedFont: null,
/**
* Currently selected font version info.
*/
selectedFontVersion: null,
selectedFontLastModified: null,
/**
* Loaded font preview stylesheets.
*/
loadedFonts: {},
/**
* Initialize the admin functionality.
*/
init: function() {
this.bindEvents();
},
/**
* Bind event handlers.
*/
bindEvents: function() {
// Search button click
$('#mlf-search-btn').on('click', this.handleSearchClick.bind(this));
// Search on Enter key
$('#mlf-font-search').on('keypress', this.handleSearchKeypress.bind(this));
// Click outside to close search results
$(document).on('click', this.handleDocumentClick.bind(this));
// Prevent closing when clicking inside search area (but not on result items)
$('.mlf-import-section').on('click', function(e) {
// Don't stop propagation if clicking on a result item
if (!$(e.target).closest('.mlf-result-item').length) {
e.stopPropagation();
}
});
// Font selection from results - bind to results container
$('#mlf-search-results').on('click', '.mlf-result-item', this.handleFontSelect.bind(this));
// Change font button
$('#mlf-change-font').on('click', this.handleChangeFont.bind(this));
// Form submission
$('#mlf-import-form').on('submit', this.handleDownload.bind(this));
// Delete button clicks
$(document).on('click', '.mlf-delete-btn', this.handleDelete.bind(this));
// Check for updates button
$('#mlf-check-updates').on('click', this.handleCheckUpdates.bind(this));
// Update button clicks
$(document).on('click', '.mlf-update-btn', this.handleUpdateFont.bind(this));
},
/**
* Handle search button click.
*
* @param {Event} e Click event.
*/
handleSearchClick: function(e) {
e.preventDefault();
this.triggerSearch();
},
/**
* Handle Enter key in search input.
*
* @param {Event} e Keypress event.
*/
handleSearchKeypress: function(e) {
if (e.which === 13) {
e.preventDefault();
this.triggerSearch();
}
},
/**
* Trigger a search with the current input value.
*/
triggerSearch: function() {
var query = $('#mlf-font-search').val().trim();
if (query.length < 2) {
this.displayError(mapleLocalFontsData.strings.minChars || 'Please enter at least 2 characters.');
return;
}
this.performSearch(query);
},
/**
* Perform the search.
*
* @param {string} query Search query.
*/
performSearch: function(query) {
var $spinner = $('#mlf-search-spinner');
var $button = $('#mlf-search-btn');
// Show spinner, disable button
$spinner.addClass('is-active');
$button.prop('disabled', true);
// Send AJAX request
$.ajax({
url: mapleLocalFontsData.ajaxUrl,
type: 'POST',
data: {
action: 'mlf_search_fonts',
nonce: mapleLocalFontsData.searchNonce,
query: query
},
success: function(response) {
if (response.success && response.data && response.data.fonts) {
if (response.data.fonts.length > 0) {
MLF.displaySearchResults(response.data.fonts);
} else {
MLF.displayNoResults();
}
} else {
// Handle error response
var errorMsg = (response.data && response.data.message)
? response.data.message
: (mapleLocalFontsData.strings.error || 'An error occurred.');
MLF.displayError(errorMsg);
}
},
error: function(xhr) {
var errorMsg = mapleLocalFontsData.strings.error || 'An error occurred.';
if (xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message) {
errorMsg = xhr.responseJSON.data.message;
}
MLF.displayError(errorMsg);
},
complete: function() {
$spinner.removeClass('is-active');
$button.prop('disabled', false);
}
});
},
/**
* Display search results.
*
* @param {Array} fonts Array of font objects.
*/
displaySearchResults: function(fonts) {
var $results = $('#mlf-search-results');
var $list = $('#mlf-results-list');
if (!fonts || fonts.length === 0) {
this.displayNoResults();
return;
}
var html = '';
var previewText = mapleLocalFontsData.strings.previewText || 'Maple Fonts Preview';
var validFonts = 0;
fonts.forEach(function(font) {
// Skip fonts with missing family name
if (!font || !font.family) {
return;
}
var fontFamily = font.family;
var category = font.category || 'sans-serif';
var categoryLabel = MLF.getCategoryLabel(category);
var badges = [];
if (font.has_variable) {
badges.push('<span class="mlf-badge mlf-badge-variable">Variable</span>');
}
html += '<div class="mlf-result-item" data-font-family="' + MLF.escapeHtml(fontFamily) + '" data-font-version="' + MLF.escapeHtml(font.version || '') + '" data-font-modified="' + MLF.escapeHtml(font.lastModified || '') + '">';
html += ' <div class="mlf-result-info">';
html += ' <span class="mlf-result-name">' + MLF.escapeHtml(fontFamily) + '</span>';
html += ' <span class="mlf-result-category">' + MLF.escapeHtml(categoryLabel) + '</span>';
if (badges.length > 0) {
html += ' <span class="mlf-result-badges">' + badges.join('') + '</span>';
}
html += ' </div>';
html += ' <div class="mlf-result-preview" style="font-family: \'' + MLF.escapeHtml(fontFamily) + '\', ' + category + ';">';
html += MLF.escapeHtml(previewText);
html += ' </div>';
html += '</div>';
// Load font for preview
MLF.loadFontPreview(fontFamily);
validFonts++;
});
// If no valid fonts were found, show no results
if (validFonts === 0) {
this.displayNoResults();
return;
}
$list.html(html);
$results.show();
},
/**
* Display no results message.
*/
displayNoResults: function() {
var $results = $('#mlf-search-results');
var $list = $('#mlf-results-list');
var message = mapleLocalFontsData.strings.noResults || 'No fonts found. Try a different search term.';
$list.html('<div class="mlf-no-results">' + MLF.escapeHtml(message) + '</div>');
$results.show();
},
/**
* Display an error message in search results.
*
* @param {string} message Error message.
*/
displayError: function(message) {
var $results = $('#mlf-search-results');
var $list = $('#mlf-results-list');
$list.html('<div class="mlf-search-error">' + MLF.escapeHtml(message) + '</div>');
$results.show();
},
/**
* Hide search results.
*/
hideSearchResults: function() {
$('#mlf-search-results').hide();
},
/**
* Maximum number of font previews to load.
*/
maxLoadedFonts: 50,
/**
* Load a font for preview from Google Fonts.
*
* @param {string} fontFamily Font family name.
*/
loadFontPreview: function(fontFamily) {
// Skip if already loaded
if (this.loadedFonts[fontFamily]) {
return;
}
// Limit number of loaded fonts to prevent memory accumulation
if (Object.keys(this.loadedFonts).length >= this.maxLoadedFonts) {
return;
}
// Mark as loading
this.loadedFonts[fontFamily] = true;
// Create Google Fonts link (spaces become %20 which Google accepts)
var fontUrl = 'https://fonts.googleapis.com/css2?family=' +
encodeURIComponent(fontFamily) +
':wght@400&display=swap';
// Create and append link element
var $link = $('<link>', {
rel: 'stylesheet',
href: fontUrl
});
$('head').append($link);
},
/**
* Handle font selection.
*
* @param {Event} e Click event.
*/
handleFontSelect: function(e) {
var $item = $(e.currentTarget);
var fontFamily = $item.data('font-family');
// Store selected font and version info
this.selectedFont = fontFamily;
this.selectedFontVersion = $item.data('font-version') || '';
this.selectedFontLastModified = $item.data('font-modified') || '';
// Update UI
$('#mlf-font-name').val(fontFamily);
$('#mlf-selected-name').text(fontFamily);
// Hide search, show selected font panel
this.hideSearchResults();
$('#mlf-font-search').closest('.mlf-form-row').hide();
$('#mlf-selected-font').show();
// Clear search input
$('#mlf-font-search').val('');
},
/**
* Handle change font button.
*
* @param {Event} e Click event.
*/
handleChangeFont: function(e) {
e.preventDefault();
// Clear selection
this.selectedFont = null;
$('#mlf-font-name').val('');
// Show search, hide selected font panel
$('#mlf-selected-font').hide();
$('#mlf-font-search').closest('.mlf-form-row').show();
$('#mlf-font-search').focus();
},
/**
* Handle document click (close search results).
*
* @param {Event} e Click event.
*/
handleDocumentClick: function(e) {
if (!$(e.target).closest('.mlf-import-section').length) {
this.hideSearchResults();
}
},
/**
* Get human-readable category label.
*
* @param {string} category Category slug.
* @return {string} Category label.
*/
getCategoryLabel: function(category) {
var labels = {
'sans-serif': 'Sans Serif',
'serif': 'Serif',
'display': 'Display',
'handwriting': 'Handwriting',
'monospace': 'Monospace'
};
return labels[category] || category;
},
/**
* Escape HTML entities.
*
* @param {string} str String to escape.
* @return {string} Escaped string.
*/
escapeHtml: function(str) {
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
},
/**
* Handle font download form submission.
*
* @param {Event} e Form submit event.
*/
handleDownload: function(e) {
e.preventDefault();
var $form = $('#mlf-import-form');
var $button = $('#mlf-download-btn');
var $spinner = $('#mlf-spinner');
var $message = $('#mlf-message');
// Get form values
var fontName = $('#mlf-font-name').val().trim();
var includeItalic = $('#mlf-include-italic').is(':checked') ? '1' : '0';
// Validate
if (!fontName) {
this.showMessage($message, mapleLocalFontsData.strings.selectFont || 'Please select a font.', 'error');
return;
}
// Store original button text
if (!$button.data('original-text')) {
$button.data('original-text', $button.text());
}
// Disable form
$form.addClass('mlf-loading');
$button.prop('disabled', true).text(mapleLocalFontsData.strings.downloading);
$spinner.addClass('is-active');
$message.hide();
// Send AJAX request
$.ajax({
url: mapleLocalFontsData.ajaxUrl,
type: 'POST',
data: {
action: 'mlf_download_font',
nonce: mapleLocalFontsData.downloadNonce,
font_name: fontName,
include_italic: includeItalic,
font_version: this.selectedFontVersion || '',
font_last_modified: this.selectedFontLastModified || ''
},
success: function(response) {
if (response.success) {
MLF.showMessage($message, response.data.message, 'success');
// Reload page to show new font
setTimeout(function() {
window.location.reload();
}, 1500);
} else {
MLF.showMessage($message, response.data.message || mapleLocalFontsData.strings.error, 'error');
}
},
error: function(xhr) {
var message = mapleLocalFontsData.strings.error;
if (xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message) {
message = xhr.responseJSON.data.message;
}
MLF.showMessage($message, message, 'error');
},
complete: function() {
$form.removeClass('mlf-loading');
$button.prop('disabled', false).text($button.data('original-text'));
$spinner.removeClass('is-active');
}
});
},
/**
* Handle font deletion.
*
* @param {Event} e Click event.
*/
handleDelete: function(e) {
e.preventDefault();
var $button = $(e.currentTarget);
var fontId = $button.data('font-id');
var $fontItem = $button.closest('.mlf-font-item');
// Confirm deletion
if (!confirm(mapleLocalFontsData.strings.confirmDelete)) {
return;
}
// Store original button text
var originalText = $button.text();
// Disable button
$button.prop('disabled', true).text(mapleLocalFontsData.strings.deleting);
$fontItem.addClass('mlf-loading');
// Send AJAX request
$.ajax({
url: mapleLocalFontsData.ajaxUrl,
type: 'POST',
data: {
action: 'mlf_delete_font',
nonce: mapleLocalFontsData.deleteNonce,
font_id: fontId
},
success: function(response) {
if (response.success) {
// Remove font item with animation
$fontItem.slideUp(300, function() {
$(this).remove();
// Check if any fonts remain
if ($('.mlf-font-item').length === 0) {
$('#mlf-font-list').replaceWith(
'<p class="mlf-no-fonts">No fonts installed yet. Search and select a font above to get started.</p>'
);
}
});
} else {
alert(response.data.message || mapleLocalFontsData.strings.error);
$button.prop('disabled', false).text(originalText);
$fontItem.removeClass('mlf-loading');
}
},
error: function(xhr) {
var message = mapleLocalFontsData.strings.error;
if (xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message) {
message = xhr.responseJSON.data.message;
}
alert(message);
$button.prop('disabled', false).text(originalText);
$fontItem.removeClass('mlf-loading');
}
});
},
/**
* Show a message to the user.
*
* @param {jQuery} $element Message element.
* @param {string} message Message text.
* @param {string} type Message type (success or error).
*/
showMessage: function($element, message, type) {
$element
.removeClass('mlf-message-success mlf-message-error')
.addClass('mlf-message-' + type)
.text(message)
.show();
},
/**
* Handle check for updates button click.
*
* @param {Event} e Click event.
*/
handleCheckUpdates: function(e) {
e.preventDefault();
var $button = $('#mlf-check-updates');
var originalText = $button.text();
// Disable button and show checking state
$button.prop('disabled', true).text(mapleLocalFontsData.strings.checking || 'Checking...');
// Send AJAX request
$.ajax({
url: mapleLocalFontsData.ajaxUrl,
type: 'POST',
data: {
action: 'mlf_check_updates',
nonce: mapleLocalFontsData.checkUpdatesNonce
},
success: function(response) {
if (response.success) {
var updates = response.data.updates || {};
var updateCount = Object.keys(updates).length;
// Update UI for each font
$('.mlf-font-item').each(function() {
var $item = $(this);
var fontId = $item.data('font-id');
var $badge = $item.find('.mlf-update-badge');
var $updateBtn = $item.find('.mlf-update-btn');
if (updates[fontId]) {
// Show update available badge and button
$badge.show();
$updateBtn.show();
// Store the latest version info on the button
$updateBtn.data('latest-version', updates[fontId].latest_version);
} else {
// Hide update badge and button
$badge.hide();
$updateBtn.hide();
}
});
// Show summary message
if (updateCount > 0) {
var message = mapleLocalFontsData.strings.updatesFound || 'Updates available for %d font(s).';
alert(message.replace('%d', updateCount));
} else {
alert(mapleLocalFontsData.strings.noUpdates || 'All fonts are up to date.');
}
} else {
alert(response.data.message || mapleLocalFontsData.strings.error);
}
},
error: function(xhr) {
var message = mapleLocalFontsData.strings.error;
if (xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message) {
message = xhr.responseJSON.data.message;
}
alert(message);
},
complete: function() {
$button.prop('disabled', false).text(originalText);
}
});
},
/**
* Handle font update button click.
*
* @param {Event} e Click event.
*/
handleUpdateFont: function(e) {
e.preventDefault();
var $button = $(e.currentTarget);
var fontId = $button.data('font-id');
var fontName = $button.data('font-name');
var $fontItem = $button.closest('.mlf-font-item');
// Confirm update
if (!confirm('Update ' + fontName + ' to the latest version?')) {
return;
}
// Store original button text
var originalText = $button.text();
// Disable button and show updating state
$button.prop('disabled', true).text(mapleLocalFontsData.strings.updating || 'Updating...');
$fontItem.addClass('mlf-loading');
// Send AJAX request
$.ajax({
url: mapleLocalFontsData.ajaxUrl,
type: 'POST',
data: {
action: 'mlf_update_font',
nonce: mapleLocalFontsData.updateFontNonce,
font_id: fontId
},
success: function(response) {
if (response.success) {
// Reload page to show updated font
alert(response.data.message);
window.location.reload();
} else {
alert(response.data.message || mapleLocalFontsData.strings.error);
$button.prop('disabled', false).text(originalText);
$fontItem.removeClass('mlf-loading');
}
},
error: function(xhr) {
var message = mapleLocalFontsData.strings.error;
if (xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message) {
message = xhr.responseJSON.data.message;
}
alert(message);
$button.prop('disabled', false).text(originalText);
$fontItem.removeClass('mlf-loading');
}
});
}
};
// Initialize on document ready
$(document).ready(function() {
MLF.init();
});
})(jQuery);

View file

@ -0,0 +1,5 @@
<?php
// Silence is golden.
if (!defined('ABSPATH')) {
exit;
}

View file

@ -0,0 +1,39 @@
{
"name": "mapleopentech/maple-local-fonts",
"description": "Import Google Fonts to local storage for GDPR-compliant, privacy-friendly typography in WordPress.",
"type": "wordpress-plugin",
"license": "GPL-2.0-or-later",
"authors": [
{
"name": "Maple Open Technologies",
"homepage": "https://mapleopentech.org"
}
],
"require": {
"php": ">=7.4"
},
"require-dev": {
"phpunit/phpunit": "^9.6",
"wp-coding-standards/wpcs": "^3.0",
"phpcompatibility/phpcompatibility-wp": "^2.1",
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
"yoast/phpunit-polyfills": "^2.0"
},
"scripts": {
"test": "phpunit",
"phpcs": "phpcs",
"phpcbf": "phpcbf",
"compat": "phpcs -p --standard=PHPCompatibilityWP --runtime-set testVersion 7.4- --extensions=php --ignore=vendor,tests ."
},
"config": {
"allow-plugins": {
"dealerdirect/phpcodesniffer-composer-installer": true
},
"sort-packages": true
},
"autoload-dev": {
"psr-4": {
"MapleLocalFonts\\Tests\\": "tests/"
}
}
}

View file

@ -0,0 +1,271 @@
<?php
/**
* Admin Page for Maple Local Fonts.
*
* @package Maple_Local_Fonts
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class MLF_Admin_Page
*
* Handles the admin settings page rendering.
*/
class MLF_Admin_Page {
/**
* Constructor.
*/
public function __construct() {
// Empty constructor - class is instantiated for rendering
}
/**
* Handle settings form submission.
*/
private function handle_settings_save() {
if (!isset($_POST['mlf_save_settings'])) {
return;
}
// Verify nonce
if (!isset($_POST['mlf_settings_nonce']) || !wp_verify_nonce($_POST['mlf_settings_nonce'], 'mlf_save_settings')) {
add_settings_error('mlf_settings', 'nonce_error', __('Security check failed.', 'maple-local-fonts'), 'error');
return;
}
// Verify capability
$capability = function_exists('mlf_get_capability') ? mlf_get_capability() : 'edit_theme_options';
if (!current_user_can($capability)) {
return;
}
// Save compatibility mode setting
$compatibility_mode = isset($_POST['mlf_compatibility_mode']) && $_POST['mlf_compatibility_mode'] === '1';
update_option('mlf_compatibility_mode', $compatibility_mode);
// Clear font caches to apply new URL format
delete_transient('mlf_imported_fonts_list');
if (class_exists('WP_Theme_JSON_Resolver')) {
WP_Theme_JSON_Resolver::clean_cached_data();
}
add_settings_error('mlf_settings', 'settings_saved', __('Settings saved.', 'maple-local-fonts'), 'success');
}
/**
* Render the admin page.
*/
public function render() {
$capability = function_exists('mlf_get_capability') ? mlf_get_capability() : 'edit_theme_options';
if (!current_user_can($capability)) {
wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'maple-local-fonts'));
}
// Handle settings save
$this->handle_settings_save();
$registry = new MLF_Font_Registry();
$installed_fonts = $registry->get_imported_fonts();
?>
<div class="wrap mlf-wrap">
<h1><?php esc_html_e('Maple Fonts', 'maple-local-fonts'); ?></h1>
<?php settings_errors('mlf_settings'); ?>
<p class="mlf-description"><?php esc_html_e('Import Google Fonts to your local server for privacy-friendly, GDPR-compliant typography.', 'maple-local-fonts'); ?></p>
<div class="mlf-container">
<!-- Import Section -->
<div class="mlf-section mlf-import-section">
<h2><?php esc_html_e('Import from Google Fonts', 'maple-local-fonts'); ?></h2>
<form id="mlf-import-form" class="mlf-form">
<!-- Search Input -->
<div class="mlf-form-row">
<label for="mlf-font-search"><?php esc_html_e('Search Fonts', 'maple-local-fonts'); ?></label>
<div class="mlf-search-wrapper">
<input type="text"
id="mlf-font-search"
class="mlf-search-input"
placeholder="<?php esc_attr_e('Enter font name...', 'maple-local-fonts'); ?>"
autocomplete="off" />
<button type="button" class="button mlf-search-btn" id="mlf-search-btn">
<?php esc_html_e('Search', 'maple-local-fonts'); ?>
</button>
<span class="spinner mlf-search-spinner" id="mlf-search-spinner"></span>
</div>
<p class="description"><?php esc_html_e('Enter at least 2 characters and click Search.', 'maple-local-fonts'); ?></p>
</div>
<!-- Search Results -->
<div class="mlf-search-results" id="mlf-search-results" style="display: none;">
<div class="mlf-results-list" id="mlf-results-list"></div>
</div>
<!-- Selected Font (hidden until a font is selected) -->
<div class="mlf-selected-font" id="mlf-selected-font" style="display: none;">
<div class="mlf-selected-font-header">
<span class="mlf-selected-label"><?php esc_html_e('Selected Font:', 'maple-local-fonts'); ?></span>
<span class="mlf-selected-name" id="mlf-selected-name"></span>
<button type="button" class="mlf-change-font" id="mlf-change-font">
<?php esc_html_e('Change', 'maple-local-fonts'); ?>
</button>
</div>
<!-- Hidden input for font name -->
<input type="hidden" id="mlf-font-name" name="font_name" value="" />
<div class="mlf-form-row mlf-italic-row">
<label class="mlf-checkbox-label mlf-italic-toggle">
<input type="checkbox" name="include_italic" id="mlf-include-italic" value="1" checked />
<span><?php esc_html_e('Include Italic styles', 'maple-local-fonts'); ?></span>
</label>
<p class="description"><?php esc_html_e('Italic styles are useful for emphasized text. Uncheck to reduce download size.', 'maple-local-fonts'); ?></p>
</div>
<div class="mlf-form-row mlf-form-row-submit">
<button type="submit" class="button button-primary" id="mlf-download-btn">
<?php esc_html_e('Download & Install', 'maple-local-fonts'); ?>
</button>
<span class="spinner" id="mlf-spinner"></span>
</div>
</div>
<div id="mlf-message" class="mlf-message" style="display: none;"></div>
</form>
<div class="mlf-info-note">
<span class="dashicons dashicons-info-outline"></span>
<span><?php esc_html_e('Weights 300-900 (Light to Black) will be downloaded. Variable fonts are used when available, which include all weights in a single efficient file.', 'maple-local-fonts'); ?></span>
</div>
</div>
<!-- Installed Fonts Section -->
<div class="mlf-section mlf-installed-section">
<div class="mlf-section-header">
<h2><?php esc_html_e('Installed Fonts', 'maple-local-fonts'); ?></h2>
<?php if (!empty($installed_fonts)) : ?>
<button type="button" class="button mlf-check-updates-btn" id="mlf-check-updates">
<?php esc_html_e('Check for Updates', 'maple-local-fonts'); ?>
</button>
<?php endif; ?>
</div>
<?php if (empty($installed_fonts)) : ?>
<p class="mlf-no-fonts"><?php esc_html_e('No fonts installed yet. Search and select a font above to get started.', 'maple-local-fonts'); ?></p>
<?php else : ?>
<div id="mlf-font-list" class="mlf-font-list">
<?php foreach ($installed_fonts as $font) : ?>
<div class="mlf-font-item" data-font-id="<?php echo esc_attr($font['id']); ?>" data-font-name="<?php echo esc_attr($font['name']); ?>" data-font-version="<?php echo esc_attr($font['version']); ?>">
<div class="mlf-font-info">
<div class="mlf-font-header">
<h3 class="mlf-font-name"><?php echo esc_html($font['name']); ?></h3>
<span class="mlf-update-badge" style="display: none;"><?php esc_html_e('Update available', 'maple-local-fonts'); ?></span>
</div>
<p class="mlf-font-variants">
<?php
$variant_strings = [];
foreach ($font['variants'] as $variant) {
$variant_strings[] = sprintf('%s %s', $variant['weight'], $variant['style']);
}
echo esc_html(implode(', ', $variant_strings));
?>
</p>
<p class="mlf-font-meta">
<?php if (!empty($font['version'])) : ?>
<span class="mlf-font-version"><?php echo esc_html($font['version']); ?></span>
<?php endif; ?>
<?php if (!empty($font['last_modified'])) : ?>
<span class="mlf-font-modified"><?php
/* translators: %s: date */
printf(esc_html__('Updated: %s', 'maple-local-fonts'), esc_html($font['last_modified']));
?></span>
<?php endif; ?>
</p>
</div>
<div class="mlf-font-actions">
<button type="button" class="button mlf-update-btn" data-font-id="<?php echo esc_attr($font['id']); ?>" data-font-name="<?php echo esc_attr($font['name']); ?>" style="display: none;">
<?php esc_html_e('Update', 'maple-local-fonts'); ?>
</button>
<button type="button" class="button mlf-delete-btn" data-font-id="<?php echo esc_attr($font['id']); ?>">
<?php esc_html_e('Delete', 'maple-local-fonts'); ?>
</button>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<!-- Settings Section -->
<div class="mlf-section mlf-settings-section">
<h2><?php esc_html_e('Settings', 'maple-local-fonts'); ?></h2>
<form method="post" action="">
<?php wp_nonce_field('mlf_save_settings', 'mlf_settings_nonce'); ?>
<div class="mlf-form-row">
<label class="mlf-checkbox-label mlf-setting-toggle">
<input type="checkbox" name="mlf_compatibility_mode" value="1" <?php checked(get_option('mlf_compatibility_mode', true)); ?> />
<span><strong><?php esc_html_e('Compatibility Mode', 'maple-local-fonts'); ?></strong></span>
</label>
<p class="description">
<?php esc_html_e('Enable this if fonts are not loading on mobile devices or behind reverse proxies (Caddy, Nginx, Cloudflare). This serves fonts through WordPress for guaranteed header compatibility.', 'maple-local-fonts'); ?>
</p>
</div>
<div class="mlf-form-row mlf-form-row-submit">
<button type="submit" name="mlf_save_settings" class="button button-secondary">
<?php esc_html_e('Save Settings', 'maple-local-fonts'); ?>
</button>
</div>
</form>
</div>
<!-- Info Section -->
<div class="mlf-section mlf-info-section">
<?php if (wp_is_block_theme()) : ?>
<div class="mlf-info-box">
<span class="dashicons dashicons-editor-textcolor"></span>
<div>
<p><strong><?php esc_html_e('How to use your fonts', 'maple-local-fonts'); ?></strong></p>
<p>
<?php
printf(
/* translators: %s: link to WordPress Editor */
esc_html__('Go to %s to apply fonts to your site.', 'maple-local-fonts'),
'<a href="' . esc_url(admin_url('site-editor.php?path=%2Fwp_global_styles')) . '">' . esc_html__('Appearance → Editor → Styles → Typography', 'maple-local-fonts') . '</a>'
);
?>
</p>
</div>
</div>
<?php else : ?>
<div class="mlf-info-box mlf-info-box-classic">
<span class="dashicons dashicons-editor-textcolor"></span>
<div class="mlf-classic-theme-info">
<p><strong><?php esc_html_e('Classic Theme Detected', 'maple-local-fonts'); ?></strong></p>
<p><?php esc_html_e('Your theme does not support the Full Site Editor. To use imported fonts, add custom CSS to your theme:', 'maple-local-fonts'); ?></p>
<pre class="mlf-code-example">body {
font-family: "Open Sans", sans-serif;
}
h1, h2, h3, h4, h5, h6 {
font-family: "Open Sans", sans-serif;
}</pre>
<p class="description">
<?php
printf(
/* translators: %s: link to Customizer */
esc_html__('Add this CSS in %s or your theme\'s style.css file.', 'maple-local-fonts'),
'<a href="' . esc_url(admin_url('customize.php')) . '">' . esc_html__('Appearance → Customize → Additional CSS', 'maple-local-fonts') . '</a>'
);
?>
</p>
</div>
</div>
<?php endif; ?>
</div>
</div>
</div>
<?php
}
}

View file

@ -0,0 +1,372 @@
<?php
/**
* AJAX Handler for Maple Local Fonts.
*
* @package Maple_Local_Fonts
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class MLF_Ajax_Handler
*
* Handles all AJAX requests for font download and deletion.
*/
class MLF_Ajax_Handler {
/**
* Rate limiter instance.
*
* @var MLF_Rate_Limiter
*/
private $rate_limiter;
/**
* Constructor.
*/
public function __construct() {
add_action('wp_ajax_mlf_download_font', [$this, 'handle_download']);
add_action('wp_ajax_mlf_delete_font', [$this, 'handle_delete']);
add_action('wp_ajax_mlf_check_updates', [$this, 'handle_check_updates']);
add_action('wp_ajax_mlf_update_font', [$this, 'handle_update_font']);
// NEVER add wp_ajax_nopriv_ - admin only functionality
// Initialize rate limiter: 10 requests per minute
$this->rate_limiter = new MLF_Rate_Limiter(10, 60);
}
/**
* Handle font download AJAX request.
*/
public function handle_download() {
// 1. NONCE CHECK
if (!check_ajax_referer('mlf_download_font', 'nonce', false)) {
wp_send_json_error(['message' => __('Security check failed.', 'maple-local-fonts')], 403);
}
// 2. CAPABILITY CHECK
$capability = function_exists('mlf_get_capability') ? mlf_get_capability() : 'edit_theme_options';
if (!current_user_can($capability)) {
wp_send_json_error(['message' => __('Unauthorized.', 'maple-local-fonts')], 403);
}
// 3. RATE LIMIT CHECK
if (!$this->rate_limiter->check_and_record('download')) {
wp_send_json_error([
'message' => __('Too many requests. Please wait a moment and try again.', 'maple-local-fonts'),
], 429);
}
// 4. INPUT VALIDATION
// Validate font name
$font_name = isset($_POST['font_name']) ? sanitize_text_field(wp_unslash($_POST['font_name'])) : '';
if (empty($font_name)) {
wp_send_json_error(['message' => __('Font name is required.', 'maple-local-fonts')]);
}
// Strict allowlist pattern - alphanumeric, spaces, hyphens only
if (!preg_match('/^[a-zA-Z0-9\s\-]+$/', $font_name)) {
wp_send_json_error(['message' => __('Invalid font name: only letters, numbers, spaces, and hyphens allowed.', 'maple-local-fonts')]);
}
if (strlen($font_name) > 100) {
wp_send_json_error(['message' => __('Font name is too long.', 'maple-local-fonts')]);
}
// Validate include_italic (boolean)
$include_italic = isset($_POST['include_italic']) && $_POST['include_italic'] === '1';
// Get version info (optional)
$font_version = isset($_POST['font_version']) ? sanitize_text_field(wp_unslash($_POST['font_version'])) : '';
$font_last_modified = isset($_POST['font_last_modified']) ? sanitize_text_field(wp_unslash($_POST['font_last_modified'])) : '';
// 5. PROCESS REQUEST
try {
$downloader = new MLF_Font_Downloader();
$download_result = $downloader->download($font_name, $include_italic);
if (is_wp_error($download_result)) {
wp_send_json_error(['message' => $this->get_user_error_message($download_result)]);
}
// Register font with WordPress
$registry = new MLF_Font_Registry();
$result = $registry->register_font(
$download_result['font_name'],
$download_result['font_slug'],
$download_result['files'],
$font_version,
$font_last_modified
);
if (is_wp_error($result)) {
wp_send_json_error(['message' => $this->get_user_error_message($result)]);
}
wp_send_json_success([
'message' => sprintf(
/* translators: %s: font name */
__('Successfully installed %s.', 'maple-local-fonts'),
esc_html($font_name)
),
'font_id' => $result,
]);
} catch (Exception $e) {
// Sanitize exception message before logging (defense in depth)
error_log('MLF Download Error: ' . sanitize_text_field($e->getMessage()));
wp_send_json_error(['message' => __('An unexpected error occurred.', 'maple-local-fonts')]);
}
}
/**
* Handle font deletion AJAX request.
*/
public function handle_delete() {
// 1. NONCE CHECK
if (!check_ajax_referer('mlf_delete_font', 'nonce', false)) {
wp_send_json_error(['message' => __('Security check failed.', 'maple-local-fonts')], 403);
}
// 2. CAPABILITY CHECK
$capability = function_exists('mlf_get_capability') ? mlf_get_capability() : 'edit_theme_options';
if (!current_user_can($capability)) {
wp_send_json_error(['message' => __('Unauthorized.', 'maple-local-fonts')], 403);
}
// 3. RATE LIMIT CHECK
if (!$this->rate_limiter->check_and_record('delete')) {
wp_send_json_error([
'message' => __('Too many requests. Please wait a moment and try again.', 'maple-local-fonts'),
], 429);
}
// 4. INPUT VALIDATION
$font_id = isset($_POST['font_id']) ? absint($_POST['font_id']) : 0;
if ($font_id < 1) {
wp_send_json_error(['message' => __('Invalid font ID.', 'maple-local-fonts')]);
}
// Verify font exists and is a font family
$font = get_post($font_id);
if (!$font || $font->post_type !== 'wp_font_family') {
wp_send_json_error(['message' => __('Font not found.', 'maple-local-fonts')]);
}
// Verify it's one we imported (not a theme font)
if (get_post_meta($font_id, '_mlf_imported', true) !== '1') {
wp_send_json_error(['message' => __('Cannot delete fonts not imported by this plugin.', 'maple-local-fonts')]);
}
// 5. PROCESS REQUEST
try {
$registry = new MLF_Font_Registry();
$result = $registry->delete_font($font_id);
if (is_wp_error($result)) {
wp_send_json_error(['message' => $this->get_user_error_message($result)]);
}
wp_send_json_success(['message' => __('Font deleted successfully.', 'maple-local-fonts')]);
} catch (Exception $e) {
// Sanitize exception message before logging (defense in depth)
error_log('MLF Delete Error: ' . sanitize_text_field($e->getMessage()));
wp_send_json_error(['message' => __('An unexpected error occurred.', 'maple-local-fonts')]);
}
}
/**
* Handle check for updates AJAX request.
*/
public function handle_check_updates() {
// 1. NONCE CHECK
if (!check_ajax_referer('mlf_check_updates', 'nonce', false)) {
wp_send_json_error(['message' => __('Security check failed.', 'maple-local-fonts')], 403);
}
// 2. CAPABILITY CHECK
$capability = function_exists('mlf_get_capability') ? mlf_get_capability() : 'edit_theme_options';
if (!current_user_can($capability)) {
wp_send_json_error(['message' => __('Unauthorized.', 'maple-local-fonts')], 403);
}
// 3. RATE LIMIT CHECK
if (!$this->rate_limiter->check_and_record('check_updates')) {
wp_send_json_error([
'message' => __('Too many requests. Please wait a moment and try again.', 'maple-local-fonts'),
], 429);
}
// 4. GET INSTALLED FONTS AND CHECK VERSIONS
try {
$registry = new MLF_Font_Registry();
$installed_fonts = $registry->get_imported_fonts();
if (empty($installed_fonts)) {
wp_send_json_success(['updates' => []]);
}
$font_search = new MLF_Font_Search();
$updates = [];
foreach ($installed_fonts as $font) {
$current_version = $font_search->get_font_version($font['name']);
if ($current_version && !empty($current_version['version'])) {
$installed_version = $font['version'] ?? '';
// Compare versions
if (!empty($installed_version) && $installed_version !== $current_version['version']) {
$updates[$font['id']] = [
'installed_version' => $installed_version,
'latest_version' => $current_version['version'],
'last_modified' => $current_version['lastModified'],
];
} elseif (empty($installed_version)) {
// No version stored, consider it needs update
$updates[$font['id']] = [
'installed_version' => '',
'latest_version' => $current_version['version'],
'last_modified' => $current_version['lastModified'],
];
}
}
}
wp_send_json_success(['updates' => $updates]);
} catch (Exception $e) {
error_log('MLF Check Updates Error: ' . sanitize_text_field($e->getMessage()));
wp_send_json_error(['message' => __('An unexpected error occurred.', 'maple-local-fonts')]);
}
}
/**
* Handle font update (re-download) AJAX request.
*/
public function handle_update_font() {
// 1. NONCE CHECK
if (!check_ajax_referer('mlf_update_font', 'nonce', false)) {
wp_send_json_error(['message' => __('Security check failed.', 'maple-local-fonts')], 403);
}
// 2. CAPABILITY CHECK
$capability = function_exists('mlf_get_capability') ? mlf_get_capability() : 'edit_theme_options';
if (!current_user_can($capability)) {
wp_send_json_error(['message' => __('Unauthorized.', 'maple-local-fonts')], 403);
}
// 3. RATE LIMIT CHECK
if (!$this->rate_limiter->check_and_record('update')) {
wp_send_json_error([
'message' => __('Too many requests. Please wait a moment and try again.', 'maple-local-fonts'),
], 429);
}
// 4. INPUT VALIDATION
$font_id = isset($_POST['font_id']) ? absint($_POST['font_id']) : 0;
if ($font_id < 1) {
wp_send_json_error(['message' => __('Invalid font ID.', 'maple-local-fonts')]);
}
// Verify font exists and is one we imported
$font = get_post($font_id);
if (!$font || $font->post_type !== 'wp_font_family') {
wp_send_json_error(['message' => __('Font not found.', 'maple-local-fonts')]);
}
if (get_post_meta($font_id, '_mlf_imported', true) !== '1') {
wp_send_json_error(['message' => __('Cannot update fonts not imported by this plugin.', 'maple-local-fonts')]);
}
// Get font name from post content
$settings = json_decode($font->post_content, true);
$font_name = $settings['name'] ?? $font->post_title;
// 5. DELETE OLD FONT AND RE-DOWNLOAD
try {
$registry = new MLF_Font_Registry();
// Delete old font
$delete_result = $registry->delete_font($font_id);
if (is_wp_error($delete_result)) {
wp_send_json_error(['message' => $this->get_user_error_message($delete_result)]);
}
// Get latest version info
$font_search = new MLF_Font_Search();
$version_info = $font_search->get_font_version($font_name);
$font_version = $version_info['version'] ?? '';
$font_last_modified = $version_info['lastModified'] ?? '';
// Re-download font (include italic by default)
$downloader = new MLF_Font_Downloader();
$download_result = $downloader->download($font_name, true);
if (is_wp_error($download_result)) {
wp_send_json_error(['message' => $this->get_user_error_message($download_result)]);
}
// Register font with new version info
$result = $registry->register_font(
$download_result['font_name'],
$download_result['font_slug'],
$download_result['files'],
$font_version,
$font_last_modified
);
if (is_wp_error($result)) {
wp_send_json_error(['message' => $this->get_user_error_message($result)]);
}
wp_send_json_success([
'message' => sprintf(
/* translators: %s: font name */
__('Successfully updated %s.', 'maple-local-fonts'),
esc_html($font_name)
),
'font_id' => $result,
'version' => $font_version,
]);
} catch (Exception $e) {
error_log('MLF Update Font Error: ' . sanitize_text_field($e->getMessage()));
wp_send_json_error(['message' => __('An unexpected error occurred.', 'maple-local-fonts')]);
}
}
/**
* Convert internal error codes to user-friendly messages.
*
* @param WP_Error $error The error object.
* @return string User-friendly message.
*/
private function get_user_error_message($error) {
$code = $error->get_error_code();
$messages = [
'font_not_found' => __('Font not found on Google Fonts. Please check the spelling and try again.', 'maple-local-fonts'),
'font_exists' => __('This font is already installed.', 'maple-local-fonts'),
'request_failed' => __('Could not connect to Google Fonts. Please check your internet connection and try again.', 'maple-local-fonts'),
'http_error' => __('Google Fonts returned an error. Please try again later.', 'maple-local-fonts'),
'parse_failed' => __('Could not process the font data. The font may not be available.', 'maple-local-fonts'),
'download_failed' => __('Could not download the font files. Please try again.', 'maple-local-fonts'),
'write_failed' => __('Could not save font files. Please check that wp-content/fonts is writable.', 'maple-local-fonts'),
'mkdir_failed' => __('Could not create fonts directory. Please check file permissions.', 'maple-local-fonts'),
'invalid_path' => __('Invalid file path.', 'maple-local-fonts'),
'invalid_url' => __('Invalid font URL.', 'maple-local-fonts'),
'invalid_name' => __('Invalid font name.', 'maple-local-fonts'),
'not_found' => __('Font not found.', 'maple-local-fonts'),
'not_ours' => __('Cannot delete fonts not imported by this plugin.', 'maple-local-fonts'),
'response_too_large' => __('The font data is too large to process.', 'maple-local-fonts'),
'file_too_large' => __('The font file is too large to download.', 'maple-local-fonts'),
'no_variable' => __('Variable font not available, trying static fonts...', 'maple-local-fonts'),
'no_fonts' => __('No font files found. The font may not support the requested styles.', 'maple-local-fonts'),
];
return $messages[$code] ?? __('An unexpected error occurred. Please try again.', 'maple-local-fonts');
}
}

View file

@ -0,0 +1,709 @@
<?php
/**
* Font Downloader for Maple Local Fonts.
*
* @package Maple_Local_Fonts
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class MLF_Font_Downloader
*
* Handles downloading fonts from Google Fonts CSS2 API.
* Attempts variable fonts first, falls back to static fonts.
*/
class MLF_Font_Downloader {
/**
* User agent to send with requests (needed to get WOFF2 format).
*
* @var string
*/
private $user_agent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
/**
* Common font weights to request.
*
* Most fonts support at least 300-700. We also include 800-900 for fonts
* that support Black/Heavy weights.
*
* @var array
*/
private $all_weights = [300, 400, 500, 600, 700, 800, 900];
/**
* Download a font from Google Fonts.
*
* Attempts variable font first, falls back to static if not available.
*
* @param string $font_name Font family name.
* @param bool $include_italic Whether to include italic styles.
* @return array|WP_Error Download result or error.
*/
public function download($font_name, $include_italic = true) {
// Validate font name
if (empty($font_name) || !preg_match('/^[a-zA-Z0-9\s\-]+$/', $font_name)) {
return new WP_Error('invalid_name', 'Invalid font name');
}
if (strlen($font_name) > 100) {
return new WP_Error('invalid_name', 'Font name too long');
}
// Try variable font first
$result = $this->try_variable_font($font_name, $include_italic);
if (!is_wp_error($result)) {
return $result;
}
// Fall back to static fonts
return $this->download_static_fonts($font_name, $include_italic);
}
/**
* Attempt to download variable font.
*
* @param string $font_name Font family name.
* @param bool $include_italic Whether to include italic styles.
* @return array|WP_Error Download result or error.
*/
private function try_variable_font($font_name, $include_italic) {
$font_slug = sanitize_title($font_name);
$downloaded = [];
// Try to fetch variable font CSS (roman/upright)
$css = $this->fetch_variable_css($font_name, false);
if (is_wp_error($css)) {
return $css;
}
// Parse and download roman variable font
$roman_faces = $this->parse_variable_css($css, $font_name);
if (is_wp_error($roman_faces) || empty($roman_faces)) {
return new WP_Error('no_variable', 'Variable font not available');
}
// Download roman variable font file(s)
foreach ($roman_faces as $face) {
$result = $this->download_single_file(
$face['url'],
$font_slug,
$face['weight'],
'normal',
true // is_variable
);
if (!is_wp_error($result)) {
$downloaded[] = [
'path' => $result,
'weight' => $face['weight'],
'style' => 'normal',
'is_variable' => true,
];
}
}
// Try italic variable font if requested
if ($include_italic) {
$italic_css = $this->fetch_variable_css($font_name, true);
if (!is_wp_error($italic_css)) {
$italic_faces = $this->parse_variable_css($italic_css, $font_name);
if (!is_wp_error($italic_faces) && !empty($italic_faces)) {
foreach ($italic_faces as $face) {
$result = $this->download_single_file(
$face['url'],
$font_slug,
$face['weight'],
'italic',
true
);
if (!is_wp_error($result)) {
$downloaded[] = [
'path' => $result,
'weight' => $face['weight'],
'style' => 'italic',
'is_variable' => true,
];
}
}
}
}
}
if (empty($downloaded)) {
return new WP_Error('download_failed', 'Could not download variable font files');
}
return [
'font_name' => $font_name,
'font_slug' => $font_slug,
'files' => $downloaded,
'is_variable' => true,
];
}
/**
* Download static fonts (fallback when variable not available).
*
* Tries different weight combinations since fonts support varying weights.
*
* @param string $font_name Font family name.
* @param bool $include_italic Whether to include italic styles.
* @return array|WP_Error Download result or error.
*/
private function download_static_fonts($font_name, $include_italic) {
$styles = $include_italic ? ['normal', 'italic'] : ['normal'];
// Try different weight combinations - fonts support varying weights
$weight_sets = [
[300, 400, 500, 600, 700, 800, 900], // Full range
[300, 400, 500, 600, 700, 800], // Without 900
[300, 400, 500, 600, 700], // Common range
[400, 500, 600, 700], // Without light
[400, 700], // Just regular and bold
[400], // Single weight (display fonts)
];
$css = null;
foreach ($weight_sets as $weights) {
$css = $this->fetch_static_css($font_name, $weights, $styles);
if (!is_wp_error($css)) {
break;
}
// If error is not "font not found", stop trying
if ($css->get_error_code() !== 'font_not_found') {
return $css;
}
}
if (is_wp_error($css)) {
return $css;
}
// Parse CSS to get font face data
$font_faces = $this->parse_static_css($css, $font_name);
if (is_wp_error($font_faces)) {
return $font_faces;
}
// Download each font file
$font_slug = sanitize_title($font_name);
$downloaded = $this->download_files($font_faces, $font_slug);
if (is_wp_error($downloaded)) {
return $downloaded;
}
return [
'font_name' => $font_name,
'font_slug' => $font_slug,
'files' => $downloaded,
'is_variable' => false,
];
}
/**
* Fetch variable font CSS from Google Fonts API.
*
* Tries progressively smaller weight ranges until one works,
* since different fonts support different weight ranges.
*
* @param string $font_name Font family name.
* @param bool $italic Whether to fetch italic variant.
* @return string|WP_Error CSS content or error.
*/
private function fetch_variable_css($font_name, $italic = false) {
$family = str_replace(' ', '+', $font_name);
// Try different weight ranges - fonts support varying ranges
// Also try single weight 400 for fonts that only have one weight
$weight_ranges = ['300..900', '300..800', '300..700', '400..700', '400'];
foreach ($weight_ranges as $range) {
if ($italic) {
$url = "https://fonts.googleapis.com/css2?family={$family}:ital,wght@1,{$range}&display=swap";
} else {
$url = "https://fonts.googleapis.com/css2?family={$family}:wght@{$range}&display=swap";
}
$result = $this->fetch_css($url);
// If successful or error is not "font not found", return
if (!is_wp_error($result) || $result->get_error_code() !== 'font_not_found') {
return $result;
}
}
// All ranges failed
return new WP_Error('no_variable', 'Variable font not available');
}
/**
* Fetch static font CSS from Google Fonts API.
*
* @param string $font_name Font family name.
* @param array $weights Weights to fetch.
* @param array $styles Styles to fetch.
* @return string|WP_Error CSS content or error.
*/
private function fetch_static_css($font_name, $weights, $styles) {
$url = $this->build_static_url($font_name, $weights, $styles);
return $this->fetch_css($url);
}
/**
* Fetch CSS from a Google Fonts URL.
*
* @param string $url Google Fonts CSS URL.
* @return string|WP_Error CSS content or error.
*/
private function fetch_css($url) {
// Validate URL
if (!$this->is_valid_google_fonts_url($url)) {
return new WP_Error('invalid_url', 'Invalid Google Fonts URL');
}
$response = wp_remote_get($url, [
'timeout' => MLF_REQUEST_TIMEOUT,
'sslverify' => true,
'user-agent' => $this->user_agent,
]);
if (is_wp_error($response)) {
return new WP_Error('request_failed', $response->get_error_message());
}
$status = wp_remote_retrieve_response_code($response);
if ($status === 400) {
return new WP_Error('font_not_found', 'Font not found');
}
if ($status !== 200) {
return new WP_Error('http_error', 'HTTP ' . $status);
}
$css = wp_remote_retrieve_body($response);
if (empty($css)) {
return new WP_Error('empty_response', 'Empty response from Google Fonts');
}
// Check CSS response size
$max_size = defined('MLF_MAX_CSS_SIZE') ? MLF_MAX_CSS_SIZE : 512 * 1024;
if (strlen($css) > $max_size) {
return new WP_Error('response_too_large', 'CSS response exceeds maximum size limit');
}
// Verify we got WOFF2
if (strpos($css, '.woff2)') === false) {
return new WP_Error('wrong_format', 'Did not receive WOFF2 format');
}
return $css;
}
/**
* Build static font URL.
*
* @param string $font_name Font family name.
* @param array $weights Array of weights.
* @param array $styles Array of styles.
* @return string Google Fonts CSS2 URL.
*/
private function build_static_url($font_name, $weights, $styles) {
$family = str_replace(' ', '+', $font_name);
sort($weights);
$has_italic = in_array('italic', $styles, true);
$has_normal = in_array('normal', $styles, true);
if ($has_normal && !$has_italic) {
$wght = implode(';', $weights);
return "https://fonts.googleapis.com/css2?family={$family}:wght@{$wght}&display=swap";
}
$variations = [];
foreach ($weights as $weight) {
if ($has_normal) {
$variations[] = "0,{$weight}";
}
if ($has_italic) {
$variations[] = "1,{$weight}";
}
}
$variation_string = implode(';', $variations);
return "https://fonts.googleapis.com/css2?family={$family}:ital,wght@{$variation_string}&display=swap";
}
/**
* Parse variable font CSS.
*
* @param string $css CSS content.
* @param string $font_name Expected font family name.
* @return array|WP_Error Array of font face data or error.
*/
private function parse_variable_css($css, $font_name) {
$font_faces = [];
// Match all @font-face blocks
$pattern = '/@font-face\s*\{([^}]+)\}/s';
if (!preg_match_all($pattern, $css, $matches)) {
return new WP_Error('parse_failed', 'No @font-face rules found');
}
foreach ($matches[1] as $block) {
$face_data = $this->parse_font_face_block($block, true);
if (is_wp_error($face_data)) {
continue;
}
// Verify font family matches
if (strcasecmp($face_data['family'], $font_name) !== 0) {
continue;
}
// For variable fonts, prefer latin subset
$key = $face_data['weight'] . '-' . $face_data['style'];
$is_latin = $this->is_latin_subset($face_data['unicode_range']);
if (!isset($font_faces[$key]) || $is_latin) {
$font_faces[$key] = $face_data;
}
}
return array_values($font_faces);
}
/**
* Parse static font CSS.
*
* @param string $css CSS content.
* @param string $font_name Expected font family name.
* @return array|WP_Error Array of font face data or error.
*/
private function parse_static_css($css, $font_name) {
$font_faces = [];
$pattern = '/@font-face\s*\{([^}]+)\}/s';
if (!preg_match_all($pattern, $css, $matches)) {
return new WP_Error('parse_failed', 'No @font-face rules found');
}
foreach ($matches[1] as $block) {
$face_data = $this->parse_font_face_block($block, false);
if (is_wp_error($face_data)) {
continue;
}
if (strcasecmp($face_data['family'], $font_name) !== 0) {
continue;
}
$key = $face_data['weight'] . '-' . $face_data['style'];
$is_latin = $this->is_latin_subset($face_data['unicode_range']);
if (!isset($font_faces[$key]) || $is_latin) {
$font_faces[$key] = $face_data;
}
}
if (empty($font_faces)) {
return new WP_Error('no_fonts', 'No valid font faces found');
}
// Limit number of font faces
$max_faces = defined('MLF_MAX_FONT_FACES') ? MLF_MAX_FONT_FACES : 20;
$result = array_values($font_faces);
if (count($result) > $max_faces) {
$result = array_slice($result, 0, $max_faces);
}
return $result;
}
/**
* Parse a single @font-face block.
*
* @param string $block Content inside @font-face { }.
* @param bool $is_variable Whether this is a variable font.
* @return array|WP_Error Parsed data or error.
*/
private function parse_font_face_block($block, $is_variable = false) {
$data = [];
// Extract font-family
if (preg_match('/font-family:\s*[\'"]?([^;\'"]+)[\'"]?;/i', $block, $m)) {
$data['family'] = trim($m[1]);
} else {
return new WP_Error('missing_family', 'Missing font-family');
}
// Extract font-weight (can be single value or range for variable)
if (preg_match('/font-weight:\s*(\d+(?:\s+\d+)?);/i', $block, $m)) {
$data['weight'] = trim($m[1]);
} else {
return new WP_Error('missing_weight', 'Missing font-weight');
}
// Extract font-style
if (preg_match('/font-style:\s*(\w+);/i', $block, $m)) {
$data['style'] = $m[1];
} else {
$data['style'] = 'normal';
}
// Extract src URL - MUST be fonts.gstatic.com
if (preg_match('/src:\s*url\((https:\/\/fonts\.gstatic\.com\/[^)]+\.woff2)\)/i', $block, $m)) {
$data['url'] = $m[1];
} else {
return new WP_Error('missing_src', 'Missing or invalid src URL');
}
// Extract unicode-range
if (preg_match('/unicode-range:\s*([^;]+);/i', $block, $m)) {
$data['unicode_range'] = trim($m[1]);
} else {
$data['unicode_range'] = '';
}
return $data;
}
/**
* Check if unicode-range indicates latin subset.
*
* @param string $range Unicode range string.
* @return bool True if appears to be latin subset.
*/
private function is_latin_subset($range) {
if (empty($range)) {
return true;
}
if (preg_match('/U\+0000/', $range) && !preg_match('/^U\+0100/', $range)) {
return true;
}
return false;
}
/**
* Download all font files.
*
* @param array $font_faces Array of font face data.
* @param string $font_slug Font slug for filename.
* @return array|WP_Error Array of downloaded file info or error.
*/
private function download_files($font_faces, $font_slug) {
$downloaded = [];
$errors = [];
foreach ($font_faces as $face) {
$result = $this->download_single_file(
$face['url'],
$font_slug,
$face['weight'],
$face['style'],
false
);
if (is_wp_error($result)) {
$errors[] = $result->get_error_message();
continue;
}
$downloaded[] = [
'path' => $result,
'weight' => $face['weight'],
'style' => $face['style'],
'is_variable' => false,
];
}
if (empty($downloaded)) {
return new WP_Error(
'download_failed',
'Could not download any font files: ' . implode(', ', $errors)
);
}
return $downloaded;
}
/**
* Download a single WOFF2 file.
*
* @param string $url Google Fonts static URL.
* @param string $font_slug Font slug for filename.
* @param string $weight Font weight (single or range).
* @param string $style Font style.
* @param bool $is_variable Whether this is a variable font.
* @return string|WP_Error Local file path or error.
*/
private function download_single_file($url, $font_slug, $weight, $style, $is_variable = false) {
if (!$this->is_valid_google_fonts_url($url)) {
return new WP_Error('invalid_url', 'URL is not from Google Fonts');
}
// Build filename
$weight_slug = str_replace(' ', '-', $weight);
if ($is_variable) {
$filename = sprintf('%s_%s_variable.woff2', $font_slug, $style);
} else {
$filename = sprintf('%s_%s_%s.woff2', $font_slug, $style, $weight_slug);
}
$filename = sanitize_file_name($filename);
$filename = $this->sanitize_font_filename($filename);
if ($filename === false) {
return new WP_Error('invalid_filename', 'Invalid filename');
}
$font_dir = wp_get_font_dir();
$destination = trailingslashit($font_dir['path']) . $filename;
if (!$this->validate_font_path($destination)) {
return new WP_Error('invalid_path', 'Invalid destination path');
}
if (!wp_mkdir_p($font_dir['path'])) {
return new WP_Error('mkdir_failed', 'Could not create fonts directory');
}
$response = wp_remote_get($url, [
'timeout' => MLF_REQUEST_TIMEOUT,
'sslverify' => true,
'user-agent' => $this->user_agent,
]);
if (is_wp_error($response)) {
return new WP_Error('download_failed', 'Failed to download: ' . $response->get_error_message());
}
$status = wp_remote_retrieve_response_code($response);
if ($status !== 200) {
return new WP_Error('http_error', 'Download returned HTTP ' . $status);
}
$content = wp_remote_retrieve_body($response);
if (empty($content)) {
return new WP_Error('empty_file', 'Downloaded file is empty');
}
$max_size = defined('MLF_MAX_FONT_FILE_SIZE') ? MLF_MAX_FONT_FILE_SIZE : 5 * 1024 * 1024;
if (strlen($content) > $max_size) {
return new WP_Error('file_too_large', 'Font file exceeds maximum size');
}
// Verify WOFF2 magic bytes
if (substr($content, 0, 4) !== 'wOF2') {
return new WP_Error('invalid_format', 'Downloaded file is not valid WOFF2');
}
global $wp_filesystem;
if (empty($wp_filesystem)) {
require_once ABSPATH . 'wp-admin/includes/file.php';
WP_Filesystem();
}
if (!$wp_filesystem->put_contents($destination, $content, FS_CHMOD_FILE)) {
return new WP_Error('write_failed', 'Could not write font file');
}
return $destination;
}
/**
* Validate Google Fonts URL.
*
* @param string $url URL to validate.
* @return bool True if valid.
*/
private function is_valid_google_fonts_url($url) {
$parsed = wp_parse_url($url);
if (!$parsed || !isset($parsed['host'])) {
return false;
}
$allowed_hosts = [
'fonts.googleapis.com',
'fonts.gstatic.com',
];
return in_array($parsed['host'], $allowed_hosts, true);
}
/**
* Sanitize font filename.
*
* @param string $filename Filename to sanitize.
* @return string|false Sanitized filename or false.
*/
private function sanitize_font_filename($filename) {
$filename = sanitize_file_name($filename);
if (pathinfo($filename, PATHINFO_EXTENSION) !== 'woff2') {
return false;
}
if ($filename !== basename($filename)) {
return false;
}
if (strlen($filename) > 200) {
return false;
}
return $filename;
}
/**
* Validate font path is within fonts directory.
*
* @param string $path Path to validate.
* @return bool True if valid.
*/
private function validate_font_path($path) {
$font_dir = wp_get_font_dir();
$fonts_path = wp_normalize_path(trailingslashit($font_dir['path']));
$real_path = realpath($path);
if ($real_path === false) {
$dir = dirname($path);
$real_dir = realpath($dir);
if ($real_dir === false) {
$parent_dir = dirname($dir);
$real_parent = realpath($parent_dir);
if ($real_parent === false) {
return false;
}
$real_path = wp_normalize_path($real_parent . '/' . basename($dir) . '/' . basename($path));
} else {
$real_path = wp_normalize_path($real_dir . '/' . basename($path));
}
} else {
$real_path = wp_normalize_path($real_path);
}
return strpos($real_path, $fonts_path) === 0;
}
}

View file

@ -0,0 +1,502 @@
<?php
/**
* Font Registry for Maple Local Fonts.
*
* @package Maple_Local_Fonts
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class MLF_Font_Registry
*
* Handles registering fonts with WordPress Font Library API.
*/
class MLF_Font_Registry {
/**
* Register a font family with WordPress Font Library.
*
* @param string $font_name Display name (e.g., "Open Sans").
* @param string $font_slug Slug (e.g., "open-sans").
* @param array $files Array of downloaded file data.
* @param string $font_version Google Fonts version (e.g., "v35").
* @param string $last_modified Google Fonts last modified date.
* @return int|WP_Error Font family post ID or error.
*/
public function register_font($font_name, $font_slug, $files, $font_version = '', $last_modified = '') {
// Check if font already exists
$existing = get_posts([
'post_type' => 'wp_font_family',
'name' => $font_slug,
'posts_per_page' => 1,
'post_status' => 'any',
]);
if (!empty($existing)) {
return new WP_Error('font_exists', 'Font family already installed');
}
// Get font directory info
$font_dir = wp_get_font_dir();
// Build font face array for WordPress
$font_faces = [];
foreach ($files as $file) {
$filename = basename($file['path']);
// Determine if this is a variable font (weight is a range like "100 900")
$is_variable = isset($file['is_variable']) && $file['is_variable'];
$weight = $file['weight'];
$face_data = [
'fontFamily' => $font_name,
'fontStyle' => $file['style'],
'fontWeight' => $weight,
'src' => 'file:./' . $filename,
];
$font_faces[] = $face_data;
}
// Determine font category for fallback
// Default to sans-serif, but could be enhanced to detect from Google Fonts metadata
$fallback = 'sans-serif';
// Build font family settings (this is what Gutenberg reads)
$font_family_settings = [
'name' => $font_name,
'slug' => $font_slug,
'fontFamily' => "'{$font_name}', {$fallback}",
'fontFace' => $font_faces,
];
// Create font family post
$family_id = wp_insert_post([
'post_type' => 'wp_font_family',
'post_title' => $font_name,
'post_name' => $font_slug,
'post_status' => 'publish',
'post_content' => wp_json_encode($font_family_settings),
], true);
if (is_wp_error($family_id)) {
return $family_id;
}
// Mark as imported by our plugin (for identification)
update_post_meta($family_id, '_mlf_imported', '1');
update_post_meta($family_id, '_mlf_import_date', current_time('mysql'));
// Store version info for update checking
if (!empty($font_version)) {
update_post_meta($family_id, '_mlf_font_version', $font_version);
}
if (!empty($last_modified)) {
update_post_meta($family_id, '_mlf_font_last_modified', $last_modified);
}
// Create font face posts (children) - WordPress also reads these
foreach ($files as $file) {
$filename = basename($file['path']);
$weight = $file['weight'];
$face_settings = [
'fontFamily' => $font_name,
'fontWeight' => $weight,
'fontStyle' => $file['style'],
'src' => 'file:./' . $filename,
];
wp_insert_post([
'post_type' => 'wp_font_face',
'post_parent' => $family_id,
'post_title' => sprintf('%s %s %s', $font_name, $weight, $file['style']),
'post_name' => sanitize_title(sprintf('%s-%s-%s', $font_slug, $weight, $file['style'])),
'post_status' => 'publish',
'post_content' => wp_json_encode($face_settings),
]);
}
// Clear all font-related caches
$this->clear_font_caches();
return $family_id;
}
/**
* Delete a font family and its files.
*
* @param int $family_id Font family post ID.
* @return bool|WP_Error True on success, error on failure.
*/
public function delete_font($family_id) {
$family = get_post($family_id);
if (!$family || $family->post_type !== 'wp_font_family') {
return new WP_Error('not_found', 'Font family not found');
}
// Verify it's one we imported
if (get_post_meta($family_id, '_mlf_imported', true) !== '1') {
return new WP_Error('not_ours', 'Cannot delete fonts not imported by this plugin');
}
// Get font info before deletion for cleanup
$settings = json_decode($family->post_content, true);
$font_slug = $settings['slug'] ?? $family->post_name;
// Get font faces (children)
$faces = get_children([
'post_parent' => $family_id,
'post_type' => 'wp_font_face',
]);
$font_dir = wp_get_font_dir();
// Delete font face files and posts
foreach ($faces as $face) {
$face_settings = json_decode($face->post_content, true);
if (isset($face_settings['src'])) {
// Convert file:. URL to path
$src = $face_settings['src'];
$src = str_replace('file:./', '', $src);
$file_path = trailingslashit($font_dir['path']) . basename($src);
// Validate path and extension before deletion
if ($this->validate_font_path($file_path)
&& pathinfo($file_path, PATHINFO_EXTENSION) === 'woff2'
&& file_exists($file_path)) {
wp_delete_file($file_path);
}
}
wp_delete_post($face->ID, true);
}
// Delete family post
wp_delete_post($family_id, true);
// Clean up global styles references to this font
$this->remove_font_from_global_styles($font_slug);
// Clear all font-related caches
$this->clear_font_caches();
return true;
}
/**
* Remove references to a deleted font from global styles.
*
* This prevents block errors when a font is deleted but still referenced.
*
* @param string $font_slug The font slug to remove.
*/
private function remove_font_from_global_styles($font_slug) {
// Get the global styles post for the current theme
$global_styles = get_posts([
'post_type' => 'wp_global_styles',
'posts_per_page' => 1,
'post_status' => 'publish',
'tax_query' => [
[
'taxonomy' => 'wp_theme',
'field' => 'name',
'terms' => get_stylesheet(),
],
],
]);
if (empty($global_styles)) {
return;
}
$global_styles_post = $global_styles[0];
$content = json_decode($global_styles_post->post_content, true);
if (empty($content)) {
return;
}
$modified = false;
// Helper function to recursively remove font references
$remove_font_refs = function (&$data) use ($font_slug, &$modified, &$remove_font_refs) {
if (!is_array($data)) {
return;
}
foreach ($data as $key => &$value) {
// Check for fontFamily references using the slug pattern
if ($key === 'fontFamily' && is_string($value)) {
// WordPress uses format like: var(--wp--preset--font-family--font-slug)
if (strpos($value, '--font-family--' . $font_slug) !== false) {
unset($data[$key]);
$modified = true;
}
}
// Check for typography.fontFamily in element/block styles
if (is_array($value)) {
$remove_font_refs($value);
}
}
};
$remove_font_refs($content);
if ($modified) {
wp_update_post([
'ID' => $global_styles_post->ID,
'post_content' => wp_json_encode($content),
]);
}
}
/**
* Clear all font-related caches.
*/
private function clear_font_caches() {
// Clear WordPress Font Library cache
delete_transient('wp_font_library_fonts');
// Clear our plugin's cache
delete_transient('mlf_imported_fonts_list');
// Clear global settings cache (used by Gutenberg)
wp_cache_delete('wp_get_global_settings', 'theme_json');
wp_cache_delete('wp_get_global_stylesheet', 'theme_json');
// Clear theme.json related caches
delete_transient('global_styles');
delete_transient('global_styles_' . get_stylesheet());
// Clear WP_Theme_JSON caches
if (class_exists('WP_Theme_JSON_Resolver')) {
WP_Theme_JSON_Resolver::clean_cached_data();
}
// Clear object cache for post queries
wp_cache_flush_group('posts');
// Clear any theme mods cache
delete_transient('theme_mods_' . get_stylesheet());
// Trigger action for other plugins/themes that might cache fonts
do_action('mlf_fonts_cache_cleared');
}
/**
* Get all fonts imported by this plugin.
*
* Uses optimized queries to avoid N+1 pattern.
*
* @return array Array of font data.
*/
public function get_imported_fonts() {
// Check transient cache first
$cached = get_transient('mlf_imported_fonts_list');
if ($cached !== false) {
return $cached;
}
$fonts = get_posts([
'post_type' => 'wp_font_family',
'posts_per_page' => 100,
'post_status' => 'publish',
'meta_key' => '_mlf_imported',
'meta_value' => '1',
]);
if (empty($fonts)) {
set_transient('mlf_imported_fonts_list', [], 5 * MINUTE_IN_SECONDS);
return [];
}
// Collect all font IDs for batch query
$font_ids = wp_list_pluck($fonts, 'ID');
// Single query to get ALL font faces for ALL fonts (fixes N+1)
$all_faces = get_posts([
'post_type' => 'wp_font_face',
'posts_per_page' => 1000,
'post_status' => 'publish',
'post_parent__in' => $font_ids,
]);
// Group faces by parent font ID
$faces_by_font = [];
foreach ($all_faces as $face) {
$parent_id = $face->post_parent;
if (!isset($faces_by_font[$parent_id])) {
$faces_by_font[$parent_id] = [];
}
$faces_by_font[$parent_id][] = $face;
}
// Batch get all metadata
$import_dates = [];
$font_versions = [];
$font_last_modified = [];
foreach ($font_ids as $font_id) {
$import_dates[$font_id] = get_post_meta($font_id, '_mlf_import_date', true);
$font_versions[$font_id] = get_post_meta($font_id, '_mlf_font_version', true);
$font_last_modified[$font_id] = get_post_meta($font_id, '_mlf_font_last_modified', true);
}
$result = [];
foreach ($fonts as $font) {
$settings = json_decode($font->post_content, true);
// Get variants from pre-fetched data
$faces = $faces_by_font[$font->ID] ?? [];
$variants = [];
foreach ($faces as $face) {
$face_settings = json_decode($face->post_content, true);
$variants[] = [
'weight' => $face_settings['fontWeight'] ?? '400',
'style' => $face_settings['fontStyle'] ?? 'normal',
];
}
// Sort variants by weight then style
usort($variants, function($a, $b) {
// Handle weight ranges (variable fonts)
$a_weight = is_numeric($a['weight']) ? intval($a['weight']) : intval(explode(' ', $a['weight'])[0]);
$b_weight = is_numeric($b['weight']) ? intval($b['weight']) : intval(explode(' ', $b['weight'])[0]);
$weight_cmp = $a_weight - $b_weight;
if ($weight_cmp !== 0) {
return $weight_cmp;
}
return strcmp($a['style'], $b['style']);
});
$result[] = [
'id' => $font->ID,
'name' => $settings['name'] ?? $font->post_title,
'slug' => $settings['slug'] ?? $font->post_name,
'variants' => $variants,
'import_date' => $import_dates[$font->ID] ?? '',
'version' => $font_versions[$font->ID] ?? '',
'last_modified' => $font_last_modified[$font->ID] ?? '',
];
}
// Cache for 5 minutes
set_transient('mlf_imported_fonts_list', $result, 5 * MINUTE_IN_SECONDS);
return $result;
}
/**
* Get all fonts imported by this plugin, including actual filenames.
*
* This method includes the actual filename stored in the database,
* which is needed to correctly reference variable vs static font files.
*
* @return array Array of font data with filenames.
*/
public function get_imported_fonts_with_src() {
$fonts = get_posts([
'post_type' => 'wp_font_family',
'posts_per_page' => 100,
'post_status' => 'publish',
'meta_key' => '_mlf_imported',
'meta_value' => '1',
]);
if (empty($fonts)) {
return [];
}
// Collect all font IDs for batch query
$font_ids = wp_list_pluck($fonts, 'ID');
// Single query to get ALL font faces for ALL fonts
$all_faces = get_posts([
'post_type' => 'wp_font_face',
'posts_per_page' => 1000,
'post_status' => 'publish',
'post_parent__in' => $font_ids,
]);
// Group faces by parent font ID
$faces_by_font = [];
foreach ($all_faces as $face) {
$parent_id = $face->post_parent;
if (!isset($faces_by_font[$parent_id])) {
$faces_by_font[$parent_id] = [];
}
$faces_by_font[$parent_id][] = $face;
}
$result = [];
foreach ($fonts as $font) {
$settings = json_decode($font->post_content, true);
// Get variants from pre-fetched data
$faces = $faces_by_font[$font->ID] ?? [];
$variants = [];
foreach ($faces as $face) {
$face_settings = json_decode($face->post_content, true);
// Extract filename from src (format: "file:./filename.woff2")
$src = $face_settings['src'] ?? '';
$filename = str_replace('file:./', '', $src);
$variants[] = [
'weight' => $face_settings['fontWeight'] ?? '400',
'style' => $face_settings['fontStyle'] ?? 'normal',
'filename' => $filename,
];
}
$result[] = [
'id' => $font->ID,
'name' => $settings['name'] ?? $font->post_title,
'slug' => $settings['slug'] ?? $font->post_name,
'variants' => $variants,
];
}
return $result;
}
/**
* Validate that a path is within the WordPress fonts directory.
*
* @param string $path Full path to validate.
* @return bool True if path is safe, false otherwise.
*/
private function validate_font_path($path) {
$font_dir = wp_get_font_dir();
$fonts_path = wp_normalize_path(trailingslashit($font_dir['path']));
// Resolve to real path (handles ../ etc)
$real_path = realpath($path);
// If realpath fails, file doesn't exist yet - validate the directory
if ($real_path === false) {
$dir = dirname($path);
$real_dir = realpath($dir);
if ($real_dir === false) {
return false;
}
$real_path = wp_normalize_path($real_dir . '/' . basename($path));
} else {
$real_path = wp_normalize_path($real_path);
}
// Must be within fonts directory
return strpos($real_path, $fonts_path) === 0;
}
}

View file

@ -0,0 +1,298 @@
<?php
/**
* Font Search for Maple Local Fonts.
*
* @package Maple_Local_Fonts
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class MLF_Font_Search
*
* Handles searching Google Fonts metadata.
*/
class MLF_Font_Search {
/**
* Cache key for fonts metadata.
*
* @var string
*/
const CACHE_KEY = 'mlf_google_fonts_metadata';
/**
* Cache duration in seconds (24 hours).
*
* @var int
*/
const CACHE_DURATION = DAY_IN_SECONDS;
/**
* Google Fonts metadata URL.
*
* @var string
*/
const METADATA_URL = 'https://fonts.google.com/metadata/fonts';
/**
* Maximum metadata response size (10MB).
*
* Google Fonts metadata for 1500+ fonts can be 5-8MB.
*
* @var int
*/
const MAX_METADATA_SIZE = 10 * 1024 * 1024;
/**
* Rate limiter instance.
*
* @var MLF_Rate_Limiter
*/
private $rate_limiter;
/**
* Constructor.
*/
public function __construct() {
add_action('wp_ajax_mlf_search_fonts', [$this, 'handle_search']);
// No nopriv - admin only
// Initialize rate limiter: 30 requests per minute (search can be rapid while typing)
$this->rate_limiter = new MLF_Rate_Limiter(30, 60);
}
/**
* Handle font search AJAX request.
*/
public function handle_search() {
// 1. NONCE CHECK
if (!check_ajax_referer('mlf_search_fonts', 'nonce', false)) {
wp_send_json_error(['message' => __('Security check failed.', 'maple-local-fonts')], 403);
}
// 2. CAPABILITY CHECK
$capability = function_exists('mlf_get_capability') ? mlf_get_capability() : 'edit_theme_options';
if (!current_user_can($capability)) {
wp_send_json_error(['message' => __('Unauthorized.', 'maple-local-fonts')], 403);
}
// 3. RATE LIMIT CHECK
if (!$this->rate_limiter->check_and_record('search')) {
wp_send_json_error([
'message' => __('Too many requests. Please wait a moment and try again.', 'maple-local-fonts'),
], 429);
}
// 4. INPUT VALIDATION
$query = isset($_POST['query']) ? sanitize_text_field(wp_unslash($_POST['query'])) : '';
if (strlen($query) < 2) {
wp_send_json_success(['fonts' => []]);
}
// Limit query length
if (strlen($query) > 100) {
wp_send_json_error(['message' => __('Search query too long.', 'maple-local-fonts')]);
}
// 5. PROCESS REQUEST
$fonts = $this->get_fonts_metadata();
if (is_wp_error($fonts)) {
wp_send_json_error(['message' => $fonts->get_error_message()]);
}
// Search fonts
$results = $this->search_fonts($fonts, $query);
wp_send_json_success(['fonts' => $results]);
}
/**
* Get Google Fonts metadata (cached).
*
* @return array|WP_Error Array of fonts or error.
*/
public function get_fonts_metadata() {
// Check cache first
$cached = get_transient(self::CACHE_KEY);
if ($cached !== false) {
return $cached;
}
// Fetch from Google
$response = wp_remote_get(self::METADATA_URL, [
'timeout' => 30,
'sslverify' => true,
]);
if (is_wp_error($response)) {
return new WP_Error('fetch_failed', __('Could not fetch fonts list from Google.', 'maple-local-fonts'));
}
$status = wp_remote_retrieve_response_code($response);
if ($status !== 200) {
return new WP_Error('fetch_failed', __('Google Fonts returned an error.', 'maple-local-fonts'));
}
$body = wp_remote_retrieve_body($response);
// Validate response size
if (strlen($body) > self::MAX_METADATA_SIZE) {
return new WP_Error('response_too_large', __('Font metadata response too large.', 'maple-local-fonts'));
}
// Validate response is not empty
if (empty($body)) {
return new WP_Error('empty_response', __('Empty response from Google Fonts.', 'maple-local-fonts'));
}
// Google's metadata has a )]}' prefix for security, remove it
$body = preg_replace('/^\)\]\}\'\s*/', '', $body);
$data = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return new WP_Error('parse_failed', __('Could not parse fonts data.', 'maple-local-fonts'));
}
if (!$data || !isset($data['familyMetadataList'])) {
return new WP_Error('parse_failed', __('Could not parse fonts data.', 'maple-local-fonts'));
}
// Process and simplify the data
$fonts = [];
foreach ($data['familyMetadataList'] as $font) {
$fonts[] = [
'family' => $font['family'],
'category' => $font['category'] ?? 'sans-serif',
'variants' => $font['fonts'] ?? [],
'subsets' => $font['subsets'] ?? ['latin'],
'version' => $font['version'] ?? '',
'lastModified' => $font['lastModified'] ?? '',
];
}
// Cache for 24 hours
set_transient(self::CACHE_KEY, $fonts, self::CACHE_DURATION);
return $fonts;
}
/**
* Search fonts by query.
*
* @param array $fonts All fonts.
* @param string $query Search query.
* @return array Matching fonts (max 20).
*/
private function search_fonts($fonts, $query) {
$query_lower = strtolower($query);
$exact_matches = [];
$starts_with = [];
$contains = [];
foreach ($fonts as $font) {
$family_lower = strtolower($font['family']);
// Exact match
if ($family_lower === $query_lower) {
$exact_matches[] = $this->format_font_result($font);
}
// Starts with query
elseif (strpos($family_lower, $query_lower) === 0) {
$starts_with[] = $this->format_font_result($font);
}
// Contains query
elseif (strpos($family_lower, $query_lower) !== false) {
$contains[] = $this->format_font_result($font);
}
}
// Combine results: exact matches first, then starts with, then contains
$results = array_merge($exact_matches, $starts_with, $contains);
// Limit to 20 results
return array_slice($results, 0, 20);
}
/**
* Format a font for the search results.
*
* @param array $font Font data.
* @return array Formatted font.
*/
private function format_font_result($font) {
// Check if font has variable version
$has_variable = false;
$has_italic = false;
$weights = [];
if (!empty($font['variants'])) {
foreach ($font['variants'] as $variant => $data) {
// Variable fonts have ranges like "100..900"
if (strpos($variant, '..') !== false) {
$has_variable = true;
}
if (strpos($variant, 'i') !== false || strpos($variant, 'italic') !== false) {
$has_italic = true;
}
// Extract weight
$weight = preg_replace('/[^0-9]/', '', $variant);
if ($weight && !in_array($weight, $weights, true)) {
$weights[] = $weight;
}
}
}
// Sort weights
sort($weights, SORT_NUMERIC);
return [
'family' => $font['family'],
'category' => $font['category'],
'has_variable' => $has_variable,
'has_italic' => $has_italic,
'weights' => $weights,
'version' => $font['version'] ?? '',
'lastModified' => $font['lastModified'] ?? '',
];
}
/**
* Clear the fonts metadata cache.
*/
public function clear_cache() {
delete_transient(self::CACHE_KEY);
}
/**
* Get version info for a specific font.
*
* @param string $font_family Font family name.
* @return array|null Version info or null if not found.
*/
public function get_font_version($font_family) {
$fonts = $this->get_fonts_metadata();
if (is_wp_error($fonts)) {
return null;
}
$font_family_lower = strtolower($font_family);
foreach ($fonts as $font) {
if (strtolower($font['family']) === $font_family_lower) {
return [
'version' => $font['version'] ?? '',
'lastModified' => $font['lastModified'] ?? '',
];
}
}
return null;
}
}

View file

@ -0,0 +1,182 @@
<?php
/**
* Font Server for Maple Local Fonts.
*
* Serves font files and CSS through PHP with proper headers.
* This ensures CORS and MIME types work regardless of server configuration.
*
* @package Maple_Local_Fonts
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class MLF_Font_Server
*
* Handles serving font files and CSS with proper headers via REST API.
*/
class MLF_Font_Server {
/**
* Register REST API routes.
*/
public function register_routes() {
// Serve individual font files
register_rest_route('mlf/v1', '/font/(?P<filename>[a-zA-Z0-9_\-]+\.woff2)', [
'methods' => 'GET',
'callback' => [$this, 'serve_font'],
'permission_callback' => '__return_true',
'args' => [
'filename' => [
'required' => true,
'validate_callback' => [$this, 'validate_filename'],
],
],
]);
// Serve CSS file (like Google Fonts does)
register_rest_route('mlf/v1', '/css', [
'methods' => 'GET',
'callback' => [$this, 'serve_css'],
'permission_callback' => '__return_true',
]);
}
/**
* Validate font filename.
*
* @param string $filename The filename to validate.
* @return bool True if valid.
*/
public function validate_filename($filename) {
// Only allow alphanumeric, underscore, hyphen, and .woff2 extension
if (!preg_match('/^[a-zA-Z0-9_\-]+\.woff2$/', $filename)) {
return false;
}
// Prevent directory traversal
if (strpos($filename, '..') !== false || strpos($filename, '/') !== false) {
return false;
}
return true;
}
/**
* Serve a font file with proper headers.
*
* @param WP_REST_Request $request The request object.
* @return WP_REST_Response|WP_Error Response or error.
*/
public function serve_font($request) {
$filename = $request->get_param('filename');
// Get font directory
$font_dir = wp_get_font_dir();
$file_path = trailingslashit($font_dir['path']) . $filename;
// Validate file exists and is within fonts directory
$real_path = realpath($file_path);
$fonts_path = realpath($font_dir['path']);
if (!$real_path || !$fonts_path || strpos($real_path, $fonts_path) !== 0) {
return new WP_Error('not_found', 'Font not found', ['status' => 404]);
}
if (!file_exists($real_path)) {
return new WP_Error('not_found', 'Font not found', ['status' => 404]);
}
// Verify it's a woff2 file
if (pathinfo($real_path, PATHINFO_EXTENSION) !== 'woff2') {
return new WP_Error('invalid_type', 'Invalid file type', ['status' => 400]);
}
// Read file content
$content = file_get_contents($real_path);
if ($content === false) {
return new WP_Error('read_error', 'Could not read font file', ['status' => 500]);
}
// Verify WOFF2 magic bytes
if (substr($content, 0, 4) !== 'wOF2') {
return new WP_Error('invalid_format', 'Invalid font format', ['status' => 400]);
}
// Send font with proper headers
$response = new WP_REST_Response($content);
$response->set_status(200);
$response->set_headers([
'Content-Type' => 'font/woff2',
'Content-Length' => strlen($content),
'Access-Control-Allow-Origin' => '*',
'Access-Control-Allow-Methods' => 'GET, OPTIONS',
'Cross-Origin-Resource-Policy' => 'cross-origin',
'Cache-Control' => 'public, max-age=31536000, immutable',
'Vary' => 'Accept-Encoding',
]);
return $response;
}
/**
* Serve CSS file with @font-face rules (mimics Google Fonts).
*
* @param WP_REST_Request $request The request object.
* @return WP_REST_Response Response with CSS content.
*/
public function serve_css($request) {
$registry = new MLF_Font_Registry();
$fonts = $registry->get_imported_fonts();
if (empty($fonts)) {
$response = new WP_REST_Response('/* No fonts installed */');
$response->set_headers(['Content-Type' => 'text/css; charset=UTF-8']);
return $response;
}
$css = "/* Maple Local Fonts - Generated CSS */\n\n";
foreach ($fonts as $font) {
$font_slug = sanitize_title($font['name']);
foreach ($font['variants'] as $variant) {
$weight = $variant['weight'];
$style = $variant['style'];
// Build filename
if (strpos($weight, ' ') !== false) {
$filename = sprintf('%s_%s_variable.woff2', $font_slug, $style);
} else {
$filename = sprintf('%s_%s_%s.woff2', $font_slug, $style, $weight);
}
// Use REST API URL for font file
$font_url = rest_url('mlf/v1/font/' . $filename);
// Generate @font-face rule (format like Google Fonts)
$css .= "/* {$style} {$weight} */\n";
$css .= "@font-face {\n";
$css .= " font-family: '{$font['name']}';\n";
$css .= " font-style: {$style};\n";
$css .= " font-weight: {$weight};\n";
$css .= " font-display: swap;\n";
$css .= " src: url({$font_url}) format('woff2');\n";
$css .= "}\n\n";
}
}
$response = new WP_REST_Response($css);
$response->set_status(200);
$response->set_headers([
'Content-Type' => 'text/css; charset=UTF-8',
'Access-Control-Allow-Origin' => '*',
'Cache-Control' => 'public, max-age=86400',
]);
return $response;
}
}

View file

@ -0,0 +1,188 @@
<?php
/**
* Rate Limiter for Maple Local Fonts.
*
* @package Maple_Local_Fonts
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class MLF_Rate_Limiter
*
* Provides rate limiting functionality to prevent abuse of AJAX endpoints.
*/
class MLF_Rate_Limiter {
/**
* Default rate limit (requests per window).
*
* @var int
*/
private $limit = 10;
/**
* Default time window in seconds.
*
* @var int
*/
private $window = 60;
/**
* Constructor.
*
* @param int $limit Maximum requests allowed per window.
* @param int $window Time window in seconds.
*/
public function __construct($limit = 10, $window = 60) {
$this->limit = absint($limit);
$this->window = absint($window);
}
/**
* Check if the current user/IP is rate limited.
*
* @param string $action The action being rate limited.
* @return bool True if rate limited (should block), false if allowed.
*/
public function is_limited($action) {
$key = $this->get_rate_limit_key($action);
$data = get_transient($key);
if ($data === false) {
// First request, not limited
return false;
}
return $data['count'] >= $this->limit;
}
/**
* Record a request for rate limiting purposes.
*
* @param string $action The action being recorded.
* @return void
*/
public function record_request($action) {
$key = $this->get_rate_limit_key($action);
$data = get_transient($key);
if ($data === false) {
// First request in this window
$data = [
'count' => 1,
'start' => time(),
];
} else {
$data['count']++;
}
// Set/update transient with remaining window time
$elapsed = time() - $data['start'];
$remaining = max(1, $this->window - $elapsed);
set_transient($key, $data, $remaining);
}
/**
* Check rate limit and record request in one call.
*
* @param string $action The action being checked.
* @return bool True if request is allowed, false if rate limited.
*/
public function check_and_record($action) {
if ($this->is_limited($action)) {
return false;
}
$this->record_request($action);
return true;
}
/**
* Get the number of remaining requests in the current window.
*
* @param string $action The action to check.
* @return int Number of remaining requests.
*/
public function get_remaining($action) {
$key = $this->get_rate_limit_key($action);
$data = get_transient($key);
if ($data === false) {
return $this->limit;
}
return max(0, $this->limit - $data['count']);
}
/**
* Get the rate limit key for the current user/IP.
*
* @param string $action The action being rate limited.
* @return string Transient key.
*/
private function get_rate_limit_key($action) {
// Use user ID if logged in, otherwise IP
$user_id = get_current_user_id();
if ($user_id > 0) {
$identifier = 'user_' . $user_id;
} else {
// Sanitize and hash IP for privacy
$ip = $this->get_client_ip();
$identifier = 'ip_' . md5($ip);
}
return 'mlf_rate_' . sanitize_key($action) . '_' . $identifier;
}
/**
* Get the client IP address.
*
* @return string Client IP address.
*/
private function get_client_ip() {
$ip = '';
// Check for various headers (in order of reliability)
$headers = [
'HTTP_CLIENT_IP',
'HTTP_X_FORWARDED_FOR',
'HTTP_X_FORWARDED',
'HTTP_X_CLUSTER_CLIENT_IP',
'HTTP_FORWARDED_FOR',
'HTTP_FORWARDED',
'REMOTE_ADDR',
];
foreach ($headers as $header) {
if (!empty($_SERVER[$header])) {
// X-Forwarded-For can contain multiple IPs, get the first one
$ips = explode(',', sanitize_text_field(wp_unslash($_SERVER[$header])));
$ip = trim($ips[0]);
// Validate IP
if (filter_var($ip, FILTER_VALIDATE_IP)) {
break;
}
}
}
// Fallback to localhost if no valid IP found
return $ip ?: '127.0.0.1';
}
/**
* Clear rate limit for a specific action and user/IP.
*
* @param string $action The action to clear.
* @return void
*/
public function clear($action) {
$key = $this->get_rate_limit_key($action);
delete_transient($key);
}
}

View file

@ -0,0 +1,477 @@
<?php
/**
* REST API Controller for Maple Local Fonts.
*
* @package Maple_Local_Fonts
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class MLF_Rest_Controller
*
* Provides REST API endpoints for font management.
*/
class MLF_Rest_Controller extends WP_REST_Controller {
/**
* Namespace for the API.
*
* @var string
*/
protected $namespace = 'mlf/v1';
/**
* Resource name.
*
* @var string
*/
protected $rest_base = 'fonts';
/**
* Rate limiter instance.
*
* @var MLF_Rate_Limiter
*/
private $rate_limiter;
/**
* Constructor.
*/
public function __construct() {
$this->rate_limiter = new MLF_Rate_Limiter(10, 60);
}
/**
* Register the routes for the controller.
*/
public function register_routes() {
// GET /wp-json/mlf/v1/fonts - List all fonts
register_rest_route(
$this->namespace,
'/' . $this->rest_base,
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [$this, 'get_items'],
'permission_callback' => [$this, 'get_items_permissions_check'],
],
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [$this, 'create_item'],
'permission_callback' => [$this, 'create_item_permissions_check'],
'args' => $this->get_create_item_args(),
],
'schema' => [$this, 'get_public_item_schema'],
]
);
// DELETE /wp-json/mlf/v1/fonts/{id} - Delete a font
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)',
[
[
'methods' => WP_REST_Server::DELETABLE,
'callback' => [$this, 'delete_item'],
'permission_callback' => [$this, 'delete_item_permissions_check'],
'args' => [
'id' => [
'description' => __('Unique identifier for the font.', 'maple-local-fonts'),
'type' => 'integer',
'required' => true,
],
],
],
]
);
// GET /wp-json/mlf/v1/fonts/{id} - Get a single font
register_rest_route(
$this->namespace,
'/' . $this->rest_base . '/(?P<id>[\d]+)',
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [$this, 'get_item'],
'permission_callback' => [$this, 'get_items_permissions_check'],
'args' => [
'id' => [
'description' => __('Unique identifier for the font.', 'maple-local-fonts'),
'type' => 'integer',
'required' => true,
],
],
],
]
);
}
/**
* Check if a given request has access to get items.
*
* @param WP_REST_Request $request Full data about the request.
* @return bool|WP_Error True if the request has read access, WP_Error object otherwise.
*/
public function get_items_permissions_check($request) {
$capability = function_exists('mlf_get_capability') ? mlf_get_capability() : 'edit_theme_options';
if (!current_user_can($capability)) {
return new WP_Error(
'rest_forbidden',
__('Sorry, you are not allowed to view fonts.', 'maple-local-fonts'),
['status' => rest_authorization_required_code()]
);
}
return true;
}
/**
* Check if a given request has access to create items.
*
* @param WP_REST_Request $request Full data about the request.
* @return bool|WP_Error True if the request has create access, WP_Error object otherwise.
*/
public function create_item_permissions_check($request) {
$capability = function_exists('mlf_get_capability') ? mlf_get_capability() : 'edit_theme_options';
if (!current_user_can($capability)) {
return new WP_Error(
'rest_forbidden',
__('Sorry, you are not allowed to create fonts.', 'maple-local-fonts'),
['status' => rest_authorization_required_code()]
);
}
// Rate limit check
if (!$this->rate_limiter->check_and_record('rest_create')) {
return new WP_Error(
'rest_rate_limited',
__('Too many requests. Please wait a moment and try again.', 'maple-local-fonts'),
['status' => 429]
);
}
return true;
}
/**
* Check if a given request has access to delete a specific item.
*
* @param WP_REST_Request $request Full data about the request.
* @return bool|WP_Error True if the request has delete access, WP_Error object otherwise.
*/
public function delete_item_permissions_check($request) {
$capability = function_exists('mlf_get_capability') ? mlf_get_capability() : 'edit_theme_options';
if (!current_user_can($capability)) {
return new WP_Error(
'rest_forbidden',
__('Sorry, you are not allowed to delete fonts.', 'maple-local-fonts'),
['status' => rest_authorization_required_code()]
);
}
// Rate limit check
if (!$this->rate_limiter->check_and_record('rest_delete')) {
return new WP_Error(
'rest_rate_limited',
__('Too many requests. Please wait a moment and try again.', 'maple-local-fonts'),
['status' => 429]
);
}
return true;
}
/**
* Get a collection of fonts.
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_items($request) {
$registry = new MLF_Font_Registry();
$fonts = $registry->get_imported_fonts();
$data = [];
foreach ($fonts as $font) {
$data[] = $this->prepare_item_for_response($font, $request);
}
return rest_ensure_response($data);
}
/**
* Get a single font.
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function get_item($request) {
$font_id = absint($request->get_param('id'));
$font = get_post($font_id);
if (!$font || $font->post_type !== 'wp_font_family') {
return new WP_Error(
'rest_font_not_found',
__('Font not found.', 'maple-local-fonts'),
['status' => 404]
);
}
// Verify it's one we imported
if (get_post_meta($font_id, '_mlf_imported', true) !== '1') {
return new WP_Error(
'rest_font_not_found',
__('Font not found.', 'maple-local-fonts'),
['status' => 404]
);
}
$registry = new MLF_Font_Registry();
$fonts = $registry->get_imported_fonts();
$font_data = null;
foreach ($fonts as $f) {
if ($f['id'] === $font_id) {
$font_data = $f;
break;
}
}
if (!$font_data) {
return new WP_Error(
'rest_font_not_found',
__('Font not found.', 'maple-local-fonts'),
['status' => 404]
);
}
return rest_ensure_response($this->prepare_item_for_response($font_data, $request));
}
/**
* Create a font.
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function create_item($request) {
$font_name = sanitize_text_field($request->get_param('font_name'));
$include_italic = (bool) $request->get_param('include_italic');
// Validate font name
if (!preg_match('/^[a-zA-Z0-9\s\-]+$/', $font_name)) {
return new WP_Error(
'rest_invalid_param',
__('Invalid font name: only letters, numbers, spaces, and hyphens allowed.', 'maple-local-fonts'),
['status' => 400]
);
}
if (strlen($font_name) > 100) {
return new WP_Error(
'rest_invalid_param',
__('Font name is too long.', 'maple-local-fonts'),
['status' => 400]
);
}
try {
$downloader = new MLF_Font_Downloader();
$download_result = $downloader->download($font_name, $include_italic);
if (is_wp_error($download_result)) {
return new WP_Error(
'rest_download_failed',
$download_result->get_error_message(),
['status' => 400]
);
}
// Register font with WordPress
$registry = new MLF_Font_Registry();
$result = $registry->register_font(
$download_result['font_name'],
$download_result['font_slug'],
$download_result['files']
);
if (is_wp_error($result)) {
return new WP_Error(
'rest_register_failed',
$result->get_error_message(),
['status' => 400]
);
}
// Return the created font
$fonts = $registry->get_imported_fonts();
$created_font = null;
foreach ($fonts as $font) {
if ($font['id'] === $result) {
$created_font = $font;
break;
}
}
$response = rest_ensure_response($this->prepare_item_for_response($created_font, $request));
$response->set_status(201);
$response->header('Location', rest_url(sprintf('%s/%s/%d', $this->namespace, $this->rest_base, $result)));
return $response;
} catch (Exception $e) {
error_log('MLF REST Create Error: ' . sanitize_text_field($e->getMessage()));
return new WP_Error(
'rest_internal_error',
__('An unexpected error occurred.', 'maple-local-fonts'),
['status' => 500]
);
}
}
/**
* Delete a font.
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
public function delete_item($request) {
$font_id = absint($request->get_param('id'));
$font = get_post($font_id);
if (!$font || $font->post_type !== 'wp_font_family') {
return new WP_Error(
'rest_font_not_found',
__('Font not found.', 'maple-local-fonts'),
['status' => 404]
);
}
// Verify it's one we imported
if (get_post_meta($font_id, '_mlf_imported', true) !== '1') {
return new WP_Error(
'rest_cannot_delete',
__('Cannot delete fonts not imported by this plugin.', 'maple-local-fonts'),
['status' => 403]
);
}
try {
$registry = new MLF_Font_Registry();
$result = $registry->delete_font($font_id);
if (is_wp_error($result)) {
return new WP_Error(
'rest_delete_failed',
$result->get_error_message(),
['status' => 400]
);
}
return rest_ensure_response([
'deleted' => true,
'message' => __('Font deleted successfully.', 'maple-local-fonts'),
]);
} catch (Exception $e) {
error_log('MLF REST Delete Error: ' . sanitize_text_field($e->getMessage()));
return new WP_Error(
'rest_internal_error',
__('An unexpected error occurred.', 'maple-local-fonts'),
['status' => 500]
);
}
}
/**
* Prepare a font for the REST response.
*
* @param array $font Font data.
* @param WP_REST_Request $request Request object.
* @return array Prepared font data.
*/
public function prepare_item_for_response($font, $request) {
return [
'id' => $font['id'],
'name' => $font['name'],
'slug' => $font['slug'],
'variants' => $font['variants'],
'_links' => [
'self' => [
['href' => rest_url(sprintf('%s/%s/%d', $this->namespace, $this->rest_base, $font['id']))],
],
'collection' => [
['href' => rest_url(sprintf('%s/%s', $this->namespace, $this->rest_base))],
],
],
];
}
/**
* Get the argument schema for creating items.
*
* @return array Arguments schema.
*/
protected function get_create_item_args() {
return [
'font_name' => [
'description' => __('The font family name from Google Fonts.', 'maple-local-fonts'),
'type' => 'string',
'required' => true,
'sanitize_callback' => 'sanitize_text_field',
],
'include_italic' => [
'description' => __('Whether to include italic styles.', 'maple-local-fonts'),
'type' => 'boolean',
'default' => true,
],
];
}
/**
* Get the font schema.
*
* @return array Schema definition.
*/
public function get_item_schema() {
return [
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'font',
'type' => 'object',
'properties' => [
'id' => [
'description' => __('Unique identifier for the font.', 'maple-local-fonts'),
'type' => 'integer',
'context' => ['view'],
'readonly' => true,
],
'name' => [
'description' => __('The font family name.', 'maple-local-fonts'),
'type' => 'string',
'context' => ['view'],
],
'slug' => [
'description' => __('The font slug.', 'maple-local-fonts'),
'type' => 'string',
'context' => ['view'],
],
'variants' => [
'description' => __('Array of font variants.', 'maple-local-fonts'),
'type' => 'array',
'context' => ['view'],
'items' => [
'type' => 'object',
'properties' => [
'weight' => [
'type' => 'string',
],
'style' => [
'type' => 'string',
],
],
],
],
],
];
}
}

View file

@ -0,0 +1,5 @@
<?php
// Silence is golden.
if (!defined('ABSPATH')) {
exit;
}

View file

@ -0,0 +1,5 @@
<?php
// Silence is golden.
if (!defined('ABSPATH')) {
exit;
}

View file

@ -0,0 +1,5 @@
<?php
// Silence is golden.
if (!defined('ABSPATH')) {
exit;
}

View file

@ -0,0 +1,222 @@
# Translation of Maple Local Fonts in Arabic
# Copyright (C) 2024 Maple Open Technologies
# This file is distributed under the GPL-2.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: Maple Local Fonts 1.0.0\n"
"Report-Msgid-Bugs-To: https://mapleopentech.org/\n"
"Last-Translator: Maple Open Technologies\n"
"Language-Team: Arabic\n"
"Language: ar\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2024-01-01T00:00:00+00:00\n"
"PO-Revision-Date: 2024-01-01T00:00:00+00:00\n"
"X-Generator: Manual\n"
"X-Domain: maple-local-fonts\n"
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 ? 4 : 5);\n"
#. Plugin Name of the plugin
msgid "Maple Local Fonts"
msgstr "Maple Local Fonts"
#. Description of the plugin
msgid "Import Google Fonts to local storage and register them with WordPress Font Library for GDPR-compliant, privacy-friendly typography."
msgstr "استورد خطوط Google Fonts إلى التخزين المحلي وسجلها في مكتبة خطوط WordPress للحصول على طباعة متوافقة مع GDPR وصديقة للخصوصية."
#. Author of the plugin
msgid "Maple Open Technologies"
msgstr "Maple Open Technologies"
msgid "Maple Local Fonts requires WordPress 6.5 or higher for Font Library support."
msgstr "يتطلب Maple Local Fonts إصدار WordPress 6.5 أو أعلى لدعم مكتبة الخطوط."
msgid "Plugin Activation Error"
msgstr "خطأ في تفعيل الإضافة"
msgid "Maple Local Fonts has been deactivated. It requires WordPress 6.5 or higher."
msgstr "تم إلغاء تفعيل Maple Local Fonts. يتطلب WordPress 6.5 أو أعلى."
msgid "Local Fonts"
msgstr "الخطوط المحلية"
msgid "Downloading..."
msgstr "جارٍ التنزيل..."
msgid "Deleting..."
msgstr "جارٍ الحذف..."
msgid "Are you sure you want to delete this font?"
msgstr "هل أنت متأكد أنك تريد حذف هذا الخط؟"
msgid "An error occurred. Please try again."
msgstr "حدث خطأ. يرجى المحاولة مرة أخرى."
msgid "Please select at least one weight."
msgstr "يرجى اختيار وزن واحد على الأقل."
msgid "Please select at least one style."
msgstr "يرجى اختيار نمط واحد على الأقل."
msgid "Please enter a font name."
msgstr "يرجى إدخال اسم الخط."
msgid "Security check failed."
msgstr "فشل التحقق الأمني."
msgid "Unauthorized."
msgstr "غير مصرح."
msgid "Font name is required."
msgstr "اسم الخط مطلوب."
msgid "Invalid font name: only letters, numbers, spaces, and hyphens allowed."
msgstr "اسم خط غير صالح: يُسمح فقط بالأحرف والأرقام والمسافات والشرطات."
msgid "Font name is too long."
msgstr "اسم الخط طويل جداً."
msgid "At least one weight is required."
msgstr "مطلوب وزن واحد على الأقل."
msgid "Too many weights selected."
msgstr "تم اختيار أوزان كثيرة جداً."
msgid "At least one style is required."
msgstr "مطلوب نمط واحد على الأقل."
msgid "Successfully installed %s."
msgstr "تم تثبيت %s بنجاح."
msgid "An unexpected error occurred."
msgstr "حدث خطأ غير متوقع."
msgid "Invalid font ID."
msgstr "معرف خط غير صالح."
msgid "Font not found."
msgstr "الخط غير موجود."
msgid "Cannot delete fonts not imported by this plugin."
msgstr "لا يمكن حذف الخطوط التي لم يتم استيرادها بواسطة هذه الإضافة."
msgid "Font deleted successfully."
msgstr "تم حذف الخط بنجاح."
msgid "Font not found on Google Fonts. Please check the spelling and try again."
msgstr "الخط غير موجود في Google Fonts. يرجى التحقق من الإملاء والمحاولة مرة أخرى."
msgid "This font is already installed."
msgstr "هذا الخط مثبت بالفعل."
msgid "Could not connect to Google Fonts. Please check your internet connection and try again."
msgstr "تعذر الاتصال بـ Google Fonts. يرجى التحقق من اتصالك بالإنترنت والمحاولة مرة أخرى."
msgid "Google Fonts returned an error. Please try again later."
msgstr "أرجع Google Fonts خطأ. يرجى المحاولة لاحقاً."
msgid "Could not process the font data. The font may not be available."
msgstr "تعذرت معالجة بيانات الخط. قد لا يكون الخط متاحاً."
msgid "Could not download the font files. Please try again."
msgstr "تعذر تنزيل ملفات الخط. يرجى المحاولة مرة أخرى."
msgid "Could not save font files. Please check that wp-content/fonts is writable."
msgstr "تعذر حفظ ملفات الخط. يرجى التحقق من أن wp-content/fonts قابل للكتابة."
msgid "Could not create fonts directory. Please check file permissions."
msgstr "تعذر إنشاء دليل الخطوط. يرجى التحقق من أذونات الملفات."
msgid "Invalid file path."
msgstr "مسار ملف غير صالح."
msgid "Invalid font URL."
msgstr "رابط خط غير صالح."
msgid "Invalid font name."
msgstr "اسم خط غير صالح."
msgid "No valid weights specified."
msgstr "لم يتم تحديد أوزان صالحة."
msgid "No valid styles specified."
msgstr "لم يتم تحديد أنماط صالحة."
msgid "An unexpected error occurred. Please try again."
msgstr "حدث خطأ غير متوقع. يرجى المحاولة مرة أخرى."
msgid "Thin"
msgstr "رفيع"
msgid "Extra Light"
msgstr "خفيف جداً"
msgid "Light"
msgstr "خفيف"
msgid "Regular"
msgstr "عادي"
msgid "Medium"
msgstr "متوسط"
msgid "Semi Bold"
msgstr "شبه عريض"
msgid "Bold"
msgstr "عريض"
msgid "Extra Bold"
msgstr "عريض جداً"
msgid "Black"
msgstr "أسود"
msgid "You do not have sufficient permissions to access this page."
msgstr "ليس لديك الصلاحيات الكافية للوصول إلى هذه الصفحة."
msgid "Import from Google Fonts"
msgstr "استيراد من Google Fonts"
msgid "Font Name"
msgstr "اسم الخط"
msgid "e.g., Open Sans"
msgstr "مثال: Open Sans"
msgid "Enter the exact font name as it appears on Google Fonts."
msgstr "أدخل اسم الخط الدقيق كما يظهر في Google Fonts."
msgid "Weights"
msgstr "الأوزان"
msgid "Styles"
msgstr "الأنماط"
msgid "Normal"
msgstr "عادي"
msgid "Italic"
msgstr "مائل"
msgid "Files to download:"
msgstr "الملفات للتنزيل:"
msgid "Download & Install"
msgstr "تنزيل وتثبيت"
msgid "Installed Fonts"
msgstr "الخطوط المثبتة"
msgid "No fonts installed yet."
msgstr "لم يتم تثبيت أي خطوط بعد."
msgid "Delete"
msgstr "حذف"
msgid "Use %s to apply fonts to your site."
msgstr "استخدم %s لتطبيق الخطوط على موقعك."
msgid "Appearance → Editor → Styles → Typography"
msgstr "المظهر ← المحرر ← الأنماط ← الطباعة"

View file

@ -0,0 +1,222 @@
# Translation of Maple Local Fonts in German
# Copyright (C) 2024 Maple Open Technologies
# This file is distributed under the GPL-2.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: Maple Local Fonts 1.0.0\n"
"Report-Msgid-Bugs-To: https://mapleopentech.org/\n"
"Last-Translator: Maple Open Technologies\n"
"Language-Team: German\n"
"Language: de_DE\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2024-01-01T00:00:00+00:00\n"
"PO-Revision-Date: 2024-01-01T00:00:00+00:00\n"
"X-Generator: Manual\n"
"X-Domain: maple-local-fonts\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. Plugin Name of the plugin
msgid "Maple Local Fonts"
msgstr "Maple Local Fonts"
#. Description of the plugin
msgid "Import Google Fonts to local storage and register them with WordPress Font Library for GDPR-compliant, privacy-friendly typography."
msgstr "Importieren Sie Google Fonts in den lokalen Speicher und registrieren Sie sie in der WordPress-Schriftbibliothek für DSGVO-konforme, datenschutzfreundliche Typografie."
#. Author of the plugin
msgid "Maple Open Technologies"
msgstr "Maple Open Technologies"
msgid "Maple Local Fonts requires WordPress 6.5 or higher for Font Library support."
msgstr "Maple Local Fonts erfordert WordPress 6.5 oder höher für die Unterstützung der Schriftbibliothek."
msgid "Plugin Activation Error"
msgstr "Plugin-Aktivierungsfehler"
msgid "Maple Local Fonts has been deactivated. It requires WordPress 6.5 or higher."
msgstr "Maple Local Fonts wurde deaktiviert. Es erfordert WordPress 6.5 oder höher."
msgid "Local Fonts"
msgstr "Lokale Schriften"
msgid "Downloading..."
msgstr "Wird heruntergeladen..."
msgid "Deleting..."
msgstr "Wird gelöscht..."
msgid "Are you sure you want to delete this font?"
msgstr "Sind Sie sicher, dass Sie diese Schrift löschen möchten?"
msgid "An error occurred. Please try again."
msgstr "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut."
msgid "Please select at least one weight."
msgstr "Bitte wählen Sie mindestens eine Schriftstärke aus."
msgid "Please select at least one style."
msgstr "Bitte wählen Sie mindestens einen Stil aus."
msgid "Please enter a font name."
msgstr "Bitte geben Sie einen Schriftnamen ein."
msgid "Security check failed."
msgstr "Sicherheitsprüfung fehlgeschlagen."
msgid "Unauthorized."
msgstr "Nicht autorisiert."
msgid "Font name is required."
msgstr "Schriftname ist erforderlich."
msgid "Invalid font name: only letters, numbers, spaces, and hyphens allowed."
msgstr "Ungültiger Schriftname: Nur Buchstaben, Zahlen, Leerzeichen und Bindestriche erlaubt."
msgid "Font name is too long."
msgstr "Schriftname ist zu lang."
msgid "At least one weight is required."
msgstr "Mindestens eine Schriftstärke ist erforderlich."
msgid "Too many weights selected."
msgstr "Zu viele Schriftstärken ausgewählt."
msgid "At least one style is required."
msgstr "Mindestens ein Stil ist erforderlich."
msgid "Successfully installed %s."
msgstr "%s wurde erfolgreich installiert."
msgid "An unexpected error occurred."
msgstr "Ein unerwarteter Fehler ist aufgetreten."
msgid "Invalid font ID."
msgstr "Ungültige Schrift-ID."
msgid "Font not found."
msgstr "Schrift nicht gefunden."
msgid "Cannot delete fonts not imported by this plugin."
msgstr "Schriften, die nicht von diesem Plugin importiert wurden, können nicht gelöscht werden."
msgid "Font deleted successfully."
msgstr "Schrift erfolgreich gelöscht."
msgid "Font not found on Google Fonts. Please check the spelling and try again."
msgstr "Schrift bei Google Fonts nicht gefunden. Bitte überprüfen Sie die Schreibweise und versuchen Sie es erneut."
msgid "This font is already installed."
msgstr "Diese Schrift ist bereits installiert."
msgid "Could not connect to Google Fonts. Please check your internet connection and try again."
msgstr "Verbindung zu Google Fonts konnte nicht hergestellt werden. Bitte überprüfen Sie Ihre Internetverbindung und versuchen Sie es erneut."
msgid "Google Fonts returned an error. Please try again later."
msgstr "Google Fonts hat einen Fehler zurückgegeben. Bitte versuchen Sie es später erneut."
msgid "Could not process the font data. The font may not be available."
msgstr "Schriftdaten konnten nicht verarbeitet werden. Die Schrift ist möglicherweise nicht verfügbar."
msgid "Could not download the font files. Please try again."
msgstr "Schriftdateien konnten nicht heruntergeladen werden. Bitte versuchen Sie es erneut."
msgid "Could not save font files. Please check that wp-content/fonts is writable."
msgstr "Schriftdateien konnten nicht gespeichert werden. Bitte überprüfen Sie, ob wp-content/fonts beschreibbar ist."
msgid "Could not create fonts directory. Please check file permissions."
msgstr "Schriftverzeichnis konnte nicht erstellt werden. Bitte überprüfen Sie die Dateiberechtigungen."
msgid "Invalid file path."
msgstr "Ungültiger Dateipfad."
msgid "Invalid font URL."
msgstr "Ungültige Schrift-URL."
msgid "Invalid font name."
msgstr "Ungültiger Schriftname."
msgid "No valid weights specified."
msgstr "Keine gültigen Schriftstärken angegeben."
msgid "No valid styles specified."
msgstr "Keine gültigen Stile angegeben."
msgid "An unexpected error occurred. Please try again."
msgstr "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut."
msgid "Thin"
msgstr "Dünn"
msgid "Extra Light"
msgstr "Extraleicht"
msgid "Light"
msgstr "Leicht"
msgid "Regular"
msgstr "Normal"
msgid "Medium"
msgstr "Mittel"
msgid "Semi Bold"
msgstr "Halbfett"
msgid "Bold"
msgstr "Fett"
msgid "Extra Bold"
msgstr "Extrafett"
msgid "Black"
msgstr "Schwarz"
msgid "You do not have sufficient permissions to access this page."
msgstr "Sie haben nicht die erforderlichen Berechtigungen, um auf diese Seite zuzugreifen."
msgid "Import from Google Fonts"
msgstr "Von Google Fonts importieren"
msgid "Font Name"
msgstr "Schriftname"
msgid "e.g., Open Sans"
msgstr "z.B. Open Sans"
msgid "Enter the exact font name as it appears on Google Fonts."
msgstr "Geben Sie den genauen Schriftnamen ein, wie er bei Google Fonts erscheint."
msgid "Weights"
msgstr "Schriftstärken"
msgid "Styles"
msgstr "Stile"
msgid "Normal"
msgstr "Normal"
msgid "Italic"
msgstr "Kursiv"
msgid "Files to download:"
msgstr "Herunterzuladende Dateien:"
msgid "Download & Install"
msgstr "Herunterladen & Installieren"
msgid "Installed Fonts"
msgstr "Installierte Schriften"
msgid "No fonts installed yet."
msgstr "Noch keine Schriften installiert."
msgid "Delete"
msgstr "Löschen"
msgid "Use %s to apply fonts to your site."
msgstr "Verwenden Sie %s, um Schriften auf Ihre Website anzuwenden."
msgid "Appearance → Editor → Styles → Typography"
msgstr "Design → Editor → Stile → Typografie"

View file

@ -0,0 +1,222 @@
# Translation of Maple Local Fonts in English (Canada)
# Copyright (C) 2024 Maple Open Technologies
# This file is distributed under the GPL-2.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: Maple Local Fonts 1.0.0\n"
"Report-Msgid-Bugs-To: https://mapleopentech.org/\n"
"Last-Translator: Maple Open Technologies\n"
"Language-Team: English (Canada)\n"
"Language: en_CA\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2024-01-01T00:00:00+00:00\n"
"PO-Revision-Date: 2024-01-01T00:00:00+00:00\n"
"X-Generator: Manual\n"
"X-Domain: maple-local-fonts\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. Plugin Name of the plugin
msgid "Maple Local Fonts"
msgstr "Maple Local Fonts"
#. Description of the plugin
msgid "Import Google Fonts to local storage and register them with WordPress Font Library for GDPR-compliant, privacy-friendly typography."
msgstr "Import Google Fonts to local storage and register them with WordPress Font Library for GDPR-compliant, privacy-friendly typography."
#. Author of the plugin
msgid "Maple Open Technologies"
msgstr "Maple Open Technologies"
msgid "Maple Local Fonts requires WordPress 6.5 or higher for Font Library support."
msgstr "Maple Local Fonts requires WordPress 6.5 or higher for Font Library support."
msgid "Plugin Activation Error"
msgstr "Plugin Activation Error"
msgid "Maple Local Fonts has been deactivated. It requires WordPress 6.5 or higher."
msgstr "Maple Local Fonts has been deactivated. It requires WordPress 6.5 or higher."
msgid "Local Fonts"
msgstr "Local Fonts"
msgid "Downloading..."
msgstr "Downloading..."
msgid "Deleting..."
msgstr "Deleting..."
msgid "Are you sure you want to delete this font?"
msgstr "Are you sure you want to delete this font?"
msgid "An error occurred. Please try again."
msgstr "An error occurred. Please try again."
msgid "Please select at least one weight."
msgstr "Please select at least one weight."
msgid "Please select at least one style."
msgstr "Please select at least one style."
msgid "Please enter a font name."
msgstr "Please enter a font name."
msgid "Security check failed."
msgstr "Security check failed."
msgid "Unauthorized."
msgstr "Unauthorized."
msgid "Font name is required."
msgstr "Font name is required."
msgid "Invalid font name: only letters, numbers, spaces, and hyphens allowed."
msgstr "Invalid font name: only letters, numbers, spaces, and hyphens allowed."
msgid "Font name is too long."
msgstr "Font name is too long."
msgid "At least one weight is required."
msgstr "At least one weight is required."
msgid "Too many weights selected."
msgstr "Too many weights selected."
msgid "At least one style is required."
msgstr "At least one style is required."
msgid "Successfully installed %s."
msgstr "Successfully installed %s."
msgid "An unexpected error occurred."
msgstr "An unexpected error occurred."
msgid "Invalid font ID."
msgstr "Invalid font ID."
msgid "Font not found."
msgstr "Font not found."
msgid "Cannot delete fonts not imported by this plugin."
msgstr "Cannot delete fonts not imported by this plugin."
msgid "Font deleted successfully."
msgstr "Font deleted successfully."
msgid "Font not found on Google Fonts. Please check the spelling and try again."
msgstr "Font not found on Google Fonts. Please check the spelling and try again."
msgid "This font is already installed."
msgstr "This font is already installed."
msgid "Could not connect to Google Fonts. Please check your internet connection and try again."
msgstr "Could not connect to Google Fonts. Please check your internet connection and try again."
msgid "Google Fonts returned an error. Please try again later."
msgstr "Google Fonts returned an error. Please try again later."
msgid "Could not process the font data. The font may not be available."
msgstr "Could not process the font data. The font may not be available."
msgid "Could not download the font files. Please try again."
msgstr "Could not download the font files. Please try again."
msgid "Could not save font files. Please check that wp-content/fonts is writable."
msgstr "Could not save font files. Please check that wp-content/fonts is writable."
msgid "Could not create fonts directory. Please check file permissions."
msgstr "Could not create fonts directory. Please check file permissions."
msgid "Invalid file path."
msgstr "Invalid file path."
msgid "Invalid font URL."
msgstr "Invalid font URL."
msgid "Invalid font name."
msgstr "Invalid font name."
msgid "No valid weights specified."
msgstr "No valid weights specified."
msgid "No valid styles specified."
msgstr "No valid styles specified."
msgid "An unexpected error occurred. Please try again."
msgstr "An unexpected error occurred. Please try again."
msgid "Thin"
msgstr "Thin"
msgid "Extra Light"
msgstr "Extra Light"
msgid "Light"
msgstr "Light"
msgid "Regular"
msgstr "Regular"
msgid "Medium"
msgstr "Medium"
msgid "Semi Bold"
msgstr "Semi Bold"
msgid "Bold"
msgstr "Bold"
msgid "Extra Bold"
msgstr "Extra Bold"
msgid "Black"
msgstr "Black"
msgid "You do not have sufficient permissions to access this page."
msgstr "You do not have sufficient permissions to access this page."
msgid "Import from Google Fonts"
msgstr "Import from Google Fonts"
msgid "Font Name"
msgstr "Font Name"
msgid "e.g., Open Sans"
msgstr "e.g., Open Sans"
msgid "Enter the exact font name as it appears on Google Fonts."
msgstr "Enter the exact font name as it appears on Google Fonts."
msgid "Weights"
msgstr "Weights"
msgid "Styles"
msgstr "Styles"
msgid "Normal"
msgstr "Normal"
msgid "Italic"
msgstr "Italic"
msgid "Files to download:"
msgstr "Files to download:"
msgid "Download & Install"
msgstr "Download & Install"
msgid "Installed Fonts"
msgstr "Installed Fonts"
msgid "No fonts installed yet."
msgstr "No fonts installed yet."
msgid "Delete"
msgstr "Delete"
msgid "Use %s to apply fonts to your site."
msgstr "Use %s to apply fonts to your site."
msgid "Appearance → Editor → Styles → Typography"
msgstr "Appearance → Editor → Styles → Typography"

View file

@ -0,0 +1,222 @@
# Translation of Maple Local Fonts in English (United Kingdom)
# Copyright (C) 2024 Maple Open Technologies
# This file is distributed under the GPL-2.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: Maple Local Fonts 1.0.0\n"
"Report-Msgid-Bugs-To: https://mapleopentech.org/\n"
"Last-Translator: Maple Open Technologies\n"
"Language-Team: English (United Kingdom)\n"
"Language: en_GB\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2024-01-01T00:00:00+00:00\n"
"PO-Revision-Date: 2024-01-01T00:00:00+00:00\n"
"X-Generator: Manual\n"
"X-Domain: maple-local-fonts\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. Plugin Name of the plugin
msgid "Maple Local Fonts"
msgstr "Maple Local Fonts"
#. Description of the plugin
msgid "Import Google Fonts to local storage and register them with WordPress Font Library for GDPR-compliant, privacy-friendly typography."
msgstr "Import Google Fonts to local storage and register them with WordPress Font Library for GDPR-compliant, privacy-friendly typography."
#. Author of the plugin
msgid "Maple Open Technologies"
msgstr "Maple Open Technologies"
msgid "Maple Local Fonts requires WordPress 6.5 or higher for Font Library support."
msgstr "Maple Local Fonts requires WordPress 6.5 or higher for Font Library support."
msgid "Plugin Activation Error"
msgstr "Plugin Activation Error"
msgid "Maple Local Fonts has been deactivated. It requires WordPress 6.5 or higher."
msgstr "Maple Local Fonts has been deactivated. It requires WordPress 6.5 or higher."
msgid "Local Fonts"
msgstr "Local Fonts"
msgid "Downloading..."
msgstr "Downloading..."
msgid "Deleting..."
msgstr "Deleting..."
msgid "Are you sure you want to delete this font?"
msgstr "Are you sure you want to delete this font?"
msgid "An error occurred. Please try again."
msgstr "An error occurred. Please try again."
msgid "Please select at least one weight."
msgstr "Please select at least one weight."
msgid "Please select at least one style."
msgstr "Please select at least one style."
msgid "Please enter a font name."
msgstr "Please enter a font name."
msgid "Security check failed."
msgstr "Security check failed."
msgid "Unauthorised."
msgstr "Unauthorised."
msgid "Font name is required."
msgstr "Font name is required."
msgid "Invalid font name: only letters, numbers, spaces, and hyphens allowed."
msgstr "Invalid font name: only letters, numbers, spaces, and hyphens allowed."
msgid "Font name is too long."
msgstr "Font name is too long."
msgid "At least one weight is required."
msgstr "At least one weight is required."
msgid "Too many weights selected."
msgstr "Too many weights selected."
msgid "At least one style is required."
msgstr "At least one style is required."
msgid "Successfully installed %s."
msgstr "Successfully installed %s."
msgid "An unexpected error occurred."
msgstr "An unexpected error occurred."
msgid "Invalid font ID."
msgstr "Invalid font ID."
msgid "Font not found."
msgstr "Font not found."
msgid "Cannot delete fonts not imported by this plugin."
msgstr "Cannot delete fonts not imported by this plugin."
msgid "Font deleted successfully."
msgstr "Font deleted successfully."
msgid "Font not found on Google Fonts. Please check the spelling and try again."
msgstr "Font not found on Google Fonts. Please check the spelling and try again."
msgid "This font is already installed."
msgstr "This font is already installed."
msgid "Could not connect to Google Fonts. Please check your internet connection and try again."
msgstr "Could not connect to Google Fonts. Please check your internet connection and try again."
msgid "Google Fonts returned an error. Please try again later."
msgstr "Google Fonts returned an error. Please try again later."
msgid "Could not process the font data. The font may not be available."
msgstr "Could not process the font data. The font may not be available."
msgid "Could not download the font files. Please try again."
msgstr "Could not download the font files. Please try again."
msgid "Could not save font files. Please check that wp-content/fonts is writable."
msgstr "Could not save font files. Please check that wp-content/fonts is writable."
msgid "Could not create fonts directory. Please check file permissions."
msgstr "Could not create fonts directory. Please check file permissions."
msgid "Invalid file path."
msgstr "Invalid file path."
msgid "Invalid font URL."
msgstr "Invalid font URL."
msgid "Invalid font name."
msgstr "Invalid font name."
msgid "No valid weights specified."
msgstr "No valid weights specified."
msgid "No valid styles specified."
msgstr "No valid styles specified."
msgid "An unexpected error occurred. Please try again."
msgstr "An unexpected error occurred. Please try again."
msgid "Thin"
msgstr "Thin"
msgid "Extra Light"
msgstr "Extra Light"
msgid "Light"
msgstr "Light"
msgid "Regular"
msgstr "Regular"
msgid "Medium"
msgstr "Medium"
msgid "Semi Bold"
msgstr "Semi Bold"
msgid "Bold"
msgstr "Bold"
msgid "Extra Bold"
msgstr "Extra Bold"
msgid "Black"
msgstr "Black"
msgid "You do not have sufficient permissions to access this page."
msgstr "You do not have sufficient permissions to access this page."
msgid "Import from Google Fonts"
msgstr "Import from Google Fonts"
msgid "Font Name"
msgstr "Font Name"
msgid "e.g., Open Sans"
msgstr "e.g., Open Sans"
msgid "Enter the exact font name as it appears on Google Fonts."
msgstr "Enter the exact font name as it appears on Google Fonts."
msgid "Weights"
msgstr "Weights"
msgid "Styles"
msgstr "Styles"
msgid "Normal"
msgstr "Normal"
msgid "Italic"
msgstr "Italic"
msgid "Files to download:"
msgstr "Files to download:"
msgid "Download & Install"
msgstr "Download & Install"
msgid "Installed Fonts"
msgstr "Installed Fonts"
msgid "No fonts installed yet."
msgstr "No fonts installed yet."
msgid "Delete"
msgstr "Delete"
msgid "Use %s to apply fonts to your site."
msgstr "Use %s to apply fonts to your site."
msgid "Appearance → Editor → Styles → Typography"
msgstr "Appearance → Editor → Styles → Typography"

View file

@ -0,0 +1,222 @@
# Translation of Maple Local Fonts in English (United States)
# Copyright (C) 2024 Maple Open Technologies
# This file is distributed under the GPL-2.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: Maple Local Fonts 1.0.0\n"
"Report-Msgid-Bugs-To: https://mapleopentech.org/\n"
"Last-Translator: Maple Open Technologies\n"
"Language-Team: English (United States)\n"
"Language: en_US\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2024-01-01T00:00:00+00:00\n"
"PO-Revision-Date: 2024-01-01T00:00:00+00:00\n"
"X-Generator: Manual\n"
"X-Domain: maple-local-fonts\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. Plugin Name of the plugin
msgid "Maple Local Fonts"
msgstr "Maple Local Fonts"
#. Description of the plugin
msgid "Import Google Fonts to local storage and register them with WordPress Font Library for GDPR-compliant, privacy-friendly typography."
msgstr "Import Google Fonts to local storage and register them with WordPress Font Library for GDPR-compliant, privacy-friendly typography."
#. Author of the plugin
msgid "Maple Open Technologies"
msgstr "Maple Open Technologies"
msgid "Maple Local Fonts requires WordPress 6.5 or higher for Font Library support."
msgstr "Maple Local Fonts requires WordPress 6.5 or higher for Font Library support."
msgid "Plugin Activation Error"
msgstr "Plugin Activation Error"
msgid "Maple Local Fonts has been deactivated. It requires WordPress 6.5 or higher."
msgstr "Maple Local Fonts has been deactivated. It requires WordPress 6.5 or higher."
msgid "Local Fonts"
msgstr "Local Fonts"
msgid "Downloading..."
msgstr "Downloading..."
msgid "Deleting..."
msgstr "Deleting..."
msgid "Are you sure you want to delete this font?"
msgstr "Are you sure you want to delete this font?"
msgid "An error occurred. Please try again."
msgstr "An error occurred. Please try again."
msgid "Please select at least one weight."
msgstr "Please select at least one weight."
msgid "Please select at least one style."
msgstr "Please select at least one style."
msgid "Please enter a font name."
msgstr "Please enter a font name."
msgid "Security check failed."
msgstr "Security check failed."
msgid "Unauthorized."
msgstr "Unauthorized."
msgid "Font name is required."
msgstr "Font name is required."
msgid "Invalid font name: only letters, numbers, spaces, and hyphens allowed."
msgstr "Invalid font name: only letters, numbers, spaces, and hyphens allowed."
msgid "Font name is too long."
msgstr "Font name is too long."
msgid "At least one weight is required."
msgstr "At least one weight is required."
msgid "Too many weights selected."
msgstr "Too many weights selected."
msgid "At least one style is required."
msgstr "At least one style is required."
msgid "Successfully installed %s."
msgstr "Successfully installed %s."
msgid "An unexpected error occurred."
msgstr "An unexpected error occurred."
msgid "Invalid font ID."
msgstr "Invalid font ID."
msgid "Font not found."
msgstr "Font not found."
msgid "Cannot delete fonts not imported by this plugin."
msgstr "Cannot delete fonts not imported by this plugin."
msgid "Font deleted successfully."
msgstr "Font deleted successfully."
msgid "Font not found on Google Fonts. Please check the spelling and try again."
msgstr "Font not found on Google Fonts. Please check the spelling and try again."
msgid "This font is already installed."
msgstr "This font is already installed."
msgid "Could not connect to Google Fonts. Please check your internet connection and try again."
msgstr "Could not connect to Google Fonts. Please check your internet connection and try again."
msgid "Google Fonts returned an error. Please try again later."
msgstr "Google Fonts returned an error. Please try again later."
msgid "Could not process the font data. The font may not be available."
msgstr "Could not process the font data. The font may not be available."
msgid "Could not download the font files. Please try again."
msgstr "Could not download the font files. Please try again."
msgid "Could not save font files. Please check that wp-content/fonts is writable."
msgstr "Could not save font files. Please check that wp-content/fonts is writable."
msgid "Could not create fonts directory. Please check file permissions."
msgstr "Could not create fonts directory. Please check file permissions."
msgid "Invalid file path."
msgstr "Invalid file path."
msgid "Invalid font URL."
msgstr "Invalid font URL."
msgid "Invalid font name."
msgstr "Invalid font name."
msgid "No valid weights specified."
msgstr "No valid weights specified."
msgid "No valid styles specified."
msgstr "No valid styles specified."
msgid "An unexpected error occurred. Please try again."
msgstr "An unexpected error occurred. Please try again."
msgid "Thin"
msgstr "Thin"
msgid "Extra Light"
msgstr "Extra Light"
msgid "Light"
msgstr "Light"
msgid "Regular"
msgstr "Regular"
msgid "Medium"
msgstr "Medium"
msgid "Semi Bold"
msgstr "Semi Bold"
msgid "Bold"
msgstr "Bold"
msgid "Extra Bold"
msgstr "Extra Bold"
msgid "Black"
msgstr "Black"
msgid "You do not have sufficient permissions to access this page."
msgstr "You do not have sufficient permissions to access this page."
msgid "Import from Google Fonts"
msgstr "Import from Google Fonts"
msgid "Font Name"
msgstr "Font Name"
msgid "e.g., Open Sans"
msgstr "e.g., Open Sans"
msgid "Enter the exact font name as it appears on Google Fonts."
msgstr "Enter the exact font name as it appears on Google Fonts."
msgid "Weights"
msgstr "Weights"
msgid "Styles"
msgstr "Styles"
msgid "Normal"
msgstr "Normal"
msgid "Italic"
msgstr "Italic"
msgid "Files to download:"
msgstr "Files to download:"
msgid "Download & Install"
msgstr "Download & Install"
msgid "Installed Fonts"
msgstr "Installed Fonts"
msgid "No fonts installed yet."
msgstr "No fonts installed yet."
msgid "Delete"
msgstr "Delete"
msgid "Use %s to apply fonts to your site."
msgstr "Use %s to apply fonts to your site."
msgid "Appearance → Editor → Styles → Typography"
msgstr "Appearance → Editor → Styles → Typography"

View file

@ -0,0 +1,222 @@
# Translation of Maple Local Fonts in Spanish (Latin America)
# Copyright (C) 2024 Maple Open Technologies
# This file is distributed under the GPL-2.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: Maple Local Fonts 1.0.0\n"
"Report-Msgid-Bugs-To: https://mapleopentech.org/\n"
"Last-Translator: Maple Open Technologies\n"
"Language-Team: Spanish (Latin America)\n"
"Language: es_419\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2024-01-01T00:00:00+00:00\n"
"PO-Revision-Date: 2024-01-01T00:00:00+00:00\n"
"X-Generator: Manual\n"
"X-Domain: maple-local-fonts\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. Plugin Name of the plugin
msgid "Maple Local Fonts"
msgstr "Maple Local Fonts"
#. Description of the plugin
msgid "Import Google Fonts to local storage and register them with WordPress Font Library for GDPR-compliant, privacy-friendly typography."
msgstr "Importa fuentes de Google Fonts al almacenamiento local y regístralas con la biblioteca de fuentes de WordPress para una tipografía compatible con GDPR y respetuosa con la privacidad."
#. Author of the plugin
msgid "Maple Open Technologies"
msgstr "Maple Open Technologies"
msgid "Maple Local Fonts requires WordPress 6.5 or higher for Font Library support."
msgstr "Maple Local Fonts requiere WordPress 6.5 o superior para el soporte de la biblioteca de fuentes."
msgid "Plugin Activation Error"
msgstr "Error de activación del plugin"
msgid "Maple Local Fonts has been deactivated. It requires WordPress 6.5 or higher."
msgstr "Maple Local Fonts ha sido desactivado. Requiere WordPress 6.5 o superior."
msgid "Local Fonts"
msgstr "Fuentes locales"
msgid "Downloading..."
msgstr "Descargando..."
msgid "Deleting..."
msgstr "Eliminando..."
msgid "Are you sure you want to delete this font?"
msgstr "¿Estás seguro de que quieres eliminar esta fuente?"
msgid "An error occurred. Please try again."
msgstr "Ocurrió un error. Por favor, intenta de nuevo."
msgid "Please select at least one weight."
msgstr "Por favor, selecciona al menos un peso."
msgid "Please select at least one style."
msgstr "Por favor, selecciona al menos un estilo."
msgid "Please enter a font name."
msgstr "Por favor, ingresa un nombre de fuente."
msgid "Security check failed."
msgstr "Falló la verificación de seguridad."
msgid "Unauthorized."
msgstr "No autorizado."
msgid "Font name is required."
msgstr "El nombre de la fuente es requerido."
msgid "Invalid font name: only letters, numbers, spaces, and hyphens allowed."
msgstr "Nombre de fuente inválido: solo se permiten letras, números, espacios y guiones."
msgid "Font name is too long."
msgstr "El nombre de la fuente es demasiado largo."
msgid "At least one weight is required."
msgstr "Se requiere al menos un peso."
msgid "Too many weights selected."
msgstr "Demasiados pesos seleccionados."
msgid "At least one style is required."
msgstr "Se requiere al menos un estilo."
msgid "Successfully installed %s."
msgstr "%s se instaló correctamente."
msgid "An unexpected error occurred."
msgstr "Ocurrió un error inesperado."
msgid "Invalid font ID."
msgstr "ID de fuente inválido."
msgid "Font not found."
msgstr "Fuente no encontrada."
msgid "Cannot delete fonts not imported by this plugin."
msgstr "No se pueden eliminar fuentes que no fueron importadas por este plugin."
msgid "Font deleted successfully."
msgstr "Fuente eliminada correctamente."
msgid "Font not found on Google Fonts. Please check the spelling and try again."
msgstr "Fuente no encontrada en Google Fonts. Por favor, verifica la ortografía e intenta de nuevo."
msgid "This font is already installed."
msgstr "Esta fuente ya está instalada."
msgid "Could not connect to Google Fonts. Please check your internet connection and try again."
msgstr "No se pudo conectar a Google Fonts. Por favor, verifica tu conexión a internet e intenta de nuevo."
msgid "Google Fonts returned an error. Please try again later."
msgstr "Google Fonts devolvió un error. Por favor, intenta más tarde."
msgid "Could not process the font data. The font may not be available."
msgstr "No se pudieron procesar los datos de la fuente. La fuente puede no estar disponible."
msgid "Could not download the font files. Please try again."
msgstr "No se pudieron descargar los archivos de la fuente. Por favor, intenta de nuevo."
msgid "Could not save font files. Please check that wp-content/fonts is writable."
msgstr "No se pudieron guardar los archivos de la fuente. Por favor, verifica que wp-content/fonts tenga permisos de escritura."
msgid "Could not create fonts directory. Please check file permissions."
msgstr "No se pudo crear el directorio de fuentes. Por favor, verifica los permisos de archivos."
msgid "Invalid file path."
msgstr "Ruta de archivo inválida."
msgid "Invalid font URL."
msgstr "URL de fuente inválida."
msgid "Invalid font name."
msgstr "Nombre de fuente inválido."
msgid "No valid weights specified."
msgstr "No se especificaron pesos válidos."
msgid "No valid styles specified."
msgstr "No se especificaron estilos válidos."
msgid "An unexpected error occurred. Please try again."
msgstr "Ocurrió un error inesperado. Por favor, intenta de nuevo."
msgid "Thin"
msgstr "Delgada"
msgid "Extra Light"
msgstr "Extra ligera"
msgid "Light"
msgstr "Ligera"
msgid "Regular"
msgstr "Normal"
msgid "Medium"
msgstr "Media"
msgid "Semi Bold"
msgstr "Semi negrita"
msgid "Bold"
msgstr "Negrita"
msgid "Extra Bold"
msgstr "Extra negrita"
msgid "Black"
msgstr "Negra"
msgid "You do not have sufficient permissions to access this page."
msgstr "No tienes permisos suficientes para acceder a esta página."
msgid "Import from Google Fonts"
msgstr "Importar desde Google Fonts"
msgid "Font Name"
msgstr "Nombre de la fuente"
msgid "e.g., Open Sans"
msgstr "ej., Open Sans"
msgid "Enter the exact font name as it appears on Google Fonts."
msgstr "Ingresa el nombre exacto de la fuente como aparece en Google Fonts."
msgid "Weights"
msgstr "Pesos"
msgid "Styles"
msgstr "Estilos"
msgid "Normal"
msgstr "Normal"
msgid "Italic"
msgstr "Cursiva"
msgid "Files to download:"
msgstr "Archivos a descargar:"
msgid "Download & Install"
msgstr "Descargar e instalar"
msgid "Installed Fonts"
msgstr "Fuentes instaladas"
msgid "No fonts installed yet."
msgstr "Aún no hay fuentes instaladas."
msgid "Delete"
msgstr "Eliminar"
msgid "Use %s to apply fonts to your site."
msgstr "Usa %s para aplicar fuentes a tu sitio."
msgid "Appearance → Editor → Styles → Typography"
msgstr "Apariencia → Editor → Estilos → Tipografía"

View file

@ -0,0 +1,222 @@
# Translation of Maple Local Fonts in Spanish (Spain)
# Copyright (C) 2024 Maple Open Technologies
# This file is distributed under the GPL-2.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: Maple Local Fonts 1.0.0\n"
"Report-Msgid-Bugs-To: https://mapleopentech.org/\n"
"Last-Translator: Maple Open Technologies\n"
"Language-Team: Spanish (Spain)\n"
"Language: es_ES\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2024-01-01T00:00:00+00:00\n"
"PO-Revision-Date: 2024-01-01T00:00:00+00:00\n"
"X-Generator: Manual\n"
"X-Domain: maple-local-fonts\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. Plugin Name of the plugin
msgid "Maple Local Fonts"
msgstr "Maple Local Fonts"
#. Description of the plugin
msgid "Import Google Fonts to local storage and register them with WordPress Font Library for GDPR-compliant, privacy-friendly typography."
msgstr "Importa fuentes de Google Fonts al almacenamiento local y regístralas con la biblioteca de fuentes de WordPress para una tipografía compatible con el RGPD y respetuosa con la privacidad."
#. Author of the plugin
msgid "Maple Open Technologies"
msgstr "Maple Open Technologies"
msgid "Maple Local Fonts requires WordPress 6.5 or higher for Font Library support."
msgstr "Maple Local Fonts requiere WordPress 6.5 o superior para el soporte de la biblioteca de fuentes."
msgid "Plugin Activation Error"
msgstr "Error de activación del plugin"
msgid "Maple Local Fonts has been deactivated. It requires WordPress 6.5 or higher."
msgstr "Maple Local Fonts ha sido desactivado. Requiere WordPress 6.5 o superior."
msgid "Local Fonts"
msgstr "Fuentes locales"
msgid "Downloading..."
msgstr "Descargando..."
msgid "Deleting..."
msgstr "Eliminando..."
msgid "Are you sure you want to delete this font?"
msgstr "¿Estás seguro de que quieres eliminar esta fuente?"
msgid "An error occurred. Please try again."
msgstr "Se ha producido un error. Por favor, inténtalo de nuevo."
msgid "Please select at least one weight."
msgstr "Por favor, selecciona al menos un peso."
msgid "Please select at least one style."
msgstr "Por favor, selecciona al menos un estilo."
msgid "Please enter a font name."
msgstr "Por favor, introduce un nombre de fuente."
msgid "Security check failed."
msgstr "Ha fallado la verificación de seguridad."
msgid "Unauthorized."
msgstr "No autorizado."
msgid "Font name is required."
msgstr "El nombre de la fuente es obligatorio."
msgid "Invalid font name: only letters, numbers, spaces, and hyphens allowed."
msgstr "Nombre de fuente no válido: solo se permiten letras, números, espacios y guiones."
msgid "Font name is too long."
msgstr "El nombre de la fuente es demasiado largo."
msgid "At least one weight is required."
msgstr "Se requiere al menos un peso."
msgid "Too many weights selected."
msgstr "Demasiados pesos seleccionados."
msgid "At least one style is required."
msgstr "Se requiere al menos un estilo."
msgid "Successfully installed %s."
msgstr "%s se ha instalado correctamente."
msgid "An unexpected error occurred."
msgstr "Se ha producido un error inesperado."
msgid "Invalid font ID."
msgstr "ID de fuente no válido."
msgid "Font not found."
msgstr "Fuente no encontrada."
msgid "Cannot delete fonts not imported by this plugin."
msgstr "No se pueden eliminar fuentes que no hayan sido importadas por este plugin."
msgid "Font deleted successfully."
msgstr "Fuente eliminada correctamente."
msgid "Font not found on Google Fonts. Please check the spelling and try again."
msgstr "Fuente no encontrada en Google Fonts. Por favor, comprueba la ortografía e inténtalo de nuevo."
msgid "This font is already installed."
msgstr "Esta fuente ya está instalada."
msgid "Could not connect to Google Fonts. Please check your internet connection and try again."
msgstr "No se ha podido conectar a Google Fonts. Por favor, comprueba tu conexión a internet e inténtalo de nuevo."
msgid "Google Fonts returned an error. Please try again later."
msgstr "Google Fonts ha devuelto un error. Por favor, inténtalo más tarde."
msgid "Could not process the font data. The font may not be available."
msgstr "No se han podido procesar los datos de la fuente. La fuente puede no estar disponible."
msgid "Could not download the font files. Please try again."
msgstr "No se han podido descargar los archivos de la fuente. Por favor, inténtalo de nuevo."
msgid "Could not save font files. Please check that wp-content/fonts is writable."
msgstr "No se han podido guardar los archivos de la fuente. Por favor, comprueba que wp-content/fonts tenga permisos de escritura."
msgid "Could not create fonts directory. Please check file permissions."
msgstr "No se ha podido crear el directorio de fuentes. Por favor, comprueba los permisos de archivos."
msgid "Invalid file path."
msgstr "Ruta de archivo no válida."
msgid "Invalid font URL."
msgstr "URL de fuente no válida."
msgid "Invalid font name."
msgstr "Nombre de fuente no válido."
msgid "No valid weights specified."
msgstr "No se han especificado pesos válidos."
msgid "No valid styles specified."
msgstr "No se han especificado estilos válidos."
msgid "An unexpected error occurred. Please try again."
msgstr "Se ha producido un error inesperado. Por favor, inténtalo de nuevo."
msgid "Thin"
msgstr "Fina"
msgid "Extra Light"
msgstr "Extra ligera"
msgid "Light"
msgstr "Ligera"
msgid "Regular"
msgstr "Normal"
msgid "Medium"
msgstr "Media"
msgid "Semi Bold"
msgstr "Semi negrita"
msgid "Bold"
msgstr "Negrita"
msgid "Extra Bold"
msgstr "Extra negrita"
msgid "Black"
msgstr "Negra"
msgid "You do not have sufficient permissions to access this page."
msgstr "No tienes permisos suficientes para acceder a esta página."
msgid "Import from Google Fonts"
msgstr "Importar desde Google Fonts"
msgid "Font Name"
msgstr "Nombre de la fuente"
msgid "e.g., Open Sans"
msgstr "ej., Open Sans"
msgid "Enter the exact font name as it appears on Google Fonts."
msgstr "Introduce el nombre exacto de la fuente como aparece en Google Fonts."
msgid "Weights"
msgstr "Pesos"
msgid "Styles"
msgstr "Estilos"
msgid "Normal"
msgstr "Normal"
msgid "Italic"
msgstr "Cursiva"
msgid "Files to download:"
msgstr "Archivos a descargar:"
msgid "Download & Install"
msgstr "Descargar e instalar"
msgid "Installed Fonts"
msgstr "Fuentes instaladas"
msgid "No fonts installed yet."
msgstr "Aún no hay fuentes instaladas."
msgid "Delete"
msgstr "Eliminar"
msgid "Use %s to apply fonts to your site."
msgstr "Usa %s para aplicar fuentes a tu sitio."
msgid "Appearance → Editor → Styles → Typography"
msgstr "Apariencia → Editor → Estilos → Tipografía"

View file

@ -0,0 +1,222 @@
# Translation of Maple Local Fonts in Spanish (Mexico)
# Copyright (C) 2024 Maple Open Technologies
# This file is distributed under the GPL-2.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: Maple Local Fonts 1.0.0\n"
"Report-Msgid-Bugs-To: https://mapleopentech.org/\n"
"Last-Translator: Maple Open Technologies\n"
"Language-Team: Spanish (Mexico)\n"
"Language: es_MX\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2024-01-01T00:00:00+00:00\n"
"PO-Revision-Date: 2024-01-01T00:00:00+00:00\n"
"X-Generator: Manual\n"
"X-Domain: maple-local-fonts\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. Plugin Name of the plugin
msgid "Maple Local Fonts"
msgstr "Maple Local Fonts"
#. Description of the plugin
msgid "Import Google Fonts to local storage and register them with WordPress Font Library for GDPR-compliant, privacy-friendly typography."
msgstr "Importa fuentes de Google Fonts al almacenamiento local y regístralas con la biblioteca de fuentes de WordPress para una tipografía compatible con GDPR y respetuosa con la privacidad."
#. Author of the plugin
msgid "Maple Open Technologies"
msgstr "Maple Open Technologies"
msgid "Maple Local Fonts requires WordPress 6.5 or higher for Font Library support."
msgstr "Maple Local Fonts requiere WordPress 6.5 o superior para el soporte de la biblioteca de fuentes."
msgid "Plugin Activation Error"
msgstr "Error de activación del plugin"
msgid "Maple Local Fonts has been deactivated. It requires WordPress 6.5 or higher."
msgstr "Maple Local Fonts ha sido desactivado. Requiere WordPress 6.5 o superior."
msgid "Local Fonts"
msgstr "Fuentes locales"
msgid "Downloading..."
msgstr "Descargando..."
msgid "Deleting..."
msgstr "Eliminando..."
msgid "Are you sure you want to delete this font?"
msgstr "¿Estás seguro de que quieres eliminar esta fuente?"
msgid "An error occurred. Please try again."
msgstr "Ocurrió un error. Por favor, inténtalo de nuevo."
msgid "Please select at least one weight."
msgstr "Por favor, selecciona al menos un peso."
msgid "Please select at least one style."
msgstr "Por favor, selecciona al menos un estilo."
msgid "Please enter a font name."
msgstr "Por favor, ingresa un nombre de fuente."
msgid "Security check failed."
msgstr "Falló la verificación de seguridad."
msgid "Unauthorized."
msgstr "No autorizado."
msgid "Font name is required."
msgstr "El nombre de la fuente es requerido."
msgid "Invalid font name: only letters, numbers, spaces, and hyphens allowed."
msgstr "Nombre de fuente inválido: solo se permiten letras, números, espacios y guiones."
msgid "Font name is too long."
msgstr "El nombre de la fuente es demasiado largo."
msgid "At least one weight is required."
msgstr "Se requiere al menos un peso."
msgid "Too many weights selected."
msgstr "Demasiados pesos seleccionados."
msgid "At least one style is required."
msgstr "Se requiere al menos un estilo."
msgid "Successfully installed %s."
msgstr "%s se instaló correctamente."
msgid "An unexpected error occurred."
msgstr "Ocurrió un error inesperado."
msgid "Invalid font ID."
msgstr "ID de fuente inválido."
msgid "Font not found."
msgstr "Fuente no encontrada."
msgid "Cannot delete fonts not imported by this plugin."
msgstr "No se pueden eliminar fuentes que no fueron importadas por este plugin."
msgid "Font deleted successfully."
msgstr "Fuente eliminada correctamente."
msgid "Font not found on Google Fonts. Please check the spelling and try again."
msgstr "Fuente no encontrada en Google Fonts. Por favor, verifica la ortografía e inténtalo de nuevo."
msgid "This font is already installed."
msgstr "Esta fuente ya está instalada."
msgid "Could not connect to Google Fonts. Please check your internet connection and try again."
msgstr "No se pudo conectar a Google Fonts. Por favor, verifica tu conexión a internet e inténtalo de nuevo."
msgid "Google Fonts returned an error. Please try again later."
msgstr "Google Fonts devolvió un error. Por favor, inténtalo más tarde."
msgid "Could not process the font data. The font may not be available."
msgstr "No se pudieron procesar los datos de la fuente. La fuente puede no estar disponible."
msgid "Could not download the font files. Please try again."
msgstr "No se pudieron descargar los archivos de la fuente. Por favor, inténtalo de nuevo."
msgid "Could not save font files. Please check that wp-content/fonts is writable."
msgstr "No se pudieron guardar los archivos de la fuente. Por favor, verifica que wp-content/fonts tenga permisos de escritura."
msgid "Could not create fonts directory. Please check file permissions."
msgstr "No se pudo crear el directorio de fuentes. Por favor, verifica los permisos de archivos."
msgid "Invalid file path."
msgstr "Ruta de archivo inválida."
msgid "Invalid font URL."
msgstr "URL de fuente inválida."
msgid "Invalid font name."
msgstr "Nombre de fuente inválido."
msgid "No valid weights specified."
msgstr "No se especificaron pesos válidos."
msgid "No valid styles specified."
msgstr "No se especificaron estilos válidos."
msgid "An unexpected error occurred. Please try again."
msgstr "Ocurrió un error inesperado. Por favor, inténtalo de nuevo."
msgid "Thin"
msgstr "Delgada"
msgid "Extra Light"
msgstr "Extra ligera"
msgid "Light"
msgstr "Ligera"
msgid "Regular"
msgstr "Normal"
msgid "Medium"
msgstr "Media"
msgid "Semi Bold"
msgstr "Semi negrita"
msgid "Bold"
msgstr "Negrita"
msgid "Extra Bold"
msgstr "Extra negrita"
msgid "Black"
msgstr "Negra"
msgid "You do not have sufficient permissions to access this page."
msgstr "No tienes permisos suficientes para acceder a esta página."
msgid "Import from Google Fonts"
msgstr "Importar desde Google Fonts"
msgid "Font Name"
msgstr "Nombre de la fuente"
msgid "e.g., Open Sans"
msgstr "ej., Open Sans"
msgid "Enter the exact font name as it appears on Google Fonts."
msgstr "Ingresa el nombre exacto de la fuente como aparece en Google Fonts."
msgid "Weights"
msgstr "Pesos"
msgid "Styles"
msgstr "Estilos"
msgid "Normal"
msgstr "Normal"
msgid "Italic"
msgstr "Cursiva"
msgid "Files to download:"
msgstr "Archivos a descargar:"
msgid "Download & Install"
msgstr "Descargar e instalar"
msgid "Installed Fonts"
msgstr "Fuentes instaladas"
msgid "No fonts installed yet."
msgstr "Aún no hay fuentes instaladas."
msgid "Delete"
msgstr "Eliminar"
msgid "Use %s to apply fonts to your site."
msgstr "Usa %s para aplicar fuentes a tu sitio."
msgid "Appearance → Editor → Styles → Typography"
msgstr "Apariencia → Editor → Estilos → Tipografía"

View file

@ -0,0 +1,222 @@
# Translation of Maple Local Fonts in French (Canada)
# Copyright (C) 2024 Maple Open Technologies
# This file is distributed under the GPL-2.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: Maple Local Fonts 1.0.0\n"
"Report-Msgid-Bugs-To: https://mapleopentech.org/\n"
"Last-Translator: Maple Open Technologies\n"
"Language-Team: French (Canada)\n"
"Language: fr_CA\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2024-01-01T00:00:00+00:00\n"
"PO-Revision-Date: 2024-01-01T00:00:00+00:00\n"
"X-Generator: Manual\n"
"X-Domain: maple-local-fonts\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#. Plugin Name of the plugin
msgid "Maple Local Fonts"
msgstr "Maple Local Fonts"
#. Description of the plugin
msgid "Import Google Fonts to local storage and register them with WordPress Font Library for GDPR-compliant, privacy-friendly typography."
msgstr "Importez des polices Google Fonts vers le stockage local et enregistrez-les avec la bibliothèque de polices WordPress pour une typographie conforme au RGPD et respectueuse de la vie privée."
#. Author of the plugin
msgid "Maple Open Technologies"
msgstr "Maple Open Technologies"
msgid "Maple Local Fonts requires WordPress 6.5 or higher for Font Library support."
msgstr "Maple Local Fonts nécessite WordPress 6.5 ou supérieur pour la prise en charge de la bibliothèque de polices."
msgid "Plugin Activation Error"
msgstr "Erreur d'activation de l'extension"
msgid "Maple Local Fonts has been deactivated. It requires WordPress 6.5 or higher."
msgstr "Maple Local Fonts a été désactivé. Il nécessite WordPress 6.5 ou supérieur."
msgid "Local Fonts"
msgstr "Polices locales"
msgid "Downloading..."
msgstr "Téléchargement..."
msgid "Deleting..."
msgstr "Suppression..."
msgid "Are you sure you want to delete this font?"
msgstr "Êtes-vous certain de vouloir supprimer cette police?"
msgid "An error occurred. Please try again."
msgstr "Une erreur s'est produite. Veuillez réessayer."
msgid "Please select at least one weight."
msgstr "Veuillez sélectionner au moins une graisse."
msgid "Please select at least one style."
msgstr "Veuillez sélectionner au moins un style."
msgid "Please enter a font name."
msgstr "Veuillez entrer un nom de police."
msgid "Security check failed."
msgstr "Échec de la vérification de sécurité."
msgid "Unauthorized."
msgstr "Non autorisé."
msgid "Font name is required."
msgstr "Le nom de la police est requis."
msgid "Invalid font name: only letters, numbers, spaces, and hyphens allowed."
msgstr "Nom de police invalide : seuls les lettres, chiffres, espaces et tirets sont autorisés."
msgid "Font name is too long."
msgstr "Le nom de la police est trop long."
msgid "At least one weight is required."
msgstr "Au moins une graisse est requise."
msgid "Too many weights selected."
msgstr "Trop de graisses sélectionnées."
msgid "At least one style is required."
msgstr "Au moins un style est requis."
msgid "Successfully installed %s."
msgstr "%s a été installé avec succès."
msgid "An unexpected error occurred."
msgstr "Une erreur inattendue s'est produite."
msgid "Invalid font ID."
msgstr "ID de police invalide."
msgid "Font not found."
msgstr "Police introuvable."
msgid "Cannot delete fonts not imported by this plugin."
msgstr "Impossible de supprimer les polices non importées par cette extension."
msgid "Font deleted successfully."
msgstr "Police supprimée avec succès."
msgid "Font not found on Google Fonts. Please check the spelling and try again."
msgstr "Police introuvable sur Google Fonts. Veuillez vérifier l'orthographe et réessayer."
msgid "This font is already installed."
msgstr "Cette police est déjà installée."
msgid "Could not connect to Google Fonts. Please check your internet connection and try again."
msgstr "Connexion à Google Fonts impossible. Veuillez vérifier votre connexion Internet et réessayer."
msgid "Google Fonts returned an error. Please try again later."
msgstr "Google Fonts a retourné une erreur. Veuillez réessayer plus tard."
msgid "Could not process the font data. The font may not be available."
msgstr "Impossible de traiter les données de la police. La police n'est peut-être pas disponible."
msgid "Could not download the font files. Please try again."
msgstr "Impossible de télécharger les fichiers de police. Veuillez réessayer."
msgid "Could not save font files. Please check that wp-content/fonts is writable."
msgstr "Impossible d'enregistrer les fichiers de police. Veuillez vérifier que wp-content/fonts est accessible en écriture."
msgid "Could not create fonts directory. Please check file permissions."
msgstr "Impossible de créer le répertoire des polices. Veuillez vérifier les permissions de fichiers."
msgid "Invalid file path."
msgstr "Chemin de fichier invalide."
msgid "Invalid font URL."
msgstr "URL de police invalide."
msgid "Invalid font name."
msgstr "Nom de police invalide."
msgid "No valid weights specified."
msgstr "Aucune graisse valide spécifiée."
msgid "No valid styles specified."
msgstr "Aucun style valide spécifié."
msgid "An unexpected error occurred. Please try again."
msgstr "Une erreur inattendue s'est produite. Veuillez réessayer."
msgid "Thin"
msgstr "Fin"
msgid "Extra Light"
msgstr "Extra léger"
msgid "Light"
msgstr "Léger"
msgid "Regular"
msgstr "Normal"
msgid "Medium"
msgstr "Moyen"
msgid "Semi Bold"
msgstr "Semi-gras"
msgid "Bold"
msgstr "Gras"
msgid "Extra Bold"
msgstr "Extra gras"
msgid "Black"
msgstr "Noir"
msgid "You do not have sufficient permissions to access this page."
msgstr "Vous n'avez pas les permissions suffisantes pour accéder à cette page."
msgid "Import from Google Fonts"
msgstr "Importer depuis Google Fonts"
msgid "Font Name"
msgstr "Nom de la police"
msgid "e.g., Open Sans"
msgstr "ex. : Open Sans"
msgid "Enter the exact font name as it appears on Google Fonts."
msgstr "Entrez le nom exact de la police tel qu'il apparaît sur Google Fonts."
msgid "Weights"
msgstr "Graisses"
msgid "Styles"
msgstr "Styles"
msgid "Normal"
msgstr "Normal"
msgid "Italic"
msgstr "Italique"
msgid "Files to download:"
msgstr "Fichiers à télécharger :"
msgid "Download & Install"
msgstr "Télécharger et installer"
msgid "Installed Fonts"
msgstr "Polices installées"
msgid "No fonts installed yet."
msgstr "Aucune police installée pour le moment."
msgid "Delete"
msgstr "Supprimer"
msgid "Use %s to apply fonts to your site."
msgstr "Utilisez %s pour appliquer les polices à votre site."
msgid "Appearance → Editor → Styles → Typography"
msgstr "Apparence → Éditeur → Styles → Typographie"

View file

@ -0,0 +1,222 @@
# Translation of Maple Local Fonts in French (France)
# Copyright (C) 2024 Maple Open Technologies
# This file is distributed under the GPL-2.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: Maple Local Fonts 1.0.0\n"
"Report-Msgid-Bugs-To: https://mapleopentech.org/\n"
"Last-Translator: Maple Open Technologies\n"
"Language-Team: French (France)\n"
"Language: fr_FR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2024-01-01T00:00:00+00:00\n"
"PO-Revision-Date: 2024-01-01T00:00:00+00:00\n"
"X-Generator: Manual\n"
"X-Domain: maple-local-fonts\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#. Plugin Name of the plugin
msgid "Maple Local Fonts"
msgstr "Maple Local Fonts"
#. Description of the plugin
msgid "Import Google Fonts to local storage and register them with WordPress Font Library for GDPR-compliant, privacy-friendly typography."
msgstr "Importez des polices Google Fonts vers le stockage local et enregistrez-les avec la bibliothèque de polices WordPress pour une typographie conforme au RGPD et respectueuse de la vie privée."
#. Author of the plugin
msgid "Maple Open Technologies"
msgstr "Maple Open Technologies"
msgid "Maple Local Fonts requires WordPress 6.5 or higher for Font Library support."
msgstr "Maple Local Fonts nécessite WordPress 6.5 ou supérieur pour la prise en charge de la bibliothèque de polices."
msgid "Plugin Activation Error"
msgstr "Erreur d'activation de l'extension"
msgid "Maple Local Fonts has been deactivated. It requires WordPress 6.5 or higher."
msgstr "Maple Local Fonts a été désactivé. Il nécessite WordPress 6.5 ou supérieur."
msgid "Local Fonts"
msgstr "Polices locales"
msgid "Downloading..."
msgstr "Téléchargement..."
msgid "Deleting..."
msgstr "Suppression..."
msgid "Are you sure you want to delete this font?"
msgstr "Êtes-vous sûr de vouloir supprimer cette police ?"
msgid "An error occurred. Please try again."
msgstr "Une erreur s'est produite. Veuillez réessayer."
msgid "Please select at least one weight."
msgstr "Veuillez sélectionner au moins une graisse."
msgid "Please select at least one style."
msgstr "Veuillez sélectionner au moins un style."
msgid "Please enter a font name."
msgstr "Veuillez saisir un nom de police."
msgid "Security check failed."
msgstr "Échec de la vérification de sécurité."
msgid "Unauthorized."
msgstr "Non autorisé."
msgid "Font name is required."
msgstr "Le nom de la police est requis."
msgid "Invalid font name: only letters, numbers, spaces, and hyphens allowed."
msgstr "Nom de police non valide : seuls les lettres, chiffres, espaces et tirets sont autorisés."
msgid "Font name is too long."
msgstr "Le nom de la police est trop long."
msgid "At least one weight is required."
msgstr "Au moins une graisse est requise."
msgid "Too many weights selected."
msgstr "Trop de graisses sélectionnées."
msgid "At least one style is required."
msgstr "Au moins un style est requis."
msgid "Successfully installed %s."
msgstr "%s a été installé avec succès."
msgid "An unexpected error occurred."
msgstr "Une erreur inattendue s'est produite."
msgid "Invalid font ID."
msgstr "ID de police non valide."
msgid "Font not found."
msgstr "Police introuvable."
msgid "Cannot delete fonts not imported by this plugin."
msgstr "Impossible de supprimer les polices non importées par cette extension."
msgid "Font deleted successfully."
msgstr "Police supprimée avec succès."
msgid "Font not found on Google Fonts. Please check the spelling and try again."
msgstr "Police introuvable sur Google Fonts. Veuillez vérifier l'orthographe et réessayer."
msgid "This font is already installed."
msgstr "Cette police est déjà installée."
msgid "Could not connect to Google Fonts. Please check your internet connection and try again."
msgstr "Impossible de se connecter à Google Fonts. Veuillez vérifier votre connexion Internet et réessayer."
msgid "Google Fonts returned an error. Please try again later."
msgstr "Google Fonts a renvoyé une erreur. Veuillez réessayer plus tard."
msgid "Could not process the font data. The font may not be available."
msgstr "Impossible de traiter les données de la police. La police n'est peut-être pas disponible."
msgid "Could not download the font files. Please try again."
msgstr "Impossible de télécharger les fichiers de police. Veuillez réessayer."
msgid "Could not save font files. Please check that wp-content/fonts is writable."
msgstr "Impossible d'enregistrer les fichiers de police. Veuillez vérifier que wp-content/fonts est accessible en écriture."
msgid "Could not create fonts directory. Please check file permissions."
msgstr "Impossible de créer le répertoire des polices. Veuillez vérifier les droits d'accès aux fichiers."
msgid "Invalid file path."
msgstr "Chemin de fichier non valide."
msgid "Invalid font URL."
msgstr "URL de police non valide."
msgid "Invalid font name."
msgstr "Nom de police non valide."
msgid "No valid weights specified."
msgstr "Aucune graisse valide spécifiée."
msgid "No valid styles specified."
msgstr "Aucun style valide spécifié."
msgid "An unexpected error occurred. Please try again."
msgstr "Une erreur inattendue s'est produite. Veuillez réessayer."
msgid "Thin"
msgstr "Fin"
msgid "Extra Light"
msgstr "Extra léger"
msgid "Light"
msgstr "Léger"
msgid "Regular"
msgstr "Normal"
msgid "Medium"
msgstr "Moyen"
msgid "Semi Bold"
msgstr "Semi-gras"
msgid "Bold"
msgstr "Gras"
msgid "Extra Bold"
msgstr "Extra gras"
msgid "Black"
msgstr "Noir"
msgid "You do not have sufficient permissions to access this page."
msgstr "Vous n'avez pas les droits suffisants pour accéder à cette page."
msgid "Import from Google Fonts"
msgstr "Importer depuis Google Fonts"
msgid "Font Name"
msgstr "Nom de la police"
msgid "e.g., Open Sans"
msgstr "ex. : Open Sans"
msgid "Enter the exact font name as it appears on Google Fonts."
msgstr "Saisissez le nom exact de la police tel qu'il apparaît sur Google Fonts."
msgid "Weights"
msgstr "Graisses"
msgid "Styles"
msgstr "Styles"
msgid "Normal"
msgstr "Normal"
msgid "Italic"
msgstr "Italique"
msgid "Files to download:"
msgstr "Fichiers à télécharger :"
msgid "Download & Install"
msgstr "Télécharger et installer"
msgid "Installed Fonts"
msgstr "Polices installées"
msgid "No fonts installed yet."
msgstr "Aucune police installée pour le moment."
msgid "Delete"
msgstr "Supprimer"
msgid "Use %s to apply fonts to your site."
msgstr "Utilisez %s pour appliquer les polices à votre site."
msgid "Appearance → Editor → Styles → Typography"
msgstr "Apparence → Éditeur → Styles → Typographie"

View file

@ -0,0 +1,222 @@
# Translation of Maple Local Fonts in Hindi
# Copyright (C) 2024 Maple Open Technologies
# This file is distributed under the GPL-2.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: Maple Local Fonts 1.0.0\n"
"Report-Msgid-Bugs-To: https://mapleopentech.org/\n"
"Last-Translator: Maple Open Technologies\n"
"Language-Team: Hindi\n"
"Language: hi_IN\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2024-01-01T00:00:00+00:00\n"
"PO-Revision-Date: 2024-01-01T00:00:00+00:00\n"
"X-Generator: Manual\n"
"X-Domain: maple-local-fonts\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. Plugin Name of the plugin
msgid "Maple Local Fonts"
msgstr "Maple Local Fonts"
#. Description of the plugin
msgid "Import Google Fonts to local storage and register them with WordPress Font Library for GDPR-compliant, privacy-friendly typography."
msgstr "Google Fonts को स्थानीय भंडारण में आयात करें और उन्हें GDPR-अनुपालन और गोपनीयता-अनुकूल टाइपोग्राफी के लिए WordPress फ़ॉन्ट लाइब्रेरी में पंजीकृत करें।"
#. Author of the plugin
msgid "Maple Open Technologies"
msgstr "Maple Open Technologies"
msgid "Maple Local Fonts requires WordPress 6.5 or higher for Font Library support."
msgstr "Maple Local Fonts को फ़ॉन्ट लाइब्रेरी सहायता के लिए WordPress 6.5 या उच्चतर की आवश्यकता है।"
msgid "Plugin Activation Error"
msgstr "प्लगइन सक्रियण त्रुटि"
msgid "Maple Local Fonts has been deactivated. It requires WordPress 6.5 or higher."
msgstr "Maple Local Fonts निष्क्रिय कर दिया गया है। इसके लिए WordPress 6.5 या उच्चतर आवश्यक है।"
msgid "Local Fonts"
msgstr "स्थानीय फ़ॉन्ट"
msgid "Downloading..."
msgstr "डाउनलोड हो रहा है..."
msgid "Deleting..."
msgstr "हटाया जा रहा है..."
msgid "Are you sure you want to delete this font?"
msgstr "क्या आप वाकई इस फ़ॉन्ट को हटाना चाहते हैं?"
msgid "An error occurred. Please try again."
msgstr "एक त्रुटि हुई। कृपया पुनः प्रयास करें।"
msgid "Please select at least one weight."
msgstr "कृपया कम से कम एक वज़न चुनें।"
msgid "Please select at least one style."
msgstr "कृपया कम से कम एक शैली चुनें।"
msgid "Please enter a font name."
msgstr "कृपया फ़ॉन्ट का नाम दर्ज करें।"
msgid "Security check failed."
msgstr "सुरक्षा जांच विफल।"
msgid "Unauthorized."
msgstr "अनधिकृत।"
msgid "Font name is required."
msgstr "फ़ॉन्ट नाम आवश्यक है।"
msgid "Invalid font name: only letters, numbers, spaces, and hyphens allowed."
msgstr "अमान्य फ़ॉन्ट नाम: केवल अक्षर, संख्याएं, रिक्त स्थान और हाइफ़न की अनुमति है।"
msgid "Font name is too long."
msgstr "फ़ॉन्ट नाम बहुत लंबा है।"
msgid "At least one weight is required."
msgstr "कम से कम एक वज़न आवश्यक है।"
msgid "Too many weights selected."
msgstr "बहुत अधिक वज़न चुने गए।"
msgid "At least one style is required."
msgstr "कम से कम एक शैली आवश्यक है।"
msgid "Successfully installed %s."
msgstr "%s सफलतापूर्वक स्थापित किया गया।"
msgid "An unexpected error occurred."
msgstr "एक अप्रत्याशित त्रुटि हुई।"
msgid "Invalid font ID."
msgstr "अमान्य फ़ॉन्ट ID।"
msgid "Font not found."
msgstr "फ़ॉन्ट नहीं मिला।"
msgid "Cannot delete fonts not imported by this plugin."
msgstr "इस प्लगइन द्वारा आयात नहीं किए गए फ़ॉन्ट को हटाया नहीं जा सकता।"
msgid "Font deleted successfully."
msgstr "फ़ॉन्ट सफलतापूर्वक हटाया गया।"
msgid "Font not found on Google Fonts. Please check the spelling and try again."
msgstr "Google Fonts पर फ़ॉन्ट नहीं मिला। कृपया वर्तनी जांचें और पुनः प्रयास करें।"
msgid "This font is already installed."
msgstr "यह फ़ॉन्ट पहले से स्थापित है।"
msgid "Could not connect to Google Fonts. Please check your internet connection and try again."
msgstr "Google Fonts से कनेक्ट नहीं हो सका। कृपया अपना इंटरनेट कनेक्शन जांचें और पुनः प्रयास करें।"
msgid "Google Fonts returned an error. Please try again later."
msgstr "Google Fonts ने एक त्रुटि लौटाई। कृपया बाद में पुनः प्रयास करें।"
msgid "Could not process the font data. The font may not be available."
msgstr "फ़ॉन्ट डेटा को संसाधित नहीं किया जा सका। फ़ॉन्ट उपलब्ध नहीं हो सकता है।"
msgid "Could not download the font files. Please try again."
msgstr "फ़ॉन्ट फ़ाइलें डाउनलोड नहीं हो सकीं। कृपया पुनः प्रयास करें।"
msgid "Could not save font files. Please check that wp-content/fonts is writable."
msgstr "फ़ॉन्ट फ़ाइलें सहेजी नहीं जा सकीं। कृपया जांचें कि wp-content/fonts लिखने योग्य है।"
msgid "Could not create fonts directory. Please check file permissions."
msgstr "फ़ॉन्ट निर्देशिका नहीं बनाई जा सकी। कृपया फ़ाइल अनुमतियां जांचें।"
msgid "Invalid file path."
msgstr "अमान्य फ़ाइल पथ।"
msgid "Invalid font URL."
msgstr "अमान्य फ़ॉन्ट URL।"
msgid "Invalid font name."
msgstr "अमान्य फ़ॉन्ट नाम।"
msgid "No valid weights specified."
msgstr "कोई वैध वज़न निर्दिष्ट नहीं।"
msgid "No valid styles specified."
msgstr "कोई वैध शैली निर्दिष्ट नहीं।"
msgid "An unexpected error occurred. Please try again."
msgstr "एक अप्रत्याशित त्रुटि हुई। कृपया पुनः प्रयास करें।"
msgid "Thin"
msgstr "पतला"
msgid "Extra Light"
msgstr "अति हल्का"
msgid "Light"
msgstr "हल्का"
msgid "Regular"
msgstr "सामान्य"
msgid "Medium"
msgstr "मध्यम"
msgid "Semi Bold"
msgstr "अर्ध-मोटा"
msgid "Bold"
msgstr "मोटा"
msgid "Extra Bold"
msgstr "अति मोटा"
msgid "Black"
msgstr "काला"
msgid "You do not have sufficient permissions to access this page."
msgstr "आपके पास इस पृष्ठ तक पहुंचने की पर्याप्त अनुमति नहीं है।"
msgid "Import from Google Fonts"
msgstr "Google Fonts से आयात करें"
msgid "Font Name"
msgstr "फ़ॉन्ट नाम"
msgid "e.g., Open Sans"
msgstr "उदा., Open Sans"
msgid "Enter the exact font name as it appears on Google Fonts."
msgstr "Google Fonts पर दिखाई देने वाला सटीक फ़ॉन्ट नाम दर्ज करें।"
msgid "Weights"
msgstr "वज़न"
msgid "Styles"
msgstr "शैलियां"
msgid "Normal"
msgstr "सामान्य"
msgid "Italic"
msgstr "इटैलिक"
msgid "Files to download:"
msgstr "डाउनलोड करने के लिए फ़ाइलें:"
msgid "Download & Install"
msgstr "डाउनलोड और इंस्टॉल करें"
msgid "Installed Fonts"
msgstr "स्थापित फ़ॉन्ट"
msgid "No fonts installed yet."
msgstr "अभी तक कोई फ़ॉन्ट स्थापित नहीं।"
msgid "Delete"
msgstr "हटाएं"
msgid "Use %s to apply fonts to your site."
msgstr "अपनी साइट पर फ़ॉन्ट लागू करने के लिए %s का उपयोग करें।"
msgid "Appearance → Editor → Styles → Typography"
msgstr "प्रकटन → संपादक → शैलियां → टाइपोग्राफी"

View file

@ -0,0 +1,222 @@
# Translation of Maple Local Fonts in Indonesian
# Copyright (C) 2024 Maple Open Technologies
# This file is distributed under the GPL-2.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: Maple Local Fonts 1.0.0\n"
"Report-Msgid-Bugs-To: https://mapleopentech.org/\n"
"Last-Translator: Maple Open Technologies\n"
"Language-Team: Indonesian\n"
"Language: id_ID\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2024-01-01T00:00:00+00:00\n"
"PO-Revision-Date: 2024-01-01T00:00:00+00:00\n"
"X-Generator: Manual\n"
"X-Domain: maple-local-fonts\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#. Plugin Name of the plugin
msgid "Maple Local Fonts"
msgstr "Maple Local Fonts"
#. Description of the plugin
msgid "Import Google Fonts to local storage and register them with WordPress Font Library for GDPR-compliant, privacy-friendly typography."
msgstr "Impor Google Fonts ke penyimpanan lokal dan daftarkan ke Perpustakaan Font WordPress untuk tipografi yang sesuai GDPR dan ramah privasi."
#. Author of the plugin
msgid "Maple Open Technologies"
msgstr "Maple Open Technologies"
msgid "Maple Local Fonts requires WordPress 6.5 or higher for Font Library support."
msgstr "Maple Local Fonts memerlukan WordPress 6.5 atau lebih tinggi untuk dukungan Perpustakaan Font."
msgid "Plugin Activation Error"
msgstr "Kesalahan Aktivasi Plugin"
msgid "Maple Local Fonts has been deactivated. It requires WordPress 6.5 or higher."
msgstr "Maple Local Fonts telah dinonaktifkan. Memerlukan WordPress 6.5 atau lebih tinggi."
msgid "Local Fonts"
msgstr "Font Lokal"
msgid "Downloading..."
msgstr "Mengunduh..."
msgid "Deleting..."
msgstr "Menghapus..."
msgid "Are you sure you want to delete this font?"
msgstr "Apakah Anda yakin ingin menghapus font ini?"
msgid "An error occurred. Please try again."
msgstr "Terjadi kesalahan. Silakan coba lagi."
msgid "Please select at least one weight."
msgstr "Silakan pilih setidaknya satu ketebalan."
msgid "Please select at least one style."
msgstr "Silakan pilih setidaknya satu gaya."
msgid "Please enter a font name."
msgstr "Silakan masukkan nama font."
msgid "Security check failed."
msgstr "Pemeriksaan keamanan gagal."
msgid "Unauthorized."
msgstr "Tidak diizinkan."
msgid "Font name is required."
msgstr "Nama font diperlukan."
msgid "Invalid font name: only letters, numbers, spaces, and hyphens allowed."
msgstr "Nama font tidak valid: hanya huruf, angka, spasi, dan tanda hubung yang diperbolehkan."
msgid "Font name is too long."
msgstr "Nama font terlalu panjang."
msgid "At least one weight is required."
msgstr "Setidaknya satu ketebalan diperlukan."
msgid "Too many weights selected."
msgstr "Terlalu banyak ketebalan dipilih."
msgid "At least one style is required."
msgstr "Setidaknya satu gaya diperlukan."
msgid "Successfully installed %s."
msgstr "Berhasil menginstal %s."
msgid "An unexpected error occurred."
msgstr "Terjadi kesalahan yang tidak terduga."
msgid "Invalid font ID."
msgstr "ID font tidak valid."
msgid "Font not found."
msgstr "Font tidak ditemukan."
msgid "Cannot delete fonts not imported by this plugin."
msgstr "Tidak dapat menghapus font yang tidak diimpor oleh plugin ini."
msgid "Font deleted successfully."
msgstr "Font berhasil dihapus."
msgid "Font not found on Google Fonts. Please check the spelling and try again."
msgstr "Font tidak ditemukan di Google Fonts. Silakan periksa ejaan dan coba lagi."
msgid "This font is already installed."
msgstr "Font ini sudah terinstal."
msgid "Could not connect to Google Fonts. Please check your internet connection and try again."
msgstr "Tidak dapat terhubung ke Google Fonts. Silakan periksa koneksi internet Anda dan coba lagi."
msgid "Google Fonts returned an error. Please try again later."
msgstr "Google Fonts mengembalikan kesalahan. Silakan coba lagi nanti."
msgid "Could not process the font data. The font may not be available."
msgstr "Tidak dapat memproses data font. Font mungkin tidak tersedia."
msgid "Could not download the font files. Please try again."
msgstr "Tidak dapat mengunduh file font. Silakan coba lagi."
msgid "Could not save font files. Please check that wp-content/fonts is writable."
msgstr "Tidak dapat menyimpan file font. Silakan periksa apakah wp-content/fonts dapat ditulis."
msgid "Could not create fonts directory. Please check file permissions."
msgstr "Tidak dapat membuat direktori font. Silakan periksa izin file."
msgid "Invalid file path."
msgstr "Jalur file tidak valid."
msgid "Invalid font URL."
msgstr "URL font tidak valid."
msgid "Invalid font name."
msgstr "Nama font tidak valid."
msgid "No valid weights specified."
msgstr "Tidak ada ketebalan valid yang ditentukan."
msgid "No valid styles specified."
msgstr "Tidak ada gaya valid yang ditentukan."
msgid "An unexpected error occurred. Please try again."
msgstr "Terjadi kesalahan yang tidak terduga. Silakan coba lagi."
msgid "Thin"
msgstr "Tipis"
msgid "Extra Light"
msgstr "Ekstra Ringan"
msgid "Light"
msgstr "Ringan"
msgid "Regular"
msgstr "Reguler"
msgid "Medium"
msgstr "Sedang"
msgid "Semi Bold"
msgstr "Semi Tebal"
msgid "Bold"
msgstr "Tebal"
msgid "Extra Bold"
msgstr "Ekstra Tebal"
msgid "Black"
msgstr "Hitam"
msgid "You do not have sufficient permissions to access this page."
msgstr "Anda tidak memiliki izin yang cukup untuk mengakses halaman ini."
msgid "Import from Google Fonts"
msgstr "Impor dari Google Fonts"
msgid "Font Name"
msgstr "Nama Font"
msgid "e.g., Open Sans"
msgstr "contoh: Open Sans"
msgid "Enter the exact font name as it appears on Google Fonts."
msgstr "Masukkan nama font persis seperti yang muncul di Google Fonts."
msgid "Weights"
msgstr "Ketebalan"
msgid "Styles"
msgstr "Gaya"
msgid "Normal"
msgstr "Normal"
msgid "Italic"
msgstr "Miring"
msgid "Files to download:"
msgstr "File untuk diunduh:"
msgid "Download & Install"
msgstr "Unduh & Instal"
msgid "Installed Fonts"
msgstr "Font Terinstal"
msgid "No fonts installed yet."
msgstr "Belum ada font yang terinstal."
msgid "Delete"
msgstr "Hapus"
msgid "Use %s to apply fonts to your site."
msgstr "Gunakan %s untuk menerapkan font ke situs Anda."
msgid "Appearance → Editor → Styles → Typography"
msgstr "Tampilan → Editor → Gaya → Tipografi"

View file

@ -0,0 +1,222 @@
# Translation of Maple Local Fonts in Italian
# Copyright (C) 2024 Maple Open Technologies
# This file is distributed under the GPL-2.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: Maple Local Fonts 1.0.0\n"
"Report-Msgid-Bugs-To: https://mapleopentech.org/\n"
"Last-Translator: Maple Open Technologies\n"
"Language-Team: Italian\n"
"Language: it_IT\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2024-01-01T00:00:00+00:00\n"
"PO-Revision-Date: 2024-01-01T00:00:00+00:00\n"
"X-Generator: Manual\n"
"X-Domain: maple-local-fonts\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. Plugin Name of the plugin
msgid "Maple Local Fonts"
msgstr "Maple Local Fonts"
#. Description of the plugin
msgid "Import Google Fonts to local storage and register them with WordPress Font Library for GDPR-compliant, privacy-friendly typography."
msgstr "Importa i font di Google Fonts nello storage locale e registrali nella libreria font di WordPress per una tipografia conforme al GDPR e rispettosa della privacy."
#. Author of the plugin
msgid "Maple Open Technologies"
msgstr "Maple Open Technologies"
msgid "Maple Local Fonts requires WordPress 6.5 or higher for Font Library support."
msgstr "Maple Local Fonts richiede WordPress 6.5 o superiore per il supporto della libreria font."
msgid "Plugin Activation Error"
msgstr "Errore di attivazione del plugin"
msgid "Maple Local Fonts has been deactivated. It requires WordPress 6.5 or higher."
msgstr "Maple Local Fonts è stato disattivato. Richiede WordPress 6.5 o superiore."
msgid "Local Fonts"
msgstr "Font locali"
msgid "Downloading..."
msgstr "Download in corso..."
msgid "Deleting..."
msgstr "Eliminazione in corso..."
msgid "Are you sure you want to delete this font?"
msgstr "Sei sicuro di voler eliminare questo font?"
msgid "An error occurred. Please try again."
msgstr "Si è verificato un errore. Riprova."
msgid "Please select at least one weight."
msgstr "Seleziona almeno un peso."
msgid "Please select at least one style."
msgstr "Seleziona almeno uno stile."
msgid "Please enter a font name."
msgstr "Inserisci un nome del font."
msgid "Security check failed."
msgstr "Controllo di sicurezza fallito."
msgid "Unauthorized."
msgstr "Non autorizzato."
msgid "Font name is required."
msgstr "Il nome del font è obbligatorio."
msgid "Invalid font name: only letters, numbers, spaces, and hyphens allowed."
msgstr "Nome del font non valido: sono consentiti solo lettere, numeri, spazi e trattini."
msgid "Font name is too long."
msgstr "Il nome del font è troppo lungo."
msgid "At least one weight is required."
msgstr "È richiesto almeno un peso."
msgid "Too many weights selected."
msgstr "Troppi pesi selezionati."
msgid "At least one style is required."
msgstr "È richiesto almeno uno stile."
msgid "Successfully installed %s."
msgstr "%s è stato installato con successo."
msgid "An unexpected error occurred."
msgstr "Si è verificato un errore imprevisto."
msgid "Invalid font ID."
msgstr "ID font non valido."
msgid "Font not found."
msgstr "Font non trovato."
msgid "Cannot delete fonts not imported by this plugin."
msgstr "Impossibile eliminare i font non importati da questo plugin."
msgid "Font deleted successfully."
msgstr "Font eliminato con successo."
msgid "Font not found on Google Fonts. Please check the spelling and try again."
msgstr "Font non trovato su Google Fonts. Controlla l'ortografia e riprova."
msgid "This font is already installed."
msgstr "Questo font è già installato."
msgid "Could not connect to Google Fonts. Please check your internet connection and try again."
msgstr "Impossibile connettersi a Google Fonts. Controlla la tua connessione internet e riprova."
msgid "Google Fonts returned an error. Please try again later."
msgstr "Google Fonts ha restituito un errore. Riprova più tardi."
msgid "Could not process the font data. The font may not be available."
msgstr "Impossibile elaborare i dati del font. Il font potrebbe non essere disponibile."
msgid "Could not download the font files. Please try again."
msgstr "Impossibile scaricare i file del font. Riprova."
msgid "Could not save font files. Please check that wp-content/fonts is writable."
msgstr "Impossibile salvare i file del font. Verifica che wp-content/fonts sia scrivibile."
msgid "Could not create fonts directory. Please check file permissions."
msgstr "Impossibile creare la directory dei font. Controlla i permessi dei file."
msgid "Invalid file path."
msgstr "Percorso file non valido."
msgid "Invalid font URL."
msgstr "URL del font non valido."
msgid "Invalid font name."
msgstr "Nome del font non valido."
msgid "No valid weights specified."
msgstr "Nessun peso valido specificato."
msgid "No valid styles specified."
msgstr "Nessuno stile valido specificato."
msgid "An unexpected error occurred. Please try again."
msgstr "Si è verificato un errore imprevisto. Riprova."
msgid "Thin"
msgstr "Sottile"
msgid "Extra Light"
msgstr "Extra leggero"
msgid "Light"
msgstr "Leggero"
msgid "Regular"
msgstr "Normale"
msgid "Medium"
msgstr "Medio"
msgid "Semi Bold"
msgstr "Semi grassetto"
msgid "Bold"
msgstr "Grassetto"
msgid "Extra Bold"
msgstr "Extra grassetto"
msgid "Black"
msgstr "Nero"
msgid "You do not have sufficient permissions to access this page."
msgstr "Non hai i permessi sufficienti per accedere a questa pagina."
msgid "Import from Google Fonts"
msgstr "Importa da Google Fonts"
msgid "Font Name"
msgstr "Nome del font"
msgid "e.g., Open Sans"
msgstr "es. Open Sans"
msgid "Enter the exact font name as it appears on Google Fonts."
msgstr "Inserisci il nome esatto del font come appare su Google Fonts."
msgid "Weights"
msgstr "Pesi"
msgid "Styles"
msgstr "Stili"
msgid "Normal"
msgstr "Normale"
msgid "Italic"
msgstr "Corsivo"
msgid "Files to download:"
msgstr "File da scaricare:"
msgid "Download & Install"
msgstr "Scarica e installa"
msgid "Installed Fonts"
msgstr "Font installati"
msgid "No fonts installed yet."
msgstr "Nessun font installato."
msgid "Delete"
msgstr "Elimina"
msgid "Use %s to apply fonts to your site."
msgstr "Usa %s per applicare i font al tuo sito."
msgid "Appearance → Editor → Styles → Typography"
msgstr "Aspetto → Editor → Stili → Tipografia"

View file

@ -0,0 +1,222 @@
# Translation of Maple Local Fonts in Japanese
# Copyright (C) 2024 Maple Open Technologies
# This file is distributed under the GPL-2.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: Maple Local Fonts 1.0.0\n"
"Report-Msgid-Bugs-To: https://mapleopentech.org/\n"
"Last-Translator: Maple Open Technologies\n"
"Language-Team: Japanese\n"
"Language: ja\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2024-01-01T00:00:00+00:00\n"
"PO-Revision-Date: 2024-01-01T00:00:00+00:00\n"
"X-Generator: Manual\n"
"X-Domain: maple-local-fonts\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#. Plugin Name of the plugin
msgid "Maple Local Fonts"
msgstr "Maple Local Fonts"
#. Description of the plugin
msgid "Import Google Fonts to local storage and register them with WordPress Font Library for GDPR-compliant, privacy-friendly typography."
msgstr "Google FontsをローカルストレージにインポートしてWordPressフォントライブラリに登録し、GDPR準拠でプライバシーに配慮したタイポグラフィを実現します。"
#. Author of the plugin
msgid "Maple Open Technologies"
msgstr "Maple Open Technologies"
msgid "Maple Local Fonts requires WordPress 6.5 or higher for Font Library support."
msgstr "Maple Local FontsはフォントライブラリのサポートにWordPress 6.5以上が必要です。"
msgid "Plugin Activation Error"
msgstr "プラグイン有効化エラー"
msgid "Maple Local Fonts has been deactivated. It requires WordPress 6.5 or higher."
msgstr "Maple Local Fontsは無効化されました。WordPress 6.5以上が必要です。"
msgid "Local Fonts"
msgstr "ローカルフォント"
msgid "Downloading..."
msgstr "ダウンロード中..."
msgid "Deleting..."
msgstr "削除中..."
msgid "Are you sure you want to delete this font?"
msgstr "このフォントを削除してもよろしいですか?"
msgid "An error occurred. Please try again."
msgstr "エラーが発生しました。もう一度お試しください。"
msgid "Please select at least one weight."
msgstr "少なくとも1つのウェイトを選択してください。"
msgid "Please select at least one style."
msgstr "少なくとも1つのスタイルを選択してください。"
msgid "Please enter a font name."
msgstr "フォント名を入力してください。"
msgid "Security check failed."
msgstr "セキュリティチェックに失敗しました。"
msgid "Unauthorized."
msgstr "権限がありません。"
msgid "Font name is required."
msgstr "フォント名は必須です。"
msgid "Invalid font name: only letters, numbers, spaces, and hyphens allowed."
msgstr "無効なフォント名:文字、数字、スペース、ハイフンのみ使用できます。"
msgid "Font name is too long."
msgstr "フォント名が長すぎます。"
msgid "At least one weight is required."
msgstr "少なくとも1つのウェイトが必要です。"
msgid "Too many weights selected."
msgstr "選択されたウェイトが多すぎます。"
msgid "At least one style is required."
msgstr "少なくとも1つのスタイルが必要です。"
msgid "Successfully installed %s."
msgstr "%sのインストールに成功しました。"
msgid "An unexpected error occurred."
msgstr "予期しないエラーが発生しました。"
msgid "Invalid font ID."
msgstr "無効なフォントID。"
msgid "Font not found."
msgstr "フォントが見つかりません。"
msgid "Cannot delete fonts not imported by this plugin."
msgstr "このプラグインでインポートされていないフォントは削除できません。"
msgid "Font deleted successfully."
msgstr "フォントが正常に削除されました。"
msgid "Font not found on Google Fonts. Please check the spelling and try again."
msgstr "Google Fontsでフォントが見つかりません。スペルを確認して、もう一度お試しください。"
msgid "This font is already installed."
msgstr "このフォントは既にインストールされています。"
msgid "Could not connect to Google Fonts. Please check your internet connection and try again."
msgstr "Google Fontsに接続できませんでした。インターネット接続を確認して、もう一度お試しください。"
msgid "Google Fonts returned an error. Please try again later."
msgstr "Google Fontsがエラーを返しました。後でもう一度お試しください。"
msgid "Could not process the font data. The font may not be available."
msgstr "フォントデータを処理できませんでした。フォントは利用できない可能性があります。"
msgid "Could not download the font files. Please try again."
msgstr "フォントファイルをダウンロードできませんでした。もう一度お試しください。"
msgid "Could not save font files. Please check that wp-content/fonts is writable."
msgstr "フォントファイルを保存できませんでした。wp-content/fontsが書き込み可能か確認してください。"
msgid "Could not create fonts directory. Please check file permissions."
msgstr "フォントディレクトリを作成できませんでした。ファイルのパーミッションを確認してください。"
msgid "Invalid file path."
msgstr "無効なファイルパス。"
msgid "Invalid font URL."
msgstr "無効なフォントURL。"
msgid "Invalid font name."
msgstr "無効なフォント名。"
msgid "No valid weights specified."
msgstr "有効なウェイトが指定されていません。"
msgid "No valid styles specified."
msgstr "有効なスタイルが指定されていません。"
msgid "An unexpected error occurred. Please try again."
msgstr "予期しないエラーが発生しました。もう一度お試しください。"
msgid "Thin"
msgstr "極細"
msgid "Extra Light"
msgstr "極細"
msgid "Light"
msgstr "細字"
msgid "Regular"
msgstr "標準"
msgid "Medium"
msgstr "中字"
msgid "Semi Bold"
msgstr "やや太字"
msgid "Bold"
msgstr "太字"
msgid "Extra Bold"
msgstr "極太"
msgid "Black"
msgstr "極太"
msgid "You do not have sufficient permissions to access this page."
msgstr "このページにアクセスする権限がありません。"
msgid "Import from Google Fonts"
msgstr "Google Fontsからインポート"
msgid "Font Name"
msgstr "フォント名"
msgid "e.g., Open Sans"
msgstr "例Open Sans"
msgid "Enter the exact font name as it appears on Google Fonts."
msgstr "Google Fontsに表示されている正確なフォント名を入力してください。"
msgid "Weights"
msgstr "ウェイト"
msgid "Styles"
msgstr "スタイル"
msgid "Normal"
msgstr "標準"
msgid "Italic"
msgstr "イタリック"
msgid "Files to download:"
msgstr "ダウンロードするファイル:"
msgid "Download & Install"
msgstr "ダウンロード&インストール"
msgid "Installed Fonts"
msgstr "インストール済みフォント"
msgid "No fonts installed yet."
msgstr "まだフォントがインストールされていません。"
msgid "Delete"
msgstr "削除"
msgid "Use %s to apply fonts to your site."
msgstr "%sを使用してサイトにフォントを適用してください。"
msgid "Appearance → Editor → Styles → Typography"
msgstr "外観 → エディター → スタイル → タイポグラフィ"

View file

@ -0,0 +1,222 @@
# Translation of Maple Local Fonts in Korean
# Copyright (C) 2024 Maple Open Technologies
# This file is distributed under the GPL-2.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: Maple Local Fonts 1.0.0\n"
"Report-Msgid-Bugs-To: https://mapleopentech.org/\n"
"Last-Translator: Maple Open Technologies\n"
"Language-Team: Korean\n"
"Language: ko_KR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2024-01-01T00:00:00+00:00\n"
"PO-Revision-Date: 2024-01-01T00:00:00+00:00\n"
"X-Generator: Manual\n"
"X-Domain: maple-local-fonts\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#. Plugin Name of the plugin
msgid "Maple Local Fonts"
msgstr "Maple Local Fonts"
#. Description of the plugin
msgid "Import Google Fonts to local storage and register them with WordPress Font Library for GDPR-compliant, privacy-friendly typography."
msgstr "Google Fonts를 로컬 저장소로 가져와 WordPress 글꼴 라이브러리에 등록하여 GDPR 준수 및 개인정보 보호 친화적인 타이포그래피를 구현합니다."
#. Author of the plugin
msgid "Maple Open Technologies"
msgstr "Maple Open Technologies"
msgid "Maple Local Fonts requires WordPress 6.5 or higher for Font Library support."
msgstr "Maple Local Fonts는 글꼴 라이브러리 지원을 위해 WordPress 6.5 이상이 필요합니다."
msgid "Plugin Activation Error"
msgstr "플러그인 활성화 오류"
msgid "Maple Local Fonts has been deactivated. It requires WordPress 6.5 or higher."
msgstr "Maple Local Fonts가 비활성화되었습니다. WordPress 6.5 이상이 필요합니다."
msgid "Local Fonts"
msgstr "로컬 글꼴"
msgid "Downloading..."
msgstr "다운로드 중..."
msgid "Deleting..."
msgstr "삭제 중..."
msgid "Are you sure you want to delete this font?"
msgstr "이 글꼴을 삭제하시겠습니까?"
msgid "An error occurred. Please try again."
msgstr "오류가 발생했습니다. 다시 시도해 주세요."
msgid "Please select at least one weight."
msgstr "최소 하나의 굵기를 선택해 주세요."
msgid "Please select at least one style."
msgstr "최소 하나의 스타일을 선택해 주세요."
msgid "Please enter a font name."
msgstr "글꼴 이름을 입력해 주세요."
msgid "Security check failed."
msgstr "보안 검사 실패."
msgid "Unauthorized."
msgstr "권한이 없습니다."
msgid "Font name is required."
msgstr "글꼴 이름은 필수입니다."
msgid "Invalid font name: only letters, numbers, spaces, and hyphens allowed."
msgstr "잘못된 글꼴 이름: 문자, 숫자, 공백 및 하이픈만 허용됩니다."
msgid "Font name is too long."
msgstr "글꼴 이름이 너무 깁니다."
msgid "At least one weight is required."
msgstr "최소 하나의 굵기가 필요합니다."
msgid "Too many weights selected."
msgstr "너무 많은 굵기가 선택되었습니다."
msgid "At least one style is required."
msgstr "최소 하나의 스타일이 필요합니다."
msgid "Successfully installed %s."
msgstr "%s이(가) 성공적으로 설치되었습니다."
msgid "An unexpected error occurred."
msgstr "예기치 않은 오류가 발생했습니다."
msgid "Invalid font ID."
msgstr "잘못된 글꼴 ID."
msgid "Font not found."
msgstr "글꼴을 찾을 수 없습니다."
msgid "Cannot delete fonts not imported by this plugin."
msgstr "이 플러그인으로 가져오지 않은 글꼴은 삭제할 수 없습니다."
msgid "Font deleted successfully."
msgstr "글꼴이 성공적으로 삭제되었습니다."
msgid "Font not found on Google Fonts. Please check the spelling and try again."
msgstr "Google Fonts에서 글꼴을 찾을 수 없습니다. 철자를 확인하고 다시 시도해 주세요."
msgid "This font is already installed."
msgstr "이 글꼴은 이미 설치되어 있습니다."
msgid "Could not connect to Google Fonts. Please check your internet connection and try again."
msgstr "Google Fonts에 연결할 수 없습니다. 인터넷 연결을 확인하고 다시 시도해 주세요."
msgid "Google Fonts returned an error. Please try again later."
msgstr "Google Fonts에서 오류가 반환되었습니다. 나중에 다시 시도해 주세요."
msgid "Could not process the font data. The font may not be available."
msgstr "글꼴 데이터를 처리할 수 없습니다. 글꼴을 사용할 수 없을 수 있습니다."
msgid "Could not download the font files. Please try again."
msgstr "글꼴 파일을 다운로드할 수 없습니다. 다시 시도해 주세요."
msgid "Could not save font files. Please check that wp-content/fonts is writable."
msgstr "글꼴 파일을 저장할 수 없습니다. wp-content/fonts가 쓰기 가능한지 확인해 주세요."
msgid "Could not create fonts directory. Please check file permissions."
msgstr "글꼴 디렉토리를 생성할 수 없습니다. 파일 권한을 확인해 주세요."
msgid "Invalid file path."
msgstr "잘못된 파일 경로."
msgid "Invalid font URL."
msgstr "잘못된 글꼴 URL."
msgid "Invalid font name."
msgstr "잘못된 글꼴 이름."
msgid "No valid weights specified."
msgstr "유효한 굵기가 지정되지 않았습니다."
msgid "No valid styles specified."
msgstr "유효한 스타일이 지정되지 않았습니다."
msgid "An unexpected error occurred. Please try again."
msgstr "예기치 않은 오류가 발생했습니다. 다시 시도해 주세요."
msgid "Thin"
msgstr "가늘게"
msgid "Extra Light"
msgstr "매우 가볍게"
msgid "Light"
msgstr "가볍게"
msgid "Regular"
msgstr "보통"
msgid "Medium"
msgstr "중간"
msgid "Semi Bold"
msgstr "약간 굵게"
msgid "Bold"
msgstr "굵게"
msgid "Extra Bold"
msgstr "매우 굵게"
msgid "Black"
msgstr "검정"
msgid "You do not have sufficient permissions to access this page."
msgstr "이 페이지에 접근할 권한이 없습니다."
msgid "Import from Google Fonts"
msgstr "Google Fonts에서 가져오기"
msgid "Font Name"
msgstr "글꼴 이름"
msgid "e.g., Open Sans"
msgstr "예: Open Sans"
msgid "Enter the exact font name as it appears on Google Fonts."
msgstr "Google Fonts에 표시된 정확한 글꼴 이름을 입력하세요."
msgid "Weights"
msgstr "굵기"
msgid "Styles"
msgstr "스타일"
msgid "Normal"
msgstr "보통"
msgid "Italic"
msgstr "기울임꼴"
msgid "Files to download:"
msgstr "다운로드할 파일:"
msgid "Download & Install"
msgstr "다운로드 및 설치"
msgid "Installed Fonts"
msgstr "설치된 글꼴"
msgid "No fonts installed yet."
msgstr "아직 설치된 글꼴이 없습니다."
msgid "Delete"
msgstr "삭제"
msgid "Use %s to apply fonts to your site."
msgstr "%s을(를) 사용하여 사이트에 글꼴을 적용하세요."
msgid "Appearance → Editor → Styles → Typography"
msgstr "외모 → 편집기 → 스타일 → 타이포그래피"

View file

@ -0,0 +1,222 @@
# Translation of Maple Local Fonts in Dutch
# Copyright (C) 2024 Maple Open Technologies
# This file is distributed under the GPL-2.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: Maple Local Fonts 1.0.0\n"
"Report-Msgid-Bugs-To: https://mapleopentech.org/\n"
"Last-Translator: Maple Open Technologies\n"
"Language-Team: Dutch\n"
"Language: nl_NL\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2024-01-01T00:00:00+00:00\n"
"PO-Revision-Date: 2024-01-01T00:00:00+00:00\n"
"X-Generator: Manual\n"
"X-Domain: maple-local-fonts\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. Plugin Name of the plugin
msgid "Maple Local Fonts"
msgstr "Maple Local Fonts"
#. Description of the plugin
msgid "Import Google Fonts to local storage and register them with WordPress Font Library for GDPR-compliant, privacy-friendly typography."
msgstr "Importeer Google Fonts naar lokale opslag en registreer ze bij de WordPress Lettertypebibliotheek voor GDPR-conforme, privacyvriendelijke typografie."
#. Author of the plugin
msgid "Maple Open Technologies"
msgstr "Maple Open Technologies"
msgid "Maple Local Fonts requires WordPress 6.5 or higher for Font Library support."
msgstr "Maple Local Fonts vereist WordPress 6.5 of hoger voor ondersteuning van de Lettertypebibliotheek."
msgid "Plugin Activation Error"
msgstr "Plugin-activeringsfout"
msgid "Maple Local Fonts has been deactivated. It requires WordPress 6.5 or higher."
msgstr "Maple Local Fonts is gedeactiveerd. Het vereist WordPress 6.5 of hoger."
msgid "Local Fonts"
msgstr "Lokale lettertypen"
msgid "Downloading..."
msgstr "Downloaden..."
msgid "Deleting..."
msgstr "Verwijderen..."
msgid "Are you sure you want to delete this font?"
msgstr "Weet je zeker dat je dit lettertype wilt verwijderen?"
msgid "An error occurred. Please try again."
msgstr "Er is een fout opgetreden. Probeer het opnieuw."
msgid "Please select at least one weight."
msgstr "Selecteer ten minste één gewicht."
msgid "Please select at least one style."
msgstr "Selecteer ten minste één stijl."
msgid "Please enter a font name."
msgstr "Voer een lettertypenaam in."
msgid "Security check failed."
msgstr "Beveiligingscontrole mislukt."
msgid "Unauthorized."
msgstr "Niet geautoriseerd."
msgid "Font name is required."
msgstr "Lettertypenaam is vereist."
msgid "Invalid font name: only letters, numbers, spaces, and hyphens allowed."
msgstr "Ongeldige lettertypenaam: alleen letters, cijfers, spaties en koppeltekens toegestaan."
msgid "Font name is too long."
msgstr "Lettertypenaam is te lang."
msgid "At least one weight is required."
msgstr "Ten minste één gewicht is vereist."
msgid "Too many weights selected."
msgstr "Te veel gewichten geselecteerd."
msgid "At least one style is required."
msgstr "Ten minste één stijl is vereist."
msgid "Successfully installed %s."
msgstr "%s is succesvol geïnstalleerd."
msgid "An unexpected error occurred."
msgstr "Er is een onverwachte fout opgetreden."
msgid "Invalid font ID."
msgstr "Ongeldige lettertype-ID."
msgid "Font not found."
msgstr "Lettertype niet gevonden."
msgid "Cannot delete fonts not imported by this plugin."
msgstr "Kan lettertypen die niet door deze plugin zijn geïmporteerd niet verwijderen."
msgid "Font deleted successfully."
msgstr "Lettertype succesvol verwijderd."
msgid "Font not found on Google Fonts. Please check the spelling and try again."
msgstr "Lettertype niet gevonden op Google Fonts. Controleer de spelling en probeer het opnieuw."
msgid "This font is already installed."
msgstr "Dit lettertype is al geïnstalleerd."
msgid "Could not connect to Google Fonts. Please check your internet connection and try again."
msgstr "Kan geen verbinding maken met Google Fonts. Controleer je internetverbinding en probeer het opnieuw."
msgid "Google Fonts returned an error. Please try again later."
msgstr "Google Fonts heeft een fout geretourneerd. Probeer het later opnieuw."
msgid "Could not process the font data. The font may not be available."
msgstr "Kan de lettertypegegevens niet verwerken. Het lettertype is mogelijk niet beschikbaar."
msgid "Could not download the font files. Please try again."
msgstr "Kan de lettertypebestanden niet downloaden. Probeer het opnieuw."
msgid "Could not save font files. Please check that wp-content/fonts is writable."
msgstr "Kan lettertypebestanden niet opslaan. Controleer of wp-content/fonts schrijfbaar is."
msgid "Could not create fonts directory. Please check file permissions."
msgstr "Kan lettertypemap niet aanmaken. Controleer de bestandsrechten."
msgid "Invalid file path."
msgstr "Ongeldig bestandspad."
msgid "Invalid font URL."
msgstr "Ongeldige lettertype-URL."
msgid "Invalid font name."
msgstr "Ongeldige lettertypenaam."
msgid "No valid weights specified."
msgstr "Geen geldige gewichten opgegeven."
msgid "No valid styles specified."
msgstr "Geen geldige stijlen opgegeven."
msgid "An unexpected error occurred. Please try again."
msgstr "Er is een onverwachte fout opgetreden. Probeer het opnieuw."
msgid "Thin"
msgstr "Dun"
msgid "Extra Light"
msgstr "Extra licht"
msgid "Light"
msgstr "Licht"
msgid "Regular"
msgstr "Normaal"
msgid "Medium"
msgstr "Medium"
msgid "Semi Bold"
msgstr "Halfvet"
msgid "Bold"
msgstr "Vet"
msgid "Extra Bold"
msgstr "Extra vet"
msgid "Black"
msgstr "Zwart"
msgid "You do not have sufficient permissions to access this page."
msgstr "Je hebt onvoldoende rechten om deze pagina te openen."
msgid "Import from Google Fonts"
msgstr "Importeren van Google Fonts"
msgid "Font Name"
msgstr "Lettertypenaam"
msgid "e.g., Open Sans"
msgstr "bijv. Open Sans"
msgid "Enter the exact font name as it appears on Google Fonts."
msgstr "Voer de exacte lettertypenaam in zoals deze wordt weergegeven op Google Fonts."
msgid "Weights"
msgstr "Gewichten"
msgid "Styles"
msgstr "Stijlen"
msgid "Normal"
msgstr "Normaal"
msgid "Italic"
msgstr "Cursief"
msgid "Files to download:"
msgstr "Te downloaden bestanden:"
msgid "Download & Install"
msgstr "Downloaden en installeren"
msgid "Installed Fonts"
msgstr "Geïnstalleerde lettertypen"
msgid "No fonts installed yet."
msgstr "Nog geen lettertypen geïnstalleerd."
msgid "Delete"
msgstr "Verwijderen"
msgid "Use %s to apply fonts to your site."
msgstr "Gebruik %s om lettertypen op je site toe te passen."
msgid "Appearance → Editor → Styles → Typography"
msgstr "Weergave → Editor → Stijlen → Typografie"

View file

@ -0,0 +1,222 @@
# Translation of Maple Local Fonts in Polish
# Copyright (C) 2024 Maple Open Technologies
# This file is distributed under the GPL-2.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: Maple Local Fonts 1.0.0\n"
"Report-Msgid-Bugs-To: https://mapleopentech.org/\n"
"Last-Translator: Maple Open Technologies\n"
"Language-Team: Polish\n"
"Language: pl_PL\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2024-01-01T00:00:00+00:00\n"
"PO-Revision-Date: 2024-01-01T00:00:00+00:00\n"
"X-Generator: Manual\n"
"X-Domain: maple-local-fonts\n"
"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n"
#. Plugin Name of the plugin
msgid "Maple Local Fonts"
msgstr "Maple Local Fonts"
#. Description of the plugin
msgid "Import Google Fonts to local storage and register them with WordPress Font Library for GDPR-compliant, privacy-friendly typography."
msgstr "Importuj czcionki Google Fonts do lokalnego magazynu i zarejestruj je w bibliotece czcionek WordPress dla typografii zgodnej z RODO i przyjaznej dla prywatności."
#. Author of the plugin
msgid "Maple Open Technologies"
msgstr "Maple Open Technologies"
msgid "Maple Local Fonts requires WordPress 6.5 or higher for Font Library support."
msgstr "Maple Local Fonts wymaga WordPress 6.5 lub nowszego do obsługi biblioteki czcionek."
msgid "Plugin Activation Error"
msgstr "Błąd aktywacji wtyczki"
msgid "Maple Local Fonts has been deactivated. It requires WordPress 6.5 or higher."
msgstr "Maple Local Fonts została dezaktywowana. Wymaga WordPress 6.5 lub nowszego."
msgid "Local Fonts"
msgstr "Lokalne czcionki"
msgid "Downloading..."
msgstr "Pobieranie..."
msgid "Deleting..."
msgstr "Usuwanie..."
msgid "Are you sure you want to delete this font?"
msgstr "Czy na pewno chcesz usunąć tę czcionkę?"
msgid "An error occurred. Please try again."
msgstr "Wystąpił błąd. Spróbuj ponownie."
msgid "Please select at least one weight."
msgstr "Wybierz co najmniej jedną grubość."
msgid "Please select at least one style."
msgstr "Wybierz co najmniej jeden styl."
msgid "Please enter a font name."
msgstr "Wprowadź nazwę czcionki."
msgid "Security check failed."
msgstr "Sprawdzenie zabezpieczeń nie powiodło się."
msgid "Unauthorized."
msgstr "Brak autoryzacji."
msgid "Font name is required."
msgstr "Nazwa czcionki jest wymagana."
msgid "Invalid font name: only letters, numbers, spaces, and hyphens allowed."
msgstr "Nieprawidłowa nazwa czcionki: dozwolone są tylko litery, cyfry, spacje i myślniki."
msgid "Font name is too long."
msgstr "Nazwa czcionki jest za długa."
msgid "At least one weight is required."
msgstr "Wymagana jest co najmniej jedna grubość."
msgid "Too many weights selected."
msgstr "Wybrano za dużo grubości."
msgid "At least one style is required."
msgstr "Wymagany jest co najmniej jeden styl."
msgid "Successfully installed %s."
msgstr "Pomyślnie zainstalowano %s."
msgid "An unexpected error occurred."
msgstr "Wystąpił nieoczekiwany błąd."
msgid "Invalid font ID."
msgstr "Nieprawidłowy identyfikator czcionki."
msgid "Font not found."
msgstr "Czcionka nie została znaleziona."
msgid "Cannot delete fonts not imported by this plugin."
msgstr "Nie można usunąć czcionek, które nie zostały zaimportowane przez tę wtyczkę."
msgid "Font deleted successfully."
msgstr "Czcionka została pomyślnie usunięta."
msgid "Font not found on Google Fonts. Please check the spelling and try again."
msgstr "Czcionka nie została znaleziona w Google Fonts. Sprawdź pisownię i spróbuj ponownie."
msgid "This font is already installed."
msgstr "Ta czcionka jest już zainstalowana."
msgid "Could not connect to Google Fonts. Please check your internet connection and try again."
msgstr "Nie można połączyć się z Google Fonts. Sprawdź połączenie internetowe i spróbuj ponownie."
msgid "Google Fonts returned an error. Please try again later."
msgstr "Google Fonts zwróciło błąd. Spróbuj ponownie później."
msgid "Could not process the font data. The font may not be available."
msgstr "Nie można przetworzyć danych czcionki. Czcionka może być niedostępna."
msgid "Could not download the font files. Please try again."
msgstr "Nie można pobrać plików czcionki. Spróbuj ponownie."
msgid "Could not save font files. Please check that wp-content/fonts is writable."
msgstr "Nie można zapisać plików czcionki. Sprawdź, czy wp-content/fonts jest zapisywalny."
msgid "Could not create fonts directory. Please check file permissions."
msgstr "Nie można utworzyć katalogu czcionek. Sprawdź uprawnienia plików."
msgid "Invalid file path."
msgstr "Nieprawidłowa ścieżka pliku."
msgid "Invalid font URL."
msgstr "Nieprawidłowy adres URL czcionki."
msgid "Invalid font name."
msgstr "Nieprawidłowa nazwa czcionki."
msgid "No valid weights specified."
msgstr "Nie określono prawidłowych grubości."
msgid "No valid styles specified."
msgstr "Nie określono prawidłowych stylów."
msgid "An unexpected error occurred. Please try again."
msgstr "Wystąpił nieoczekiwany błąd. Spróbuj ponownie."
msgid "Thin"
msgstr "Cienka"
msgid "Extra Light"
msgstr "Bardzo lekka"
msgid "Light"
msgstr "Lekka"
msgid "Regular"
msgstr "Normalna"
msgid "Medium"
msgstr "Średnia"
msgid "Semi Bold"
msgstr "Półgruba"
msgid "Bold"
msgstr "Gruba"
msgid "Extra Bold"
msgstr "Bardzo gruba"
msgid "Black"
msgstr "Czarna"
msgid "You do not have sufficient permissions to access this page."
msgstr "Nie masz wystarczających uprawnień, aby uzyskać dostęp do tej strony."
msgid "Import from Google Fonts"
msgstr "Importuj z Google Fonts"
msgid "Font Name"
msgstr "Nazwa czcionki"
msgid "e.g., Open Sans"
msgstr "np. Open Sans"
msgid "Enter the exact font name as it appears on Google Fonts."
msgstr "Wprowadź dokładną nazwę czcionki, jak jest wyświetlana w Google Fonts."
msgid "Weights"
msgstr "Grubości"
msgid "Styles"
msgstr "Style"
msgid "Normal"
msgstr "Normalna"
msgid "Italic"
msgstr "Kursywa"
msgid "Files to download:"
msgstr "Pliki do pobrania:"
msgid "Download & Install"
msgstr "Pobierz i zainstaluj"
msgid "Installed Fonts"
msgstr "Zainstalowane czcionki"
msgid "No fonts installed yet."
msgstr "Nie zainstalowano jeszcze żadnych czcionek."
msgid "Delete"
msgstr "Usuń"
msgid "Use %s to apply fonts to your site."
msgstr "Użyj %s, aby zastosować czcionki na swojej stronie."
msgid "Appearance → Editor → Styles → Typography"
msgstr "Wygląd → Edytor → Style → Typografia"

View file

@ -0,0 +1,222 @@
# Translation of Maple Local Fonts in Portuguese (Brazil)
# Copyright (C) 2024 Maple Open Technologies
# This file is distributed under the GPL-2.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: Maple Local Fonts 1.0.0\n"
"Report-Msgid-Bugs-To: https://mapleopentech.org/\n"
"Last-Translator: Maple Open Technologies\n"
"Language-Team: Portuguese (Brazil)\n"
"Language: pt_BR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2024-01-01T00:00:00+00:00\n"
"PO-Revision-Date: 2024-01-01T00:00:00+00:00\n"
"X-Generator: Manual\n"
"X-Domain: maple-local-fonts\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#. Plugin Name of the plugin
msgid "Maple Local Fonts"
msgstr "Maple Local Fonts"
#. Description of the plugin
msgid "Import Google Fonts to local storage and register them with WordPress Font Library for GDPR-compliant, privacy-friendly typography."
msgstr "Importe fontes do Google Fonts para o armazenamento local e registre-as na biblioteca de fontes do WordPress para uma tipografia compatível com GDPR e que respeita a privacidade."
#. Author of the plugin
msgid "Maple Open Technologies"
msgstr "Maple Open Technologies"
msgid "Maple Local Fonts requires WordPress 6.5 or higher for Font Library support."
msgstr "O Maple Local Fonts requer WordPress 6.5 ou superior para suporte à biblioteca de fontes."
msgid "Plugin Activation Error"
msgstr "Erro de ativação do plugin"
msgid "Maple Local Fonts has been deactivated. It requires WordPress 6.5 or higher."
msgstr "O Maple Local Fonts foi desativado. Requer WordPress 6.5 ou superior."
msgid "Local Fonts"
msgstr "Fontes locais"
msgid "Downloading..."
msgstr "Baixando..."
msgid "Deleting..."
msgstr "Excluindo..."
msgid "Are you sure you want to delete this font?"
msgstr "Tem certeza de que deseja excluir esta fonte?"
msgid "An error occurred. Please try again."
msgstr "Ocorreu um erro. Por favor, tente novamente."
msgid "Please select at least one weight."
msgstr "Por favor, selecione pelo menos um peso."
msgid "Please select at least one style."
msgstr "Por favor, selecione pelo menos um estilo."
msgid "Please enter a font name."
msgstr "Por favor, digite um nome de fonte."
msgid "Security check failed."
msgstr "Falha na verificação de segurança."
msgid "Unauthorized."
msgstr "Não autorizado."
msgid "Font name is required."
msgstr "O nome da fonte é obrigatório."
msgid "Invalid font name: only letters, numbers, spaces, and hyphens allowed."
msgstr "Nome de fonte inválido: apenas letras, números, espaços e hífens são permitidos."
msgid "Font name is too long."
msgstr "O nome da fonte é muito longo."
msgid "At least one weight is required."
msgstr "Pelo menos um peso é necessário."
msgid "Too many weights selected."
msgstr "Muitos pesos selecionados."
msgid "At least one style is required."
msgstr "Pelo menos um estilo é necessário."
msgid "Successfully installed %s."
msgstr "%s foi instalado com sucesso."
msgid "An unexpected error occurred."
msgstr "Ocorreu um erro inesperado."
msgid "Invalid font ID."
msgstr "ID de fonte inválido."
msgid "Font not found."
msgstr "Fonte não encontrada."
msgid "Cannot delete fonts not imported by this plugin."
msgstr "Não é possível excluir fontes que não foram importadas por este plugin."
msgid "Font deleted successfully."
msgstr "Fonte excluída com sucesso."
msgid "Font not found on Google Fonts. Please check the spelling and try again."
msgstr "Fonte não encontrada no Google Fonts. Verifique a ortografia e tente novamente."
msgid "This font is already installed."
msgstr "Esta fonte já está instalada."
msgid "Could not connect to Google Fonts. Please check your internet connection and try again."
msgstr "Não foi possível conectar ao Google Fonts. Verifique sua conexão com a internet e tente novamente."
msgid "Google Fonts returned an error. Please try again later."
msgstr "O Google Fonts retornou um erro. Por favor, tente novamente mais tarde."
msgid "Could not process the font data. The font may not be available."
msgstr "Não foi possível processar os dados da fonte. A fonte pode não estar disponível."
msgid "Could not download the font files. Please try again."
msgstr "Não foi possível baixar os arquivos da fonte. Por favor, tente novamente."
msgid "Could not save font files. Please check that wp-content/fonts is writable."
msgstr "Não foi possível salvar os arquivos da fonte. Verifique se wp-content/fonts tem permissão de escrita."
msgid "Could not create fonts directory. Please check file permissions."
msgstr "Não foi possível criar o diretório de fontes. Verifique as permissões de arquivo."
msgid "Invalid file path."
msgstr "Caminho de arquivo inválido."
msgid "Invalid font URL."
msgstr "URL de fonte inválida."
msgid "Invalid font name."
msgstr "Nome de fonte inválido."
msgid "No valid weights specified."
msgstr "Nenhum peso válido especificado."
msgid "No valid styles specified."
msgstr "Nenhum estilo válido especificado."
msgid "An unexpected error occurred. Please try again."
msgstr "Ocorreu um erro inesperado. Por favor, tente novamente."
msgid "Thin"
msgstr "Fina"
msgid "Extra Light"
msgstr "Extra leve"
msgid "Light"
msgstr "Leve"
msgid "Regular"
msgstr "Normal"
msgid "Medium"
msgstr "Média"
msgid "Semi Bold"
msgstr "Semi negrito"
msgid "Bold"
msgstr "Negrito"
msgid "Extra Bold"
msgstr "Extra negrito"
msgid "Black"
msgstr "Preta"
msgid "You do not have sufficient permissions to access this page."
msgstr "Você não tem permissões suficientes para acessar esta página."
msgid "Import from Google Fonts"
msgstr "Importar do Google Fonts"
msgid "Font Name"
msgstr "Nome da fonte"
msgid "e.g., Open Sans"
msgstr "ex.: Open Sans"
msgid "Enter the exact font name as it appears on Google Fonts."
msgstr "Digite o nome exato da fonte como aparece no Google Fonts."
msgid "Weights"
msgstr "Pesos"
msgid "Styles"
msgstr "Estilos"
msgid "Normal"
msgstr "Normal"
msgid "Italic"
msgstr "Itálico"
msgid "Files to download:"
msgstr "Arquivos para baixar:"
msgid "Download & Install"
msgstr "Baixar e instalar"
msgid "Installed Fonts"
msgstr "Fontes instaladas"
msgid "No fonts installed yet."
msgstr "Nenhuma fonte instalada ainda."
msgid "Delete"
msgstr "Excluir"
msgid "Use %s to apply fonts to your site."
msgstr "Use %s para aplicar fontes ao seu site."
msgid "Appearance → Editor → Styles → Typography"
msgstr "Aparência → Editor → Estilos → Tipografia"

View file

@ -0,0 +1,222 @@
# Translation of Maple Local Fonts in Russian
# Copyright (C) 2024 Maple Open Technologies
# This file is distributed under the GPL-2.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: Maple Local Fonts 1.0.0\n"
"Report-Msgid-Bugs-To: https://mapleopentech.org/\n"
"Last-Translator: Maple Open Technologies\n"
"Language-Team: Russian\n"
"Language: ru_RU\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2024-01-01T00:00:00+00:00\n"
"PO-Revision-Date: 2024-01-01T00:00:00+00:00\n"
"X-Generator: Manual\n"
"X-Domain: maple-local-fonts\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n"
#. Plugin Name of the plugin
msgid "Maple Local Fonts"
msgstr "Maple Local Fonts"
#. Description of the plugin
msgid "Import Google Fonts to local storage and register them with WordPress Font Library for GDPR-compliant, privacy-friendly typography."
msgstr "Импортируйте шрифты Google Fonts в локальное хранилище и зарегистрируйте их в библиотеке шрифтов WordPress для типографики, соответствующей GDPR и защищающей конфиденциальность."
#. Author of the plugin
msgid "Maple Open Technologies"
msgstr "Maple Open Technologies"
msgid "Maple Local Fonts requires WordPress 6.5 or higher for Font Library support."
msgstr "Maple Local Fonts требует WordPress 6.5 или выше для поддержки библиотеки шрифтов."
msgid "Plugin Activation Error"
msgstr "Ошибка активации плагина"
msgid "Maple Local Fonts has been deactivated. It requires WordPress 6.5 or higher."
msgstr "Maple Local Fonts был деактивирован. Требуется WordPress 6.5 или выше."
msgid "Local Fonts"
msgstr "Локальные шрифты"
msgid "Downloading..."
msgstr "Загрузка..."
msgid "Deleting..."
msgstr "Удаление..."
msgid "Are you sure you want to delete this font?"
msgstr "Вы уверены, что хотите удалить этот шрифт?"
msgid "An error occurred. Please try again."
msgstr "Произошла ошибка. Пожалуйста, попробуйте снова."
msgid "Please select at least one weight."
msgstr "Пожалуйста, выберите хотя бы одну толщину."
msgid "Please select at least one style."
msgstr "Пожалуйста, выберите хотя бы один стиль."
msgid "Please enter a font name."
msgstr "Пожалуйста, введите название шрифта."
msgid "Security check failed."
msgstr "Проверка безопасности не пройдена."
msgid "Unauthorized."
msgstr "Не авторизован."
msgid "Font name is required."
msgstr "Название шрифта обязательно."
msgid "Invalid font name: only letters, numbers, spaces, and hyphens allowed."
msgstr "Недопустимое название шрифта: разрешены только буквы, цифры, пробелы и дефисы."
msgid "Font name is too long."
msgstr "Название шрифта слишком длинное."
msgid "At least one weight is required."
msgstr "Требуется хотя бы одна толщина."
msgid "Too many weights selected."
msgstr "Выбрано слишком много толщин."
msgid "At least one style is required."
msgstr "Требуется хотя бы один стиль."
msgid "Successfully installed %s."
msgstr "%s успешно установлен."
msgid "An unexpected error occurred."
msgstr "Произошла непредвиденная ошибка."
msgid "Invalid font ID."
msgstr "Недопустимый ID шрифта."
msgid "Font not found."
msgstr "Шрифт не найден."
msgid "Cannot delete fonts not imported by this plugin."
msgstr "Невозможно удалить шрифты, не импортированные этим плагином."
msgid "Font deleted successfully."
msgstr "Шрифт успешно удалён."
msgid "Font not found on Google Fonts. Please check the spelling and try again."
msgstr "Шрифт не найден в Google Fonts. Проверьте правописание и попробуйте снова."
msgid "This font is already installed."
msgstr "Этот шрифт уже установлен."
msgid "Could not connect to Google Fonts. Please check your internet connection and try again."
msgstr "Не удалось подключиться к Google Fonts. Проверьте подключение к интернету и попробуйте снова."
msgid "Google Fonts returned an error. Please try again later."
msgstr "Google Fonts вернул ошибку. Пожалуйста, попробуйте позже."
msgid "Could not process the font data. The font may not be available."
msgstr "Не удалось обработать данные шрифта. Шрифт может быть недоступен."
msgid "Could not download the font files. Please try again."
msgstr "Не удалось загрузить файлы шрифта. Пожалуйста, попробуйте снова."
msgid "Could not save font files. Please check that wp-content/fonts is writable."
msgstr "Не удалось сохранить файлы шрифта. Проверьте, что wp-content/fonts доступен для записи."
msgid "Could not create fonts directory. Please check file permissions."
msgstr "Не удалось создать каталог шрифтов. Проверьте права доступа к файлам."
msgid "Invalid file path."
msgstr "Недопустимый путь к файлу."
msgid "Invalid font URL."
msgstr "Недопустимый URL шрифта."
msgid "Invalid font name."
msgstr "Недопустимое название шрифта."
msgid "No valid weights specified."
msgstr "Не указаны допустимые толщины."
msgid "No valid styles specified."
msgstr "Не указаны допустимые стили."
msgid "An unexpected error occurred. Please try again."
msgstr "Произошла непредвиденная ошибка. Пожалуйста, попробуйте снова."
msgid "Thin"
msgstr "Тонкий"
msgid "Extra Light"
msgstr "Сверхлёгкий"
msgid "Light"
msgstr "Лёгкий"
msgid "Regular"
msgstr "Обычный"
msgid "Medium"
msgstr "Средний"
msgid "Semi Bold"
msgstr "Полужирный"
msgid "Bold"
msgstr "Жирный"
msgid "Extra Bold"
msgstr "Сверхжирный"
msgid "Black"
msgstr "Чёрный"
msgid "You do not have sufficient permissions to access this page."
msgstr "У вас недостаточно прав для доступа к этой странице."
msgid "Import from Google Fonts"
msgstr "Импорт из Google Fonts"
msgid "Font Name"
msgstr "Название шрифта"
msgid "e.g., Open Sans"
msgstr "напр., Open Sans"
msgid "Enter the exact font name as it appears on Google Fonts."
msgstr "Введите точное название шрифта, как оно отображается в Google Fonts."
msgid "Weights"
msgstr "Толщины"
msgid "Styles"
msgstr "Стили"
msgid "Normal"
msgstr "Обычный"
msgid "Italic"
msgstr "Курсив"
msgid "Files to download:"
msgstr "Файлов для загрузки:"
msgid "Download & Install"
msgstr "Скачать и установить"
msgid "Installed Fonts"
msgstr "Установленные шрифты"
msgid "No fonts installed yet."
msgstr "Шрифты ещё не установлены."
msgid "Delete"
msgstr "Удалить"
msgid "Use %s to apply fonts to your site."
msgstr "Используйте %s для применения шрифтов к вашему сайту."
msgid "Appearance → Editor → Styles → Typography"
msgstr "Внешний вид → Редактор → Стили → Типографика"

View file

@ -0,0 +1,222 @@
# Translation of Maple Local Fonts in Turkish
# Copyright (C) 2024 Maple Open Technologies
# This file is distributed under the GPL-2.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: Maple Local Fonts 1.0.0\n"
"Report-Msgid-Bugs-To: https://mapleopentech.org/\n"
"Last-Translator: Maple Open Technologies\n"
"Language-Team: Turkish\n"
"Language: tr_TR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2024-01-01T00:00:00+00:00\n"
"PO-Revision-Date: 2024-01-01T00:00:00+00:00\n"
"X-Generator: Manual\n"
"X-Domain: maple-local-fonts\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#. Plugin Name of the plugin
msgid "Maple Local Fonts"
msgstr "Maple Local Fonts"
#. Description of the plugin
msgid "Import Google Fonts to local storage and register them with WordPress Font Library for GDPR-compliant, privacy-friendly typography."
msgstr "Google Fonts'u yerel depolamaya aktarın ve GDPR uyumlu, gizlilik dostu tipografi için WordPress Yazı Tipi Kitaplığı'na kaydedin."
#. Author of the plugin
msgid "Maple Open Technologies"
msgstr "Maple Open Technologies"
msgid "Maple Local Fonts requires WordPress 6.5 or higher for Font Library support."
msgstr "Maple Local Fonts, Yazı Tipi Kitaplığı desteği için WordPress 6.5 veya üstünü gerektirir."
msgid "Plugin Activation Error"
msgstr "Eklenti Etkinleştirme Hatası"
msgid "Maple Local Fonts has been deactivated. It requires WordPress 6.5 or higher."
msgstr "Maple Local Fonts devre dışı bırakıldı. WordPress 6.5 veya üstünü gerektirir."
msgid "Local Fonts"
msgstr "Yerel Yazı Tipleri"
msgid "Downloading..."
msgstr "İndiriliyor..."
msgid "Deleting..."
msgstr "Siliniyor..."
msgid "Are you sure you want to delete this font?"
msgstr "Bu yazı tipini silmek istediğinizden emin misiniz?"
msgid "An error occurred. Please try again."
msgstr "Bir hata oluştu. Lütfen tekrar deneyin."
msgid "Please select at least one weight."
msgstr "Lütfen en az bir kalınlık seçin."
msgid "Please select at least one style."
msgstr "Lütfen en az bir stil seçin."
msgid "Please enter a font name."
msgstr "Lütfen bir yazı tipi adı girin."
msgid "Security check failed."
msgstr "Güvenlik kontrolü başarısız."
msgid "Unauthorized."
msgstr "Yetkisiz."
msgid "Font name is required."
msgstr "Yazı tipi adı gereklidir."
msgid "Invalid font name: only letters, numbers, spaces, and hyphens allowed."
msgstr "Geçersiz yazı tipi adı: yalnızca harfler, sayılar, boşluklar ve tireler kullanılabilir."
msgid "Font name is too long."
msgstr "Yazı tipi adı çok uzun."
msgid "At least one weight is required."
msgstr "En az bir kalınlık gereklidir."
msgid "Too many weights selected."
msgstr "Çok fazla kalınlık seçildi."
msgid "At least one style is required."
msgstr "En az bir stil gereklidir."
msgid "Successfully installed %s."
msgstr "%s başarıyla yüklendi."
msgid "An unexpected error occurred."
msgstr "Beklenmeyen bir hata oluştu."
msgid "Invalid font ID."
msgstr "Geçersiz yazı tipi kimliği."
msgid "Font not found."
msgstr "Yazı tipi bulunamadı."
msgid "Cannot delete fonts not imported by this plugin."
msgstr "Bu eklenti tarafından içe aktarılmayan yazı tipleri silinemez."
msgid "Font deleted successfully."
msgstr "Yazı tipi başarıyla silindi."
msgid "Font not found on Google Fonts. Please check the spelling and try again."
msgstr "Yazı tipi Google Fonts'ta bulunamadı. Lütfen yazımı kontrol edin ve tekrar deneyin."
msgid "This font is already installed."
msgstr "Bu yazı tipi zaten yüklü."
msgid "Could not connect to Google Fonts. Please check your internet connection and try again."
msgstr "Google Fonts'a bağlanılamadı. Lütfen internet bağlantınızı kontrol edin ve tekrar deneyin."
msgid "Google Fonts returned an error. Please try again later."
msgstr "Google Fonts bir hata döndürdü. Lütfen daha sonra tekrar deneyin."
msgid "Could not process the font data. The font may not be available."
msgstr "Yazı tipi verileri işlenemedi. Yazı tipi kullanılamıyor olabilir."
msgid "Could not download the font files. Please try again."
msgstr "Yazı tipi dosyaları indirilemedi. Lütfen tekrar deneyin."
msgid "Could not save font files. Please check that wp-content/fonts is writable."
msgstr "Yazı tipi dosyaları kaydedilemedi. Lütfen wp-content/fonts klasörünün yazılabilir olduğunu kontrol edin."
msgid "Could not create fonts directory. Please check file permissions."
msgstr "Yazı tipi dizini oluşturulamadı. Lütfen dosya izinlerini kontrol edin."
msgid "Invalid file path."
msgstr "Geçersiz dosya yolu."
msgid "Invalid font URL."
msgstr "Geçersiz yazı tipi URL'si."
msgid "Invalid font name."
msgstr "Geçersiz yazı tipi adı."
msgid "No valid weights specified."
msgstr "Geçerli kalınlık belirtilmedi."
msgid "No valid styles specified."
msgstr "Geçerli stil belirtilmedi."
msgid "An unexpected error occurred. Please try again."
msgstr "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin."
msgid "Thin"
msgstr "İnce"
msgid "Extra Light"
msgstr "Ekstra Hafif"
msgid "Light"
msgstr "Hafif"
msgid "Regular"
msgstr "Normal"
msgid "Medium"
msgstr "Orta"
msgid "Semi Bold"
msgstr "Yarı Kalın"
msgid "Bold"
msgstr "Kalın"
msgid "Extra Bold"
msgstr "Ekstra Kalın"
msgid "Black"
msgstr "Siyah"
msgid "You do not have sufficient permissions to access this page."
msgstr "Bu sayfaya erişmek için yeterli izniniz yok."
msgid "Import from Google Fonts"
msgstr "Google Fonts'tan İçe Aktar"
msgid "Font Name"
msgstr "Yazı Tipi Adı"
msgid "e.g., Open Sans"
msgstr "örn., Open Sans"
msgid "Enter the exact font name as it appears on Google Fonts."
msgstr "Google Fonts'ta göründüğü şekliyle tam yazı tipi adını girin."
msgid "Weights"
msgstr "Kalınlıklar"
msgid "Styles"
msgstr "Stiller"
msgid "Normal"
msgstr "Normal"
msgid "Italic"
msgstr "İtalik"
msgid "Files to download:"
msgstr "İndirilecek dosyalar:"
msgid "Download & Install"
msgstr "İndir ve Yükle"
msgid "Installed Fonts"
msgstr "Yüklü Yazı Tipleri"
msgid "No fonts installed yet."
msgstr "Henüz yazı tipi yüklenmedi."
msgid "Delete"
msgstr "Sil"
msgid "Use %s to apply fonts to your site."
msgstr "Sitenize yazı tipleri uygulamak için %s kullanın."
msgid "Appearance → Editor → Styles → Typography"
msgstr "Görünüm → Düzenleyici → Stiller → Tipografi"

View file

@ -0,0 +1,222 @@
# Translation of Maple Local Fonts in Chinese (Simplified)
# Copyright (C) 2024 Maple Open Technologies
# This file is distributed under the GPL-2.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: Maple Local Fonts 1.0.0\n"
"Report-Msgid-Bugs-To: https://mapleopentech.org/\n"
"Last-Translator: Maple Open Technologies\n"
"Language-Team: Chinese (Simplified)\n"
"Language: zh_CN\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2024-01-01T00:00:00+00:00\n"
"PO-Revision-Date: 2024-01-01T00:00:00+00:00\n"
"X-Generator: Manual\n"
"X-Domain: maple-local-fonts\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#. Plugin Name of the plugin
msgid "Maple Local Fonts"
msgstr "Maple Local Fonts"
#. Description of the plugin
msgid "Import Google Fonts to local storage and register them with WordPress Font Library for GDPR-compliant, privacy-friendly typography."
msgstr "将 Google Fonts 导入本地存储并在 WordPress 字体库中注册,实现符合 GDPR 且注重隐私的排版。"
#. Author of the plugin
msgid "Maple Open Technologies"
msgstr "Maple Open Technologies"
msgid "Maple Local Fonts requires WordPress 6.5 or higher for Font Library support."
msgstr "Maple Local Fonts 需要 WordPress 6.5 或更高版本以支持字体库。"
msgid "Plugin Activation Error"
msgstr "插件激活错误"
msgid "Maple Local Fonts has been deactivated. It requires WordPress 6.5 or higher."
msgstr "Maple Local Fonts 已停用。需要 WordPress 6.5 或更高版本。"
msgid "Local Fonts"
msgstr "本地字体"
msgid "Downloading..."
msgstr "下载中..."
msgid "Deleting..."
msgstr "删除中..."
msgid "Are you sure you want to delete this font?"
msgstr "您确定要删除此字体吗?"
msgid "An error occurred. Please try again."
msgstr "发生错误。请重试。"
msgid "Please select at least one weight."
msgstr "请至少选择一个字重。"
msgid "Please select at least one style."
msgstr "请至少选择一个样式。"
msgid "Please enter a font name."
msgstr "请输入字体名称。"
msgid "Security check failed."
msgstr "安全检查失败。"
msgid "Unauthorized."
msgstr "未授权。"
msgid "Font name is required."
msgstr "字体名称为必填项。"
msgid "Invalid font name: only letters, numbers, spaces, and hyphens allowed."
msgstr "字体名称无效:只允许使用字母、数字、空格和连字符。"
msgid "Font name is too long."
msgstr "字体名称过长。"
msgid "At least one weight is required."
msgstr "至少需要一个字重。"
msgid "Too many weights selected."
msgstr "选择的字重过多。"
msgid "At least one style is required."
msgstr "至少需要一个样式。"
msgid "Successfully installed %s."
msgstr "成功安装 %s。"
msgid "An unexpected error occurred."
msgstr "发生意外错误。"
msgid "Invalid font ID."
msgstr "无效的字体 ID。"
msgid "Font not found."
msgstr "未找到字体。"
msgid "Cannot delete fonts not imported by this plugin."
msgstr "无法删除非此插件导入的字体。"
msgid "Font deleted successfully."
msgstr "字体删除成功。"
msgid "Font not found on Google Fonts. Please check the spelling and try again."
msgstr "在 Google Fonts 上未找到字体。请检查拼写并重试。"
msgid "This font is already installed."
msgstr "此字体已安装。"
msgid "Could not connect to Google Fonts. Please check your internet connection and try again."
msgstr "无法连接到 Google Fonts。请检查您的网络连接并重试。"
msgid "Google Fonts returned an error. Please try again later."
msgstr "Google Fonts 返回错误。请稍后重试。"
msgid "Could not process the font data. The font may not be available."
msgstr "无法处理字体数据。该字体可能不可用。"
msgid "Could not download the font files. Please try again."
msgstr "无法下载字体文件。请重试。"
msgid "Could not save font files. Please check that wp-content/fonts is writable."
msgstr "无法保存字体文件。请检查 wp-content/fonts 是否可写。"
msgid "Could not create fonts directory. Please check file permissions."
msgstr "无法创建字体目录。请检查文件权限。"
msgid "Invalid file path."
msgstr "无效的文件路径。"
msgid "Invalid font URL."
msgstr "无效的字体 URL。"
msgid "Invalid font name."
msgstr "无效的字体名称。"
msgid "No valid weights specified."
msgstr "未指定有效的字重。"
msgid "No valid styles specified."
msgstr "未指定有效的样式。"
msgid "An unexpected error occurred. Please try again."
msgstr "发生意外错误。请重试。"
msgid "Thin"
msgstr "极细"
msgid "Extra Light"
msgstr "特细"
msgid "Light"
msgstr "细体"
msgid "Regular"
msgstr "常规"
msgid "Medium"
msgstr "中等"
msgid "Semi Bold"
msgstr "半粗"
msgid "Bold"
msgstr "粗体"
msgid "Extra Bold"
msgstr "特粗"
msgid "Black"
msgstr "黑体"
msgid "You do not have sufficient permissions to access this page."
msgstr "您没有足够的权限访问此页面。"
msgid "Import from Google Fonts"
msgstr "从 Google Fonts 导入"
msgid "Font Name"
msgstr "字体名称"
msgid "e.g., Open Sans"
msgstr "例如Open Sans"
msgid "Enter the exact font name as it appears on Google Fonts."
msgstr "输入 Google Fonts 上显示的确切字体名称。"
msgid "Weights"
msgstr "字重"
msgid "Styles"
msgstr "样式"
msgid "Normal"
msgstr "正常"
msgid "Italic"
msgstr "斜体"
msgid "Files to download:"
msgstr "要下载的文件:"
msgid "Download & Install"
msgstr "下载并安装"
msgid "Installed Fonts"
msgstr "已安装的字体"
msgid "No fonts installed yet."
msgstr "尚未安装任何字体。"
msgid "Delete"
msgstr "删除"
msgid "Use %s to apply fonts to your site."
msgstr "使用 %s 将字体应用到您的网站。"
msgid "Appearance → Editor → Styles → Typography"
msgstr "外观 → 编辑器 → 样式 → 排版"

View file

@ -0,0 +1,222 @@
# Translation of Maple Local Fonts in Chinese (Traditional)
# Copyright (C) 2024 Maple Open Technologies
# This file is distributed under the GPL-2.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: Maple Local Fonts 1.0.0\n"
"Report-Msgid-Bugs-To: https://mapleopentech.org/\n"
"Last-Translator: Maple Open Technologies\n"
"Language-Team: Chinese (Traditional)\n"
"Language: zh_TW\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2024-01-01T00:00:00+00:00\n"
"PO-Revision-Date: 2024-01-01T00:00:00+00:00\n"
"X-Generator: Manual\n"
"X-Domain: maple-local-fonts\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#. Plugin Name of the plugin
msgid "Maple Local Fonts"
msgstr "Maple Local Fonts"
#. Description of the plugin
msgid "Import Google Fonts to local storage and register them with WordPress Font Library for GDPR-compliant, privacy-friendly typography."
msgstr "將 Google Fonts 匯入本機儲存空間並在 WordPress 字型庫中註冊,實現符合 GDPR 且注重隱私的排版。"
#. Author of the plugin
msgid "Maple Open Technologies"
msgstr "Maple Open Technologies"
msgid "Maple Local Fonts requires WordPress 6.5 or higher for Font Library support."
msgstr "Maple Local Fonts 需要 WordPress 6.5 或更高版本以支援字型庫。"
msgid "Plugin Activation Error"
msgstr "外掛啟用錯誤"
msgid "Maple Local Fonts has been deactivated. It requires WordPress 6.5 or higher."
msgstr "Maple Local Fonts 已停用。需要 WordPress 6.5 或更高版本。"
msgid "Local Fonts"
msgstr "本機字型"
msgid "Downloading..."
msgstr "下載中..."
msgid "Deleting..."
msgstr "刪除中..."
msgid "Are you sure you want to delete this font?"
msgstr "您確定要刪除此字型嗎?"
msgid "An error occurred. Please try again."
msgstr "發生錯誤。請重試。"
msgid "Please select at least one weight."
msgstr "請至少選擇一個字重。"
msgid "Please select at least one style."
msgstr "請至少選擇一個樣式。"
msgid "Please enter a font name."
msgstr "請輸入字型名稱。"
msgid "Security check failed."
msgstr "安全性檢查失敗。"
msgid "Unauthorized."
msgstr "未授權。"
msgid "Font name is required."
msgstr "字型名稱為必填項。"
msgid "Invalid font name: only letters, numbers, spaces, and hyphens allowed."
msgstr "字型名稱無效:只允許使用字母、數字、空格和連字號。"
msgid "Font name is too long."
msgstr "字型名稱過長。"
msgid "At least one weight is required."
msgstr "至少需要一個字重。"
msgid "Too many weights selected."
msgstr "選擇的字重過多。"
msgid "At least one style is required."
msgstr "至少需要一個樣式。"
msgid "Successfully installed %s."
msgstr "成功安裝 %s。"
msgid "An unexpected error occurred."
msgstr "發生意外錯誤。"
msgid "Invalid font ID."
msgstr "無效的字型 ID。"
msgid "Font not found."
msgstr "找不到字型。"
msgid "Cannot delete fonts not imported by this plugin."
msgstr "無法刪除非此外掛匯入的字型。"
msgid "Font deleted successfully."
msgstr "字型刪除成功。"
msgid "Font not found on Google Fonts. Please check the spelling and try again."
msgstr "在 Google Fonts 上找不到字型。請檢查拼寫並重試。"
msgid "This font is already installed."
msgstr "此字型已安裝。"
msgid "Could not connect to Google Fonts. Please check your internet connection and try again."
msgstr "無法連線到 Google Fonts。請檢查您的網路連線並重試。"
msgid "Google Fonts returned an error. Please try again later."
msgstr "Google Fonts 傳回錯誤。請稍後重試。"
msgid "Could not process the font data. The font may not be available."
msgstr "無法處理字型資料。該字型可能不可用。"
msgid "Could not download the font files. Please try again."
msgstr "無法下載字型檔案。請重試。"
msgid "Could not save font files. Please check that wp-content/fonts is writable."
msgstr "無法儲存字型檔案。請檢查 wp-content/fonts 是否可寫入。"
msgid "Could not create fonts directory. Please check file permissions."
msgstr "無法建立字型目錄。請檢查檔案權限。"
msgid "Invalid file path."
msgstr "無效的檔案路徑。"
msgid "Invalid font URL."
msgstr "無效的字型 URL。"
msgid "Invalid font name."
msgstr "無效的字型名稱。"
msgid "No valid weights specified."
msgstr "未指定有效的字重。"
msgid "No valid styles specified."
msgstr "未指定有效的樣式。"
msgid "An unexpected error occurred. Please try again."
msgstr "發生意外錯誤。請重試。"
msgid "Thin"
msgstr "極細"
msgid "Extra Light"
msgstr "特細"
msgid "Light"
msgstr "細體"
msgid "Regular"
msgstr "標準"
msgid "Medium"
msgstr "中等"
msgid "Semi Bold"
msgstr "半粗"
msgid "Bold"
msgstr "粗體"
msgid "Extra Bold"
msgstr "特粗"
msgid "Black"
msgstr "黑體"
msgid "You do not have sufficient permissions to access this page."
msgstr "您沒有足夠的權限存取此頁面。"
msgid "Import from Google Fonts"
msgstr "從 Google Fonts 匯入"
msgid "Font Name"
msgstr "字型名稱"
msgid "e.g., Open Sans"
msgstr "例如Open Sans"
msgid "Enter the exact font name as it appears on Google Fonts."
msgstr "輸入 Google Fonts 上顯示的確切字型名稱。"
msgid "Weights"
msgstr "字重"
msgid "Styles"
msgstr "樣式"
msgid "Normal"
msgstr "正常"
msgid "Italic"
msgstr "斜體"
msgid "Files to download:"
msgstr "要下載的檔案:"
msgid "Download & Install"
msgstr "下載並安裝"
msgid "Installed Fonts"
msgstr "已安裝的字型"
msgid "No fonts installed yet."
msgstr "尚未安裝任何字型。"
msgid "Delete"
msgstr "刪除"
msgid "Use %s to apply fonts to your site."
msgstr "使用 %s 將字型套用到您的網站。"
msgid "Appearance → Editor → Styles → Typography"
msgstr "外觀 → 編輯器 → 樣式 → 排版"

View file

@ -0,0 +1,627 @@
<?php
/**
* Plugin Name: Maple Local Fonts
* Plugin URI: https://mapleopentech.org/plugins/maple-local-fonts
* Description: Import Google Fonts to local storage and register them with WordPress Font Library for GDPR-compliant, privacy-friendly typography.
* Version: 1.0.0
* Requires at least: 6.5
* Requires PHP: 7.4
* Author: Maple Open Technologies
* Author URI: https://mapleopentech.org
* License: GPL-2.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: maple-local-fonts
* Domain Path: /languages
*/
if (!defined('ABSPATH')) {
exit;
}
// Plugin constants
define('MLF_VERSION', '1.0.0');
define('MLF_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('MLF_PLUGIN_URL', plugin_dir_url(__FILE__));
define('MLF_PLUGIN_BASENAME', plugin_basename(__FILE__));
// Limits
define('MLF_MAX_FONTS_PER_REQUEST', 10);
define('MLF_MAX_WEIGHTS_PER_FONT', 9);
define('MLF_REQUEST_TIMEOUT', 30);
define('MLF_MAX_CSS_SIZE', 512 * 1024); // 512KB max CSS response
define('MLF_MAX_FONT_FILE_SIZE', 5 * 1024 * 1024); // 5MB max font file
define('MLF_MAX_FONT_FACES', 20); // Max font faces per import (9 weights × 2 styles + buffer)
/**
* Ensure WOFF2 MIME type is registered (fixes Safari font loading).
*
* @param array $mimes Existing MIME types.
* @return array Modified MIME types.
*/
function mlf_add_woff2_mime_type($mimes) {
$mimes['woff2'] = 'font/woff2';
$mimes['woff'] = 'font/woff';
return $mimes;
}
add_filter('mime_types', 'mlf_add_woff2_mime_type');
/**
* Check WordPress version on activation.
*/
function mlf_activate() {
if (version_compare(get_bloginfo('version'), '6.5', '<')) {
deactivate_plugins(plugin_basename(__FILE__));
wp_die(
esc_html__('Maple Local Fonts requires WordPress 6.5 or higher for Font Library support.', 'maple-local-fonts'),
esc_html__('Plugin Activation Error', 'maple-local-fonts'),
['back_link' => true]
);
}
// Ensure fonts directory exists
$font_dir = wp_get_font_dir();
if (!file_exists($font_dir['path'])) {
wp_mkdir_p($font_dir['path']);
}
// Create .htaccess for proper MIME types and CORS (Apache servers)
$htaccess_path = trailingslashit($font_dir['path']) . '.htaccess';
if (!file_exists($htaccess_path)) {
$htaccess_content = "# Maple Local Fonts - Font MIME types and CORS\n";
$htaccess_content .= "<IfModule mod_mime.c>\n";
$htaccess_content .= " AddType font/woff2 .woff2\n";
$htaccess_content .= " AddType font/woff .woff\n";
$htaccess_content .= "</IfModule>\n\n";
$htaccess_content .= "<IfModule mod_headers.c>\n";
$htaccess_content .= " <FilesMatch \"\\.(woff2?|ttf|otf|eot)$\">\n";
$htaccess_content .= " Header set Access-Control-Allow-Origin \"*\"\n";
$htaccess_content .= " </FilesMatch>\n";
$htaccess_content .= "</IfModule>\n";
global $wp_filesystem;
if (empty($wp_filesystem)) {
require_once ABSPATH . 'wp-admin/includes/file.php';
WP_Filesystem();
}
if ($wp_filesystem) {
$wp_filesystem->put_contents($htaccess_path, $htaccess_content, FS_CHMOD_FILE);
}
}
// Flush rewrite rules for fonts CSS endpoint
mlf_add_rewrite_rules();
flush_rewrite_rules();
}
register_activation_hook(__FILE__, 'mlf_activate');
/**
* Flush rewrite rules on deactivation.
*/
function mlf_deactivate() {
flush_rewrite_rules();
}
register_deactivation_hook(__FILE__, 'mlf_deactivate');
/**
* Check WordPress version on admin init (in case WP was downgraded).
*/
function mlf_check_version() {
if (version_compare(get_bloginfo('version'), '6.5', '<')) {
deactivate_plugins(plugin_basename(__FILE__));
add_action('admin_notices', 'mlf_version_notice');
}
}
add_action('admin_init', 'mlf_check_version');
/**
* Ensure fonts directory htaccess exists (for MIME types and CORS).
*/
function mlf_ensure_htaccess() {
$font_dir = wp_get_font_dir();
$htaccess_path = trailingslashit($font_dir['path']) . '.htaccess';
// Always update htaccess to ensure latest headers
$htaccess_content = "# Maple Local Fonts - Font MIME types and CORS\n";
$htaccess_content .= "# Required for cross-browser font loading including iOS Safari\n\n";
$htaccess_content .= "<IfModule mod_mime.c>\n";
$htaccess_content .= " AddType font/woff2 .woff2\n";
$htaccess_content .= " AddType font/woff .woff\n";
$htaccess_content .= "</IfModule>\n\n";
$htaccess_content .= "<IfModule mod_headers.c>\n";
$htaccess_content .= " <FilesMatch \"\\.(woff2?|ttf|otf|eot)$\">\n";
$htaccess_content .= " Header set Access-Control-Allow-Origin \"*\"\n";
$htaccess_content .= " Header set Access-Control-Allow-Methods \"GET, OPTIONS\"\n";
$htaccess_content .= " Header set Access-Control-Allow-Headers \"Origin, Content-Type\"\n";
$htaccess_content .= " Header set Cross-Origin-Resource-Policy \"cross-origin\"\n";
$htaccess_content .= " Header set Timing-Allow-Origin \"*\"\n";
$htaccess_content .= " </FilesMatch>\n";
$htaccess_content .= "</IfModule>\n";
if (file_exists($font_dir['path'])) {
global $wp_filesystem;
if (empty($wp_filesystem)) {
require_once ABSPATH . 'wp-admin/includes/file.php';
WP_Filesystem();
}
if ($wp_filesystem) {
$wp_filesystem->put_contents($htaccess_path, $htaccess_content, FS_CHMOD_FILE);
}
}
}
add_action('admin_init', 'mlf_ensure_htaccess');
/**
* Display version notice.
*/
function mlf_version_notice() {
echo '<div class="error"><p>';
esc_html_e('Maple Local Fonts has been deactivated. It requires WordPress 6.5 or higher.', 'maple-local-fonts');
echo '</p></div>';
}
/**
* Declare WooCommerce HPOS compatibility.
*/
function mlf_declare_hpos_compatibility() {
if (class_exists(\Automattic\WooCommerce\Utilities\FeaturesUtil::class)) {
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility(
'custom_order_tables',
__FILE__,
true
);
}
}
add_action('before_woocommerce_init', 'mlf_declare_hpos_compatibility');
/**
* Load plugin text domain.
*/
function mlf_load_textdomain() {
load_plugin_textdomain('maple-local-fonts', false, dirname(MLF_PLUGIN_BASENAME) . '/languages');
}
add_action('plugins_loaded', 'mlf_load_textdomain');
/**
* Autoload plugin classes.
*/
function mlf_autoload($class) {
$prefix = 'MLF_';
if (strpos($class, $prefix) !== 0) {
return;
}
$class_name = str_replace($prefix, '', $class);
$class_name = str_replace('_', '-', strtolower($class_name));
$file = MLF_PLUGIN_DIR . 'includes/class-mlf-' . $class_name . '.php';
if (file_exists($file)) {
require_once $file;
}
}
spl_autoload_register('mlf_autoload');
/**
* Initialize the plugin.
*/
function mlf_init() {
// Only load admin functionality
if (is_admin()) {
new MLF_Admin_Page();
new MLF_Ajax_Handler();
new MLF_Font_Search();
}
}
add_action('plugins_loaded', 'mlf_init', 20);
/**
* Register REST API routes.
*/
function mlf_register_rest_routes() {
$controller = new MLF_Rest_Controller();
$controller->register_routes();
// Register font server routes for cross-browser compatibility
$font_server = new MLF_Font_Server();
$font_server->register_routes();
}
add_action('rest_api_init', 'mlf_register_rest_routes');
/**
* Check if we should use REST API for font serving (compatibility mode).
*
* @return bool True if using REST API font serving.
*/
function mlf_use_rest_font_serving() {
return get_option('mlf_compatibility_mode', true); // Default to true for maximum compatibility
}
/**
* Get the URL for a font file.
*
* Uses REST API endpoint for compatibility, or direct file URL for performance.
*
* @param string $filename The font filename.
* @return string The font URL.
*/
function mlf_get_font_url($filename) {
if (mlf_use_rest_font_serving()) {
// Use REST API endpoint (guaranteed proper headers)
return rest_url('mlf/v1/font/' . $filename);
} else {
// Use direct file URL (faster but depends on server config)
$font_dir = wp_get_font_dir();
return trailingslashit($font_dir['url']) . $filename;
}
}
/**
* Get the required capability for managing fonts.
*
* @return string The capability required to manage fonts.
*/
function mlf_get_capability() {
/**
* Filter the capability required to manage local fonts.
*
* @since 1.0.0
* @param string $capability Default capability is 'edit_theme_options'.
*/
return apply_filters('mlf_manage_fonts_capability', 'edit_theme_options');
}
/**
* Add imported fonts to the theme.json typography settings.
*
* This makes fonts appear in the Site Editor typography dropdown
* and generates the @font-face CSS for the frontend.
*
* @param WP_Theme_JSON_Data $theme_json The theme.json data.
* @return WP_Theme_JSON_Data Modified theme.json data.
*/
function mlf_add_fonts_to_theme_json($theme_json) {
// Wrap in try-catch to prevent breaking Site Editor if something goes wrong
try {
$registry = new MLF_Font_Registry();
$fonts = $registry->get_imported_fonts_with_src();
if (empty($fonts) || !is_array($fonts)) {
return $theme_json;
}
// Build our font families
$font_families = [];
$font_dir = wp_get_font_dir();
if (empty($font_dir['url'])) {
return $theme_json;
}
$font_base_url = trailingslashit($font_dir['url']);
foreach ($fonts as $font) {
// Validate required font properties
if (empty($font['name']) || empty($font['slug']) || empty($font['variants'])) {
continue;
}
$font_faces = [];
foreach ($font['variants'] as $variant) {
// Validate variant data
if (empty($variant['filename'])) {
continue;
}
$weight = !empty($variant['weight']) ? $variant['weight'] : '400';
$style = !empty($variant['style']) ? $variant['style'] : 'normal';
$filename = $variant['filename'];
// Use direct file URL
$font_url = $font_base_url . $filename;
$font_faces[] = [
'fontFamily' => $font['name'],
'fontWeight' => $weight,
'fontStyle' => $style,
'fontDisplay' => 'swap',
'src' => [$font_url],
];
}
// Only add font if it has valid faces
if (!empty($font_faces)) {
$font_families[] = [
'name' => $font['name'],
'slug' => $font['slug'],
'fontFamily' => "'{$font['name']}', sans-serif",
'fontFace' => $font_faces,
];
}
}
// Only update if we have valid fonts
if (empty($font_families)) {
return $theme_json;
}
// Use update_with to merge - WordPress handles the merging logic
$new_data = [
'version' => 2,
'settings' => [
'typography' => [
'fontFamilies' => $font_families,
],
],
];
return $theme_json->update_with($new_data);
} catch (Exception $e) {
// Log error but don't break the Site Editor
error_log('MLF theme.json filter error: ' . $e->getMessage());
return $theme_json;
}
}
add_filter('wp_theme_json_data_user', 'mlf_add_fonts_to_theme_json', 10);
/**
* Generate @font-face CSS for imported fonts.
*
* @return string CSS content.
*/
function mlf_get_font_face_css() {
$registry = new MLF_Font_Registry();
$fonts = $registry->get_imported_fonts();
if (empty($fonts)) {
return '';
}
$css = '';
foreach ($fonts as $font) {
$font_slug = sanitize_title($font['name']);
// Escape font name for CSS (handle quotes and special chars)
$css_font_name = addcslashes($font['name'], "'\\");
foreach ($font['variants'] as $variant) {
$weight = $variant['weight'];
$style = $variant['style'];
// Build filename based on our naming convention
if (strpos($weight, ' ') !== false) {
// Variable font
$filename = sprintf('%s_%s_variable.woff2', $font_slug, $style);
} else {
$filename = sprintf('%s_%s_%s.woff2', $font_slug, $style, $weight);
}
// Get font URL (uses REST API or direct based on setting)
$font_url = mlf_get_font_url($filename);
// Ensure HTTPS if site uses HTTPS (important for reverse proxy setups)
if (is_ssl() && strpos($font_url, 'http://') === 0) {
$font_url = str_replace('http://', 'https://', $font_url);
}
$css .= "@font-face {";
$css .= "font-family: '{$css_font_name}';";
$css .= "font-style: {$style};";
$css .= "font-weight: {$weight};";
$css .= "font-display: swap;";
$css .= "src: url('" . esc_url($font_url) . "') format('woff2');";
$css .= "}\n";
}
}
return $css;
}
/**
* Enqueue font face CSS on frontend.
*
* Uses external CSS file just like Google Fonts does.
*/
function mlf_enqueue_font_faces() {
$registry = new MLF_Font_Registry();
$fonts = $registry->get_imported_fonts();
if (empty($fonts)) {
return;
}
// Load CSS from custom endpoint (like Google Fonts serves CSS)
wp_enqueue_style(
'mlf-local-fonts',
home_url('/mlf-fonts.css'),
[],
null // No version string, like Google Fonts
);
}
add_action('wp_enqueue_scripts', 'mlf_enqueue_font_faces');
/**
* Enqueue font face CSS in block editor.
*/
function mlf_enqueue_editor_font_faces() {
$registry = new MLF_Font_Registry();
$fonts = $registry->get_imported_fonts();
if (empty($fonts)) {
return;
}
// Load CSS from custom endpoint (like Google Fonts serves CSS)
wp_enqueue_style(
'mlf-local-fonts-editor',
home_url('/mlf-fonts.css'),
[],
null
);
}
add_action('enqueue_block_editor_assets', 'mlf_enqueue_editor_font_faces');
/**
* Add rewrite rule for fonts CSS endpoint.
*/
function mlf_add_rewrite_rules() {
add_rewrite_rule('^mlf-fonts\.css$', 'index.php?mlf_fonts_css=1', 'top');
}
add_action('init', 'mlf_add_rewrite_rules');
/**
* Add query var for fonts CSS.
*/
function mlf_add_query_vars($vars) {
$vars[] = 'mlf_fonts_css';
return $vars;
}
add_filter('query_vars', 'mlf_add_query_vars');
/**
* Handle fonts CSS request.
*/
function mlf_handle_fonts_css_request() {
if (!get_query_var('mlf_fonts_css')) {
return;
}
// Send CSS headers
header('Content-Type: text/css; charset=UTF-8');
header('Access-Control-Allow-Origin: *');
header('Cache-Control: public, max-age=86400');
header('X-Content-Type-Options: nosniff');
$registry = new MLF_Font_Registry();
$fonts = $registry->get_imported_fonts_with_src();
if (empty($fonts)) {
echo "/* No fonts installed */\n";
exit;
}
echo "/* Maple Local Fonts */\n\n";
// Use direct font file URLs
$font_dir = wp_get_font_dir();
$font_base_url = trailingslashit($font_dir['url']);
foreach ($fonts as $font) {
foreach ($font['variants'] as $variant) {
$weight = $variant['weight'];
$style = $variant['style'];
// Use the actual filename from database (stored in src)
$filename = $variant['filename'];
$file_url = $font_base_url . $filename;
echo "@font-face {\n";
echo " font-family: '{$font['name']}';\n";
echo " font-style: {$style};\n";
echo " font-weight: {$weight};\n";
echo " font-display: swap;\n";
echo " src: url({$file_url}) format('woff2');\n";
echo "}\n\n";
}
}
exit;
}
add_action('template_redirect', 'mlf_handle_fonts_css_request');
/**
* Register admin menu.
*/
function mlf_register_menu() {
add_submenu_page(
'options-general.php',
__('Maple Fonts', 'maple-local-fonts'),
__('Maple Fonts', 'maple-local-fonts'),
mlf_get_capability(),
'maple-local-fonts',
'mlf_render_admin_page'
);
}
add_action('admin_menu', 'mlf_register_menu');
/**
* Add settings link to plugin action links.
*
* @param array $links Existing plugin action links.
* @return array Modified plugin action links.
*/
function mlf_plugin_action_links($links) {
$settings_link = sprintf(
'<a href="%s">%s</a>',
esc_url(admin_url('options-general.php?page=maple-local-fonts')),
esc_html__('Settings', 'maple-local-fonts')
);
array_unshift($links, $settings_link);
return $links;
}
add_filter('plugin_action_links_' . MLF_PLUGIN_BASENAME, 'mlf_plugin_action_links');
/**
* Render admin page (delegates to MLF_Admin_Page).
*/
function mlf_render_admin_page() {
$admin_page = new MLF_Admin_Page();
$admin_page->render();
}
/**
* Enqueue admin assets.
*
* @param string $hook The current admin page hook.
*/
function mlf_enqueue_admin_assets($hook) {
if ($hook !== 'settings_page_maple-local-fonts') {
return;
}
// Use filemtime for cache-busting during development
$css_version = MLF_VERSION . '.' . filemtime(MLF_PLUGIN_DIR . 'assets/admin.css');
$js_version = MLF_VERSION . '.' . filemtime(MLF_PLUGIN_DIR . 'assets/admin.js');
wp_enqueue_style(
'mlf-admin',
MLF_PLUGIN_URL . 'assets/admin.css',
[],
$css_version
);
wp_enqueue_script(
'mlf-admin',
MLF_PLUGIN_URL . 'assets/admin.js',
['jquery'],
$js_version,
true
);
wp_localize_script('mlf-admin', 'mapleLocalFontsData', [
'ajaxUrl' => admin_url('admin-ajax.php'),
'downloadNonce' => wp_create_nonce('mlf_download_font'),
'deleteNonce' => wp_create_nonce('mlf_delete_font'),
'searchNonce' => wp_create_nonce('mlf_search_fonts'),
'checkUpdatesNonce' => wp_create_nonce('mlf_check_updates'),
'updateFontNonce' => wp_create_nonce('mlf_update_font'),
'strings' => [
'downloading' => __('Downloading...', 'maple-local-fonts'),
'deleting' => __('Deleting...', 'maple-local-fonts'),
'updating' => __('Updating...', 'maple-local-fonts'),
'checking' => __('Checking...', 'maple-local-fonts'),
'confirmDelete' => __('Are you sure you want to delete this font?', 'maple-local-fonts'),
'error' => __('An error occurred. Please try again.', 'maple-local-fonts'),
'searching' => __('Searching...', 'maple-local-fonts'),
'noResults' => __('No fonts found. Try a different search term.', 'maple-local-fonts'),
'selectFont' => __('Please select a font first.', 'maple-local-fonts'),
'previewText' => __('Maple Fonts Preview', 'maple-local-fonts'),
'minChars' => __('Please enter at least 2 characters.', 'maple-local-fonts'),
'noUpdates' => __('All fonts are up to date.', 'maple-local-fonts'),
'updatesFound' => __('Updates available for %d font(s).', 'maple-local-fonts'),
],
]);
}
add_action('admin_enqueue_scripts', 'mlf_enqueue_admin_assets');

View file

@ -0,0 +1,61 @@
<?xml version="1.0"?>
<ruleset name="Maple Local Fonts">
<description>PHP Coding Standards for Maple Local Fonts WordPress Plugin</description>
<!-- Scan these files -->
<file>.</file>
<!-- Exclude these files/directories -->
<exclude-pattern>/vendor/*</exclude-pattern>
<exclude-pattern>/tests/*</exclude-pattern>
<exclude-pattern>/bin/*</exclude-pattern>
<exclude-pattern>/build/*</exclude-pattern>
<exclude-pattern>/node_modules/*</exclude-pattern>
<exclude-pattern>*.min.js</exclude-pattern>
<exclude-pattern>*.min.css</exclude-pattern>
<!-- Show progress and sniff codes -->
<arg value="ps"/>
<arg name="colors"/>
<!-- Use WordPress Coding Standards -->
<rule ref="WordPress">
<!-- Allow short array syntax -->
<exclude name="Generic.Arrays.DisallowShortArraySyntax"/>
<!-- Allow file names that don't match class names (for index.php files) -->
<exclude name="WordPress.Files.FileName.InvalidClassFileName"/>
<exclude name="WordPress.Files.FileName.NotHyphenatedLowercase"/>
</rule>
<!-- Use WordPress Extra Standards -->
<rule ref="WordPress-Extra">
<exclude name="WordPress.Files.FileName"/>
</rule>
<!-- Check for PHP cross-version compatibility -->
<config name="testVersion" value="7.4-"/>
<rule ref="PHPCompatibilityWP"/>
<!-- WordPress-specific settings -->
<config name="minimum_supported_wp_version" value="6.5"/>
<!-- Text domain for i18n -->
<rule ref="WordPress.WP.I18n">
<properties>
<property name="text_domain" type="array">
<element value="maple-local-fonts"/>
</property>
</properties>
</rule>
<!-- Prefix all globals -->
<rule ref="WordPress.NamingConventions.PrefixAllGlobals">
<properties>
<property name="prefixes" type="array">
<element value="mlf"/>
<element value="MLF"/>
<element value="maple_local_fonts"/>
</property>
</properties>
</rule>
</ruleset>

View file

@ -0,0 +1,27 @@
<?xml version="1.0"?>
<phpunit
bootstrap="tests/bootstrap.php"
backupGlobals="false"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
>
<testsuites>
<testsuite name="Maple Local Fonts Test Suite">
<directory suffix=".php">./tests/</directory>
</testsuite>
</testsuites>
<filter>
<whitelist processUncoveredFilesFromWhitelist="true">
<directory suffix=".php">./includes/</directory>
<file>./maple-local-fonts.php</file>
</whitelist>
</filter>
<php>
<const name="WP_TESTS_DOMAIN" value="example.org"/>
<const name="WP_TESTS_EMAIL" value="admin@example.org"/>
<const name="WP_TESTS_TITLE" value="Test Blog"/>
<const name="WP_PHP_BINARY" value="php"/>
</php>
</phpunit>

View file

@ -0,0 +1,155 @@
=== Maple Local Fonts ===
Contributors: mapleopentech
Tags: fonts, google fonts, local fonts, gdpr, typography, privacy
Requires at least: 6.5
Tested up to: 6.7
Requires PHP: 7.4
Stable tag: 1.0.0
License: GPL-2.0-or-later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Import Google Fonts to local storage for GDPR-compliant, privacy-friendly typography. Fonts are served from your server, not Google.
== Description ==
**Maple Local Fonts** allows you to import Google Fonts to your WordPress site's local storage. Once imported, fonts are served directly from your server — Google is never contacted again.
= Why Local Fonts? =
* **GDPR Compliance**: Serving fonts from Google's servers can transfer visitor IP addresses to Google, which may violate GDPR. Local fonts eliminate this concern.
* **Performance**: Local fonts can load faster since they're served from your own server without additional DNS lookups.
* **Privacy**: Your visitors' data stays private — no third-party requests for fonts.
* **Reliability**: Fonts are always available, even if Google's services are blocked or unavailable.
= Features =
* **Simple Import**: Enter a font name, select weights and styles, and click to import.
* **WordPress Integration**: Fonts automatically appear in the Full Site Editor typography settings.
* **Clean Removal**: Uninstalling the plugin removes all imported fonts and files.
* **No Frontend Impact**: Zero JavaScript or CSS added to your frontend — only font files are served.
= How It Works =
1. Enter the Google Fonts name (e.g., "Open Sans")
2. Select the weights (400, 700, etc.) and styles (normal, italic)
3. Click "Download & Install"
4. The plugin downloads the font files and registers them with WordPress
5. Use the fonts in Appearance → Editor → Styles → Typography
= Requirements =
* WordPress 6.5 or higher (required for Font Library API)
* PHP 7.4 or higher
== Installation ==
1. Upload the `maple-local-fonts` folder to `/wp-content/plugins/`
2. Activate the plugin through the Plugins menu
3. Go to Appearance → Local Fonts
4. Import your desired fonts
== Frequently Asked Questions ==
= Does this plugin contact Google after importing a font? =
No. The plugin only contacts Google Fonts once, during the import process. After that, fonts are served entirely from your server.
= Where are the font files stored? =
Font files are stored in `wp-content/fonts/`, which is WordPress's default font directory.
= Can I use these fonts with any theme? =
**Block Themes (FSE):** Imported fonts automatically appear in Appearance → Editor → Styles → Typography.
**Classic Themes:** Fonts are registered but must be applied via custom CSS. After importing, add CSS like this to your theme or Customizer:
`body { font-family: "Open Sans", sans-serif; }`
The plugin will detect your theme type and show appropriate instructions.
= What happens to the fonts if I uninstall the plugin? =
When you delete the plugin, all imported fonts and their files are removed. This ensures a clean uninstall.
= Is this plugin GDPR compliant? =
Yes. After importing, fonts are served from your own server. No visitor data is sent to Google or any third party.
= What font formats are downloaded? =
The plugin downloads WOFF2 format, which is the most efficient and widely supported web font format.
= Can I import fonts that aren't on Google Fonts? =
No, this plugin only supports importing from Google Fonts. For other fonts, you would need to manually upload them.
= I'm using Wordfence/a firewall and imports are failing =
If font imports fail, your firewall may be blocking outbound requests. Whitelist these domains:
* `fonts.googleapis.com` (for font CSS)
* `fonts.gstatic.com` (for font files)
**Wordfence:** Go to Wordfence → Firewall → Blocking and ensure these domains aren't blocked. If using Learning Mode, imports should work and the domains will be automatically learned.
**Other firewalls:** Add the above domains to your outbound request allowlist.
= The plugin says it requires WordPress 6.5 =
WordPress 6.5 introduced the Font Library API that this plugin uses. Earlier versions don't support local font registration. Please update WordPress to use this plugin.
== Compatibility ==
= Tested With =
* **WordPress:** 6.5, 6.6, 6.7
* **WooCommerce:** 8.0+ (HPOS compatible)
* **Wordfence:** 7.10+ (see FAQ for firewall settings)
* **LearnDash:** 4.10+
* **Popular Block Themes:** Twenty Twenty-Four, Twenty Twenty-Five
= Theme Compatibility =
* **Block Themes (FSE):** Full support via Site Editor typography settings
* **Classic Themes:** Supported via custom CSS (instructions shown in admin)
== Privacy ==
= Data Collection =
This plugin does not collect any personal data from your website visitors.
= External Services =
This plugin connects to Google Fonts (fonts.googleapis.com and fonts.gstatic.com) **only during the font import process** in the WordPress admin area. This connection is:
* Initiated only by administrators
* Used only to download font files
* Not repeated after initial download
After import, no external connections are made. Fonts are served entirely from your server.
= Cookies =
This plugin does not set any cookies.
== Screenshots ==
1. The font import interface
2. Installed fonts list with delete option
3. Fonts appearing in the Full Site Editor typography settings
== Changelog ==
= 1.0.0 =
* Initial release
* Import Google Fonts to local storage
* WordPress Font Library integration
* Admin interface for font management
* Clean uninstall functionality
== Upgrade Notice ==
= 1.0.0 =
Initial release of Maple Local Fonts.

View file

@ -0,0 +1,40 @@
<?php
/**
* PHPUnit bootstrap file for Maple Local Fonts.
*
* @package Maple_Local_Fonts
*/
// Composer autoloader.
$autoload = dirname(__DIR__) . '/vendor/autoload.php';
if (file_exists($autoload)) {
require_once $autoload;
}
// Get the tests directory.
$_tests_dir = getenv('WP_TESTS_DIR');
// Try to find the WP test suite.
if (!$_tests_dir) {
$_tests_dir = rtrim(sys_get_temp_dir(), '/\\') . '/wordpress-tests-lib';
}
// Check if the test suite exists.
if (!file_exists($_tests_dir . '/includes/functions.php')) {
echo "Could not find $_tests_dir/includes/functions.php, have you run bin/install-wp-tests.sh?" . PHP_EOL;
exit(1);
}
// Give access to tests_add_filter() function.
require_once $_tests_dir . '/includes/functions.php';
/**
* Manually load the plugin being tested.
*/
function _manually_load_plugin() {
require dirname(__DIR__) . '/maple-local-fonts.php';
}
tests_add_filter('muplugins_loaded', '_manually_load_plugin');
// Start up the WP testing environment.
require $_tests_dir . '/includes/bootstrap.php';

View file

@ -0,0 +1,2 @@
<?php
// Silence is golden.

View file

@ -0,0 +1,426 @@
<?php
/**
* Tests for MLF_Ajax_Handler class.
*
* @package Maple_Local_Fonts
*/
/**
* Class Test_MLF_Ajax_Handler
*
* @covers MLF_Ajax_Handler
*/
class Test_MLF_Ajax_Handler extends WP_Ajax_UnitTestCase {
/**
* Admin user ID.
*
* @var int
*/
private $admin_id;
/**
* Subscriber user ID.
*
* @var int
*/
private $subscriber_id;
/**
* Set up test fixtures.
*/
public function set_up() {
parent::set_up();
// Create users
$this->admin_id = $this->factory->user->create(['role' => 'administrator']);
$this->subscriber_id = $this->factory->user->create(['role' => 'subscriber']);
// Initialize the handler
new MLF_Ajax_Handler();
}
/**
* Clean up after tests.
*/
public function tear_down() {
// Clean up test fonts
$fonts = get_posts([
'post_type' => 'wp_font_family',
'posts_per_page' => -1,
'meta_key' => '_mlf_imported',
'meta_value' => '1',
'fields' => 'ids',
]);
foreach ($fonts as $font_id) {
wp_delete_post($font_id, true);
}
wp_set_current_user(0);
delete_transient('mlf_imported_fonts_list');
parent::tear_down();
}
/**
* Test download without nonce.
*/
public function test_download_without_nonce() {
wp_set_current_user($this->admin_id);
$_POST['font_name'] = 'Open Sans';
$_POST['weights'] = [400];
$_POST['styles'] = ['normal'];
$this->expectException('WPAjaxDieStopException');
try {
$this->_handleAjax('mlf_download_font');
} catch (WPAjaxDieStopException $e) {
$response = json_decode($this->_last_response, true);
$this->assertFalse($response['success']);
$this->assertStringContainsString('Security', $response['data']['message']);
throw $e;
}
}
/**
* Test download with invalid nonce.
*/
public function test_download_with_invalid_nonce() {
wp_set_current_user($this->admin_id);
$_POST['nonce'] = 'invalid_nonce';
$_POST['font_name'] = 'Open Sans';
$_POST['weights'] = [400];
$_POST['styles'] = ['normal'];
$this->expectException('WPAjaxDieStopException');
try {
$this->_handleAjax('mlf_download_font');
} catch (WPAjaxDieStopException $e) {
$response = json_decode($this->_last_response, true);
$this->assertFalse($response['success']);
throw $e;
}
}
/**
* Test download without proper capability.
*/
public function test_download_without_capability() {
wp_set_current_user($this->subscriber_id);
$_POST['nonce'] = wp_create_nonce('mlf_download_font');
$_POST['font_name'] = 'Open Sans';
$_POST['weights'] = [400];
$_POST['styles'] = ['normal'];
$this->expectException('WPAjaxDieStopException');
try {
$this->_handleAjax('mlf_download_font');
} catch (WPAjaxDieStopException $e) {
$response = json_decode($this->_last_response, true);
$this->assertFalse($response['success']);
$this->assertStringContainsString('Unauthorized', $response['data']['message']);
throw $e;
}
}
/**
* Test download with empty font name.
*/
public function test_download_empty_font_name() {
wp_set_current_user($this->admin_id);
$_POST['nonce'] = wp_create_nonce('mlf_download_font');
$_POST['font_name'] = '';
$_POST['weights'] = [400];
$_POST['styles'] = ['normal'];
$this->expectException('WPAjaxDieStopException');
try {
$this->_handleAjax('mlf_download_font');
} catch (WPAjaxDieStopException $e) {
$response = json_decode($this->_last_response, true);
$this->assertFalse($response['success']);
$this->assertStringContainsString('required', $response['data']['message']);
throw $e;
}
}
/**
* Test download with invalid font name (XSS attempt).
*/
public function test_download_invalid_font_name_xss() {
wp_set_current_user($this->admin_id);
$_POST['nonce'] = wp_create_nonce('mlf_download_font');
$_POST['font_name'] = '<script>alert("xss")</script>';
$_POST['weights'] = [400];
$_POST['styles'] = ['normal'];
$this->expectException('WPAjaxDieStopException');
try {
$this->_handleAjax('mlf_download_font');
} catch (WPAjaxDieStopException $e) {
$response = json_decode($this->_last_response, true);
$this->assertFalse($response['success']);
$this->assertStringContainsString('Invalid font name', $response['data']['message']);
throw $e;
}
}
/**
* Test download with path traversal in font name.
*/
public function test_download_path_traversal() {
wp_set_current_user($this->admin_id);
$_POST['nonce'] = wp_create_nonce('mlf_download_font');
$_POST['font_name'] = '../../../etc/passwd';
$_POST['weights'] = [400];
$_POST['styles'] = ['normal'];
$this->expectException('WPAjaxDieStopException');
try {
$this->_handleAjax('mlf_download_font');
} catch (WPAjaxDieStopException $e) {
$response = json_decode($this->_last_response, true);
$this->assertFalse($response['success']);
$this->assertStringContainsString('Invalid font name', $response['data']['message']);
throw $e;
}
}
/**
* Test download with font name too long.
*/
public function test_download_font_name_too_long() {
wp_set_current_user($this->admin_id);
$_POST['nonce'] = wp_create_nonce('mlf_download_font');
$_POST['font_name'] = str_repeat('a', 101);
$_POST['weights'] = [400];
$_POST['styles'] = ['normal'];
$this->expectException('WPAjaxDieStopException');
try {
$this->_handleAjax('mlf_download_font');
} catch (WPAjaxDieStopException $e) {
$response = json_decode($this->_last_response, true);
$this->assertFalse($response['success']);
$this->assertStringContainsString('too long', $response['data']['message']);
throw $e;
}
}
/**
* Test download with no weights.
*/
public function test_download_no_weights() {
wp_set_current_user($this->admin_id);
$_POST['nonce'] = wp_create_nonce('mlf_download_font');
$_POST['font_name'] = 'Open Sans';
$_POST['weights'] = [];
$_POST['styles'] = ['normal'];
$this->expectException('WPAjaxDieStopException');
try {
$this->_handleAjax('mlf_download_font');
} catch (WPAjaxDieStopException $e) {
$response = json_decode($this->_last_response, true);
$this->assertFalse($response['success']);
$this->assertStringContainsString('weight', $response['data']['message']);
throw $e;
}
}
/**
* Test download with invalid weights.
*/
public function test_download_invalid_weights() {
wp_set_current_user($this->admin_id);
$_POST['nonce'] = wp_create_nonce('mlf_download_font');
$_POST['font_name'] = 'Open Sans';
$_POST['weights'] = [999, 1000]; // Invalid weights
$_POST['styles'] = ['normal'];
$this->expectException('WPAjaxDieStopException');
try {
$this->_handleAjax('mlf_download_font');
} catch (WPAjaxDieStopException $e) {
$response = json_decode($this->_last_response, true);
$this->assertFalse($response['success']);
$this->assertStringContainsString('weight', $response['data']['message']);
throw $e;
}
}
/**
* Test download with no styles.
*/
public function test_download_no_styles() {
wp_set_current_user($this->admin_id);
$_POST['nonce'] = wp_create_nonce('mlf_download_font');
$_POST['font_name'] = 'Open Sans';
$_POST['weights'] = [400];
$_POST['styles'] = [];
$this->expectException('WPAjaxDieStopException');
try {
$this->_handleAjax('mlf_download_font');
} catch (WPAjaxDieStopException $e) {
$response = json_decode($this->_last_response, true);
$this->assertFalse($response['success']);
$this->assertStringContainsString('style', $response['data']['message']);
throw $e;
}
}
/**
* Test delete without nonce.
*/
public function test_delete_without_nonce() {
wp_set_current_user($this->admin_id);
$font_id = $this->create_test_font();
$_POST['font_id'] = $font_id;
$this->expectException('WPAjaxDieStopException');
try {
$this->_handleAjax('mlf_delete_font');
} catch (WPAjaxDieStopException $e) {
$response = json_decode($this->_last_response, true);
$this->assertFalse($response['success']);
throw $e;
}
}
/**
* Test delete without capability.
*/
public function test_delete_without_capability() {
wp_set_current_user($this->subscriber_id);
$font_id = $this->create_test_font();
$_POST['nonce'] = wp_create_nonce('mlf_delete_font');
$_POST['font_id'] = $font_id;
$this->expectException('WPAjaxDieStopException');
try {
$this->_handleAjax('mlf_delete_font');
} catch (WPAjaxDieStopException $e) {
$response = json_decode($this->_last_response, true);
$this->assertFalse($response['success']);
$this->assertStringContainsString('Unauthorized', $response['data']['message']);
throw $e;
}
}
/**
* Test delete with invalid font ID.
*/
public function test_delete_invalid_font_id() {
wp_set_current_user($this->admin_id);
$_POST['nonce'] = wp_create_nonce('mlf_delete_font');
$_POST['font_id'] = 0;
$this->expectException('WPAjaxDieStopException');
try {
$this->_handleAjax('mlf_delete_font');
} catch (WPAjaxDieStopException $e) {
$response = json_decode($this->_last_response, true);
$this->assertFalse($response['success']);
$this->assertStringContainsString('Invalid', $response['data']['message']);
throw $e;
}
}
/**
* Test delete font not found.
*/
public function test_delete_font_not_found() {
wp_set_current_user($this->admin_id);
$_POST['nonce'] = wp_create_nonce('mlf_delete_font');
$_POST['font_id'] = 99999;
$this->expectException('WPAjaxDieStopException');
try {
$this->_handleAjax('mlf_delete_font');
} catch (WPAjaxDieStopException $e) {
$response = json_decode($this->_last_response, true);
$this->assertFalse($response['success']);
$this->assertStringContainsString('not found', $response['data']['message']);
throw $e;
}
}
/**
* Test delete font not imported by plugin.
*/
public function test_delete_font_not_ours() {
wp_set_current_user($this->admin_id);
// Create font without our meta
$font_id = wp_insert_post([
'post_type' => 'wp_font_family',
'post_title' => 'Theme Font',
'post_status' => 'publish',
]);
$_POST['nonce'] = wp_create_nonce('mlf_delete_font');
$_POST['font_id'] = $font_id;
$this->expectException('WPAjaxDieStopException');
try {
$this->_handleAjax('mlf_delete_font');
} catch (WPAjaxDieStopException $e) {
$response = json_decode($this->_last_response, true);
$this->assertFalse($response['success']);
$this->assertStringContainsString('not imported', $response['data']['message']);
throw $e;
}
// Clean up
wp_delete_post($font_id, true);
}
/**
* Helper to create a test font.
*
* @return int Font ID.
*/
private function create_test_font() {
$font_id = wp_insert_post([
'post_type' => 'wp_font_family',
'post_title' => 'Test Font',
'post_name' => 'test-font',
'post_status' => 'publish',
]);
update_post_meta($font_id, '_mlf_imported', '1');
return $font_id;
}
}

View file

@ -0,0 +1,303 @@
<?php
/**
* Tests for MLF_Font_Registry class.
*
* @package Maple_Local_Fonts
*/
/**
* Class Test_MLF_Font_Registry
*
* @covers MLF_Font_Registry
*/
class Test_MLF_Font_Registry extends WP_UnitTestCase {
/**
* Font registry instance.
*
* @var MLF_Font_Registry
*/
private $registry;
/**
* Set up test fixtures.
*/
public function set_up() {
parent::set_up();
$this->registry = new MLF_Font_Registry();
// Clear font cache
delete_transient('mlf_imported_fonts_list');
}
/**
* Clean up after tests.
*/
public function tear_down() {
// Clean up any test fonts
$fonts = get_posts([
'post_type' => 'wp_font_family',
'posts_per_page' => -1,
'meta_key' => '_mlf_imported',
'meta_value' => '1',
'fields' => 'ids',
]);
foreach ($fonts as $font_id) {
wp_delete_post($font_id, true);
}
delete_transient('mlf_imported_fonts_list');
parent::tear_down();
}
/**
* Test that get_imported_fonts returns empty array when no fonts installed.
*/
public function test_get_imported_fonts_empty() {
$fonts = $this->registry->get_imported_fonts();
$this->assertIsArray($fonts);
$this->assertEmpty($fonts);
}
/**
* Test font registration.
*/
public function test_register_font() {
$font_name = 'Test Font';
$font_slug = 'test-font';
$files = [
[
'path' => '/tmp/test-font_normal_400.woff2',
'weight' => '400',
'style' => 'normal',
],
];
// Create a dummy file for the test
file_put_contents($files[0]['path'], 'wOF2dummy');
$result = $this->registry->register_font($font_name, $font_slug, $files);
$this->assertIsInt($result);
$this->assertGreaterThan(0, $result);
// Verify font was registered
$font = get_post($result);
$this->assertEquals('wp_font_family', $font->post_type);
$this->assertEquals($font_name, $font->post_title);
// Clean up
unlink($files[0]['path']);
}
/**
* Test that duplicate fonts are rejected.
*/
public function test_register_font_duplicate() {
$font_name = 'Duplicate Font';
$font_slug = 'duplicate-font';
$files = [
[
'path' => '/tmp/duplicate-font_normal_400.woff2',
'weight' => '400',
'style' => 'normal',
],
];
file_put_contents($files[0]['path'], 'wOF2dummy');
// Register first time
$result1 = $this->registry->register_font($font_name, $font_slug, $files);
$this->assertIsInt($result1);
// Try to register again
$result2 = $this->registry->register_font($font_name, $font_slug, $files);
$this->assertWPError($result2);
$this->assertEquals('font_exists', $result2->get_error_code());
// Clean up
unlink($files[0]['path']);
}
/**
* Test get_imported_fonts returns registered fonts.
*/
public function test_get_imported_fonts_returns_fonts() {
$font_name = 'Listed Font';
$font_slug = 'listed-font';
$files = [
[
'path' => '/tmp/listed-font_normal_400.woff2',
'weight' => '400',
'style' => 'normal',
],
[
'path' => '/tmp/listed-font_normal_700.woff2',
'weight' => '700',
'style' => 'normal',
],
];
foreach ($files as $file) {
file_put_contents($file['path'], 'wOF2dummy');
}
$font_id = $this->registry->register_font($font_name, $font_slug, $files);
// Clear cache to test fresh retrieval
delete_transient('mlf_imported_fonts_list');
$fonts = $this->registry->get_imported_fonts();
$this->assertCount(1, $fonts);
$this->assertEquals($font_id, $fonts[0]['id']);
$this->assertEquals($font_name, $fonts[0]['name']);
$this->assertEquals($font_slug, $fonts[0]['slug']);
$this->assertCount(2, $fonts[0]['variants']);
// Clean up
foreach ($files as $file) {
unlink($file['path']);
}
}
/**
* Test font deletion.
*/
public function test_delete_font() {
$font_name = 'Delete Font';
$font_slug = 'delete-font';
$files = [
[
'path' => '/tmp/delete-font_normal_400.woff2',
'weight' => '400',
'style' => 'normal',
],
];
file_put_contents($files[0]['path'], 'wOF2dummy');
$font_id = $this->registry->register_font($font_name, $font_slug, $files);
// Delete the font
$result = $this->registry->delete_font($font_id);
$this->assertTrue($result);
// Verify font is gone
$font = get_post($font_id);
$this->assertNull($font);
}
/**
* Test delete_font rejects non-existent fonts.
*/
public function test_delete_font_not_found() {
$result = $this->registry->delete_font(99999);
$this->assertWPError($result);
$this->assertEquals('not_found', $result->get_error_code());
}
/**
* Test delete_font rejects fonts not imported by plugin.
*/
public function test_delete_font_not_ours() {
// Create a font family post without our meta
$font_id = wp_insert_post([
'post_type' => 'wp_font_family',
'post_title' => 'Theme Font',
'post_status' => 'publish',
]);
$result = $this->registry->delete_font($font_id);
$this->assertWPError($result);
$this->assertEquals('not_ours', $result->get_error_code());
// Clean up
wp_delete_post($font_id, true);
}
/**
* Test that cache is cleared on register.
*/
public function test_cache_cleared_on_register() {
// Set a dummy cache
set_transient('mlf_imported_fonts_list', ['cached' => true], 300);
$font_name = 'Cache Test Font';
$font_slug = 'cache-test-font';
$files = [
[
'path' => '/tmp/cache-test-font_normal_400.woff2',
'weight' => '400',
'style' => 'normal',
],
];
file_put_contents($files[0]['path'], 'wOF2dummy');
$this->registry->register_font($font_name, $font_slug, $files);
// Cache should be cleared
$cached = get_transient('mlf_imported_fonts_list');
$this->assertFalse($cached);
// Clean up
unlink($files[0]['path']);
}
/**
* Test that cache is cleared on delete.
*/
public function test_cache_cleared_on_delete() {
$font_name = 'Cache Delete Font';
$font_slug = 'cache-delete-font';
$files = [
[
'path' => '/tmp/cache-delete-font_normal_400.woff2',
'weight' => '400',
'style' => 'normal',
],
];
file_put_contents($files[0]['path'], 'wOF2dummy');
$font_id = $this->registry->register_font($font_name, $font_slug, $files);
// Set a dummy cache
set_transient('mlf_imported_fonts_list', ['cached' => true], 300);
$this->registry->delete_font($font_id);
// Cache should be cleared
$cached = get_transient('mlf_imported_fonts_list');
$this->assertFalse($cached);
}
/**
* Test font_exists method.
*/
public function test_font_exists() {
$font_name = 'Exists Font';
$font_slug = 'exists-font';
$files = [
[
'path' => '/tmp/exists-font_normal_400.woff2',
'weight' => '400',
'style' => 'normal',
],
];
file_put_contents($files[0]['path'], 'wOF2dummy');
// Before registration
$this->assertFalse($this->registry->font_exists($font_slug));
// After registration
$this->registry->register_font($font_name, $font_slug, $files);
$this->assertTrue($this->registry->font_exists($font_slug));
// Clean up
unlink($files[0]['path']);
}
}

View file

@ -0,0 +1,177 @@
<?php
/**
* Tests for MLF_Rate_Limiter class.
*
* @package Maple_Local_Fonts
*/
/**
* Class Test_MLF_Rate_Limiter
*
* @covers MLF_Rate_Limiter
*/
class Test_MLF_Rate_Limiter extends WP_UnitTestCase {
/**
* Rate limiter instance.
*
* @var MLF_Rate_Limiter
*/
private $rate_limiter;
/**
* Set up test fixtures.
*/
public function set_up() {
parent::set_up();
$this->rate_limiter = new MLF_Rate_Limiter(3, 60); // 3 requests per 60 seconds
}
/**
* Clean up after tests.
*/
public function tear_down() {
// Clear any transients
$this->rate_limiter->clear('test_action');
parent::tear_down();
}
/**
* Test that first request is not limited.
*/
public function test_first_request_not_limited() {
$this->assertFalse($this->rate_limiter->is_limited('test_action'));
}
/**
* Test that remaining count starts at limit.
*/
public function test_remaining_starts_at_limit() {
$this->assertEquals(3, $this->rate_limiter->get_remaining('test_action'));
}
/**
* Test that check_and_record allows requests within limit.
*/
public function test_check_and_record_allows_within_limit() {
$this->assertTrue($this->rate_limiter->check_and_record('test_action'));
$this->assertTrue($this->rate_limiter->check_and_record('test_action'));
$this->assertTrue($this->rate_limiter->check_and_record('test_action'));
}
/**
* Test that check_and_record blocks after limit exceeded.
*/
public function test_check_and_record_blocks_after_limit() {
// Use up all allowed requests
$this->rate_limiter->check_and_record('test_action');
$this->rate_limiter->check_and_record('test_action');
$this->rate_limiter->check_and_record('test_action');
// Next request should be blocked
$this->assertFalse($this->rate_limiter->check_and_record('test_action'));
}
/**
* Test that remaining count decreases correctly.
*/
public function test_remaining_decreases() {
$this->rate_limiter->record_request('test_action');
$this->assertEquals(2, $this->rate_limiter->get_remaining('test_action'));
$this->rate_limiter->record_request('test_action');
$this->assertEquals(1, $this->rate_limiter->get_remaining('test_action'));
$this->rate_limiter->record_request('test_action');
$this->assertEquals(0, $this->rate_limiter->get_remaining('test_action'));
}
/**
* Test that is_limited returns true after limit exceeded.
*/
public function test_is_limited_after_exceeding() {
$this->rate_limiter->record_request('test_action');
$this->rate_limiter->record_request('test_action');
$this->rate_limiter->record_request('test_action');
$this->assertTrue($this->rate_limiter->is_limited('test_action'));
}
/**
* Test that clear resets the rate limit.
*/
public function test_clear_resets_limit() {
// Use up all requests
$this->rate_limiter->record_request('test_action');
$this->rate_limiter->record_request('test_action');
$this->rate_limiter->record_request('test_action');
$this->assertTrue($this->rate_limiter->is_limited('test_action'));
// Clear and verify reset
$this->rate_limiter->clear('test_action');
$this->assertFalse($this->rate_limiter->is_limited('test_action'));
$this->assertEquals(3, $this->rate_limiter->get_remaining('test_action'));
}
/**
* Test that different actions are tracked separately.
*/
public function test_different_actions_tracked_separately() {
// Use up all requests for action1
$this->rate_limiter->record_request('action1');
$this->rate_limiter->record_request('action1');
$this->rate_limiter->record_request('action1');
// action1 should be limited
$this->assertTrue($this->rate_limiter->is_limited('action1'));
// action2 should not be limited
$this->assertFalse($this->rate_limiter->is_limited('action2'));
$this->assertEquals(3, $this->rate_limiter->get_remaining('action2'));
// Clean up
$this->rate_limiter->clear('action1');
$this->rate_limiter->clear('action2');
}
/**
* Test that logged in user uses user ID for tracking.
*/
public function test_logged_in_user_tracking() {
// Create and log in a user
$user_id = $this->factory->user->create(['role' => 'administrator']);
wp_set_current_user($user_id);
// Use up requests
$this->rate_limiter->record_request('test_action');
$this->rate_limiter->record_request('test_action');
$this->rate_limiter->record_request('test_action');
$this->assertTrue($this->rate_limiter->is_limited('test_action'));
// Clean up
wp_set_current_user(0);
$this->rate_limiter->clear('test_action');
}
/**
* Test constructor with custom limits.
*/
public function test_custom_limits() {
$custom_limiter = new MLF_Rate_Limiter(5, 120);
$this->assertEquals(5, $custom_limiter->get_remaining('custom_action'));
// Use 5 requests
for ($i = 0; $i < 5; $i++) {
$this->assertTrue($custom_limiter->check_and_record('custom_action'));
}
// 6th should be blocked
$this->assertFalse($custom_limiter->check_and_record('custom_action'));
// Clean up
$custom_limiter->clear('custom_action');
}
}

View file

@ -0,0 +1,387 @@
<?php
/**
* Tests for MLF_Rest_Controller class.
*
* @package Maple_Local_Fonts
*/
/**
* Class Test_MLF_Rest_Controller
*
* @covers MLF_Rest_Controller
*/
class Test_MLF_Rest_Controller extends WP_Test_REST_Controller_Testcase {
/**
* REST controller instance.
*
* @var MLF_Rest_Controller
*/
private $controller;
/**
* Admin user ID.
*
* @var int
*/
private $admin_id;
/**
* Subscriber user ID.
*
* @var int
*/
private $subscriber_id;
/**
* Set up test fixtures.
*/
public function set_up() {
parent::set_up();
// Register REST routes
$this->controller = new MLF_Rest_Controller();
$this->controller->register_routes();
// Create users
$this->admin_id = $this->factory->user->create(['role' => 'administrator']);
$this->subscriber_id = $this->factory->user->create(['role' => 'subscriber']);
// Clear any existing fonts and cache
delete_transient('mlf_imported_fonts_list');
}
/**
* Clean up after tests.
*/
public function tear_down() {
// Clean up test fonts
$fonts = get_posts([
'post_type' => 'wp_font_family',
'posts_per_page' => -1,
'meta_key' => '_mlf_imported',
'meta_value' => '1',
'fields' => 'ids',
]);
foreach ($fonts as $font_id) {
wp_delete_post($font_id, true);
}
wp_set_current_user(0);
delete_transient('mlf_imported_fonts_list');
parent::tear_down();
}
/**
* Test route registration.
*/
public function test_register_routes() {
$routes = rest_get_server()->get_routes();
$this->assertArrayHasKey('/mlf/v1/fonts', $routes);
$this->assertArrayHasKey('/mlf/v1/fonts/(?P<id>[\d]+)', $routes);
}
/**
* Test get_items without authentication.
*/
public function test_get_items_unauthenticated() {
$request = new WP_REST_Request('GET', '/mlf/v1/fonts');
$response = rest_get_server()->dispatch($request);
$this->assertEquals(401, $response->get_status());
}
/**
* Test get_items with subscriber (insufficient permissions).
*/
public function test_get_items_insufficient_permissions() {
wp_set_current_user($this->subscriber_id);
$request = new WP_REST_Request('GET', '/mlf/v1/fonts');
$response = rest_get_server()->dispatch($request);
$this->assertEquals(403, $response->get_status());
}
/**
* Test get_items with admin.
*/
public function test_get_items_as_admin() {
wp_set_current_user($this->admin_id);
$request = new WP_REST_Request('GET', '/mlf/v1/fonts');
$response = rest_get_server()->dispatch($request);
$this->assertEquals(200, $response->get_status());
$this->assertIsArray($response->get_data());
}
/**
* Test get_items returns installed fonts.
*/
public function test_get_items_returns_fonts() {
wp_set_current_user($this->admin_id);
// Create a test font
$font_id = $this->create_test_font('Test API Font', 'test-api-font');
$request = new WP_REST_Request('GET', '/mlf/v1/fonts');
$response = rest_get_server()->dispatch($request);
$this->assertEquals(200, $response->get_status());
$data = $response->get_data();
$this->assertCount(1, $data);
$this->assertEquals($font_id, $data[0]['id']);
$this->assertEquals('Test API Font', $data[0]['name']);
}
/**
* Test get_item.
*/
public function test_get_item() {
wp_set_current_user($this->admin_id);
$font_id = $this->create_test_font('Single Font', 'single-font');
$request = new WP_REST_Request('GET', '/mlf/v1/fonts/' . $font_id);
$response = rest_get_server()->dispatch($request);
$this->assertEquals(200, $response->get_status());
$data = $response->get_data();
$this->assertEquals($font_id, $data['id']);
$this->assertEquals('Single Font', $data['name']);
}
/**
* Test get_item not found.
*/
public function test_get_item_not_found() {
wp_set_current_user($this->admin_id);
$request = new WP_REST_Request('GET', '/mlf/v1/fonts/99999');
$response = rest_get_server()->dispatch($request);
$this->assertEquals(404, $response->get_status());
}
/**
* Test create_item without authentication.
*/
public function test_create_item_unauthenticated() {
$request = new WP_REST_Request('POST', '/mlf/v1/fonts');
$request->set_param('font_name', 'Open Sans');
$request->set_param('weights', [400, 700]);
$request->set_param('styles', ['normal']);
$response = rest_get_server()->dispatch($request);
$this->assertEquals(401, $response->get_status());
}
/**
* Test create_item with subscriber.
*/
public function test_create_item_insufficient_permissions() {
wp_set_current_user($this->subscriber_id);
$request = new WP_REST_Request('POST', '/mlf/v1/fonts');
$request->set_param('font_name', 'Open Sans');
$request->set_param('weights', [400, 700]);
$request->set_param('styles', ['normal']);
$response = rest_get_server()->dispatch($request);
$this->assertEquals(403, $response->get_status());
}
/**
* Test create_item with invalid font name.
*/
public function test_create_item_invalid_font_name() {
wp_set_current_user($this->admin_id);
$request = new WP_REST_Request('POST', '/mlf/v1/fonts');
$request->set_param('font_name', '<script>alert("xss")</script>');
$request->set_param('weights', [400]);
$request->set_param('styles', ['normal']);
$response = rest_get_server()->dispatch($request);
$this->assertEquals(400, $response->get_status());
}
/**
* Test create_item with no weights.
*/
public function test_create_item_no_weights() {
wp_set_current_user($this->admin_id);
$request = new WP_REST_Request('POST', '/mlf/v1/fonts');
$request->set_param('font_name', 'Test Font');
$request->set_param('weights', []);
$request->set_param('styles', ['normal']);
$response = rest_get_server()->dispatch($request);
$this->assertEquals(400, $response->get_status());
}
/**
* Test create_item with no styles.
*/
public function test_create_item_no_styles() {
wp_set_current_user($this->admin_id);
$request = new WP_REST_Request('POST', '/mlf/v1/fonts');
$request->set_param('font_name', 'Test Font');
$request->set_param('weights', [400]);
$request->set_param('styles', []);
$response = rest_get_server()->dispatch($request);
$this->assertEquals(400, $response->get_status());
}
/**
* Test delete_item without authentication.
*/
public function test_delete_item_unauthenticated() {
$font_id = $this->create_test_font('Delete Font', 'delete-font');
$request = new WP_REST_Request('DELETE', '/mlf/v1/fonts/' . $font_id);
$response = rest_get_server()->dispatch($request);
$this->assertEquals(401, $response->get_status());
}
/**
* Test delete_item with subscriber.
*/
public function test_delete_item_insufficient_permissions() {
$font_id = $this->create_test_font('Delete Font 2', 'delete-font-2');
wp_set_current_user($this->subscriber_id);
$request = new WP_REST_Request('DELETE', '/mlf/v1/fonts/' . $font_id);
$response = rest_get_server()->dispatch($request);
$this->assertEquals(403, $response->get_status());
}
/**
* Test delete_item as admin.
*/
public function test_delete_item_as_admin() {
$font_id = $this->create_test_font('Delete Font 3', 'delete-font-3');
wp_set_current_user($this->admin_id);
$request = new WP_REST_Request('DELETE', '/mlf/v1/fonts/' . $font_id);
$response = rest_get_server()->dispatch($request);
$this->assertEquals(200, $response->get_status());
$data = $response->get_data();
$this->assertTrue($data['deleted']);
}
/**
* Test delete_item not found.
*/
public function test_delete_item_not_found() {
wp_set_current_user($this->admin_id);
$request = new WP_REST_Request('DELETE', '/mlf/v1/fonts/99999');
$response = rest_get_server()->dispatch($request);
$this->assertEquals(404, $response->get_status());
}
/**
* Test delete_item for font not imported by plugin.
*/
public function test_delete_item_not_ours() {
wp_set_current_user($this->admin_id);
// Create a font without our meta
$font_id = wp_insert_post([
'post_type' => 'wp_font_family',
'post_title' => 'Theme Font',
'post_status' => 'publish',
]);
$request = new WP_REST_Request('DELETE', '/mlf/v1/fonts/' . $font_id);
$response = rest_get_server()->dispatch($request);
$this->assertEquals(403, $response->get_status());
// Clean up
wp_delete_post($font_id, true);
}
/**
* Test item schema.
*/
public function test_get_item_schema() {
$request = new WP_REST_Request('OPTIONS', '/mlf/v1/fonts');
$response = rest_get_server()->dispatch($request);
$data = $response->get_data();
$this->assertArrayHasKey('schema', $data);
$this->assertEquals('font', $data['schema']['title']);
}
/**
* Test context parameter.
*/
public function test_context_param() {
// This test is inherited from WP_Test_REST_Controller_Testcase
// We just need to implement it to satisfy the abstract requirement
$this->assertTrue(true);
}
/**
* Helper to create a test font.
*
* @param string $name Font name.
* @param string $slug Font slug.
* @return int Font ID.
*/
private function create_test_font($name, $slug) {
// Create font family post
$font_id = wp_insert_post([
'post_type' => 'wp_font_family',
'post_title' => $name,
'post_name' => $slug,
'post_status' => 'publish',
'post_content' => wp_json_encode([
'fontFamily' => $name,
'slug' => $slug,
]),
]);
// Add our meta marker
update_post_meta($font_id, '_mlf_imported', '1');
// Create a font face
$face_id = wp_insert_post([
'post_type' => 'wp_font_face',
'post_title' => $name . ' 400 normal',
'post_status' => 'publish',
'post_parent' => $font_id,
'post_content' => wp_json_encode([
'fontFamily' => $name,
'fontWeight' => '400',
'fontStyle' => 'normal',
'src' => ['file:./fonts/' . $slug . '_normal_400.woff2'],
]),
]);
// Clear cache
delete_transient('mlf_imported_fonts_list');
return $font_id;
}
}

View file

@ -0,0 +1,124 @@
<?php
/**
* Uninstall script for Maple Local Fonts.
*
* This file is executed when the plugin is deleted from WordPress.
* It removes all fonts imported by this plugin and cleans up any data.
*
* @package Maple_Local_Fonts
*/
// If uninstall not called from WordPress, exit.
if (!defined('WP_UNINSTALL_PLUGIN')) {
exit;
}
/**
* Clean up all plugin data on uninstall.
*
* Uses batched processing to prevent memory exhaustion on sites with many fonts.
*/
function mlf_uninstall() {
$font_dir = wp_get_font_dir();
$batch_size = 50; // Process 50 fonts at a time to prevent memory issues
$processed = 0;
$max_iterations = 100; // Safety limit: max 5000 fonts (100 × 50)
// Process fonts in batches
for ($i = 0; $i < $max_iterations; $i++) {
$fonts = get_posts([
'post_type' => 'wp_font_family',
'posts_per_page' => $batch_size,
'post_status' => 'any',
'meta_key' => '_mlf_imported',
'meta_value' => '1',
'fields' => 'ids', // Only get IDs for memory efficiency
]);
// No more fonts to process
if (empty($fonts)) {
break;
}
// Get all font faces for this batch in a single query
$all_faces = get_posts([
'post_type' => 'wp_font_face',
'posts_per_page' => $batch_size * 20, // Max ~20 faces per font
'post_status' => 'any',
'post_parent__in' => $fonts,
]);
// Delete font face files and posts
foreach ($all_faces as $face) {
$settings = json_decode($face->post_content, true);
if (isset($settings['src'])) {
// Convert file:. URL to path
$src = $settings['src'];
$src = str_replace('file:./', '', $src);
$file_path = trailingslashit($font_dir['path']) . basename($src);
// Validate path and extension before deletion
if (mlf_uninstall_validate_font_path($file_path, $font_dir)
&& pathinfo($file_path, PATHINFO_EXTENSION) === 'woff2'
&& file_exists($file_path)) {
wp_delete_file($file_path);
}
}
wp_delete_post($face->ID, true);
}
// Delete family posts
foreach ($fonts as $font_id) {
wp_delete_post($font_id, true);
}
$fonts_count = count($fonts);
$processed += $fonts_count;
// Free memory
unset($fonts, $all_faces);
// If we got fewer than batch_size, we're done
if ($fonts_count < $batch_size) {
break;
}
}
// Clear caches
delete_transient('wp_font_library_fonts');
delete_transient('mlf_imported_fonts_list');
}
/**
* Validate that a path is within the WordPress fonts directory.
*
* @param string $path Full path to validate.
* @param array $font_dir Font directory info.
* @return bool True if path is safe, false otherwise.
*/
function mlf_uninstall_validate_font_path($path, $font_dir) {
$fonts_path = wp_normalize_path(trailingslashit($font_dir['path']));
// Resolve to real path (handles ../ etc)
$real_path = realpath($path);
// If realpath fails, file doesn't exist yet - validate the directory
if ($real_path === false) {
$dir = dirname($path);
$real_dir = realpath($dir);
if ($real_dir === false) {
return false;
}
$real_path = wp_normalize_path($real_dir . '/' . basename($path));
} else {
$real_path = wp_normalize_path($real_path);
}
// Must be within fonts directory
return strpos($real_path, $fonts_path) === 0;
}
// Run uninstall
mlf_uninstall();

View file

@ -0,0 +1,827 @@
# CLAUDE.md — Maple Icons WordPress Plugin
## Quick Reference
| Document | When to Reference |
|----------|-------------------|
| **CLAUDE.md** (this file) | Architecture, file structure, build order |
| **SECURITY.md** | Writing ANY PHP code (same patterns as Maple Local Fonts) |
---
## Project Overview
Build a WordPress plugin called **Maple Icons** that:
1. Fetches open-source icon sets from CDN and stores them locally
2. Provides preset icon sets: Heroicons, Lucide, Feather, Phosphor, Material
3. Only ONE icon set can be active at a time (though multiple can be downloaded)
4. Offers a Gutenberg block "Maple Icons" for inserting icons
5. Icons use `currentColor` to inherit text color from Global Styles
**Key principle:** Download once, serve locally. Zero runtime external requests after initial fetch.
---
## Requirements
- **Minimum PHP:** 7.4
- **Minimum WordPress:** 6.5 (for block.json apiVersion 3 and Global Styles)
- **License:** GPL-2.0-or-later
---
## Preset Icon Sets
All icons fetched from jsdelivr CDN with pinned versions:
| Set | Package | Version | Styles | ~Icons | viewBox |
|-----|---------|---------|--------|--------|---------|
| Heroicons | `heroicons` | 2.1.1 | outline, solid, mini | 290 | 24×24 |
| Lucide | `lucide-static` | 0.303.0 | icons | 1,400 | 24×24 |
| Feather | `feather-icons` | 4.29.1 | icons | 280 | 24×24 |
| Phosphor | `@phosphor-icons/core` | 2.1.1 | regular, bold, light, thin, fill, duotone | 1,200× styles | 256×256 (normalize) |
| Material | `@material-design-icons/svg` | 0.14.13 | filled, outlined, round, sharp, two-tone | 2,500 | 24×24 |
### CDN URL Patterns
```
Heroicons: https://cdn.jsdelivr.net/npm/heroicons@2.1.1/24/outline/{name}.svg
Lucide: https://cdn.jsdelivr.net/npm/lucide-static@0.303.0/icons/{name}.svg
Feather: https://cdn.jsdelivr.net/npm/feather-icons@4.29.1/dist/icons/{name}.svg
Phosphor: https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2.1.1/assets/{style}/{name}.svg
Material: https://cdn.jsdelivr.net/npm/@material-design-icons/svg@0.14.13/{style}/{name}.svg
```
### SVG Normalization Required
| Set | Issue | Fix |
|-----|-------|-----|
| Phosphor | viewBox="0 0 256 256" | Normalize to 24×24 |
| Material | `fill="#000000"` hardcoded | Replace with `currentColor` |
| All | May have XML declarations, comments | Strip during save |
---
## User Flow
```
Admin visits Settings → Maple Icons (or plugin action link)
Sees list of preset icon sets with Download/Delete buttons
Clicks "Download" on Heroicons → progress bar → icons saved locally
Clicks "Set Active" to make Heroicons the active set
User inserts "Maple Icons" block in Gutenberg editor
Block shows + button → click → icon picker modal with search
User searches/filters → selects icon → SVG inserted inline
SVG uses currentColor → inherits from Global Styles
All icons served from local storage (wp-content/maple-icons/)
```
---
## File Structure
```
maple-icons-wp/
├── maple-icons.php # Main plugin file
├── index.php # Silence is golden
├── uninstall.php # Clean removal
├── readme.txt # WordPress.org readme
├── package.json # Block build config
├── includes/
│ ├── index.php
│ ├── class-mi-icon-sets.php # Preset icon set definitions
│ ├── class-mi-icon-registry.php # Icon management & search
│ ├── class-mi-downloader.php # CDN fetch & local storage
│ ├── class-mi-admin-page.php # Settings page
│ └── class-mi-ajax-handler.php # AJAX handlers
├── presets/ # Bundled manifests (icon names, tags)
│ ├── index.php
│ ├── heroicons.json
│ ├── lucide.json
│ ├── feather.json
│ ├── phosphor.json
│ └── material.json
├── build/ # Compiled block assets (generated)
│ ├── index.php
│ ├── index.js
│ ├── index.asset.php
│ └── style-index.css
├── src/ # Block source (for development)
│ ├── index.js # Block registration
│ ├── edit.js # Editor component
│ ├── save.js # Save component (inline SVG)
│ ├── icon-picker.js # Icon selection modal
│ ├── editor.scss # Editor-only styles
│ └── block.json # Block metadata
├── assets/
│ ├── index.php
│ ├── admin.css # Settings page styles
│ └── admin.js # Settings page JS (download progress, etc.)
└── languages/
├── index.php
└── maple-icons.pot
```
### Local Icon Storage
Icons are stored in:
```
wp-content/maple-icons/{set-slug}/{style}/{icon-name}.svg
```
Example:
```
wp-content/maple-icons/heroicons/outline/academic-cap.svg
wp-content/maple-icons/heroicons/solid/academic-cap.svg
wp-content/maple-icons/lucide/icons/activity.svg
wp-content/maple-icons/phosphor/regular/airplane.svg
```
Using `wp-content/maple-icons/` (not uploads) to avoid cleanup plugin interference.
---
## Class Responsibilities
### MI_Icon_Sets
Static definitions of all preset icon sets.
```php
class MI_Icon_Sets {
public static function get_all(): array;
public static function get(string $slug): ?array;
public static function get_cdn_url(string $slug, string $style, string $name): string;
public static function get_manifest_path(string $slug): string;
}
```
Returns structure:
```php
[
'slug' => 'heroicons',
'name' => 'Heroicons',
'version' => '2.1.1',
'license' => 'MIT',
'url' => 'https://heroicons.com',
'cdn_base' => 'https://cdn.jsdelivr.net/npm/heroicons@2.1.1/',
'styles' => [
'outline' => ['path' => '24/outline', 'label' => 'Outline'],
'solid' => ['path' => '24/solid', 'label' => 'Solid'],
'mini' => ['path' => '20/solid', 'label' => 'Mini'],
],
'default_style' => 'outline',
'viewbox' => '0 0 24 24',
'normalize' => false, // true for Phosphor (256→24)
'color_fix' => false, // true for Material (replace hardcoded colors)
]
```
### MI_Icon_Registry
Manages downloaded sets and provides icon search/retrieval.
```php
class MI_Icon_Registry {
public function get_downloaded_sets(): array;
public function get_active_set(): ?string;
public function set_active(string $slug): bool;
public function is_downloaded(string $slug): bool;
public function get_icons_for_set(string $slug, string $style): array;
public function search_icons(string $query, int $limit = 50): array;
public function get_icon_svg(string $slug, string $style, string $name): string|WP_Error;
public function delete_set(string $slug): bool;
}
```
### MI_Downloader
Handles fetching icons from CDN and storing locally.
```php
class MI_Downloader {
public function download_set(string $slug, callable $progress_callback = null): array|WP_Error;
public function download_icon(string $slug, string $style, string $name): string|WP_Error;
private function normalize_svg(string $svg, array $set_config): string;
private function get_local_path(string $slug, string $style, string $name): string;
}
```
### MI_Admin_Page
Settings page under Settings → Maple Icons.
- List all preset icon sets
- Show download status (downloaded/not downloaded)
- Download button with progress indicator
- Delete button for downloaded sets
- Radio buttons to select active set
- Preview sample icons from each downloaded set
### MI_Ajax_Handler
AJAX endpoints:
- `mi_download_set` — Download an icon set from CDN
- `mi_delete_set` — Delete a downloaded icon set
- `mi_set_active` — Set the active icon set
- `mi_search_icons` — Search icons in active set (for block picker)
- `mi_get_icon_svg` — Get specific icon SVG (for block)
---
## Settings Storage
```php
// Option name: maple_icons_settings
[
'active_set' => 'heroicons', // Slug of active set, or empty string
'downloaded_sets' => [
'heroicons' => [
'version' => '2.1.1',
'downloaded_at' => '2024-01-15 10:30:00',
'icon_count' => 876, // Total across all styles
],
'lucide' => [
'version' => '0.303.0',
'downloaded_at' => '2024-01-15 11:00:00',
'icon_count' => 1400,
],
],
]
```
---
## Manifest Format (presets/*.json)
Each preset ships with a manifest listing all icons:
```json
{
"slug": "heroicons",
"name": "Heroicons",
"version": "2.1.1",
"icons": [
{
"name": "academic-cap",
"tags": ["education", "graduation", "school", "hat"],
"category": "objects",
"styles": ["outline", "solid", "mini"]
},
{
"name": "adjustments-horizontal",
"tags": ["settings", "controls", "sliders"],
"category": "ui",
"styles": ["outline", "solid", "mini"]
}
]
}
```
Manifests are bundled with the plugin to avoid runtime dependency on jsdelivr API.
---
## Gutenberg Block Architecture
### Block Registration (block.json)
```json
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "jetrails/maple-icons",
"version": "1.0.0",
"title": "Maple Icons",
"category": "design",
"icon": "star-filled",
"description": "Insert an icon from your downloaded icon sets.",
"keywords": ["icon", "svg", "symbol", "maple"],
"textdomain": "maple-icons",
"attributes": {
"iconSet": {
"type": "string",
"default": ""
},
"iconStyle": {
"type": "string",
"default": ""
},
"iconName": {
"type": "string",
"default": ""
},
"iconSVG": {
"type": "string",
"default": ""
},
"size": {
"type": "number",
"default": 24
},
"label": {
"type": "string",
"default": ""
},
"strokeWidth": {
"type": "number",
"default": 0
},
"strokeColor": {
"type": "string",
"default": ""
}
},
"supports": {
"html": false,
"align": ["left", "center", "right"],
"color": {
"text": true,
"background": true,
"gradients": true
},
"spacing": {
"margin": true,
"padding": true
},
"shadow": true
},
"editorScript": "file:./index.js",
"editorStyle": "file:./style-index.css"
}
```
### Block Style Controls (Inspector Panel)
The block sidebar will include these controls:
**Icon Settings:**
- Icon picker button (change icon)
- Size slider (8-256px, default 24)
- Accessibility label text input
**Color Settings (via WordPress supports):**
- Icon color (text color) — inherited by SVG via `currentColor`
- Background color
- Gradient support
**Stroke Settings (custom panel):**
- Stroke width slider (0-10px)
- Stroke color picker
**Spacing (via WordPress supports):**
- Padding controls
- Margin controls
**Effects:**
- Drop shadow presets (via WordPress `shadow` support)
### Style Application
```jsx
// In edit.js and save.js, compute styles:
const iconStyles = {
width: `${size}px`,
height: `${size}px`,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
};
// Stroke is applied via CSS filter or SVG manipulation
const svgStyles = strokeWidth > 0 ? {
filter: `drop-shadow(0 0 0 ${strokeColor})`,
WebkitTextStroke: `${strokeWidth}px ${strokeColor}`,
} : {};
// Note: For true SVG stroke, we modify the SVG's stroke attribute
// This is handled in the SVG wrapper, not CSS
```
### SVG Stroke Handling
For stroke on SVG icons, we wrap the SVG output:
```jsx
// The SVG itself uses currentColor for fill/stroke
// Additional stroke effect applied via CSS or inline style
<span
className="wp-block-maple-icon__svg"
style={{
// Apply stroke via paint-order and stroke properties
'--mi-stroke-width': strokeWidth ? `${strokeWidth}px` : undefined,
'--mi-stroke-color': strokeColor || undefined,
}}
dangerouslySetInnerHTML={{ __html: iconSVG }}
/>
```
With CSS:
```css
.wp-block-maple-icon__svg svg {
stroke: var(--mi-stroke-color, currentColor);
stroke-width: var(--mi-stroke-width, 0);
paint-order: stroke fill;
}
```
### Block Behavior
**Initial State (no icon selected):**
- Shows + button placeholder
- Click opens icon picker modal
**With icon selected:**
- Displays inline SVG
- Sidebar shows: size control, accessibility label, change icon button
**Icon Picker Modal:**
- Search input (filters as you type)
- Style selector dropdown (if set has multiple styles)
- Grid of icon thumbnails
- Click to select → SVG fetched and stored in attributes
### Save Output (save.js)
```jsx
export default function save({ attributes }) {
const { iconSVG, size, label } = attributes;
if (!iconSVG) {
return null;
}
const blockProps = useBlockProps.save({
className: 'wp-block-maple-icon',
style: {
width: `${size}px`,
height: `${size}px`,
display: 'inline-flex',
},
});
return (
<span {...blockProps}>
<span
dangerouslySetInnerHTML={{ __html: iconSVG }}
role={label ? 'img' : 'presentation'}
aria-label={label || undefined}
aria-hidden={!label ? 'true' : undefined}
/>
</span>
);
}
```
---
## Download Process
### Batch Download Flow
1. User clicks "Download" for an icon set
2. Frontend disables button, shows progress bar
3. AJAX request to `mi_download_set`
4. Backend:
a. Load manifest from `presets/{slug}.json`
b. Create local directory structure
c. For each icon in manifest:
- Fetch SVG from CDN
- Normalize (viewBox, currentColor)
- Save to local filesystem
d. Update settings with download info
5. Return success with icon count
6. Frontend updates UI to show "Downloaded" state
### Batching Strategy
To avoid timeouts and memory issues:
- Process icons in batches of 50
- Use streaming/chunked approach
- Allow resume on failure (track progress in transient)
```php
// Download in batches
$batch_size = 50;
$icons = $manifest['icons'];
$total = count($icons);
for ($i = 0; $i < $total; $i += $batch_size) {
$batch = array_slice($icons, $i, $batch_size);
foreach ($batch as $icon) {
// Download each icon in batch
}
// Update progress transient
set_transient('mi_download_progress_' . $slug, [
'completed' => min($i + $batch_size, $total),
'total' => $total,
], HOUR_IN_SECONDS);
}
```
---
## SVG Sanitization
Same approach as original CLAUDE.md — strict allowlist of SVG elements and attributes.
```php
function mi_sanitize_svg($svg) {
$allowed_tags = [
'svg' => ['xmlns', 'viewbox', 'width', 'height', 'fill', 'stroke', ...],
'path' => ['d', 'fill', 'stroke', 'stroke-width', ...],
'circle' => ['cx', 'cy', 'r', 'fill', 'stroke', ...],
'rect' => ['x', 'y', 'width', 'height', 'rx', 'ry', ...],
'line' => ['x1', 'y1', 'x2', 'y2', ...],
'polyline' => ['points', ...],
'polygon' => ['points', ...],
'ellipse' => ['cx', 'cy', 'rx', 'ry', ...],
'g' => ['fill', 'stroke', 'transform', ...],
];
// Remove dangerous content
$svg = preg_replace('/<script\b[^>]*>.*?<\/script>/is', '', $svg);
$svg = preg_replace('/\son\w+\s*=/i', ' data-removed=', $svg);
return wp_kses($svg, $allowed_tags);
}
```
### SVG Normalization
```php
function mi_normalize_svg($svg, $set_config) {
// 1. Strip XML declaration
$svg = preg_replace('/<\?xml[^>]*\?>/i', '', $svg);
// 2. Strip comments
$svg = preg_replace('/<!--.*?-->/s', '', $svg);
// 3. Normalize viewBox for Phosphor (256 → 24)
if ($set_config['normalize']) {
$svg = preg_replace('/viewBox=["\']0 0 256 256["\']/', 'viewBox="0 0 24 24"', $svg);
}
// 4. Fix hardcoded colors for Material
if ($set_config['color_fix']) {
$svg = preg_replace('/fill=["\']#[0-9a-fA-F]{3,6}["\']/', 'fill="currentColor"', $svg);
$svg = preg_replace('/fill=["\']black["\']/', 'fill="currentColor"', $svg);
}
// 5. Ensure currentColor is used
// Most sets already use currentColor, but double-check
// 6. Remove width/height attributes (let CSS control)
$svg = preg_replace('/\s(width|height)=["\'][^"\']*["\']/', '', $svg);
return trim($svg);
}
```
---
## Admin Settings Page UI
```
┌─────────────────────────────────────────────────────────────────┐
│ Maple Icons │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ICON SETS │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ◉ Heroicons ✓ Downloaded │ │
│ │ 290 icons · Outline, Solid, Mini · MIT │ │
│ │ [Preview: ⚙ 🏠 👤 ✉ 🔔 ⭐] │ │
│ │ [Delete] │ │
│ ├─────────────────────────────────────────────────────────────┤ │
│ │ ○ Lucide ✓ Downloaded │ │
│ │ 1,400 icons · Icons · ISC │ │
│ │ [Preview: ⚙ 📁 📄 💾 🖨 ⬇] │ │
│ │ [Delete] │ │
│ ├─────────────────────────────────────────────────────────────┤ │
│ │ ○ Feather Not downloaded │ │
│ │ 280 icons · Icons · MIT │ │
│ │ [Download] │ │
│ ├─────────────────────────────────────────────────────────────┤ │
│ │ ○ Phosphor Not downloaded │ │
│ │ 7,200 icons · 6 styles · MIT │ │
│ │ [Download] │ │
│ ├─────────────────────────────────────────────────────────────┤ │
│ │ ○ Material Not downloaded │ │
│ │ 2,500 icons · 5 styles · Apache 2.0 │ │
│ │ [Download] │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ◉ = Active icon set (used in Maple Icons block) │
│ │
│ ─────────────────────────────────────────────────────────────── │
│ │
│ USAGE │
│ 1. Download one or more icon sets above │
│ 2. Select which set should be active (radio button) │
│ 3. Insert icons using the "Maple Icons" block in the editor │
│ 4. Icons inherit your theme's text color automatically │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## Icon Picker Modal (Block Editor)
```
┌─────────────────────────────────────────────────────────────────┐
│ Select Icon [X] │
├─────────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 🔍 Search icons... │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ Style: [Outline ▼] │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │ │
│ │ │ ⚙ │ │ 🏠 │ │ 👤 │ │ ✉ │ │ 🔔 │ │ ⭐ │ │ ❤ │ │ 🔍 │ │ │
│ │ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘ │ │
│ │ cog home user mail bell star heart search │ │
│ │ │ │
│ │ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │ │
│ │ │ + │ │ ✓ │ │ ✕ │ │ ⬆ │ │ ⬇ │ │ ◀ │ │ ▶ │ │ ↻ │ │ │
│ │ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘ └───┘ │ │
│ │ plus check x up down left right refresh │ │
│ │ │ │
│ │ [Load More...] │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ Selected: arrow-right │
│ │
│ [Cancel] [Insert Icon] │
└─────────────────────────────────────────────────────────────────┘
```
---
## Build Order
### Phase 1: Foundation
1. `maple-icons.php` — Plugin header, constants, autoloader, activation hook
2. `index.php` files in ALL directories
3. `includes/class-mi-icon-sets.php` — Static preset definitions
### Phase 2: Download Infrastructure
4. `includes/class-mi-downloader.php` — CDN fetch, normalize, store
5. `includes/class-mi-icon-registry.php` — Downloaded set management
6. `includes/class-mi-ajax-handler.php` — Download/delete/activate endpoints
7. Create manifest files: `presets/*.json`
### Phase 3: Admin Interface
8. `includes/class-mi-admin-page.php` — Settings page
9. `assets/admin.css` — Settings page styles
10. `assets/admin.js` — Download progress, AJAX handlers
### Phase 4: Gutenberg Block
11. `src/block.json` — Block metadata
12. `src/index.js` — Block registration
13. `src/edit.js` — Editor component
14. `src/save.js` — Save component
15. `src/icon-picker.js` — Modal component
16. `src/editor.scss` — Editor styles
17. `package.json` + build
### Phase 5: Cleanup & Polish
18. `uninstall.php` — Clean removal
19. `readme.txt` — WordPress.org readme
20. Testing
---
## Constants
```php
define('MI_VERSION', '1.0.0');
define('MI_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('MI_PLUGIN_URL', plugin_dir_url(__FILE__));
define('MI_PLUGIN_BASENAME', plugin_basename(__FILE__));
define('MI_ICONS_DIR', WP_CONTENT_DIR . '/maple-icons/');
define('MI_ICONS_URL', content_url('/maple-icons/'));
define('MI_PRESETS_DIR', MI_PLUGIN_DIR . 'presets/');
// Limits
define('MI_DOWNLOAD_BATCH_SIZE', 50);
define('MI_SEARCH_LIMIT', 50);
define('MI_DOWNLOAD_TIMEOUT', 10); // Per-icon timeout in seconds
```
---
## Security Checklist
Reference SECURITY.md for full details. Key points:
- [ ] ABSPATH check on every PHP file
- [ ] index.php in every directory
- [ ] Nonce verification FIRST in every AJAX handler
- [ ] Capability check SECOND (`manage_options` for settings, `edit_posts` for block)
- [ ] Validate icon set slugs against preset allowlist
- [ ] Validate style slugs against set's defined styles
- [ ] Sanitize all SVG content before storage
- [ ] Validate file paths (prevent path traversal)
- [ ] Use `wp_remote_get()` for CDN requests
- [ ] Escape all output
---
## WordPress Hooks Used
```php
// Activation/Deactivation
register_activation_hook(__FILE__, 'mi_activate');
register_deactivation_hook(__FILE__, 'mi_deactivate');
// Block registration
add_action('init', 'mi_register_block');
// Admin menu
add_action('admin_menu', 'mi_register_settings_page');
// Admin assets (settings page only)
add_action('admin_enqueue_scripts', 'mi_enqueue_admin_assets');
// Plugin action links (Settings link in plugin list)
add_filter('plugin_action_links_' . MI_PLUGIN_BASENAME, 'mi_add_action_links');
// AJAX handlers (admin only)
add_action('wp_ajax_mi_download_set', 'mi_ajax_download_set');
add_action('wp_ajax_mi_delete_set', 'mi_ajax_delete_set');
add_action('wp_ajax_mi_set_active', 'mi_ajax_set_active');
add_action('wp_ajax_mi_search_icons', 'mi_ajax_search_icons');
add_action('wp_ajax_mi_get_icon_svg', 'mi_ajax_get_icon_svg');
// WooCommerce HPOS compatibility
add_action('before_woocommerce_init', 'mi_declare_hpos_compatibility');
```
---
## Compatibility Notes
### WooCommerce HPOS
Declare compatibility (we don't touch orders):
```php
add_action('before_woocommerce_init', function() {
if (class_exists(\Automattic\WooCommerce\Utilities\FeaturesUtil::class)) {
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility(
'custom_order_tables',
__FILE__,
true
);
}
});
```
### Caching Plugins
Icons stored locally means no cache issues. SVG is inline in post_content.
### Wordfence
CDN requests only happen during admin download action, using standard `wp_remote_get()`.
### LearnDash / WPForms
No interference — we only add a block, no frontend hooks.
---
## Testing Checklist
### Download Flow
- [ ] Download Heroicons → all icons saved locally
- [ ] Download progress shows correctly
- [ ] Download Lucide (large set) → doesn't timeout
- [ ] Delete downloaded set → files removed
- [ ] Switch active set → setting persists
### Block Editor
- [ ] Insert Maple Icons block → shows placeholder
- [ ] Click + → opens icon picker
- [ ] Search filters icons correctly
- [ ] Style dropdown works (for sets with multiple styles)
- [ ] Select icon → SVG appears in editor
- [ ] Save post → icon persists
- [ ] Frontend → icon displays correctly
- [ ] Change text color in Global Styles → icon color changes
### Security
- [ ] Download without nonce → 403
- [ ] Download as non-admin → 403
- [ ] Invalid set slug → rejected
- [ ] Path traversal attempt → rejected
### Edge Cases
- [ ] No sets downloaded → block shows helpful message
- [ ] Active set deleted → block handles gracefully
- [ ] CDN unreachable during download → appropriate error
- [ ] Partial download failure → can retry/resume

View file

@ -0,0 +1,621 @@
# SECURITY.md — Maple Local Fonts Security Requirements
## Overview
This document covers all security requirements for the Maple Local Fonts plugin. Reference this when writing ANY PHP code.
---
## ABSPATH Check (Every PHP File)
Every PHP file MUST start with this check. No exceptions.
```php
<?php
if (!defined('ABSPATH')) {
exit;
}
```
---
## Silence is Golden Files
Create `index.php` in EVERY directory:
```php
<?php
// Silence is golden.
if (!defined('ABSPATH')) {
exit;
}
```
**Required locations:**
- `/maple-local-fonts/index.php`
- `/maple-local-fonts/includes/index.php`
- `/maple-local-fonts/assets/index.php`
- `/maple-local-fonts/languages/index.php`
---
## OWASP Compliance
### A1 - Injection Prevention
**SQL Injection:**
```php
// NEVER do this
$wpdb->query("SELECT * FROM table WHERE id = " . $_POST['id']);
// ALWAYS do this
$wpdb->get_results($wpdb->prepare(
"SELECT * FROM %i WHERE id = %d",
$table_name,
absint($_POST['id'])
));
```
**Note:** This plugin should rarely need direct SQL. Use WordPress APIs (`get_posts`, `wp_insert_post`, etc.) which handle escaping internally.
### A2 - Authentication
All admin actions require capability check:
```php
if (!current_user_can('edit_theme_options')) {
wp_die('Unauthorized', 'Error', ['response' => 403]);
}
```
### A3 - Sensitive Data
- No API keys (Google Fonts CSS2 API is public)
- No user credentials stored
- No PII collected
### A5 - Broken Access Control
**Order of checks for ALL AJAX handlers:**
```php
public function handle_ajax_action() {
// 1. Nonce verification FIRST
if (!check_ajax_referer('mlf_action_name', 'nonce', false)) {
wp_send_json_error(['message' => 'Security check failed'], 403);
}
// 2. Capability check SECOND
if (!current_user_can('edit_theme_options')) {
wp_send_json_error(['message' => 'Unauthorized'], 403);
}
// 3. Input validation THIRD
// ... validate all inputs ...
// 4. Process request
// ... actual logic ...
}
```
### A7 - Cross-Site Scripting (XSS)
**Escape ALL output:**
```php
// HTML content
echo esc_html($font_name);
// HTML attributes
echo '<input value="' . esc_attr($font_name) . '">';
// URLs
echo '<a href="' . esc_url($url) . '">';
// JavaScript data
wp_localize_script('mlf-admin', 'mlfData', [
'fontName' => esc_js($font_name), // Or let wp_localize_script handle it
]);
// Translatable strings with variables
printf(
esc_html__('Installed: %s', 'maple-local-fonts'),
esc_html($font_name)
);
```
**Never trust input for output:**
```php
// WRONG - XSS vulnerability
echo '<div>' . $_POST['font_name'] . '</div>';
// RIGHT - sanitize input, escape output
$font_name = sanitize_text_field($_POST['font_name']);
echo '<div>' . esc_html($font_name) . '</div>';
```
### A8 - Insecure Deserialization
```php
// NEVER use unserialize() on external data
$data = unserialize($_POST['data']); // DANGEROUS
// Use JSON instead
$data = json_decode(sanitize_text_field($_POST['data']), true);
if (json_last_error() !== JSON_ERROR_NONE) {
wp_send_json_error(['message' => 'Invalid data format']);
}
```
### A9 - Vulnerable Components
- No external PHP libraries
- Use only WordPress core functions
- Keep dependencies to zero
---
## Nonce Implementation
### Creating Nonces
**In admin page form:**
```php
wp_nonce_field('mlf_download_font', 'mlf_nonce');
```
**For AJAX (via wp_localize_script):**
```php
wp_localize_script('mlf-admin', 'mlfData', [
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('mlf_download_font'),
]);
```
### Verifying Nonces
**AJAX handler:**
```php
// Returns false on failure, doesn't die (we handle response ourselves)
if (!check_ajax_referer('mlf_download_font', 'nonce', false)) {
wp_send_json_error(['message' => 'Security check failed'], 403);
}
```
**Form submission:**
```php
if (!wp_verify_nonce($_POST['mlf_nonce'], 'mlf_download_font')) {
wp_die('Security check failed');
}
```
### Nonce Names
Use consistent, descriptive nonce action names:
| Action | Nonce Name |
|--------|------------|
| Download font | `mlf_download_font` |
| Delete font | `mlf_delete_font` |
| Update settings | `mlf_update_settings` |
---
## Input Validation
### Font Name Validation
```php
$font_name = isset($_POST['font_name']) ? sanitize_text_field($_POST['font_name']) : '';
// Strict allowlist pattern - alphanumeric, spaces, hyphens only
if (!preg_match('/^[a-zA-Z0-9\s\-]+$/', $font_name)) {
wp_send_json_error(['message' => 'Invalid font name: only letters, numbers, spaces, and hyphens allowed']);
}
// Length limit
if (strlen($font_name) > 100) {
wp_send_json_error(['message' => 'Font name too long']);
}
// Not empty
if (empty($font_name)) {
wp_send_json_error(['message' => 'Font name required']);
}
```
### Weight Validation
```php
$weights = isset($_POST['weights']) ? (array) $_POST['weights'] : [];
// Convert to integers
$weights = array_map('absint', $weights);
// Strict allowlist
$allowed_weights = [100, 200, 300, 400, 500, 600, 700, 800, 900];
$weights = array_intersect($weights, $allowed_weights);
// Must have at least one
if (empty($weights)) {
wp_send_json_error(['message' => 'At least one weight required']);
}
```
### Style Validation
```php
$styles = isset($_POST['styles']) ? (array) $_POST['styles'] : [];
// Strict allowlist - only these two values ever
$allowed_styles = ['normal', 'italic'];
$styles = array_filter($styles, function($style) use ($allowed_styles) {
return in_array($style, $allowed_styles, true);
});
// Must have at least one
if (empty($styles)) {
wp_send_json_error(['message' => 'At least one style required']);
}
```
### Font Family ID Validation (for delete)
```php
$font_id = isset($_POST['font_id']) ? absint($_POST['font_id']) : 0;
if ($font_id < 1) {
wp_send_json_error(['message' => 'Invalid font ID']);
}
// Verify it exists and is a font family
$font = get_post($font_id);
if (!$font || $font->post_type !== 'wp_font_family') {
wp_send_json_error(['message' => 'Font not found']);
}
// Verify it's one we imported (not a theme font)
if (get_post_meta($font_id, '_mlf_imported', true) !== '1') {
wp_send_json_error(['message' => 'Cannot delete theme fonts']);
}
```
---
## File Operation Security
### Path Traversal Prevention
```php
/**
* Validate that a path is within the WordPress fonts directory.
* Prevents path traversal attacks.
*
* @param string $path Full path to validate
* @return bool True if path is safe, false otherwise
*/
function mlf_validate_font_path($path) {
$font_dir = wp_get_font_dir();
$fonts_path = wp_normalize_path(trailingslashit($font_dir['path']));
// Resolve to real path (handles ../ etc)
$real_path = realpath($path);
// If realpath fails, file doesn't exist yet - validate the directory
if ($real_path === false) {
$dir = dirname($path);
$real_dir = realpath($dir);
if ($real_dir === false) {
return false;
}
$real_path = wp_normalize_path($real_dir . '/' . basename($path));
} else {
$real_path = wp_normalize_path($real_path);
}
// Must be within fonts directory
return strpos($real_path, $fonts_path) === 0;
}
```
### Filename Sanitization
```php
/**
* Sanitize and validate a font filename.
*
* @param string $filename The filename to validate
* @return string|false Sanitized filename or false if invalid
*/
function mlf_sanitize_font_filename($filename) {
// WordPress sanitization first
$filename = sanitize_file_name($filename);
// Must have .woff2 extension
if (pathinfo($filename, PATHINFO_EXTENSION) !== 'woff2') {
return false;
}
// No path components
if ($filename !== basename($filename)) {
return false;
}
// Reasonable length
if (strlen($filename) > 200) {
return false;
}
return $filename;
}
```
### Safe File Writing
```php
/**
* Safely write a font file to the fonts directory.
*
* @param string $filename Sanitized filename
* @param string $content File content
* @return string|WP_Error File path on success, WP_Error on failure
*/
function mlf_write_font_file($filename, $content) {
// Validate filename
$safe_filename = mlf_sanitize_font_filename($filename);
if ($safe_filename === false) {
return new WP_Error('invalid_filename', 'Invalid filename');
}
// Get fonts directory
$font_dir = wp_get_font_dir();
$destination = trailingslashit($font_dir['path']) . $safe_filename;
// Validate path
if (!mlf_validate_font_path($destination)) {
return new WP_Error('invalid_path', 'Invalid file path');
}
// Ensure directory exists
if (!wp_mkdir_p($font_dir['path'])) {
return new WP_Error('mkdir_failed', 'Could not create fonts directory');
}
// Write file
global $wp_filesystem;
if (empty($wp_filesystem)) {
require_once ABSPATH . 'wp-admin/includes/file.php';
WP_Filesystem();
}
if (!$wp_filesystem->put_contents($destination, $content, FS_CHMOD_FILE)) {
return new WP_Error('write_failed', 'Could not write font file');
}
return $destination;
}
```
### Safe File Deletion
```php
/**
* Safely delete a font file.
*
* @param string $path Full path to the file
* @return bool True on success, false on failure
*/
function mlf_delete_font_file($path) {
// Validate path is within fonts directory
if (!mlf_validate_font_path($path)) {
return false;
}
// Must be a .woff2 file
if (pathinfo($path, PATHINFO_EXTENSION) !== 'woff2') {
return false;
}
// File must exist
if (!file_exists($path)) {
return true; // Already gone, that's fine
}
return wp_delete_file($path);
}
```
---
## HTTP Request Security
### Outbound Requests (Google Fonts)
```php
$response = wp_remote_get($url, [
'timeout' => 15,
'sslverify' => true, // Always verify SSL
'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
]);
// Check for errors
if (is_wp_error($response)) {
// Log error, return gracefully
error_log('MLF: Google Fonts request failed - ' . $response->get_error_message());
return new WP_Error('request_failed', 'Could not connect to Google Fonts');
}
// Check HTTP status
$status = wp_remote_retrieve_response_code($response);
if ($status !== 200) {
return new WP_Error('http_error', 'Google Fonts returned status ' . $status);
}
// Get body
$body = wp_remote_retrieve_body($response);
if (empty($body)) {
return new WP_Error('empty_response', 'Empty response from Google Fonts');
}
```
### URL Validation (Google Fonts only)
```php
/**
* Validate that a URL is a legitimate Google Fonts URL.
*
* @param string $url URL to validate
* @return bool True if valid Google Fonts URL
*/
function mlf_is_valid_google_fonts_url($url) {
$parsed = wp_parse_url($url);
if (!$parsed || !isset($parsed['host'])) {
return false;
}
// Only allow Google Fonts domains
$allowed_hosts = [
'fonts.googleapis.com',
'fonts.gstatic.com',
];
return in_array($parsed['host'], $allowed_hosts, true);
}
```
---
## AJAX Handler Complete Template
```php
<?php
if (!defined('ABSPATH')) {
exit;
}
class MLF_Ajax_Handler {
public function __construct() {
add_action('wp_ajax_mlf_download_font', [$this, 'handle_download']);
add_action('wp_ajax_mlf_delete_font', [$this, 'handle_delete']);
// NEVER add wp_ajax_nopriv_ - admin only functionality
}
/**
* Handle font download AJAX request.
*/
public function handle_download() {
// 1. NONCE CHECK
if (!check_ajax_referer('mlf_download_font', 'nonce', false)) {
wp_send_json_error(['message' => 'Security check failed'], 403);
}
// 2. CAPABILITY CHECK
if (!current_user_can('edit_theme_options')) {
wp_send_json_error(['message' => 'Unauthorized'], 403);
}
// 3. INPUT VALIDATION
$font_name = isset($_POST['font_name']) ? sanitize_text_field($_POST['font_name']) : '';
if (empty($font_name) || !preg_match('/^[a-zA-Z0-9\s\-]+$/', $font_name) || strlen($font_name) > 100) {
wp_send_json_error(['message' => 'Invalid font name']);
}
$weights = isset($_POST['weights']) ? array_map('absint', (array) $_POST['weights']) : [];
$weights = array_intersect($weights, [100, 200, 300, 400, 500, 600, 700, 800, 900]);
if (empty($weights)) {
wp_send_json_error(['message' => 'At least one weight required']);
}
$styles = isset($_POST['styles']) ? (array) $_POST['styles'] : [];
$styles = array_intersect($styles, ['normal', 'italic']);
if (empty($styles)) {
wp_send_json_error(['message' => 'At least one style required']);
}
// 4. PROCESS REQUEST
try {
$downloader = new MLF_Font_Downloader();
$result = $downloader->download($font_name, $weights, $styles);
if (is_wp_error($result)) {
wp_send_json_error(['message' => $result->get_error_message()]);
}
wp_send_json_success([
'message' => sprintf('Successfully installed %s', esc_html($font_name)),
'font_id' => $result,
]);
} catch (Exception $e) {
error_log('MLF Download Error: ' . $e->getMessage());
wp_send_json_error(['message' => 'An unexpected error occurred']);
}
}
/**
* Handle font deletion AJAX request.
*/
public function handle_delete() {
// 1. NONCE CHECK
if (!check_ajax_referer('mlf_delete_font', 'nonce', false)) {
wp_send_json_error(['message' => 'Security check failed'], 403);
}
// 2. CAPABILITY CHECK
if (!current_user_can('edit_theme_options')) {
wp_send_json_error(['message' => 'Unauthorized'], 403);
}
// 3. INPUT VALIDATION
$font_id = isset($_POST['font_id']) ? absint($_POST['font_id']) : 0;
if ($font_id < 1) {
wp_send_json_error(['message' => 'Invalid font ID']);
}
// Verify font exists and is ours
$font = get_post($font_id);
if (!$font || $font->post_type !== 'wp_font_family') {
wp_send_json_error(['message' => 'Font not found']);
}
if (get_post_meta($font_id, '_mlf_imported', true) !== '1') {
wp_send_json_error(['message' => 'Cannot delete theme fonts']);
}
// 4. PROCESS REQUEST
try {
$registry = new MLF_Font_Registry();
$result = $registry->delete_font($font_id);
if (is_wp_error($result)) {
wp_send_json_error(['message' => $result->get_error_message()]);
}
wp_send_json_success(['message' => 'Font deleted successfully']);
} catch (Exception $e) {
error_log('MLF Delete Error: ' . $e->getMessage());
wp_send_json_error(['message' => 'An unexpected error occurred']);
}
}
}
```
---
## Security Checklist
Before committing any code:
- [ ] ABSPATH check at top of every PHP file
- [ ] index.php exists in every directory
- [ ] All AJAX handlers verify nonce first
- [ ] All AJAX handlers check capability second
- [ ] All user input sanitized with appropriate function
- [ ] All user input validated against allowlists where applicable
- [ ] All output escaped with appropriate function
- [ ] File paths validated to prevent traversal
- [ ] No direct SQL queries (use WordPress APIs)
- [ ] No `unserialize()` on user input
- [ ] No `eval()` or similar dynamic execution
- [ ] External URLs validated before use
- [ ] Error messages don't expose sensitive info

View file

@ -0,0 +1,560 @@
# WORDPRESS_COMPATIBILITY.md — WordPress & Plugin Compatibility
## Overview
This document covers compatibility requirements for WordPress core systems and popular plugins. Reference this when building the font registry class and integration points.
---
## WordPress Version Requirements
**Minimum: WordPress 6.5**
WordPress 6.5 introduced the Font Library API which this plugin depends on. Earlier versions will not work.
```php
// Check on activation
register_activation_hook(__FILE__, function() {
if (version_compare(get_bloginfo('version'), '6.5', '<')) {
deactivate_plugins(plugin_basename(__FILE__));
wp_die(
'Maple Local Fonts requires WordPress 6.5 or higher for Font Library support.',
'Plugin Activation Error',
['back_link' => true]
);
}
});
// Also check on admin init (in case WP was downgraded)
add_action('admin_init', function() {
if (version_compare(get_bloginfo('version'), '6.5', '<')) {
deactivate_plugins(plugin_basename(__FILE__));
add_action('admin_notices', function() {
echo '<div class="error"><p>Maple Local Fonts has been deactivated. It requires WordPress 6.5 or higher.</p></div>';
});
}
});
```
---
## WordPress Font Library API
### How It Works
WordPress 6.5+ stores fonts using custom post types:
| Post Type | Purpose |
|-----------|---------|
| `wp_font_family` | Font family (e.g., "Open Sans") |
| `wp_font_face` | Individual weight/style variant (child of family) |
Fonts are stored in `wp-content/fonts/` by default.
### Getting the Fonts Directory
```php
// ALWAYS use this function, never hardcode paths
$font_dir = wp_get_font_dir();
// Returns:
[
'path' => '/var/www/html/wp-content/fonts',
'url' => 'https://example.com/wp-content/fonts',
'subdir' => '',
'basedir' => '/var/www/html/wp-content/fonts',
'baseurl' => 'https://example.com/wp-content/fonts',
]
```
### Registering a Font Family
```php
/**
* Register a font family with WordPress Font Library.
*
* @param string $font_name Display name (e.g., "Open Sans")
* @param string $font_slug Slug (e.g., "open-sans")
* @param array $files Array of downloaded file data
* @return int|WP_Error Font family post ID or error
*/
function mlf_register_font_family($font_name, $font_slug, $files) {
// Check if font already exists
$existing = get_posts([
'post_type' => 'wp_font_family',
'name' => $font_slug,
'posts_per_page' => 1,
'post_status' => 'publish',
]);
if (!empty($existing)) {
return new WP_Error('font_exists', 'Font family already installed');
}
// Build font family settings
$font_family_settings = [
'name' => $font_name,
'slug' => $font_slug,
'fontFamily' => sprintf('"%s", sans-serif', $font_name),
'fontFace' => [],
];
// Add each font face
foreach ($files as $file) {
$font_dir = wp_get_font_dir();
$relative_path = str_replace($font_dir['path'], '', $file['path']);
$font_family_settings['fontFace'][] = [
'fontFamily' => $font_name,
'fontWeight' => $file['weight'],
'fontStyle' => $file['style'],
'src' => 'file:.' . $font_dir['basedir'] . $relative_path,
];
}
// Create font family post
$family_id = wp_insert_post([
'post_type' => 'wp_font_family',
'post_title' => $font_name,
'post_name' => $font_slug,
'post_status' => 'publish',
'post_content' => wp_json_encode($font_family_settings),
]);
if (is_wp_error($family_id)) {
return $family_id;
}
// Mark as imported by our plugin (for identification)
update_post_meta($family_id, '_mlf_imported', '1');
update_post_meta($family_id, '_mlf_import_date', current_time('mysql'));
// Create font face posts (children)
foreach ($files as $file) {
$font_dir = wp_get_font_dir();
$face_settings = [
'fontFamily' => $font_name,
'fontWeight' => $file['weight'],
'fontStyle' => $file['style'],
'src' => 'file:.' . $font_dir['baseurl'] . '/' . basename($file['path']),
];
wp_insert_post([
'post_type' => 'wp_font_face',
'post_parent' => $family_id,
'post_status' => 'publish',
'post_content' => wp_json_encode($face_settings),
]);
}
// Clear font caches
delete_transient('wp_font_library_fonts');
return $family_id;
}
```
### Deleting a Font Family
```php
/**
* Delete a font family and its files.
*
* @param int $family_id Font family post ID
* @return bool|WP_Error True on success, error on failure
*/
function mlf_delete_font_family($family_id) {
$family = get_post($family_id);
if (!$family || $family->post_type !== 'wp_font_family') {
return new WP_Error('not_found', 'Font family not found');
}
// Verify it's one we imported
if (get_post_meta($family_id, '_mlf_imported', true) !== '1') {
return new WP_Error('not_ours', 'Cannot delete fonts not imported by this plugin');
}
// Get font faces
$faces = get_children([
'post_parent' => $family_id,
'post_type' => 'wp_font_face',
]);
$font_dir = wp_get_font_dir();
// Delete font face files and posts
foreach ($faces as $face) {
$settings = json_decode($face->post_content, true);
if (isset($settings['src'])) {
// Convert file:. URL to path
$src = $settings['src'];
$src = str_replace('file:.', '', $src);
// Handle both URL and path formats
if (strpos($src, $font_dir['baseurl']) !== false) {
$file_path = str_replace($font_dir['baseurl'], $font_dir['path'], $src);
} else {
$file_path = $font_dir['path'] . '/' . basename($src);
}
// Validate path before deletion
if (mlf_validate_font_path($file_path) && file_exists($file_path)) {
wp_delete_file($file_path);
}
}
wp_delete_post($face->ID, true);
}
// Delete family post
wp_delete_post($family_id, true);
// Clear caches
delete_transient('wp_font_library_fonts');
return true;
}
```
### Listing Installed Fonts
```php
/**
* Get all fonts imported by this plugin.
*
* @return array Array of font data
*/
function mlf_get_imported_fonts() {
$fonts = get_posts([
'post_type' => 'wp_font_family',
'posts_per_page' => 100,
'post_status' => 'publish',
'meta_key' => '_mlf_imported',
'meta_value' => '1',
]);
$result = [];
foreach ($fonts as $font) {
$settings = json_decode($font->post_content, true);
// Get variants
$faces = get_children([
'post_parent' => $font->ID,
'post_type' => 'wp_font_face',
]);
$variants = [];
foreach ($faces as $face) {
$face_settings = json_decode($face->post_content, true);
$variants[] = [
'weight' => $face_settings['fontWeight'] ?? '400',
'style' => $face_settings['fontStyle'] ?? 'normal',
];
}
$result[] = [
'id' => $font->ID,
'name' => $settings['name'] ?? $font->post_title,
'slug' => $settings['slug'] ?? $font->post_name,
'variants' => $variants,
'import_date' => get_post_meta($font->ID, '_mlf_import_date', true),
];
}
return $result;
}
```
---
## Gutenberg FSE Integration
### How Fonts Appear in the Editor
Once registered via the Font Library API, fonts automatically appear in:
1. **Global Styles** → Typography → Font dropdown
2. **Block settings** → Typography → Font dropdown (when per-block typography is enabled)
No additional integration code is needed — WordPress handles this automatically.
### Theme.json Compatibility
**DO NOT:**
- Directly modify theme.json
- Filter `wp_theme_json_data_theme` to inject fonts (let Font Library handle it)
- Override global styles CSS directly
**DO:**
- Use the Font Library API (post types)
- Let WordPress generate CSS custom properties
- Trust the system
### CSS Custom Properties
When a font is applied in Global Styles, WordPress generates:
```css
body {
--wp--preset--font-family--open-sans: "Open Sans", sans-serif;
}
```
And applies it:
```css
body {
font-family: var(--wp--preset--font-family--open-sans);
}
```
Our plugin doesn't need to touch this — it's automatic.
---
## WooCommerce Compatibility
### HPOS (High-Performance Order Storage)
WooCommerce's HPOS moves order data from post meta to custom tables. We must declare compatibility.
```php
// Declare HPOS compatibility
add_action('before_woocommerce_init', function() {
if (class_exists(\Automattic\WooCommerce\Utilities\FeaturesUtil::class)) {
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility(
'custom_order_tables',
__FILE__,
true
);
}
});
```
### Why We're Compatible
Our plugin:
- Does NOT interact with orders at all
- Does NOT query wp_posts for order data
- Does NOT use wp_postmeta for order data
- Only uses wp_font_family and wp_font_face post types
We're inherently compatible because we don't touch WooCommerce data.
### Frontend Considerations
**DO NOT:**
- Override `.woocommerce` class styles
- Override `.wc-block-*` styles
- Target cart/checkout elements specifically
**DO:**
- Let WooCommerce elements inherit from body/heading fonts
- Let global styles cascade naturally
WooCommerce product titles, descriptions, and other text will naturally inherit the fonts set via Global Styles. No special handling needed.
---
## Wordfence Compatibility
### Potential Concerns
1. **Outbound requests** to Google Fonts during import
2. **AJAX endpoints** for admin actions
3. **File operations** in wp-content
### Why We're Compatible
**Outbound Requests:**
- Only occur during admin import (user-initiated action)
- Target well-known domains (fonts.googleapis.com, fonts.gstatic.com)
- Use standard `wp_remote_get()` which Wordfence allows
- No runtime external requests on frontend
**AJAX Endpoints:**
- Use standard `admin-ajax.php` (not custom endpoints)
- Include proper nonces
- Follow WordPress patterns that Wordfence expects
**File Operations:**
- Write only to `wp-content/fonts/` (WordPress default directory)
- Use WordPress Filesystem API
- Don't create executable files
### Testing with Wordfence
Test these scenarios with Wordfence active:
- [ ] Learning Mode: Import should succeed
- [ ] Enabled Mode: Import should succeed
- [ ] Rate Limiting: Admin AJAX not blocked
- [ ] Firewall: No false positives on font download
---
## LearnDash Compatibility
### Overview
LearnDash is a WordPress LMS that uses:
- Custom post types (courses, lessons, topics, quizzes)
- Custom templates
- Focus Mode (distraction-free learning)
### Why We're Compatible
Our plugin:
- Doesn't touch LearnDash post types
- Doesn't modify LearnDash templates
- Doesn't inject CSS on frontend
- Lets Global Styles cascade to LearnDash content
LearnDash course content, lesson text, and quiz questions will inherit the fonts set in Global Styles automatically.
### Focus Mode Consideration
LearnDash Focus Mode uses its own template. Fonts set via Global Styles will apply because:
- Focus Mode still loads theme.json styles
- CSS custom properties cascade to all content
- No special handling needed
**DO NOT:**
- Target `.learndash-*` classes specifically
- Override Focus Mode styles
- Inject custom CSS for LearnDash
---
## WPForms Compatibility
### Overview
WPForms renders forms via shortcodes and blocks. Form styling is handled by WPForms.
### Why We're Compatible
- Form labels and text inherit from body font
- We don't override `.wpforms-*` classes
- No JavaScript conflicts (we have no frontend JS)
### Consideration
If a user wants form text in a different font, they should use WPForms' built-in styling options or custom CSS — not expect our plugin to handle it.
---
## General Best Practices
### What We Hook Into
```php
// Admin menu
add_action('admin_menu', [$this, 'register_menu']);
// Admin assets (only on our page)
add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_assets']);
// AJAX handlers
add_action('wp_ajax_mlf_download_font', [$this, 'handle_download']);
add_action('wp_ajax_mlf_delete_font', [$this, 'handle_delete']);
// WooCommerce HPOS compatibility
add_action('before_woocommerce_init', [$this, 'declare_hpos_compatibility']);
```
### What We DON'T Hook Into
```php
// NO frontend hooks
// add_action('wp_enqueue_scripts', ...); // DON'T DO THIS
// add_action('wp_head', ...); // DON'T DO THIS
// add_action('wp_footer', ...); // DON'T DO THIS
// NO theme modification hooks
// add_filter('wp_theme_json_data_theme', ...); // Let Font Library handle it
// NO WooCommerce hooks
// add_action('woocommerce_*', ...); // DON'T DO THIS
// NO content filters
// add_filter('the_content', ...); // DON'T DO THIS
```
---
## Conflict Debugging
If a user reports a conflict, check:
### 1. Plugin Load Order
Our plugin should load with default priority. Check if another plugin is:
- Modifying the Font Library
- Overriding font CSS
- Filtering theme.json
### 2. CSS Specificity
If fonts aren't applying:
- Check browser DevTools for CSS cascade
- Look for more specific selectors overriding global styles
- Check for `!important` declarations
### 3. Cache Issues
Font changes not appearing:
- Clear browser cache
- Clear any caching plugins (WP Rocket, W3TC, etc.)
- Clear CDN cache if applicable
- WordPress transients: `delete_transient('wp_font_library_fonts')`
### 4. JavaScript Errors
If admin page isn't working:
- Check browser console for JS errors
- Look for conflicts with other admin scripts
- Verify jQuery isn't being dequeued
---
## Compatibility Checklist
Before releasing:
### WordPress Core
- [ ] Works on WordPress 6.5
- [ ] Works on WordPress 6.6+
- [ ] Font Library API integration works
- [ ] Fonts appear in Global Styles
- [ ] Fonts apply correctly on frontend
### WooCommerce
- [ ] HPOS compatibility declared
- [ ] No errors in WooCommerce status page
- [ ] Product pages render correctly with custom fonts
- [ ] Cart/Checkout not affected
### Wordfence
- [ ] Import works with firewall enabled
- [ ] No blocked requests
- [ ] No false positive security alerts
### LearnDash
- [ ] Course content inherits fonts
- [ ] Focus Mode renders correctly
- [ ] No JavaScript conflicts
### WPForms
- [ ] Forms render correctly
- [ ] No styling conflicts
### Other
- [ ] No PHP errors in debug.log
- [ ] No JavaScript errors in console
- [ ] Admin page loads correctly
- [ ] No memory issues during import

View file

@ -0,0 +1,294 @@
/**
* Maple Icons - Admin Styles
*
* @package MapleIcons
*/
/* Admin page wrapper */
.mi-admin-wrap {
max-width: 1200px;
}
.mi-admin-intro {
background: #fff;
border: 1px solid #c3c4c7;
border-left: 4px solid #2271b1;
padding: 12px 16px;
margin: 20px 0;
}
.mi-admin-intro p {
margin: 0;
font-size: 14px;
}
/* Icon sets grid */
.mi-sets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
margin: 20px 0;
}
/* Individual set card */
.mi-set-card {
background: #fff;
border: 1px solid #c3c4c7;
border-radius: 4px;
padding: 20px;
position: relative;
transition: border-color 0.2s, box-shadow 0.2s;
}
.mi-set-card:hover {
border-color: #2271b1;
}
.mi-set-card.mi-set-active {
border-color: #00a32a;
box-shadow: 0 0 0 1px #00a32a;
}
.mi-set-card.mi-set-downloading {
opacity: 0.8;
pointer-events: none;
}
/* Card header */
.mi-set-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.mi-set-name {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #1d2327;
}
/* Badges */
.mi-badge {
display: inline-block;
padding: 2px 8px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
border-radius: 3px;
}
.mi-badge-active {
background: #00a32a;
color: #fff;
}
.mi-badge-downloaded {
background: #dcdcde;
color: #50575e;
}
/* Set metadata */
.mi-set-meta {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
font-size: 13px;
color: #50575e;
}
.mi-set-count {
font-weight: 600;
color: #1d2327;
}
/* External link */
.mi-set-link {
display: inline-block;
margin-bottom: 16px;
font-size: 13px;
text-decoration: none;
}
.mi-set-link:hover {
text-decoration: underline;
}
/* Action buttons */
.mi-set-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.mi-set-actions .button {
flex: 1;
text-align: center;
}
.mi-delete-btn {
color: #d63638 !important;
border-color: #d63638 !important;
}
.mi-delete-btn:hover {
background: #d63638 !important;
color: #fff !important;
}
/* Progress bar */
.mi-set-progress {
margin-top: 16px;
}
.mi-progress-bar {
height: 8px;
background: #dcdcde;
border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
}
.mi-progress-fill {
height: 100%;
background: #2271b1;
border-radius: 4px;
width: 0;
transition: width 0.3s ease;
}
.mi-progress-text {
font-size: 12px;
color: #50575e;
}
/* Messages */
.mi-set-message {
margin-top: 12px;
padding: 8px 12px;
border-radius: 4px;
font-size: 13px;
}
.mi-set-message.mi-message-success {
background: #edfaef;
border: 1px solid #00a32a;
color: #00450c;
}
.mi-set-message.mi-message-error {
background: #fcf0f1;
border: 1px solid #d63638;
color: #8a1f22;
}
.mi-set-message.mi-message-info {
background: #f0f6fc;
border: 1px solid #2271b1;
color: #135e96;
}
/* Usage section */
.mi-admin-usage,
.mi-admin-info {
background: #fff;
border: 1px solid #c3c4c7;
border-radius: 4px;
padding: 20px;
margin: 20px 0;
}
.mi-admin-usage h2,
.mi-admin-info h2 {
margin-top: 0;
font-size: 16px;
border-bottom: 1px solid #c3c4c7;
padding-bottom: 10px;
}
.mi-admin-usage ol {
margin: 16px 0 0;
padding-left: 24px;
}
.mi-admin-usage li {
margin-bottom: 8px;
font-size: 14px;
}
.mi-admin-info p {
font-size: 14px;
margin-bottom: 12px;
}
.mi-admin-info p:last-child {
margin-bottom: 0;
}
.mi-admin-info code {
background: #f0f0f1;
padding: 2px 6px;
border-radius: 3px;
font-size: 12px;
}
/* Loading spinner */
.mi-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #c3c4c7;
border-top-color: #2271b1;
border-radius: 50%;
animation: mi-spin 0.8s linear infinite;
margin-right: 8px;
vertical-align: middle;
}
@keyframes mi-spin {
to {
transform: rotate(360deg);
}
}
/* Button loading state */
.button.mi-loading {
position: relative;
color: transparent !important;
}
.button.mi-loading::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 14px;
height: 14px;
margin: -7px 0 0 -7px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: mi-spin 0.8s linear infinite;
}
.button-primary.mi-loading::after {
border-color: rgba(255, 255, 255, 0.3);
border-top-color: #fff;
}
/* Responsive adjustments */
@media screen and (max-width: 782px) {
.mi-sets-grid {
grid-template-columns: 1fr;
}
.mi-set-actions {
flex-direction: column;
}
.mi-set-actions .button {
width: 100%;
}
}

View file

@ -0,0 +1,255 @@
/**
* Maple Icons - Admin JavaScript
*
* @package MapleIcons
*/
(function($) {
'use strict';
/**
* Show message on a card.
*
* @param {jQuery} $card The card element.
* @param {string} message The message text.
* @param {string} type Message type: success, error, info.
*/
function showMessage($card, message, type) {
var $message = $card.find('.mi-set-message');
$message
.removeClass('mi-message-success mi-message-error mi-message-info')
.addClass('mi-message-' + type)
.text(message)
.show();
// Auto-hide success messages after 5 seconds.
if (type === 'success') {
setTimeout(function() {
$message.fadeOut();
}, 5000);
}
}
/**
* Hide message on a card.
*
* @param {jQuery} $card The card element.
*/
function hideMessage($card) {
$card.find('.mi-set-message').hide();
}
/**
* Show progress bar.
*
* @param {jQuery} $card The card element.
*/
function showProgress($card) {
$card.find('.mi-set-progress').show();
$card.find('.mi-set-actions').hide();
}
/**
* Hide progress bar.
*
* @param {jQuery} $card The card element.
*/
function hideProgress($card) {
$card.find('.mi-set-progress').hide();
$card.find('.mi-set-actions').show();
}
/**
* Update progress bar.
*
* @param {jQuery} $card The card element.
* @param {number} percentage Progress percentage (0-100).
* @param {string} text Progress text.
*/
function updateProgress($card, percentage, text) {
$card.find('.mi-progress-fill').css('width', percentage + '%');
$card.find('.mi-progress-text').text(text);
}
/**
* Poll download progress.
*
* @param {string} slug Icon set slug.
* @param {jQuery} $card The card element.
*/
function pollProgress(slug, $card) {
$.ajax({
url: miAdmin.ajaxUrl,
type: 'POST',
data: {
action: 'mi_get_progress',
nonce: miAdmin.nonce,
slug: slug
},
success: function(response) {
if (response.success && response.data.downloading) {
var percentage = response.data.percentage || 0;
var text = response.data.completed + ' / ' + response.data.total + ' icons';
updateProgress($card, percentage, text);
// Continue polling if still downloading.
setTimeout(function() {
pollProgress(slug, $card);
}, 1000);
}
}
});
}
/**
* Handle download button click.
*/
$(document).on('click', '.mi-download-btn', function(e) {
e.preventDefault();
var $btn = $(this);
var $card = $btn.closest('.mi-set-card');
var slug = $btn.data('slug');
if ($btn.hasClass('mi-loading')) {
return;
}
$btn.addClass('mi-loading');
$card.addClass('mi-set-downloading');
hideMessage($card);
showProgress($card);
updateProgress($card, 0, miAdmin.strings.downloading);
// Start polling for progress.
setTimeout(function() {
pollProgress(slug, $card);
}, 500);
$.ajax({
url: miAdmin.ajaxUrl,
type: 'POST',
data: {
action: 'mi_download_set',
nonce: miAdmin.nonce,
slug: slug
},
success: function(response) {
hideProgress($card);
$btn.removeClass('mi-loading');
$card.removeClass('mi-set-downloading');
if (response.success) {
showMessage($card, response.data.message || miAdmin.strings.downloadSuccess, 'success');
// Reload page to update UI state.
setTimeout(function() {
location.reload();
}, 1500);
} else {
showMessage($card, response.data.message || miAdmin.strings.downloadError, 'error');
}
},
error: function() {
hideProgress($card);
$btn.removeClass('mi-loading');
$card.removeClass('mi-set-downloading');
showMessage($card, miAdmin.strings.downloadError, 'error');
}
});
});
/**
* Handle delete button click.
*/
$(document).on('click', '.mi-delete-btn', function(e) {
e.preventDefault();
var $btn = $(this);
var $card = $btn.closest('.mi-set-card');
var slug = $btn.data('slug');
if ($btn.hasClass('mi-loading')) {
return;
}
if (!confirm(miAdmin.strings.confirmDelete)) {
return;
}
$btn.addClass('mi-loading');
hideMessage($card);
$.ajax({
url: miAdmin.ajaxUrl,
type: 'POST',
data: {
action: 'mi_delete_set',
nonce: miAdmin.nonce,
slug: slug
},
success: function(response) {
$btn.removeClass('mi-loading');
if (response.success) {
showMessage($card, response.data.message || miAdmin.strings.deleteSuccess, 'success');
// Reload page to update UI state.
setTimeout(function() {
location.reload();
}, 1500);
} else {
showMessage($card, response.data.message || miAdmin.strings.deleteError, 'error');
}
},
error: function() {
$btn.removeClass('mi-loading');
showMessage($card, miAdmin.strings.deleteError, 'error');
}
});
});
/**
* Handle activate button click.
*/
$(document).on('click', '.mi-activate-btn, .mi-deactivate-btn', function(e) {
e.preventDefault();
var $btn = $(this);
var $card = $btn.closest('.mi-set-card');
var slug = $btn.data('slug');
if ($btn.hasClass('mi-loading')) {
return;
}
$btn.addClass('mi-loading');
hideMessage($card);
$.ajax({
url: miAdmin.ajaxUrl,
type: 'POST',
data: {
action: 'mi_set_active',
nonce: miAdmin.nonce,
slug: slug
},
success: function(response) {
$btn.removeClass('mi-loading');
if (response.success) {
showMessage($card, response.data.message || miAdmin.strings.activateSuccess, 'success');
// Reload page to update UI state.
setTimeout(function() {
location.reload();
}, 1000);
} else {
showMessage($card, response.data.message || miAdmin.strings.activateError, 'error');
}
},
error: function() {
$btn.removeClass('mi-loading');
showMessage($card, miAdmin.strings.activateError, 'error');
}
});
});
})(jQuery);

View file

@ -0,0 +1,5 @@
<?php
// Silence is golden.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}

View file

@ -0,0 +1,224 @@
<?php
/**
* Admin Page class - Handles the settings page.
*
* @package MapleIcons
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Class MI_Admin_Page
*
* Handles the plugin settings page in WordPress admin.
*/
class MI_Admin_Page {
/**
* Constructor - Register admin hooks.
*/
public function __construct() {
add_action( 'admin_menu', array( $this, 'register_menu' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
}
/**
* Register the admin menu item.
*/
public function register_menu() {
add_options_page(
__( 'Maple Icons', 'maple-icons' ),
__( 'Maple Icons', 'maple-icons' ),
'manage_options',
'maple-icons',
array( $this, 'render_page' )
);
}
/**
* Enqueue admin assets.
*
* @param string $hook_suffix The current admin page.
*/
public function enqueue_assets( $hook_suffix ) {
if ( 'settings_page_maple-icons' !== $hook_suffix ) {
return;
}
wp_enqueue_style(
'mi-admin',
MI_PLUGIN_URL . 'assets/admin.css',
array(),
MI_VERSION
);
wp_enqueue_script(
'mi-admin',
MI_PLUGIN_URL . 'assets/admin.js',
array( 'jquery' ),
MI_VERSION,
true
);
wp_localize_script(
'mi-admin',
'miAdmin',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'mi_admin_nonce' ),
'strings' => array(
'downloading' => __( 'Downloading...', 'maple-icons' ),
'deleting' => __( 'Deleting...', 'maple-icons' ),
'activating' => __( 'Activating...', 'maple-icons' ),
'confirmDelete' => __( 'Are you sure you want to delete this icon set?', 'maple-icons' ),
'downloadError' => __( 'Download failed. Please try again.', 'maple-icons' ),
'deleteError' => __( 'Delete failed. Please try again.', 'maple-icons' ),
'activateError' => __( 'Activation failed. Please try again.', 'maple-icons' ),
'downloadSuccess'=> __( 'Icon set downloaded successfully!', 'maple-icons' ),
'deleteSuccess' => __( 'Icon set deleted successfully!', 'maple-icons' ),
'activateSuccess'=> __( 'Icon set activated!', 'maple-icons' ),
),
)
);
}
/**
* Render the settings page.
*/
public function render_page() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$registry = MI_Icon_Registry::get_instance();
$all_sets = MI_Icon_Sets::get_all();
$downloaded = $registry->get_downloaded_sets();
$active_set = $registry->get_active_set();
?>
<div class="wrap mi-admin-wrap">
<h1><?php esc_html_e( 'Maple Icons', 'maple-icons' ); ?></h1>
<div class="mi-admin-intro">
<p><?php esc_html_e( 'Download icon sets from CDN and use them in the Gutenberg block editor. Only one icon set can be active at a time.', 'maple-icons' ); ?></p>
</div>
<div class="mi-icon-sets">
<h2><?php esc_html_e( 'Available Icon Sets', 'maple-icons' ); ?></h2>
<div class="mi-sets-grid">
<?php foreach ( $all_sets as $slug => $set ) : ?>
<?php
$is_downloaded = isset( $downloaded[ $slug ] );
$is_active = $active_set === $slug;
$icon_count = $is_downloaded ? $downloaded[ $slug ]['icon_count'] : 0;
$download_date = $is_downloaded ? $downloaded[ $slug ]['downloaded_at'] : '';
?>
<div class="mi-set-card <?php echo $is_active ? 'mi-set-active' : ''; ?> <?php echo $is_downloaded ? 'mi-set-downloaded' : ''; ?>" data-slug="<?php echo esc_attr( $slug ); ?>">
<div class="mi-set-header">
<h3 class="mi-set-name"><?php echo esc_html( $set['name'] ); ?></h3>
<?php if ( $is_active ) : ?>
<span class="mi-badge mi-badge-active"><?php esc_html_e( 'Active', 'maple-icons' ); ?></span>
<?php elseif ( $is_downloaded ) : ?>
<span class="mi-badge mi-badge-downloaded"><?php esc_html_e( 'Downloaded', 'maple-icons' ); ?></span>
<?php endif; ?>
</div>
<div class="mi-set-meta">
<span class="mi-set-license">
<?php
/* translators: %s: License name */
printf( esc_html__( 'License: %s', 'maple-icons' ), esc_html( $set['license'] ) );
?>
</span>
<span class="mi-set-styles">
<?php
/* translators: %s: Style names */
printf( esc_html__( 'Styles: %s', 'maple-icons' ), esc_html( implode( ', ', $set['styles'] ) ) );
?>
</span>
<?php if ( $is_downloaded && $icon_count > 0 ) : ?>
<span class="mi-set-count">
<?php
/* translators: %d: Number of icons */
printf( esc_html( _n( '%d icon', '%d icons', $icon_count, 'maple-icons' ) ), intval( $icon_count ) );
?>
</span>
<?php endif; ?>
</div>
<?php if ( ! empty( $set['url'] ) ) : ?>
<a href="<?php echo esc_url( $set['url'] ); ?>" class="mi-set-link" target="_blank" rel="noopener noreferrer">
<?php esc_html_e( 'View on website', 'maple-icons' ); ?> &rarr;
</a>
<?php endif; ?>
<div class="mi-set-actions">
<?php if ( ! $is_downloaded ) : ?>
<button type="button" class="button button-primary mi-download-btn" data-slug="<?php echo esc_attr( $slug ); ?>">
<?php esc_html_e( 'Download', 'maple-icons' ); ?>
</button>
<?php else : ?>
<?php if ( ! $is_active ) : ?>
<button type="button" class="button button-primary mi-activate-btn" data-slug="<?php echo esc_attr( $slug ); ?>">
<?php esc_html_e( 'Set Active', 'maple-icons' ); ?>
</button>
<?php else : ?>
<button type="button" class="button mi-deactivate-btn" data-slug="">
<?php esc_html_e( 'Deactivate', 'maple-icons' ); ?>
</button>
<?php endif; ?>
<button type="button" class="button mi-delete-btn" data-slug="<?php echo esc_attr( $slug ); ?>">
<?php esc_html_e( 'Delete', 'maple-icons' ); ?>
</button>
<?php endif; ?>
</div>
<div class="mi-set-progress" style="display: none;">
<div class="mi-progress-bar">
<div class="mi-progress-fill"></div>
</div>
<span class="mi-progress-text"></span>
</div>
<div class="mi-set-message" style="display: none;"></div>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="mi-admin-usage">
<h2><?php esc_html_e( 'How to Use', 'maple-icons' ); ?></h2>
<ol>
<li><?php esc_html_e( 'Download one or more icon sets above.', 'maple-icons' ); ?></li>
<li><?php esc_html_e( 'Set one icon set as active.', 'maple-icons' ); ?></li>
<li><?php esc_html_e( 'In the Gutenberg editor, add a "Maple Icon" block.', 'maple-icons' ); ?></li>
<li><?php esc_html_e( 'Search and select an icon from your active set.', 'maple-icons' ); ?></li>
<li><?php esc_html_e( 'Customize size, color, and other settings in the block sidebar.', 'maple-icons' ); ?></li>
</ol>
</div>
<div class="mi-admin-info">
<h2><?php esc_html_e( 'About', 'maple-icons' ); ?></h2>
<p>
<?php esc_html_e( 'Maple Icons downloads SVG icons from CDN and stores them locally in your WordPress installation. Icons are sanitized for security and normalized for consistent rendering.', 'maple-icons' ); ?>
</p>
<p>
<?php esc_html_e( 'All icons use currentColor for styling, which means they automatically inherit text color from your theme or block settings.', 'maple-icons' ); ?>
</p>
<p>
<?php
printf(
/* translators: %s: Directory path */
esc_html__( 'Icons are stored in: %s', 'maple-icons' ),
'<code>' . esc_html( MI_ICONS_DIR ) . '</code>'
);
?>
</p>
</div>
</div>
<?php
}
}

View file

@ -0,0 +1,376 @@
<?php
/**
* AJAX Handler class - Handles all AJAX requests.
*
* @package MapleIcons
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Class MI_Ajax_Handler
*
* Handles all AJAX requests for the plugin.
*/
class MI_Ajax_Handler {
/**
* Constructor - Register AJAX handlers.
*/
public function __construct() {
// Admin AJAX handlers.
add_action( 'wp_ajax_mi_download_set', array( $this, 'handle_download_set' ) );
add_action( 'wp_ajax_mi_delete_set', array( $this, 'handle_delete_set' ) );
add_action( 'wp_ajax_mi_set_active', array( $this, 'handle_set_active' ) );
add_action( 'wp_ajax_mi_get_progress', array( $this, 'handle_get_progress' ) );
// Block editor AJAX handlers.
add_action( 'wp_ajax_mi_search_icons', array( $this, 'handle_search_icons' ) );
add_action( 'wp_ajax_mi_get_icon_svg', array( $this, 'handle_get_icon_svg' ) );
// No nopriv handlers - all functionality requires login.
}
/**
* Handle download set request.
*/
public function handle_download_set() {
// 1. Nonce verification.
if ( ! check_ajax_referer( 'mi_admin_nonce', 'nonce', false ) ) {
wp_send_json_error(
array( 'message' => __( 'Security check failed.', 'maple-icons' ) ),
403
);
}
// 2. Capability check.
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error(
array( 'message' => __( 'You do not have permission to do this.', 'maple-icons' ) ),
403
);
}
// 3. Input validation.
$slug = isset( $_POST['slug'] ) ? sanitize_key( $_POST['slug'] ) : '';
if ( empty( $slug ) ) {
wp_send_json_error(
array( 'message' => __( 'Icon set slug is required.', 'maple-icons' ) )
);
}
if ( ! MI_Icon_Sets::is_valid_slug( $slug ) ) {
wp_send_json_error(
array( 'message' => __( 'Invalid icon set.', 'maple-icons' ) )
);
}
// Check if already downloaded.
$registry = MI_Icon_Registry::get_instance();
if ( $registry->is_downloaded( $slug ) ) {
wp_send_json_error(
array( 'message' => __( 'This icon set is already downloaded.', 'maple-icons' ) )
);
}
// 4. Process download.
// Increase time limit for large downloads.
set_time_limit( 600 ); // 10 minutes.
$downloader = new MI_Downloader();
$result = $downloader->download_set( $slug );
if ( is_wp_error( $result ) ) {
wp_send_json_error(
array( 'message' => $result->get_error_message() )
);
}
if ( ! $result['success'] ) {
wp_send_json_error(
array(
'message' => sprintf(
/* translators: %1$d: downloaded count, %2$d: failed count */
__( 'Download completed with errors. %1$d downloaded, %2$d failed.', 'maple-icons' ),
$result['downloaded'],
$result['failed']
),
'errors' => array_slice( $result['errors'], 0, 10 ), // Limit errors shown.
'downloaded' => $result['downloaded'],
'failed' => $result['failed'],
)
);
}
// Mark as downloaded.
$registry->mark_downloaded( $slug, $result['downloaded'] );
// If no active set, make this one active.
if ( ! $registry->get_active_set() ) {
$registry->set_active( $slug );
}
$registry->refresh();
wp_send_json_success(
array(
'message' => sprintf(
/* translators: %d: number of icons */
__( 'Successfully downloaded %d icons.', 'maple-icons' ),
$result['downloaded']
),
'icon_count' => $result['downloaded'],
'is_active' => $registry->get_active_set() === $slug,
)
);
}
/**
* Handle delete set request.
*/
public function handle_delete_set() {
// 1. Nonce verification.
if ( ! check_ajax_referer( 'mi_admin_nonce', 'nonce', false ) ) {
wp_send_json_error(
array( 'message' => __( 'Security check failed.', 'maple-icons' ) ),
403
);
}
// 2. Capability check.
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error(
array( 'message' => __( 'You do not have permission to do this.', 'maple-icons' ) ),
403
);
}
// 3. Input validation.
$slug = isset( $_POST['slug'] ) ? sanitize_key( $_POST['slug'] ) : '';
if ( empty( $slug ) ) {
wp_send_json_error(
array( 'message' => __( 'Icon set slug is required.', 'maple-icons' ) )
);
}
if ( ! MI_Icon_Sets::is_valid_slug( $slug ) ) {
wp_send_json_error(
array( 'message' => __( 'Invalid icon set.', 'maple-icons' ) )
);
}
// 4. Delete the set.
$registry = MI_Icon_Registry::get_instance();
$result = $registry->delete_set( $slug );
if ( is_wp_error( $result ) ) {
wp_send_json_error(
array( 'message' => $result->get_error_message() )
);
}
wp_send_json_success(
array(
'message' => __( 'Icon set deleted successfully.', 'maple-icons' ),
)
);
}
/**
* Handle set active request.
*/
public function handle_set_active() {
// 1. Nonce verification.
if ( ! check_ajax_referer( 'mi_admin_nonce', 'nonce', false ) ) {
wp_send_json_error(
array( 'message' => __( 'Security check failed.', 'maple-icons' ) ),
403
);
}
// 2. Capability check.
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error(
array( 'message' => __( 'You do not have permission to do this.', 'maple-icons' ) ),
403
);
}
// 3. Input validation.
$slug = isset( $_POST['slug'] ) ? sanitize_key( $_POST['slug'] ) : '';
// Empty slug is allowed (to deactivate).
if ( ! empty( $slug ) && ! MI_Icon_Sets::is_valid_slug( $slug ) ) {
wp_send_json_error(
array( 'message' => __( 'Invalid icon set.', 'maple-icons' ) )
);
}
// 4. Set active.
$registry = MI_Icon_Registry::get_instance();
$result = $registry->set_active( $slug );
if ( is_wp_error( $result ) ) {
wp_send_json_error(
array( 'message' => $result->get_error_message() )
);
}
wp_send_json_success(
array(
'message' => empty( $slug )
? __( 'No icon set is now active.', 'maple-icons' )
: __( 'Icon set activated.', 'maple-icons' ),
'active_set' => $slug,
)
);
}
/**
* Handle get progress request.
*/
public function handle_get_progress() {
// 1. Nonce verification.
if ( ! check_ajax_referer( 'mi_admin_nonce', 'nonce', false ) ) {
wp_send_json_error(
array( 'message' => __( 'Security check failed.', 'maple-icons' ) ),
403
);
}
// 2. Capability check.
if ( ! current_user_can( 'manage_options' ) ) {
wp_send_json_error(
array( 'message' => __( 'You do not have permission to do this.', 'maple-icons' ) ),
403
);
}
// 3. Input validation.
$slug = isset( $_POST['slug'] ) ? sanitize_key( $_POST['slug'] ) : '';
if ( empty( $slug ) || ! MI_Icon_Sets::is_valid_slug( $slug ) ) {
wp_send_json_error(
array( 'message' => __( 'Invalid icon set.', 'maple-icons' ) )
);
}
// 4. Get progress.
$downloader = new MI_Downloader();
$progress = $downloader->get_progress( $slug );
if ( false === $progress ) {
wp_send_json_success(
array(
'downloading' => false,
'completed' => 0,
'total' => 0,
)
);
}
wp_send_json_success(
array(
'downloading' => true,
'completed' => $progress['completed'],
'total' => $progress['total'],
'percentage' => $progress['total'] > 0
? round( ( $progress['completed'] / $progress['total'] ) * 100 )
: 0,
)
);
}
/**
* Handle search icons request (for block editor).
*/
public function handle_search_icons() {
// 1. Nonce verification.
if ( ! check_ajax_referer( 'mi_block_nonce', 'nonce', false ) ) {
wp_send_json_error(
array( 'message' => __( 'Security check failed.', 'maple-icons' ) ),
403
);
}
// 2. Capability check (edit_posts for block usage).
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error(
array( 'message' => __( 'You do not have permission to do this.', 'maple-icons' ) ),
403
);
}
// 3. Input validation.
$query = isset( $_POST['query'] ) ? sanitize_text_field( $_POST['query'] ) : '';
$style = isset( $_POST['style'] ) ? sanitize_key( $_POST['style'] ) : '';
$limit = isset( $_POST['limit'] ) ? absint( $_POST['limit'] ) : MI_SEARCH_LIMIT;
$offset = isset( $_POST['offset'] ) ? absint( $_POST['offset'] ) : 0;
// Limit the limit.
if ( $limit > 100 ) {
$limit = 100;
}
// 4. Search.
$registry = MI_Icon_Registry::get_instance();
$results = $registry->search_icons( $query, $style, $limit, $offset );
wp_send_json_success( $results );
}
/**
* Handle get icon SVG request (for block editor).
*/
public function handle_get_icon_svg() {
// 1. Nonce verification.
if ( ! check_ajax_referer( 'mi_block_nonce', 'nonce', false ) ) {
wp_send_json_error(
array( 'message' => __( 'Security check failed.', 'maple-icons' ) ),
403
);
}
// 2. Capability check.
if ( ! current_user_can( 'edit_posts' ) ) {
wp_send_json_error(
array( 'message' => __( 'You do not have permission to do this.', 'maple-icons' ) ),
403
);
}
// 3. Input validation.
$slug = isset( $_POST['set'] ) ? sanitize_key( $_POST['set'] ) : '';
$style = isset( $_POST['style'] ) ? sanitize_key( $_POST['style'] ) : '';
$name = isset( $_POST['name'] ) ? sanitize_file_name( $_POST['name'] ) : '';
if ( empty( $slug ) || empty( $style ) || empty( $name ) ) {
wp_send_json_error(
array( 'message' => __( 'Missing required parameters.', 'maple-icons' ) )
);
}
// 4. Get SVG.
$registry = MI_Icon_Registry::get_instance();
$svg = $registry->get_icon_svg( $slug, $style, $name );
if ( is_wp_error( $svg ) ) {
wp_send_json_error(
array( 'message' => $svg->get_error_message() )
);
}
wp_send_json_success(
array(
'svg' => $svg,
'set' => $slug,
'style' => $style,
'name' => $name,
)
);
}
}

View file

@ -0,0 +1,505 @@
<?php
/**
* Downloader class - Handles fetching icons from CDN and storing locally.
*
* @package MapleIcons
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Class MI_Downloader
*
* Downloads icon sets from CDN and stores them locally.
*/
class MI_Downloader {
/**
* Allowed SVG elements for sanitization.
*
* @var array
*/
private static $allowed_svg_elements = array(
'svg' => array(
'xmlns' => true,
'viewbox' => true,
'width' => true,
'height' => true,
'fill' => true,
'stroke' => true,
'stroke-width' => true,
'stroke-linecap' => true,
'stroke-linejoin' => true,
'class' => true,
'aria-hidden' => true,
'role' => true,
'focusable' => true,
'style' => true,
),
'path' => array(
'd' => true,
'fill' => true,
'stroke' => true,
'stroke-width' => true,
'stroke-linecap' => true,
'stroke-linejoin' => true,
'fill-rule' => true,
'clip-rule' => true,
'opacity' => true,
'fill-opacity' => true,
'stroke-opacity' => true,
),
'circle' => array(
'cx' => true,
'cy' => true,
'r' => true,
'fill' => true,
'stroke' => true,
'stroke-width' => true,
'opacity' => true,
),
'rect' => array(
'x' => true,
'y' => true,
'width' => true,
'height' => true,
'rx' => true,
'ry' => true,
'fill' => true,
'stroke' => true,
'stroke-width' => true,
'opacity' => true,
),
'line' => array(
'x1' => true,
'y1' => true,
'x2' => true,
'y2' => true,
'stroke' => true,
'stroke-width' => true,
'stroke-linecap' => true,
'opacity' => true,
),
'polyline' => array(
'points' => true,
'fill' => true,
'stroke' => true,
'stroke-width' => true,
'stroke-linecap' => true,
'stroke-linejoin' => true,
'opacity' => true,
),
'polygon' => array(
'points' => true,
'fill' => true,
'stroke' => true,
'stroke-width' => true,
'opacity' => true,
),
'ellipse' => array(
'cx' => true,
'cy' => true,
'rx' => true,
'ry' => true,
'fill' => true,
'stroke' => true,
'stroke-width' => true,
'opacity' => true,
),
'g' => array(
'fill' => true,
'stroke' => true,
'stroke-width' => true,
'transform' => true,
'opacity' => true,
),
'defs' => array(),
'clippath' => array(
'id' => true,
),
'use' => array(
'href' => true,
'xlink:href' => true,
),
);
/**
* Download an entire icon set.
*
* @param string $slug Icon set slug.
* @param callable|null $progress_callback Optional progress callback.
* @return array|WP_Error Download results or error.
*/
public function download_set( $slug, $progress_callback = null ) {
// Validate slug.
if ( ! MI_Icon_Sets::is_valid_slug( $slug ) ) {
return new WP_Error(
'invalid_set',
__( 'Invalid icon set.', 'maple-icons' )
);
}
$set_config = MI_Icon_Sets::get( $slug );
$manifest = MI_Icon_Sets::load_manifest( $slug );
if ( is_wp_error( $manifest ) ) {
return $manifest;
}
// Create base directory.
$base_dir = MI_ICONS_DIR . $slug;
if ( ! $this->ensure_directory( $base_dir ) ) {
return new WP_Error(
'directory_error',
__( 'Could not create icon directory.', 'maple-icons' )
);
}
// Create style directories.
foreach ( $set_config['styles'] as $style_slug => $style_config ) {
$style_dir = $base_dir . '/' . $style_slug;
if ( ! $this->ensure_directory( $style_dir ) ) {
return new WP_Error(
'directory_error',
__( 'Could not create style directory.', 'maple-icons' )
);
}
}
$icons = isset( $manifest['icons'] ) ? $manifest['icons'] : array();
$total_icons = count( $icons );
$downloaded = 0;
$failed = 0;
$errors = array();
$total_to_download = 0;
// Calculate total icons to download (icon × styles).
foreach ( $icons as $icon ) {
$icon_styles = isset( $icon['styles'] ) ? $icon['styles'] : array_keys( $set_config['styles'] );
$total_to_download += count( $icon_styles );
}
// Initialize progress transient.
set_transient(
'mi_download_progress_' . $slug,
array(
'completed' => 0,
'total' => $total_to_download,
'status' => 'downloading',
),
HOUR_IN_SECONDS
);
$current = 0;
// Download each icon.
foreach ( $icons as $icon ) {
$icon_name = $icon['name'];
$icon_styles = isset( $icon['styles'] ) ? $icon['styles'] : array_keys( $set_config['styles'] );
foreach ( $icon_styles as $style ) {
if ( ! isset( $set_config['styles'][ $style ] ) ) {
continue;
}
$result = $this->download_icon( $slug, $style, $icon_name );
if ( is_wp_error( $result ) ) {
$failed++;
$errors[] = sprintf( '%s/%s: %s', $style, $icon_name, $result->get_error_message() );
} else {
$downloaded++;
}
$current++;
// Update progress.
set_transient(
'mi_download_progress_' . $slug,
array(
'completed' => $current,
'total' => $total_to_download,
'status' => 'downloading',
),
HOUR_IN_SECONDS
);
// Call progress callback if provided.
if ( is_callable( $progress_callback ) ) {
call_user_func( $progress_callback, $current, $total_to_download );
}
// Allow some breathing room for the server.
if ( 0 === $current % MI_DOWNLOAD_BATCH_SIZE ) {
usleep( 100000 ); // 100ms pause every batch.
}
}
}
// Clear progress transient.
delete_transient( 'mi_download_progress_' . $slug );
return array(
'success' => $failed === 0,
'downloaded' => $downloaded,
'failed' => $failed,
'total' => $total_to_download,
'errors' => $errors,
);
}
/**
* Download a single icon from CDN.
*
* @param string $slug Icon set slug.
* @param string $style Style slug.
* @param string $name Icon name.
* @return string|WP_Error Local file path or error.
*/
public function download_icon( $slug, $style, $name ) {
// Validate inputs.
if ( ! MI_Icon_Sets::is_valid_slug( $slug ) ) {
return new WP_Error( 'invalid_set', __( 'Invalid icon set.', 'maple-icons' ) );
}
if ( ! MI_Icon_Sets::is_valid_style( $slug, $style ) ) {
return new WP_Error( 'invalid_style', __( 'Invalid icon style.', 'maple-icons' ) );
}
// Validate icon name (only allow alphanumeric, hyphens, underscores).
if ( ! preg_match( '/^[a-z0-9\-_]+$/i', $name ) ) {
return new WP_Error( 'invalid_name', __( 'Invalid icon name.', 'maple-icons' ) );
}
$cdn_url = MI_Icon_Sets::get_cdn_url( $slug, $style, $name );
$local_path = MI_Icon_Sets::get_local_path( $slug, $style, $name );
// Check if already downloaded.
if ( file_exists( $local_path ) ) {
return $local_path;
}
// Fetch from CDN.
$response = wp_remote_get(
$cdn_url,
array(
'timeout' => MI_DOWNLOAD_TIMEOUT,
'sslverify' => true,
)
);
if ( is_wp_error( $response ) ) {
return $response;
}
$status_code = wp_remote_retrieve_response_code( $response );
if ( 200 !== $status_code ) {
return new WP_Error(
'cdn_error',
sprintf(
/* translators: %d: HTTP status code */
__( 'CDN returned status %d.', 'maple-icons' ),
$status_code
)
);
}
$svg_content = wp_remote_retrieve_body( $response );
if ( empty( $svg_content ) ) {
return new WP_Error( 'empty_response', __( 'Empty response from CDN.', 'maple-icons' ) );
}
// Get set config for normalization.
$set_config = MI_Icon_Sets::get( $slug );
// Normalize and sanitize SVG.
$svg_content = $this->normalize_svg( $svg_content, $set_config );
$svg_content = $this->sanitize_svg( $svg_content );
if ( empty( $svg_content ) ) {
return new WP_Error( 'invalid_svg', __( 'Invalid or empty SVG content.', 'maple-icons' ) );
}
// Ensure directory exists.
$dir = dirname( $local_path );
if ( ! $this->ensure_directory( $dir ) ) {
return new WP_Error( 'directory_error', __( 'Could not create directory.', 'maple-icons' ) );
}
// Write file.
global $wp_filesystem;
if ( empty( $wp_filesystem ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
WP_Filesystem();
}
if ( ! $wp_filesystem->put_contents( $local_path, $svg_content, FS_CHMOD_FILE ) ) {
return new WP_Error( 'write_error', __( 'Could not write SVG file.', 'maple-icons' ) );
}
return $local_path;
}
/**
* Normalize SVG content based on set configuration.
*
* @param string $svg SVG content.
* @param array $set_config Icon set configuration.
* @return string Normalized SVG content.
*/
private function normalize_svg( $svg, $set_config ) {
// Strip XML declaration.
$svg = preg_replace( '/<\?xml[^>]*\?>/i', '', $svg );
// Strip DOCTYPE.
$svg = preg_replace( '/<!DOCTYPE[^>]*>/i', '', $svg );
// Strip comments.
$svg = preg_replace( '/<!--.*?-->/s', '', $svg );
// Strip title and desc elements.
$svg = preg_replace( '/<title[^>]*>.*?<\/title>/is', '', $svg );
$svg = preg_replace( '/<desc[^>]*>.*?<\/desc>/is', '', $svg );
// Normalize viewBox for Phosphor (256 → 24).
if ( ! empty( $set_config['normalize'] ) ) {
$svg = preg_replace(
'/viewBox=["\']0\s+0\s+256\s+256["\']/i',
'viewBox="0 0 24 24"',
$svg
);
}
// Fix hardcoded colors for Material.
if ( ! empty( $set_config['color_fix'] ) ) {
// Replace hardcoded hex colors.
$svg = preg_replace( '/fill=["\']#[0-9a-fA-F]{3,6}["\']/', 'fill="currentColor"', $svg );
$svg = preg_replace( '/fill=["\']black["\']/', 'fill="currentColor"', $svg );
$svg = preg_replace( '/fill=["\']rgb\([^)]+\)["\']/', 'fill="currentColor"', $svg );
// Same for stroke.
$svg = preg_replace( '/stroke=["\']#[0-9a-fA-F]{3,6}["\']/', 'stroke="currentColor"', $svg );
$svg = preg_replace( '/stroke=["\']black["\']/', 'stroke="currentColor"', $svg );
}
// Remove width/height attributes (let CSS control size).
$svg = preg_replace( '/\s(width|height)=["\'][^"\']*["\']/i', '', $svg );
// Ensure there's no leading/trailing whitespace.
$svg = trim( $svg );
return $svg;
}
/**
* Sanitize SVG content to remove potentially dangerous elements.
*
* @param string $svg SVG content.
* @return string Sanitized SVG content.
*/
private function sanitize_svg( $svg ) {
// Remove script tags.
$svg = preg_replace( '/<script\b[^>]*>.*?<\/script>/is', '', $svg );
// Remove event handlers.
$svg = preg_replace( '/\s+on\w+\s*=/i', ' data-removed=', $svg );
// Remove javascript: URLs.
$svg = preg_replace( '/javascript:/i', 'removed:', $svg );
// Remove data: URLs (except for certain safe uses).
$svg = preg_replace( '/data:[^"\'>\s]+/i', 'removed:', $svg );
// Use WordPress kses with our allowed tags.
$svg = wp_kses( $svg, self::$allowed_svg_elements );
// Verify it's still a valid SVG.
if ( strpos( $svg, '<svg' ) === false || strpos( $svg, '</svg>' ) === false ) {
return '';
}
return $svg;
}
/**
* Ensure a directory exists, creating it if necessary.
*
* @param string $dir Directory path.
* @return bool True if directory exists or was created.
*/
private function ensure_directory( $dir ) {
if ( file_exists( $dir ) ) {
return is_dir( $dir );
}
return wp_mkdir_p( $dir );
}
/**
* Delete all icons for a set.
*
* @param string $slug Icon set slug.
* @return bool True on success.
*/
public function delete_set( $slug ) {
if ( ! MI_Icon_Sets::is_valid_slug( $slug ) ) {
return false;
}
$set_dir = MI_ICONS_DIR . $slug;
// Validate the path is within our icons directory.
$real_path = realpath( $set_dir );
$allowed_base = realpath( MI_ICONS_DIR );
if ( false === $real_path || false === $allowed_base ) {
// Directory doesn't exist, nothing to delete.
return true;
}
if ( strpos( $real_path, $allowed_base ) !== 0 ) {
// Path traversal attempt.
return false;
}
// Recursively delete the directory.
return $this->delete_directory( $set_dir );
}
/**
* Recursively delete a directory.
*
* @param string $dir Directory path.
* @return bool True on success.
*/
private function delete_directory( $dir ) {
if ( ! is_dir( $dir ) ) {
return true;
}
$files = array_diff( scandir( $dir ), array( '.', '..' ) );
foreach ( $files as $file ) {
$path = $dir . '/' . $file;
if ( is_dir( $path ) ) {
$this->delete_directory( $path );
} else {
wp_delete_file( $path );
}
}
return rmdir( $dir );
}
/**
* Get download progress for a set.
*
* @param string $slug Icon set slug.
* @return array|false Progress data or false if not downloading.
*/
public function get_progress( $slug ) {
return get_transient( 'mi_download_progress_' . $slug );
}
}

View file

@ -0,0 +1,416 @@
<?php
/**
* Icon Registry class - Manages downloaded sets and provides icon search/retrieval.
*
* @package MapleIcons
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Class MI_Icon_Registry
*
* Manages downloaded icon sets and provides search/retrieval functionality.
*/
class MI_Icon_Registry {
/**
* Singleton instance.
*
* @var MI_Icon_Registry|null
*/
private static $instance = null;
/**
* Cached settings.
*
* @var array|null
*/
private $settings = null;
/**
* Get singleton instance.
*
* @return MI_Icon_Registry
*/
public static function get_instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
*/
private function __construct() {
$this->load_settings();
}
/**
* Load settings from database.
*/
private function load_settings() {
$this->settings = get_option(
'maple_icons_settings',
array(
'active_set' => '',
'downloaded_sets' => array(),
)
);
}
/**
* Save settings to database.
*
* @return bool True on success.
*/
private function save_settings() {
return update_option( 'maple_icons_settings', $this->settings );
}
/**
* Get all downloaded sets.
*
* @return array Array of downloaded set data.
*/
public function get_downloaded_sets() {
return isset( $this->settings['downloaded_sets'] ) ? $this->settings['downloaded_sets'] : array();
}
/**
* Get the active set slug.
*
* @return string|null Active set slug or null if none.
*/
public function get_active_set() {
$active = isset( $this->settings['active_set'] ) ? $this->settings['active_set'] : '';
return ! empty( $active ) ? $active : null;
}
/**
* Set the active icon set.
*
* @param string $slug Icon set slug.
* @return bool|WP_Error True on success, error on failure.
*/
public function set_active( $slug ) {
// Allow empty string to deactivate.
if ( empty( $slug ) ) {
$this->settings['active_set'] = '';
return $this->save_settings();
}
// Validate slug.
if ( ! MI_Icon_Sets::is_valid_slug( $slug ) ) {
return new WP_Error( 'invalid_set', __( 'Invalid icon set.', 'maple-icons' ) );
}
// Check if downloaded.
if ( ! $this->is_downloaded( $slug ) ) {
return new WP_Error( 'not_downloaded', __( 'Icon set is not downloaded.', 'maple-icons' ) );
}
$this->settings['active_set'] = $slug;
return $this->save_settings();
}
/**
* Check if a set is downloaded.
*
* @param string $slug Icon set slug.
* @return bool True if downloaded.
*/
public function is_downloaded( $slug ) {
$downloaded = $this->get_downloaded_sets();
return isset( $downloaded[ $slug ] );
}
/**
* Mark a set as downloaded.
*
* @param string $slug Icon set slug.
* @param int $icon_count Number of icons downloaded.
* @return bool True on success.
*/
public function mark_downloaded( $slug, $icon_count ) {
if ( ! MI_Icon_Sets::is_valid_slug( $slug ) ) {
return false;
}
$set_config = MI_Icon_Sets::get( $slug );
$this->settings['downloaded_sets'][ $slug ] = array(
'version' => $set_config['version'],
'downloaded_at' => current_time( 'mysql' ),
'icon_count' => $icon_count,
);
return $this->save_settings();
}
/**
* Remove a set from downloaded list.
*
* @param string $slug Icon set slug.
* @return bool True on success.
*/
public function unmark_downloaded( $slug ) {
if ( isset( $this->settings['downloaded_sets'][ $slug ] ) ) {
unset( $this->settings['downloaded_sets'][ $slug ] );
}
// If this was the active set, clear it.
if ( $this->settings['active_set'] === $slug ) {
$this->settings['active_set'] = '';
}
return $this->save_settings();
}
/**
* Get all icons for a set and style.
*
* @param string $slug Icon set slug.
* @param string $style Style slug.
* @return array Array of icon data.
*/
public function get_icons_for_set( $slug, $style ) {
if ( ! MI_Icon_Sets::is_valid_slug( $slug ) ) {
return array();
}
if ( ! MI_Icon_Sets::is_valid_style( $slug, $style ) ) {
return array();
}
// Load manifest.
$manifest = MI_Icon_Sets::load_manifest( $slug );
if ( is_wp_error( $manifest ) ) {
return array();
}
$icons = isset( $manifest['icons'] ) ? $manifest['icons'] : array();
$result = array();
foreach ( $icons as $icon ) {
$icon_styles = isset( $icon['styles'] ) ? $icon['styles'] : array_keys( MI_Icon_Sets::get( $slug )['styles'] );
// Only include if this icon has the requested style.
if ( in_array( $style, $icon_styles, true ) ) {
$result[] = array(
'name' => $icon['name'],
'tags' => isset( $icon['tags'] ) ? $icon['tags'] : array(),
'category' => isset( $icon['category'] ) ? $icon['category'] : '',
);
}
}
return $result;
}
/**
* Search icons in the active set.
*
* @param string $query Search query.
* @param string $style Optional style filter.
* @param int $limit Maximum results.
* @param int $offset Offset for pagination.
* @return array Search results.
*/
public function search_icons( $query = '', $style = '', $limit = MI_SEARCH_LIMIT, $offset = 0 ) {
$active_set = $this->get_active_set();
if ( ! $active_set ) {
return array(
'icons' => array(),
'total' => 0,
);
}
$set_config = MI_Icon_Sets::get( $active_set );
if ( ! $set_config ) {
return array(
'icons' => array(),
'total' => 0,
);
}
// Default to first style if not specified.
if ( empty( $style ) ) {
$style = $set_config['default_style'];
}
// Validate style.
if ( ! MI_Icon_Sets::is_valid_style( $active_set, $style ) ) {
$style = $set_config['default_style'];
}
// Load manifest.
$manifest = MI_Icon_Sets::load_manifest( $active_set );
if ( is_wp_error( $manifest ) ) {
return array(
'icons' => array(),
'total' => 0,
);
}
$icons = isset( $manifest['icons'] ) ? $manifest['icons'] : array();
$query = strtolower( trim( $query ) );
$results = array();
foreach ( $icons as $icon ) {
$icon_styles = isset( $icon['styles'] ) ? $icon['styles'] : array_keys( $set_config['styles'] );
// Skip if this icon doesn't have the requested style.
if ( ! in_array( $style, $icon_styles, true ) ) {
continue;
}
// If no query, include all.
if ( empty( $query ) ) {
$results[] = $icon;
continue;
}
// Search in name.
if ( strpos( strtolower( $icon['name'] ), $query ) !== false ) {
$results[] = $icon;
continue;
}
// Search in tags.
if ( isset( $icon['tags'] ) && is_array( $icon['tags'] ) ) {
foreach ( $icon['tags'] as $tag ) {
if ( strpos( strtolower( $tag ), $query ) !== false ) {
$results[] = $icon;
break;
}
}
}
}
$total = count( $results );
// Apply offset and limit.
$results = array_slice( $results, $offset, $limit );
// Format results.
$formatted = array();
foreach ( $results as $icon ) {
$formatted[] = array(
'name' => $icon['name'],
'tags' => isset( $icon['tags'] ) ? $icon['tags'] : array(),
'category' => isset( $icon['category'] ) ? $icon['category'] : '',
'set' => $active_set,
'style' => $style,
);
}
return array(
'icons' => $formatted,
'total' => $total,
'set' => $active_set,
'style' => $style,
'styles' => MI_Icon_Sets::get_style_labels( $active_set ),
);
}
/**
* Get SVG content for a specific icon.
*
* @param string $slug Icon set slug.
* @param string $style Style slug.
* @param string $name Icon name.
* @return string|WP_Error SVG content or error.
*/
public function get_icon_svg( $slug, $style, $name ) {
// Validate inputs.
if ( ! MI_Icon_Sets::is_valid_slug( $slug ) ) {
return new WP_Error( 'invalid_set', __( 'Invalid icon set.', 'maple-icons' ) );
}
if ( ! MI_Icon_Sets::is_valid_style( $slug, $style ) ) {
return new WP_Error( 'invalid_style', __( 'Invalid icon style.', 'maple-icons' ) );
}
// Validate icon name.
if ( ! preg_match( '/^[a-z0-9\-_]+$/i', $name ) ) {
return new WP_Error( 'invalid_name', __( 'Invalid icon name.', 'maple-icons' ) );
}
$local_path = MI_Icon_Sets::get_local_path( $slug, $style, $name );
// Validate path is within icons directory (prevent path traversal).
$real_path = realpath( $local_path );
$allowed_base = realpath( MI_ICONS_DIR );
if ( false === $real_path ) {
return new WP_Error( 'not_found', __( 'Icon not found.', 'maple-icons' ) );
}
if ( false === $allowed_base || strpos( $real_path, $allowed_base ) !== 0 ) {
return new WP_Error( 'invalid_path', __( 'Invalid icon path.', 'maple-icons' ) );
}
if ( ! file_exists( $real_path ) ) {
return new WP_Error( 'not_found', __( 'Icon not found.', 'maple-icons' ) );
}
$svg = file_get_contents( $real_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
if ( false === $svg ) {
return new WP_Error( 'read_error', __( 'Could not read icon file.', 'maple-icons' ) );
}
return $svg;
}
/**
* Delete a downloaded set.
*
* @param string $slug Icon set slug.
* @return bool|WP_Error True on success, error on failure.
*/
public function delete_set( $slug ) {
if ( ! MI_Icon_Sets::is_valid_slug( $slug ) ) {
return new WP_Error( 'invalid_set', __( 'Invalid icon set.', 'maple-icons' ) );
}
if ( ! $this->is_downloaded( $slug ) ) {
return new WP_Error( 'not_downloaded', __( 'Icon set is not downloaded.', 'maple-icons' ) );
}
// Delete files.
$downloader = new MI_Downloader();
$deleted = $downloader->delete_set( $slug );
if ( ! $deleted ) {
return new WP_Error( 'delete_error', __( 'Could not delete icon files.', 'maple-icons' ) );
}
// Update settings.
$this->unmark_downloaded( $slug );
return true;
}
/**
* Get info about a downloaded set.
*
* @param string $slug Icon set slug.
* @return array|null Set info or null if not downloaded.
*/
public function get_downloaded_info( $slug ) {
$downloaded = $this->get_downloaded_sets();
return isset( $downloaded[ $slug ] ) ? $downloaded[ $slug ] : null;
}
/**
* Refresh settings from database.
*/
public function refresh() {
$this->load_settings();
}
}

View file

@ -0,0 +1,328 @@
<?php
/**
* Icon Sets class - Static definitions of all preset icon sets.
*
* @package MapleIcons
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Class MI_Icon_Sets
*
* Provides static definitions and utilities for preset icon sets.
*/
class MI_Icon_Sets {
/**
* Get all available preset icon sets.
*
* @return array Array of icon set configurations.
*/
public static function get_all() {
return array(
'heroicons' => array(
'slug' => 'heroicons',
'name' => 'Heroicons',
'version' => '2.1.1',
'license' => 'MIT',
'url' => 'https://heroicons.com',
'cdn_base' => 'https://cdn.jsdelivr.net/npm/heroicons@2.1.1/',
'styles' => array(
'outline' => array(
'path' => '24/outline',
'label' => 'Outline',
),
'solid' => array(
'path' => '24/solid',
'label' => 'Solid',
),
'mini' => array(
'path' => '20/solid',
'label' => 'Mini',
),
),
'default_style' => 'outline',
'viewbox' => '0 0 24 24',
'normalize' => false,
'color_fix' => false,
'description' => 'Beautiful hand-crafted SVG icons by the makers of Tailwind CSS.',
'icon_count' => 292,
),
'lucide' => array(
'slug' => 'lucide',
'name' => 'Lucide',
'version' => '0.303.0',
'license' => 'ISC',
'url' => 'https://lucide.dev',
'cdn_base' => 'https://cdn.jsdelivr.net/npm/lucide-static@0.303.0/',
'styles' => array(
'icons' => array(
'path' => 'icons',
'label' => 'Default',
),
),
'default_style' => 'icons',
'viewbox' => '0 0 24 24',
'normalize' => false,
'color_fix' => false,
'description' => 'Beautiful & consistent icon toolkit made by the community.',
'icon_count' => 1411,
),
'feather' => array(
'slug' => 'feather',
'name' => 'Feather',
'version' => '4.29.1',
'license' => 'MIT',
'url' => 'https://feathericons.com',
'cdn_base' => 'https://cdn.jsdelivr.net/npm/feather-icons@4.29.1/',
'styles' => array(
'icons' => array(
'path' => 'dist/icons',
'label' => 'Default',
),
),
'default_style' => 'icons',
'viewbox' => '0 0 24 24',
'normalize' => false,
'color_fix' => false,
'description' => 'Simply beautiful open source icons.',
'icon_count' => 287,
),
'phosphor' => array(
'slug' => 'phosphor',
'name' => 'Phosphor',
'version' => '2.1.1',
'license' => 'MIT',
'url' => 'https://phosphoricons.com',
'cdn_base' => 'https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2.1.1/',
'styles' => array(
'regular' => array(
'path' => 'assets/regular',
'label' => 'Regular',
),
'bold' => array(
'path' => 'assets/bold',
'label' => 'Bold',
),
'light' => array(
'path' => 'assets/light',
'label' => 'Light',
),
'thin' => array(
'path' => 'assets/thin',
'label' => 'Thin',
),
'fill' => array(
'path' => 'assets/fill',
'label' => 'Fill',
),
'duotone' => array(
'path' => 'assets/duotone',
'label' => 'Duotone',
),
),
'default_style' => 'regular',
'viewbox' => '0 0 256 256',
'normalize' => true, // Needs viewBox normalization to 24x24.
'color_fix' => false,
'description' => 'A flexible icon family for interfaces, diagrams, presentations, and more.',
'icon_count' => 1248,
),
'material' => array(
'slug' => 'material',
'name' => 'Material Design Icons',
'version' => '0.14.13',
'license' => 'Apache-2.0',
'url' => 'https://fonts.google.com/icons',
'cdn_base' => 'https://cdn.jsdelivr.net/npm/@material-design-icons/svg@0.14.13/',
'styles' => array(
'filled' => array(
'path' => 'filled',
'label' => 'Filled',
),
'outlined' => array(
'path' => 'outlined',
'label' => 'Outlined',
),
'round' => array(
'path' => 'round',
'label' => 'Round',
),
'sharp' => array(
'path' => 'sharp',
'label' => 'Sharp',
),
'two-tone' => array(
'path' => 'two-tone',
'label' => 'Two Tone',
),
),
'default_style' => 'filled',
'viewbox' => '0 0 24 24',
'normalize' => false,
'color_fix' => true, // Needs hardcoded color replacement.
'description' => 'Material Design Icons by Google. Beautiful, delightful, and easy to use.',
'icon_count' => 2189,
),
);
}
/**
* Get a specific icon set by slug.
*
* @param string $slug Icon set slug.
* @return array|null Icon set configuration or null if not found.
*/
public static function get( $slug ) {
$sets = self::get_all();
return isset( $sets[ $slug ] ) ? $sets[ $slug ] : null;
}
/**
* Get all available set slugs.
*
* @return array Array of set slugs.
*/
public static function get_slugs() {
return array_keys( self::get_all() );
}
/**
* Validate that a slug is a valid preset.
*
* @param string $slug Slug to validate.
* @return bool True if valid, false otherwise.
*/
public static function is_valid_slug( $slug ) {
return in_array( $slug, self::get_slugs(), true );
}
/**
* Validate that a style is valid for a given set.
*
* @param string $slug Icon set slug.
* @param string $style Style slug.
* @return bool True if valid, false otherwise.
*/
public static function is_valid_style( $slug, $style ) {
$set = self::get( $slug );
if ( ! $set ) {
return false;
}
return isset( $set['styles'][ $style ] );
}
/**
* Get the CDN URL for a specific icon.
*
* @param string $slug Icon set slug.
* @param string $style Style slug.
* @param string $name Icon name.
* @return string|null CDN URL or null if invalid.
*/
public static function get_cdn_url( $slug, $style, $name ) {
$set = self::get( $slug );
if ( ! $set || ! isset( $set['styles'][ $style ] ) ) {
return null;
}
$style_config = $set['styles'][ $style ];
return $set['cdn_base'] . $style_config['path'] . '/' . $name . '.svg';
}
/**
* Get the local file path for a specific icon.
*
* @param string $slug Icon set slug.
* @param string $style Style slug.
* @param string $name Icon name.
* @return string Local file path.
*/
public static function get_local_path( $slug, $style, $name ) {
return MI_ICONS_DIR . $slug . '/' . $style . '/' . $name . '.svg';
}
/**
* Get the local URL for a specific icon.
*
* @param string $slug Icon set slug.
* @param string $style Style slug.
* @param string $name Icon name.
* @return string Local URL.
*/
public static function get_local_url( $slug, $style, $name ) {
return MI_ICONS_URL . $slug . '/' . $style . '/' . $name . '.svg';
}
/**
* Get the manifest file path for a set.
*
* @param string $slug Icon set slug.
* @return string Manifest file path.
*/
public static function get_manifest_path( $slug ) {
return MI_PRESETS_DIR . $slug . '.json';
}
/**
* Load and parse a manifest file.
*
* @param string $slug Icon set slug.
* @return array|WP_Error Manifest data or error.
*/
public static function load_manifest( $slug ) {
$path = self::get_manifest_path( $slug );
if ( ! file_exists( $path ) ) {
return new WP_Error(
'manifest_not_found',
sprintf(
/* translators: %s: Icon set name */
__( 'Manifest file not found for %s.', 'maple-icons' ),
$slug
)
);
}
$content = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
if ( false === $content ) {
return new WP_Error(
'manifest_read_error',
__( 'Could not read manifest file.', 'maple-icons' )
);
}
$manifest = json_decode( $content, true );
if ( json_last_error() !== JSON_ERROR_NONE ) {
return new WP_Error(
'manifest_parse_error',
__( 'Could not parse manifest file.', 'maple-icons' )
);
}
return $manifest;
}
/**
* Get style labels for a set.
*
* @param string $slug Icon set slug.
* @return array Array of style labels.
*/
public static function get_style_labels( $slug ) {
$set = self::get( $slug );
if ( ! $set ) {
return array();
}
$labels = array();
foreach ( $set['styles'] as $style_slug => $style_config ) {
$labels[ $style_slug ] = $style_config['label'];
}
return $labels;
}
}

View file

@ -0,0 +1,5 @@
<?php
// Silence is golden.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}

View file

@ -0,0 +1,5 @@
<?php
// Silence is golden.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}

View file

@ -0,0 +1,5 @@
<?php
// Silence is golden.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}

View file

@ -0,0 +1,230 @@
<?php
/**
* Plugin Name: Maple Icons
* Plugin URI: https://jetrails.com/maple-icons
* Description: Insert beautiful open-source icons into your content with a Gutenberg block. Download icon sets from CDN and serve locally.
* Version: 1.0.0
* Requires at least: 6.5
* Requires PHP: 7.4
* Author: JetRails
* Author URI: https://jetrails.com
* License: GPL-2.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: maple-icons
* Domain Path: /languages
*
* @package MapleIcons
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Plugin constants.
*/
define( 'MI_VERSION', '1.0.0' );
define( 'MI_PLUGIN_FILE', __FILE__ );
define( 'MI_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'MI_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'MI_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
define( 'MI_ICONS_DIR', WP_CONTENT_DIR . '/maple-icons/' );
define( 'MI_ICONS_URL', content_url( '/maple-icons/' ) );
define( 'MI_PRESETS_DIR', MI_PLUGIN_DIR . 'presets/' );
// Limits.
define( 'MI_DOWNLOAD_BATCH_SIZE', 50 );
define( 'MI_SEARCH_LIMIT', 50 );
define( 'MI_DOWNLOAD_TIMEOUT', 10 );
/**
* Autoloader for plugin classes.
*
* @param string $class_name The class name to load.
*/
function mi_autoloader( $class_name ) {
// Only handle our classes.
if ( strpos( $class_name, 'MI_' ) !== 0 ) {
return;
}
// Convert class name to filename.
$class_file = 'class-' . strtolower( str_replace( '_', '-', $class_name ) ) . '.php';
$class_path = MI_PLUGIN_DIR . 'includes/' . $class_file;
if ( file_exists( $class_path ) ) {
require_once $class_path;
}
}
spl_autoload_register( 'mi_autoloader' );
/**
* Plugin activation hook.
*/
function mi_activate() {
// Check WordPress version.
if ( version_compare( get_bloginfo( 'version' ), '6.5', '<' ) ) {
deactivate_plugins( MI_PLUGIN_BASENAME );
wp_die(
esc_html__( 'Maple Icons requires WordPress 6.5 or higher.', 'maple-icons' ),
esc_html__( 'Plugin Activation Error', 'maple-icons' ),
array( 'back_link' => true )
);
}
// Create icons directory if it doesn't exist.
if ( ! file_exists( MI_ICONS_DIR ) ) {
wp_mkdir_p( MI_ICONS_DIR );
}
// Initialize default settings.
$default_settings = array(
'active_set' => '',
'downloaded_sets' => array(),
);
if ( false === get_option( 'maple_icons_settings' ) ) {
add_option( 'maple_icons_settings', $default_settings );
}
// Flush rewrite rules.
flush_rewrite_rules();
}
register_activation_hook( MI_PLUGIN_FILE, 'mi_activate' );
/**
* Plugin deactivation hook.
*/
function mi_deactivate() {
// Clean up transients.
global $wpdb;
$wpdb->query(
"DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_mi_%' OR option_name LIKE '_transient_timeout_mi_%'"
);
flush_rewrite_rules();
}
register_deactivation_hook( MI_PLUGIN_FILE, 'mi_deactivate' );
/**
* Check WordPress version on admin init (in case WP was downgraded).
*/
function mi_check_wp_version() {
if ( version_compare( get_bloginfo( 'version' ), '6.5', '<' ) ) {
deactivate_plugins( MI_PLUGIN_BASENAME );
add_action( 'admin_notices', 'mi_wp_version_notice' );
}
}
add_action( 'admin_init', 'mi_check_wp_version' );
/**
* Display admin notice for WordPress version requirement.
*/
function mi_wp_version_notice() {
echo '<div class="error"><p>';
esc_html_e( 'Maple Icons has been deactivated. It requires WordPress 6.5 or higher.', 'maple-icons' );
echo '</p></div>';
}
/**
* Initialize the plugin.
*/
function mi_init() {
// Load text domain.
load_plugin_textdomain( 'maple-icons', false, dirname( MI_PLUGIN_BASENAME ) . '/languages' );
// Register the Gutenberg block.
mi_register_block();
}
add_action( 'init', 'mi_init' );
/**
* Register the Maple Icons Gutenberg block.
*/
function mi_register_block() {
// Check if build directory exists.
$block_path = MI_PLUGIN_DIR . 'build';
if ( ! file_exists( $block_path ) ) {
return;
}
register_block_type( $block_path );
}
/**
* Enqueue block editor assets.
*/
function mi_enqueue_block_assets() {
// Only load in block editor.
if ( ! is_admin() ) {
return;
}
$screen = get_current_screen();
if ( ! $screen || ! $screen->is_block_editor() ) {
return;
}
// Localize script data for the block editor.
$active_set = '';
$settings = get_option( 'maple_icons_settings', array() );
if ( ! empty( $settings['active_set'] ) ) {
$active_set = $settings['active_set'];
}
wp_localize_script(
'maple-icon-editor-script',
'miBlock',
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'mi_block_nonce' ),
'activeSet' => $active_set,
'settingsUrl' => admin_url( 'options-general.php?page=maple-icons' ),
)
);
}
add_action( 'enqueue_block_editor_assets', 'mi_enqueue_block_assets' );
/**
* Initialize admin functionality.
*/
function mi_admin_init() {
// Initialize admin page.
new MI_Admin_Page();
// Initialize AJAX handler.
new MI_Ajax_Handler();
}
add_action( 'admin_init', 'mi_admin_init' );
/**
* Add settings link to plugin action links.
*
* @param array $links Plugin action links.
* @return array Modified action links.
*/
function mi_add_action_links( $links ) {
$settings_link = sprintf(
'<a href="%s">%s</a>',
admin_url( 'options-general.php?page=maple-icons' ),
__( 'Settings', 'maple-icons' )
);
array_unshift( $links, $settings_link );
return $links;
}
add_filter( 'plugin_action_links_' . MI_PLUGIN_BASENAME, 'mi_add_action_links' );
/**
* Declare WooCommerce HPOS compatibility.
*/
function mi_declare_hpos_compatibility() {
if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility(
'custom_order_tables',
MI_PLUGIN_FILE,
true
);
}
}
add_action( 'before_woocommerce_init', 'mi_declare_hpos_compatibility' );

View file

@ -0,0 +1,293 @@
{
"slug": "feather",
"name": "Feather",
"version": "4.29.1",
"icons": [
{"name": "activity", "tags": ["pulse", "health", "action", "motion"], "category": "health", "styles": ["icons"]},
{"name": "airplay", "tags": ["stream", "cast", "mirroring"], "category": "devices", "styles": ["icons"]},
{"name": "alert-circle", "tags": ["warning", "alert", "danger"], "category": "alerts", "styles": ["icons"]},
{"name": "alert-octagon", "tags": ["warning", "alert", "danger", "stop"], "category": "alerts", "styles": ["icons"]},
{"name": "alert-triangle", "tags": ["warning", "alert", "danger"], "category": "alerts", "styles": ["icons"]},
{"name": "align-center", "tags": ["text", "alignment", "center"], "category": "text", "styles": ["icons"]},
{"name": "align-justify", "tags": ["text", "alignment", "justify"], "category": "text", "styles": ["icons"]},
{"name": "align-left", "tags": ["text", "alignment", "left"], "category": "text", "styles": ["icons"]},
{"name": "align-right", "tags": ["text", "alignment", "right"], "category": "text", "styles": ["icons"]},
{"name": "anchor", "tags": ["link", "nav", "navigation"], "category": "navigation", "styles": ["icons"]},
{"name": "aperture", "tags": ["camera", "photo", "lens"], "category": "photography", "styles": ["icons"]},
{"name": "archive", "tags": ["box", "storage", "backup"], "category": "files", "styles": ["icons"]},
{"name": "arrow-down", "tags": ["direction", "down"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-down-circle", "tags": ["direction", "down", "circle"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-down-left", "tags": ["direction", "diagonal"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-down-right", "tags": ["direction", "diagonal"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-left", "tags": ["direction", "back", "previous"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-left-circle", "tags": ["direction", "back", "circle"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-right", "tags": ["direction", "forward", "next"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-right-circle", "tags": ["direction", "forward", "circle"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-up", "tags": ["direction", "up"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-up-circle", "tags": ["direction", "up", "circle"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-up-left", "tags": ["direction", "diagonal"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-up-right", "tags": ["direction", "diagonal"], "category": "arrows", "styles": ["icons"]},
{"name": "at-sign", "tags": ["email", "mention", "at"], "category": "communication", "styles": ["icons"]},
{"name": "award", "tags": ["achievement", "badge", "trophy"], "category": "objects", "styles": ["icons"]},
{"name": "bar-chart", "tags": ["analytics", "statistics", "graph"], "category": "charts", "styles": ["icons"]},
{"name": "bar-chart-2", "tags": ["analytics", "statistics", "graph"], "category": "charts", "styles": ["icons"]},
{"name": "battery", "tags": ["power", "energy", "charge"], "category": "devices", "styles": ["icons"]},
{"name": "battery-charging", "tags": ["power", "energy", "charge"], "category": "devices", "styles": ["icons"]},
{"name": "bell", "tags": ["notification", "alert", "alarm"], "category": "notifications", "styles": ["icons"]},
{"name": "bell-off", "tags": ["notification", "mute", "silent"], "category": "notifications", "styles": ["icons"]},
{"name": "bluetooth", "tags": ["wireless", "connection"], "category": "connectivity", "styles": ["icons"]},
{"name": "bold", "tags": ["text", "format", "strong"], "category": "text", "styles": ["icons"]},
{"name": "book", "tags": ["read", "library", "education"], "category": "objects", "styles": ["icons"]},
{"name": "book-open", "tags": ["read", "library", "education"], "category": "objects", "styles": ["icons"]},
{"name": "bookmark", "tags": ["save", "favorite", "tag"], "category": "actions", "styles": ["icons"]},
{"name": "box", "tags": ["package", "container", "storage"], "category": "objects", "styles": ["icons"]},
{"name": "briefcase", "tags": ["work", "job", "business"], "category": "objects", "styles": ["icons"]},
{"name": "calendar", "tags": ["date", "schedule", "event"], "category": "time", "styles": ["icons"]},
{"name": "camera", "tags": ["photo", "picture", "image"], "category": "devices", "styles": ["icons"]},
{"name": "camera-off", "tags": ["photo", "disabled", "off"], "category": "devices", "styles": ["icons"]},
{"name": "cast", "tags": ["stream", "broadcast", "chromecast"], "category": "devices", "styles": ["icons"]},
{"name": "check", "tags": ["done", "complete", "success"], "category": "actions", "styles": ["icons"]},
{"name": "check-circle", "tags": ["done", "complete", "success"], "category": "actions", "styles": ["icons"]},
{"name": "check-square", "tags": ["done", "complete", "checkbox"], "category": "actions", "styles": ["icons"]},
{"name": "chevron-down", "tags": ["expand", "dropdown", "arrow"], "category": "arrows", "styles": ["icons"]},
{"name": "chevron-left", "tags": ["back", "previous", "arrow"], "category": "arrows", "styles": ["icons"]},
{"name": "chevron-right", "tags": ["forward", "next", "arrow"], "category": "arrows", "styles": ["icons"]},
{"name": "chevron-up", "tags": ["collapse", "up", "arrow"], "category": "arrows", "styles": ["icons"]},
{"name": "chevrons-down", "tags": ["expand", "more", "double"], "category": "arrows", "styles": ["icons"]},
{"name": "chevrons-left", "tags": ["back", "previous", "double"], "category": "arrows", "styles": ["icons"]},
{"name": "chevrons-right", "tags": ["forward", "next", "double"], "category": "arrows", "styles": ["icons"]},
{"name": "chevrons-up", "tags": ["collapse", "less", "double"], "category": "arrows", "styles": ["icons"]},
{"name": "chrome", "tags": ["browser", "google", "web"], "category": "brands", "styles": ["icons"]},
{"name": "circle", "tags": ["shape", "dot", "record"], "category": "shapes", "styles": ["icons"]},
{"name": "clipboard", "tags": ["copy", "paste", "board"], "category": "actions", "styles": ["icons"]},
{"name": "clock", "tags": ["time", "watch", "schedule"], "category": "time", "styles": ["icons"]},
{"name": "cloud", "tags": ["weather", "storage", "sky"], "category": "weather", "styles": ["icons"]},
{"name": "cloud-drizzle", "tags": ["weather", "rain", "drizzle"], "category": "weather", "styles": ["icons"]},
{"name": "cloud-lightning", "tags": ["weather", "storm", "thunder"], "category": "weather", "styles": ["icons"]},
{"name": "cloud-off", "tags": ["weather", "offline", "disconnect"], "category": "weather", "styles": ["icons"]},
{"name": "cloud-rain", "tags": ["weather", "rain", "precipitation"], "category": "weather", "styles": ["icons"]},
{"name": "cloud-snow", "tags": ["weather", "snow", "winter"], "category": "weather", "styles": ["icons"]},
{"name": "code", "tags": ["programming", "development", "html"], "category": "development", "styles": ["icons"]},
{"name": "codepen", "tags": ["brand", "code", "development"], "category": "brands", "styles": ["icons"]},
{"name": "codesandbox", "tags": ["brand", "code", "development"], "category": "brands", "styles": ["icons"]},
{"name": "coffee", "tags": ["drink", "cup", "cafe"], "category": "objects", "styles": ["icons"]},
{"name": "columns", "tags": ["layout", "grid", "table"], "category": "layout", "styles": ["icons"]},
{"name": "command", "tags": ["keyboard", "mac", "key"], "category": "devices", "styles": ["icons"]},
{"name": "compass", "tags": ["navigation", "direction", "explore"], "category": "navigation", "styles": ["icons"]},
{"name": "copy", "tags": ["duplicate", "clone", "clipboard"], "category": "actions", "styles": ["icons"]},
{"name": "corner-down-left", "tags": ["arrow", "return", "enter"], "category": "arrows", "styles": ["icons"]},
{"name": "corner-down-right", "tags": ["arrow", "direction"], "category": "arrows", "styles": ["icons"]},
{"name": "corner-left-down", "tags": ["arrow", "direction"], "category": "arrows", "styles": ["icons"]},
{"name": "corner-left-up", "tags": ["arrow", "direction"], "category": "arrows", "styles": ["icons"]},
{"name": "corner-right-down", "tags": ["arrow", "direction"], "category": "arrows", "styles": ["icons"]},
{"name": "corner-right-up", "tags": ["arrow", "direction"], "category": "arrows", "styles": ["icons"]},
{"name": "corner-up-left", "tags": ["arrow", "direction"], "category": "arrows", "styles": ["icons"]},
{"name": "corner-up-right", "tags": ["arrow", "direction"], "category": "arrows", "styles": ["icons"]},
{"name": "cpu", "tags": ["processor", "chip", "computer"], "category": "devices", "styles": ["icons"]},
{"name": "credit-card", "tags": ["payment", "card", "money"], "category": "commerce", "styles": ["icons"]},
{"name": "crop", "tags": ["image", "edit", "resize"], "category": "editing", "styles": ["icons"]},
{"name": "crosshair", "tags": ["target", "focus", "aim"], "category": "ui", "styles": ["icons"]},
{"name": "database", "tags": ["storage", "data", "server"], "category": "development", "styles": ["icons"]},
{"name": "delete", "tags": ["remove", "backspace", "erase"], "category": "actions", "styles": ["icons"]},
{"name": "disc", "tags": ["cd", "music", "record"], "category": "media", "styles": ["icons"]},
{"name": "divide", "tags": ["math", "division", "split"], "category": "math", "styles": ["icons"]},
{"name": "divide-circle", "tags": ["math", "division", "split"], "category": "math", "styles": ["icons"]},
{"name": "divide-square", "tags": ["math", "division", "split"], "category": "math", "styles": ["icons"]},
{"name": "dollar-sign", "tags": ["money", "currency", "payment"], "category": "commerce", "styles": ["icons"]},
{"name": "download", "tags": ["save", "export", "download"], "category": "actions", "styles": ["icons"]},
{"name": "download-cloud", "tags": ["save", "cloud", "download"], "category": "actions", "styles": ["icons"]},
{"name": "dribbble", "tags": ["brand", "design", "social"], "category": "brands", "styles": ["icons"]},
{"name": "droplet", "tags": ["water", "liquid", "drop"], "category": "weather", "styles": ["icons"]},
{"name": "edit", "tags": ["pencil", "write", "modify"], "category": "actions", "styles": ["icons"]},
{"name": "edit-2", "tags": ["pencil", "write", "modify"], "category": "actions", "styles": ["icons"]},
{"name": "edit-3", "tags": ["pencil", "write", "modify"], "category": "actions", "styles": ["icons"]},
{"name": "external-link", "tags": ["link", "open", "new window"], "category": "actions", "styles": ["icons"]},
{"name": "eye", "tags": ["view", "visible", "show"], "category": "actions", "styles": ["icons"]},
{"name": "eye-off", "tags": ["hide", "invisible", "hidden"], "category": "actions", "styles": ["icons"]},
{"name": "facebook", "tags": ["brand", "social", "network"], "category": "brands", "styles": ["icons"]},
{"name": "fast-forward", "tags": ["media", "skip", "forward"], "category": "media", "styles": ["icons"]},
{"name": "feather", "tags": ["brand", "logo", "icon"], "category": "brands", "styles": ["icons"]},
{"name": "figma", "tags": ["brand", "design", "tool"], "category": "brands", "styles": ["icons"]},
{"name": "file", "tags": ["document", "page", "paper"], "category": "files", "styles": ["icons"]},
{"name": "file-minus", "tags": ["document", "remove", "delete"], "category": "files", "styles": ["icons"]},
{"name": "file-plus", "tags": ["document", "add", "new"], "category": "files", "styles": ["icons"]},
{"name": "file-text", "tags": ["document", "text", "content"], "category": "files", "styles": ["icons"]},
{"name": "film", "tags": ["video", "movie", "cinema"], "category": "media", "styles": ["icons"]},
{"name": "filter", "tags": ["funnel", "sort", "filter"], "category": "actions", "styles": ["icons"]},
{"name": "flag", "tags": ["report", "mark", "country"], "category": "objects", "styles": ["icons"]},
{"name": "folder", "tags": ["directory", "files", "organize"], "category": "files", "styles": ["icons"]},
{"name": "folder-minus", "tags": ["directory", "remove", "delete"], "category": "files", "styles": ["icons"]},
{"name": "folder-plus", "tags": ["directory", "add", "new"], "category": "files", "styles": ["icons"]},
{"name": "framer", "tags": ["brand", "design", "tool"], "category": "brands", "styles": ["icons"]},
{"name": "frown", "tags": ["sad", "unhappy", "emoji"], "category": "emoji", "styles": ["icons"]},
{"name": "gift", "tags": ["present", "reward", "surprise"], "category": "objects", "styles": ["icons"]},
{"name": "git-branch", "tags": ["version", "control", "code"], "category": "development", "styles": ["icons"]},
{"name": "git-commit", "tags": ["version", "control", "code"], "category": "development", "styles": ["icons"]},
{"name": "git-merge", "tags": ["version", "control", "code"], "category": "development", "styles": ["icons"]},
{"name": "git-pull-request", "tags": ["version", "control", "code"], "category": "development", "styles": ["icons"]},
{"name": "github", "tags": ["brand", "code", "repository"], "category": "brands", "styles": ["icons"]},
{"name": "gitlab", "tags": ["brand", "code", "repository"], "category": "brands", "styles": ["icons"]},
{"name": "globe", "tags": ["world", "earth", "web"], "category": "objects", "styles": ["icons"]},
{"name": "grid", "tags": ["layout", "squares", "dashboard"], "category": "layout", "styles": ["icons"]},
{"name": "hard-drive", "tags": ["storage", "disk", "data"], "category": "devices", "styles": ["icons"]},
{"name": "hash", "tags": ["tag", "hashtag", "number"], "category": "communication", "styles": ["icons"]},
{"name": "headphones", "tags": ["audio", "music", "listen"], "category": "devices", "styles": ["icons"]},
{"name": "heart", "tags": ["love", "favorite", "like"], "category": "objects", "styles": ["icons"]},
{"name": "help-circle", "tags": ["question", "help", "support"], "category": "actions", "styles": ["icons"]},
{"name": "hexagon", "tags": ["shape", "polygon", "6"], "category": "shapes", "styles": ["icons"]},
{"name": "home", "tags": ["house", "main", "dashboard"], "category": "navigation", "styles": ["icons"]},
{"name": "image", "tags": ["picture", "photo", "gallery"], "category": "media", "styles": ["icons"]},
{"name": "inbox", "tags": ["email", "messages", "mail"], "category": "communication", "styles": ["icons"]},
{"name": "info", "tags": ["information", "help", "about"], "category": "actions", "styles": ["icons"]},
{"name": "instagram", "tags": ["brand", "social", "photo"], "category": "brands", "styles": ["icons"]},
{"name": "italic", "tags": ["text", "format", "style"], "category": "text", "styles": ["icons"]},
{"name": "key", "tags": ["password", "security", "lock"], "category": "security", "styles": ["icons"]},
{"name": "layers", "tags": ["stack", "levels", "depth"], "category": "design", "styles": ["icons"]},
{"name": "layout", "tags": ["grid", "template", "design"], "category": "layout", "styles": ["icons"]},
{"name": "life-buoy", "tags": ["help", "support", "rescue"], "category": "objects", "styles": ["icons"]},
{"name": "link", "tags": ["url", "chain", "connect"], "category": "actions", "styles": ["icons"]},
{"name": "link-2", "tags": ["url", "chain", "connect"], "category": "actions", "styles": ["icons"]},
{"name": "linkedin", "tags": ["brand", "social", "professional"], "category": "brands", "styles": ["icons"]},
{"name": "list", "tags": ["menu", "items", "bullet"], "category": "text", "styles": ["icons"]},
{"name": "loader", "tags": ["loading", "spinner", "wait"], "category": "ui", "styles": ["icons"]},
{"name": "lock", "tags": ["security", "private", "password"], "category": "security", "styles": ["icons"]},
{"name": "log-in", "tags": ["login", "signin", "enter"], "category": "actions", "styles": ["icons"]},
{"name": "log-out", "tags": ["logout", "signout", "exit"], "category": "actions", "styles": ["icons"]},
{"name": "mail", "tags": ["email", "message", "envelope"], "category": "communication", "styles": ["icons"]},
{"name": "map", "tags": ["location", "navigation", "directions"], "category": "maps", "styles": ["icons"]},
{"name": "map-pin", "tags": ["location", "marker", "place"], "category": "maps", "styles": ["icons"]},
{"name": "maximize", "tags": ["fullscreen", "expand", "resize"], "category": "actions", "styles": ["icons"]},
{"name": "maximize-2", "tags": ["fullscreen", "expand", "resize"], "category": "actions", "styles": ["icons"]},
{"name": "meh", "tags": ["neutral", "emoji", "face"], "category": "emoji", "styles": ["icons"]},
{"name": "menu", "tags": ["hamburger", "navigation", "bars"], "category": "navigation", "styles": ["icons"]},
{"name": "message-circle", "tags": ["chat", "comment", "conversation"], "category": "communication", "styles": ["icons"]},
{"name": "message-square", "tags": ["chat", "comment", "conversation"], "category": "communication", "styles": ["icons"]},
{"name": "mic", "tags": ["microphone", "audio", "record"], "category": "media", "styles": ["icons"]},
{"name": "mic-off", "tags": ["microphone", "mute", "silent"], "category": "media", "styles": ["icons"]},
{"name": "minimize", "tags": ["collapse", "shrink", "resize"], "category": "actions", "styles": ["icons"]},
{"name": "minimize-2", "tags": ["collapse", "shrink", "resize"], "category": "actions", "styles": ["icons"]},
{"name": "minus", "tags": ["subtract", "remove", "delete"], "category": "math", "styles": ["icons"]},
{"name": "minus-circle", "tags": ["subtract", "remove", "delete"], "category": "math", "styles": ["icons"]},
{"name": "minus-square", "tags": ["subtract", "remove", "delete"], "category": "math", "styles": ["icons"]},
{"name": "monitor", "tags": ["screen", "display", "desktop"], "category": "devices", "styles": ["icons"]},
{"name": "moon", "tags": ["dark", "night", "theme"], "category": "weather", "styles": ["icons"]},
{"name": "more-horizontal", "tags": ["menu", "options", "dots"], "category": "ui", "styles": ["icons"]},
{"name": "more-vertical", "tags": ["menu", "options", "dots"], "category": "ui", "styles": ["icons"]},
{"name": "mouse-pointer", "tags": ["cursor", "click", "select"], "category": "ui", "styles": ["icons"]},
{"name": "move", "tags": ["drag", "reorder", "arrows"], "category": "actions", "styles": ["icons"]},
{"name": "music", "tags": ["audio", "sound", "note"], "category": "media", "styles": ["icons"]},
{"name": "navigation", "tags": ["direction", "arrow", "location"], "category": "navigation", "styles": ["icons"]},
{"name": "navigation-2", "tags": ["direction", "arrow", "location"], "category": "navigation", "styles": ["icons"]},
{"name": "octagon", "tags": ["shape", "stop", "polygon"], "category": "shapes", "styles": ["icons"]},
{"name": "package", "tags": ["box", "delivery", "shipping"], "category": "objects", "styles": ["icons"]},
{"name": "paperclip", "tags": ["attachment", "file", "clip"], "category": "actions", "styles": ["icons"]},
{"name": "pause", "tags": ["media", "stop", "wait"], "category": "media", "styles": ["icons"]},
{"name": "pause-circle", "tags": ["media", "stop", "wait"], "category": "media", "styles": ["icons"]},
{"name": "pen-tool", "tags": ["design", "draw", "vector"], "category": "design", "styles": ["icons"]},
{"name": "percent", "tags": ["discount", "math", "percentage"], "category": "math", "styles": ["icons"]},
{"name": "phone", "tags": ["call", "contact", "mobile"], "category": "communication", "styles": ["icons"]},
{"name": "phone-call", "tags": ["call", "ringing", "incoming"], "category": "communication", "styles": ["icons"]},
{"name": "phone-forwarded", "tags": ["call", "forward", "redirect"], "category": "communication", "styles": ["icons"]},
{"name": "phone-incoming", "tags": ["call", "receive", "answer"], "category": "communication", "styles": ["icons"]},
{"name": "phone-missed", "tags": ["call", "missed", "unanswered"], "category": "communication", "styles": ["icons"]},
{"name": "phone-off", "tags": ["call", "hang up", "end"], "category": "communication", "styles": ["icons"]},
{"name": "phone-outgoing", "tags": ["call", "dial", "outbound"], "category": "communication", "styles": ["icons"]},
{"name": "pie-chart", "tags": ["analytics", "statistics", "data"], "category": "charts", "styles": ["icons"]},
{"name": "play", "tags": ["media", "start", "video"], "category": "media", "styles": ["icons"]},
{"name": "play-circle", "tags": ["media", "start", "video"], "category": "media", "styles": ["icons"]},
{"name": "plus", "tags": ["add", "create", "new"], "category": "math", "styles": ["icons"]},
{"name": "plus-circle", "tags": ["add", "create", "new"], "category": "math", "styles": ["icons"]},
{"name": "plus-square", "tags": ["add", "create", "new"], "category": "math", "styles": ["icons"]},
{"name": "pocket", "tags": ["save", "bookmark", "read later"], "category": "brands", "styles": ["icons"]},
{"name": "power", "tags": ["on", "off", "shutdown"], "category": "devices", "styles": ["icons"]},
{"name": "printer", "tags": ["print", "document", "paper"], "category": "devices", "styles": ["icons"]},
{"name": "radio", "tags": ["audio", "broadcast", "music"], "category": "media", "styles": ["icons"]},
{"name": "refresh-ccw", "tags": ["reload", "sync", "update"], "category": "actions", "styles": ["icons"]},
{"name": "refresh-cw", "tags": ["reload", "sync", "update"], "category": "actions", "styles": ["icons"]},
{"name": "repeat", "tags": ["loop", "replay", "refresh"], "category": "media", "styles": ["icons"]},
{"name": "rewind", "tags": ["media", "back", "previous"], "category": "media", "styles": ["icons"]},
{"name": "rotate-ccw", "tags": ["undo", "rotate", "turn"], "category": "actions", "styles": ["icons"]},
{"name": "rotate-cw", "tags": ["redo", "rotate", "turn"], "category": "actions", "styles": ["icons"]},
{"name": "rss", "tags": ["feed", "subscribe", "blog"], "category": "communication", "styles": ["icons"]},
{"name": "save", "tags": ["disk", "floppy", "store"], "category": "actions", "styles": ["icons"]},
{"name": "scissors", "tags": ["cut", "trim", "edit"], "category": "actions", "styles": ["icons"]},
{"name": "search", "tags": ["find", "magnify", "look"], "category": "actions", "styles": ["icons"]},
{"name": "send", "tags": ["message", "email", "submit"], "category": "communication", "styles": ["icons"]},
{"name": "server", "tags": ["hosting", "database", "backend"], "category": "devices", "styles": ["icons"]},
{"name": "settings", "tags": ["gear", "cog", "preferences"], "category": "ui", "styles": ["icons"]},
{"name": "share", "tags": ["social", "send", "forward"], "category": "actions", "styles": ["icons"]},
{"name": "share-2", "tags": ["social", "send", "network"], "category": "actions", "styles": ["icons"]},
{"name": "shield", "tags": ["security", "protection", "safe"], "category": "security", "styles": ["icons"]},
{"name": "shield-off", "tags": ["security", "unprotected", "unsafe"], "category": "security", "styles": ["icons"]},
{"name": "shopping-bag", "tags": ["shop", "purchase", "buy"], "category": "commerce", "styles": ["icons"]},
{"name": "shopping-cart", "tags": ["shop", "cart", "ecommerce"], "category": "commerce", "styles": ["icons"]},
{"name": "shuffle", "tags": ["random", "mix", "music"], "category": "media", "styles": ["icons"]},
{"name": "sidebar", "tags": ["layout", "panel", "navigation"], "category": "layout", "styles": ["icons"]},
{"name": "skip-back", "tags": ["media", "previous", "rewind"], "category": "media", "styles": ["icons"]},
{"name": "skip-forward", "tags": ["media", "next", "forward"], "category": "media", "styles": ["icons"]},
{"name": "slack", "tags": ["brand", "chat", "communication"], "category": "brands", "styles": ["icons"]},
{"name": "slash", "tags": ["ban", "cancel", "disabled"], "category": "ui", "styles": ["icons"]},
{"name": "sliders", "tags": ["settings", "controls", "adjust"], "category": "ui", "styles": ["icons"]},
{"name": "smartphone", "tags": ["phone", "mobile", "device"], "category": "devices", "styles": ["icons"]},
{"name": "smile", "tags": ["happy", "emoji", "face"], "category": "emoji", "styles": ["icons"]},
{"name": "speaker", "tags": ["audio", "sound", "volume"], "category": "media", "styles": ["icons"]},
{"name": "square", "tags": ["shape", "box", "rectangle"], "category": "shapes", "styles": ["icons"]},
{"name": "star", "tags": ["favorite", "rating", "bookmark"], "category": "objects", "styles": ["icons"]},
{"name": "stop-circle", "tags": ["media", "stop", "end"], "category": "media", "styles": ["icons"]},
{"name": "sun", "tags": ["light", "day", "brightness"], "category": "weather", "styles": ["icons"]},
{"name": "sunrise", "tags": ["morning", "dawn", "sun"], "category": "weather", "styles": ["icons"]},
{"name": "sunset", "tags": ["evening", "dusk", "sun"], "category": "weather", "styles": ["icons"]},
{"name": "tablet", "tags": ["device", "ipad", "screen"], "category": "devices", "styles": ["icons"]},
{"name": "tag", "tags": ["label", "category", "price"], "category": "commerce", "styles": ["icons"]},
{"name": "target", "tags": ["goal", "aim", "focus"], "category": "ui", "styles": ["icons"]},
{"name": "terminal", "tags": ["console", "command", "code"], "category": "development", "styles": ["icons"]},
{"name": "thermometer", "tags": ["temperature", "weather", "health"], "category": "weather", "styles": ["icons"]},
{"name": "thumbs-down", "tags": ["dislike", "bad", "negative"], "category": "actions", "styles": ["icons"]},
{"name": "thumbs-up", "tags": ["like", "good", "positive"], "category": "actions", "styles": ["icons"]},
{"name": "toggle-left", "tags": ["switch", "off", "disable"], "category": "ui", "styles": ["icons"]},
{"name": "toggle-right", "tags": ["switch", "on", "enable"], "category": "ui", "styles": ["icons"]},
{"name": "tool", "tags": ["wrench", "settings", "repair"], "category": "objects", "styles": ["icons"]},
{"name": "trash", "tags": ["delete", "remove", "bin"], "category": "actions", "styles": ["icons"]},
{"name": "trash-2", "tags": ["delete", "remove", "bin"], "category": "actions", "styles": ["icons"]},
{"name": "trello", "tags": ["brand", "project", "kanban"], "category": "brands", "styles": ["icons"]},
{"name": "trending-down", "tags": ["chart", "decrease", "analytics"], "category": "charts", "styles": ["icons"]},
{"name": "trending-up", "tags": ["chart", "increase", "analytics"], "category": "charts", "styles": ["icons"]},
{"name": "triangle", "tags": ["shape", "polygon", "warning"], "category": "shapes", "styles": ["icons"]},
{"name": "truck", "tags": ["delivery", "shipping", "transport"], "category": "objects", "styles": ["icons"]},
{"name": "tv", "tags": ["television", "screen", "display"], "category": "devices", "styles": ["icons"]},
{"name": "twitch", "tags": ["brand", "streaming", "gaming"], "category": "brands", "styles": ["icons"]},
{"name": "twitter", "tags": ["brand", "social", "network"], "category": "brands", "styles": ["icons"]},
{"name": "type", "tags": ["text", "font", "typography"], "category": "text", "styles": ["icons"]},
{"name": "umbrella", "tags": ["weather", "rain", "protection"], "category": "weather", "styles": ["icons"]},
{"name": "underline", "tags": ["text", "format", "style"], "category": "text", "styles": ["icons"]},
{"name": "unlock", "tags": ["security", "open", "access"], "category": "security", "styles": ["icons"]},
{"name": "upload", "tags": ["export", "send", "share"], "category": "actions", "styles": ["icons"]},
{"name": "upload-cloud", "tags": ["export", "cloud", "share"], "category": "actions", "styles": ["icons"]},
{"name": "user", "tags": ["person", "account", "profile"], "category": "users", "styles": ["icons"]},
{"name": "user-check", "tags": ["person", "verified", "approved"], "category": "users", "styles": ["icons"]},
{"name": "user-minus", "tags": ["person", "remove", "unfriend"], "category": "users", "styles": ["icons"]},
{"name": "user-plus", "tags": ["person", "add", "invite"], "category": "users", "styles": ["icons"]},
{"name": "user-x", "tags": ["person", "delete", "remove"], "category": "users", "styles": ["icons"]},
{"name": "users", "tags": ["people", "team", "group"], "category": "users", "styles": ["icons"]},
{"name": "video", "tags": ["camera", "film", "record"], "category": "media", "styles": ["icons"]},
{"name": "video-off", "tags": ["camera", "disabled", "mute"], "category": "media", "styles": ["icons"]},
{"name": "voicemail", "tags": ["message", "audio", "phone"], "category": "communication", "styles": ["icons"]},
{"name": "volume", "tags": ["sound", "audio", "speaker"], "category": "media", "styles": ["icons"]},
{"name": "volume-1", "tags": ["sound", "audio", "low"], "category": "media", "styles": ["icons"]},
{"name": "volume-2", "tags": ["sound", "audio", "high"], "category": "media", "styles": ["icons"]},
{"name": "volume-x", "tags": ["mute", "silent", "no sound"], "category": "media", "styles": ["icons"]},
{"name": "watch", "tags": ["time", "wearable", "clock"], "category": "devices", "styles": ["icons"]},
{"name": "wifi", "tags": ["wireless", "internet", "connection"], "category": "connectivity", "styles": ["icons"]},
{"name": "wifi-off", "tags": ["wireless", "offline", "disconnect"], "category": "connectivity", "styles": ["icons"]},
{"name": "wind", "tags": ["weather", "air", "breeze"], "category": "weather", "styles": ["icons"]},
{"name": "x", "tags": ["close", "cancel", "remove"], "category": "actions", "styles": ["icons"]},
{"name": "x-circle", "tags": ["close", "cancel", "error"], "category": "actions", "styles": ["icons"]},
{"name": "x-octagon", "tags": ["close", "stop", "error"], "category": "actions", "styles": ["icons"]},
{"name": "x-square", "tags": ["close", "cancel", "remove"], "category": "actions", "styles": ["icons"]},
{"name": "youtube", "tags": ["brand", "video", "streaming"], "category": "brands", "styles": ["icons"]},
{"name": "zap", "tags": ["lightning", "power", "energy"], "category": "objects", "styles": ["icons"]},
{"name": "zap-off", "tags": ["lightning", "disabled", "off"], "category": "objects", "styles": ["icons"]},
{"name": "zoom-in", "tags": ["magnify", "enlarge", "plus"], "category": "actions", "styles": ["icons"]},
{"name": "zoom-out", "tags": ["magnify", "shrink", "minus"], "category": "actions", "styles": ["icons"]}
]
}

View file

@ -0,0 +1,295 @@
{
"slug": "heroicons",
"name": "Heroicons",
"version": "2.1.1",
"icons": [
{"name": "academic-cap", "tags": ["education", "graduation", "school", "hat", "learning"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "adjustments-horizontal", "tags": ["settings", "controls", "sliders", "options", "configuration"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "adjustments-vertical", "tags": ["settings", "controls", "sliders", "options", "configuration"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "archive-box", "tags": ["storage", "box", "container", "archive"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "archive-box-arrow-down", "tags": ["download", "storage", "archive", "save"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "archive-box-x-mark", "tags": ["delete", "remove", "archive", "cancel"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-down", "tags": ["direction", "navigation", "down", "move"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-down-circle", "tags": ["direction", "navigation", "down", "circle"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-down-left", "tags": ["direction", "navigation", "diagonal"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-down-on-square", "tags": ["download", "save", "import"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-down-on-square-stack", "tags": ["download", "save", "multiple"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-down-right", "tags": ["direction", "navigation", "diagonal"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-down-tray", "tags": ["download", "save", "import"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-left", "tags": ["direction", "navigation", "back", "previous"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-left-circle", "tags": ["direction", "navigation", "back", "circle"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-left-end-on-rectangle", "tags": ["logout", "exit", "leave"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-left-start-on-rectangle", "tags": ["login", "enter", "signin"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-long-down", "tags": ["direction", "navigation", "long"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-long-left", "tags": ["direction", "navigation", "long", "back"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-long-right", "tags": ["direction", "navigation", "long", "forward"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-long-up", "tags": ["direction", "navigation", "long"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-path", "tags": ["refresh", "reload", "sync", "repeat"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-path-rounded-square", "tags": ["refresh", "reload", "sync"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-right", "tags": ["direction", "navigation", "forward", "next"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-right-circle", "tags": ["direction", "navigation", "forward", "circle"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-right-end-on-rectangle", "tags": ["login", "enter", "signin"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-right-start-on-rectangle", "tags": ["logout", "exit", "leave"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-top-right-on-square", "tags": ["external", "link", "open", "new window"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-trending-down", "tags": ["chart", "decrease", "analytics", "down"], "category": "charts", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-trending-up", "tags": ["chart", "increase", "analytics", "up"], "category": "charts", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-up", "tags": ["direction", "navigation", "up", "move"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-up-circle", "tags": ["direction", "navigation", "up", "circle"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-up-left", "tags": ["direction", "navigation", "diagonal"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-up-on-square", "tags": ["upload", "share", "export"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-up-on-square-stack", "tags": ["upload", "share", "multiple"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-up-right", "tags": ["direction", "navigation", "diagonal"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-up-tray", "tags": ["upload", "export", "share"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-uturn-down", "tags": ["undo", "return", "back"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-uturn-left", "tags": ["undo", "return", "back"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-uturn-right", "tags": ["redo", "forward"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "arrow-uturn-up", "tags": ["return", "back"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "arrows-pointing-in", "tags": ["minimize", "collapse", "shrink"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "arrows-pointing-out", "tags": ["maximize", "expand", "fullscreen"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "arrows-right-left", "tags": ["swap", "exchange", "transfer"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "arrows-up-down", "tags": ["sort", "reorder", "move"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "at-symbol", "tags": ["email", "at", "mention", "contact"], "category": "communication", "styles": ["outline", "solid", "mini"]},
{"name": "backspace", "tags": ["delete", "remove", "erase", "keyboard"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "backward", "tags": ["media", "rewind", "previous"], "category": "media", "styles": ["outline", "solid", "mini"]},
{"name": "banknotes", "tags": ["money", "cash", "payment", "currency"], "category": "commerce", "styles": ["outline", "solid", "mini"]},
{"name": "bars-2", "tags": ["menu", "hamburger", "navigation"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "bars-3", "tags": ["menu", "hamburger", "navigation"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "bars-3-bottom-left", "tags": ["menu", "text", "align left"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "bars-3-bottom-right", "tags": ["menu", "text", "align right"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "bars-3-center-left", "tags": ["menu", "text", "align"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "bars-4", "tags": ["menu", "list", "navigation"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "bars-arrow-down", "tags": ["sort", "descending", "order"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "bars-arrow-up", "tags": ["sort", "ascending", "order"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "battery-0", "tags": ["power", "energy", "empty"], "category": "devices", "styles": ["outline", "solid", "mini"]},
{"name": "battery-50", "tags": ["power", "energy", "half"], "category": "devices", "styles": ["outline", "solid", "mini"]},
{"name": "battery-100", "tags": ["power", "energy", "full"], "category": "devices", "styles": ["outline", "solid", "mini"]},
{"name": "beaker", "tags": ["science", "lab", "chemistry", "experiment"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "bell", "tags": ["notification", "alert", "alarm", "ring"], "category": "communication", "styles": ["outline", "solid", "mini"]},
{"name": "bell-alert", "tags": ["notification", "alert", "alarm", "warning"], "category": "communication", "styles": ["outline", "solid", "mini"]},
{"name": "bell-slash", "tags": ["notification", "mute", "silent", "off"], "category": "communication", "styles": ["outline", "solid", "mini"]},
{"name": "bell-snooze", "tags": ["notification", "snooze", "delay", "later"], "category": "communication", "styles": ["outline", "solid", "mini"]},
{"name": "bolt", "tags": ["lightning", "power", "energy", "electric"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "bolt-slash", "tags": ["lightning", "power", "off", "disabled"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "book-open", "tags": ["read", "education", "learning", "library"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "bookmark", "tags": ["save", "favorite", "tag", "mark"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "bookmark-slash", "tags": ["unsave", "remove", "unbookmark"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "bookmark-square", "tags": ["save", "favorite", "square"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "briefcase", "tags": ["work", "job", "business", "career"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "bug-ant", "tags": ["bug", "debug", "error", "insect"], "category": "development", "styles": ["outline", "solid", "mini"]},
{"name": "building-library", "tags": ["library", "institution", "government"], "category": "buildings", "styles": ["outline", "solid", "mini"]},
{"name": "building-office", "tags": ["office", "work", "business", "company"], "category": "buildings", "styles": ["outline", "solid", "mini"]},
{"name": "building-office-2", "tags": ["office", "skyscraper", "business"], "category": "buildings", "styles": ["outline", "solid", "mini"]},
{"name": "building-storefront", "tags": ["shop", "store", "retail", "commerce"], "category": "buildings", "styles": ["outline", "solid", "mini"]},
{"name": "cake", "tags": ["birthday", "celebration", "party", "dessert"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "calculator", "tags": ["math", "calculate", "numbers", "finance"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "calendar", "tags": ["date", "schedule", "event", "time"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "calendar-days", "tags": ["date", "schedule", "month", "planner"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "camera", "tags": ["photo", "picture", "image", "photography"], "category": "devices", "styles": ["outline", "solid", "mini"]},
{"name": "chart-bar", "tags": ["analytics", "statistics", "graph", "data"], "category": "charts", "styles": ["outline", "solid", "mini"]},
{"name": "chart-bar-square", "tags": ["analytics", "statistics", "graph"], "category": "charts", "styles": ["outline", "solid", "mini"]},
{"name": "chart-pie", "tags": ["analytics", "statistics", "pie chart", "data"], "category": "charts", "styles": ["outline", "solid", "mini"]},
{"name": "chat-bubble-bottom-center", "tags": ["message", "chat", "comment", "conversation"], "category": "communication", "styles": ["outline", "solid", "mini"]},
{"name": "chat-bubble-bottom-center-text", "tags": ["message", "chat", "text", "comment"], "category": "communication", "styles": ["outline", "solid", "mini"]},
{"name": "chat-bubble-left", "tags": ["message", "chat", "comment"], "category": "communication", "styles": ["outline", "solid", "mini"]},
{"name": "chat-bubble-left-ellipsis", "tags": ["message", "typing", "chat"], "category": "communication", "styles": ["outline", "solid", "mini"]},
{"name": "chat-bubble-left-right", "tags": ["conversation", "chat", "discuss"], "category": "communication", "styles": ["outline", "solid", "mini"]},
{"name": "chat-bubble-oval-left", "tags": ["message", "chat", "bubble"], "category": "communication", "styles": ["outline", "solid", "mini"]},
{"name": "chat-bubble-oval-left-ellipsis", "tags": ["message", "typing", "chat"], "category": "communication", "styles": ["outline", "solid", "mini"]},
{"name": "check", "tags": ["done", "complete", "success", "tick"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "check-badge", "tags": ["verified", "approved", "certified"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "check-circle", "tags": ["done", "complete", "success", "approved"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "chevron-double-down", "tags": ["expand", "more", "navigation"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "chevron-double-left", "tags": ["previous", "back", "navigation"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "chevron-double-right", "tags": ["next", "forward", "navigation"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "chevron-double-up", "tags": ["collapse", "less", "navigation"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "chevron-down", "tags": ["expand", "dropdown", "more"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "chevron-left", "tags": ["previous", "back", "navigation"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "chevron-right", "tags": ["next", "forward", "navigation"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "chevron-up", "tags": ["collapse", "less", "close"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "chevron-up-down", "tags": ["sort", "select", "dropdown"], "category": "arrows", "styles": ["outline", "solid", "mini"]},
{"name": "circle-stack", "tags": ["database", "storage", "data", "layers"], "category": "development", "styles": ["outline", "solid", "mini"]},
{"name": "clipboard", "tags": ["copy", "paste", "clipboard"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "clipboard-document", "tags": ["copy", "document", "paste"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "clipboard-document-check", "tags": ["copy", "done", "verified"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "clipboard-document-list", "tags": ["list", "tasks", "checklist"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "clock", "tags": ["time", "schedule", "watch", "hour"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "cloud", "tags": ["weather", "storage", "sky", "upload"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "cloud-arrow-down", "tags": ["download", "cloud", "sync"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "cloud-arrow-up", "tags": ["upload", "cloud", "sync"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "code-bracket", "tags": ["code", "development", "programming"], "category": "development", "styles": ["outline", "solid", "mini"]},
{"name": "code-bracket-square", "tags": ["code", "development", "programming"], "category": "development", "styles": ["outline", "solid", "mini"]},
{"name": "cog", "tags": ["settings", "gear", "configuration", "options"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "cog-6-tooth", "tags": ["settings", "gear", "configuration"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "cog-8-tooth", "tags": ["settings", "gear", "configuration"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "command-line", "tags": ["terminal", "console", "cli", "code"], "category": "development", "styles": ["outline", "solid", "mini"]},
{"name": "computer-desktop", "tags": ["desktop", "monitor", "screen", "pc"], "category": "devices", "styles": ["outline", "solid", "mini"]},
{"name": "cpu-chip", "tags": ["processor", "chip", "hardware", "computer"], "category": "devices", "styles": ["outline", "solid", "mini"]},
{"name": "credit-card", "tags": ["payment", "card", "money", "finance"], "category": "commerce", "styles": ["outline", "solid", "mini"]},
{"name": "cube", "tags": ["3d", "box", "shape", "object"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "cube-transparent", "tags": ["3d", "box", "transparent"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "currency-bangladeshi", "tags": ["money", "currency", "taka"], "category": "commerce", "styles": ["outline", "solid", "mini"]},
{"name": "currency-dollar", "tags": ["money", "currency", "usd", "dollar"], "category": "commerce", "styles": ["outline", "solid", "mini"]},
{"name": "currency-euro", "tags": ["money", "currency", "eur", "euro"], "category": "commerce", "styles": ["outline", "solid", "mini"]},
{"name": "currency-pound", "tags": ["money", "currency", "gbp", "pound"], "category": "commerce", "styles": ["outline", "solid", "mini"]},
{"name": "currency-rupee", "tags": ["money", "currency", "inr", "rupee"], "category": "commerce", "styles": ["outline", "solid", "mini"]},
{"name": "currency-yen", "tags": ["money", "currency", "jpy", "yen"], "category": "commerce", "styles": ["outline", "solid", "mini"]},
{"name": "cursor-arrow-rays", "tags": ["cursor", "click", "pointer"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "cursor-arrow-ripple", "tags": ["cursor", "click", "pointer"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "device-phone-mobile", "tags": ["phone", "mobile", "smartphone"], "category": "devices", "styles": ["outline", "solid", "mini"]},
{"name": "device-tablet", "tags": ["tablet", "ipad", "device"], "category": "devices", "styles": ["outline", "solid", "mini"]},
{"name": "document", "tags": ["file", "document", "page", "paper"], "category": "files", "styles": ["outline", "solid", "mini"]},
{"name": "document-arrow-down", "tags": ["download", "file", "document"], "category": "files", "styles": ["outline", "solid", "mini"]},
{"name": "document-arrow-up", "tags": ["upload", "file", "document"], "category": "files", "styles": ["outline", "solid", "mini"]},
{"name": "document-chart-bar", "tags": ["report", "analytics", "document"], "category": "files", "styles": ["outline", "solid", "mini"]},
{"name": "document-check", "tags": ["verified", "approved", "document"], "category": "files", "styles": ["outline", "solid", "mini"]},
{"name": "document-duplicate", "tags": ["copy", "duplicate", "clone"], "category": "files", "styles": ["outline", "solid", "mini"]},
{"name": "document-magnifying-glass", "tags": ["search", "find", "document"], "category": "files", "styles": ["outline", "solid", "mini"]},
{"name": "document-minus", "tags": ["remove", "delete", "document"], "category": "files", "styles": ["outline", "solid", "mini"]},
{"name": "document-plus", "tags": ["add", "new", "create", "document"], "category": "files", "styles": ["outline", "solid", "mini"]},
{"name": "document-text", "tags": ["file", "text", "document", "content"], "category": "files", "styles": ["outline", "solid", "mini"]},
{"name": "ellipsis-horizontal", "tags": ["more", "menu", "options", "dots"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "ellipsis-horizontal-circle", "tags": ["more", "menu", "options"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "ellipsis-vertical", "tags": ["more", "menu", "options", "dots"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "envelope", "tags": ["email", "mail", "message", "letter"], "category": "communication", "styles": ["outline", "solid", "mini"]},
{"name": "envelope-open", "tags": ["email", "mail", "read", "open"], "category": "communication", "styles": ["outline", "solid", "mini"]},
{"name": "exclamation-circle", "tags": ["warning", "alert", "error", "danger"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "exclamation-triangle", "tags": ["warning", "alert", "caution", "danger"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "eye", "tags": ["view", "visible", "show", "watch"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "eye-dropper", "tags": ["color", "picker", "dropper", "design"], "category": "design", "styles": ["outline", "solid", "mini"]},
{"name": "eye-slash", "tags": ["hide", "invisible", "hidden", "privacy"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "face-frown", "tags": ["sad", "unhappy", "emoji", "emotion"], "category": "emoji", "styles": ["outline", "solid", "mini"]},
{"name": "face-smile", "tags": ["happy", "smile", "emoji", "emotion"], "category": "emoji", "styles": ["outline", "solid", "mini"]},
{"name": "film", "tags": ["video", "movie", "media", "cinema"], "category": "media", "styles": ["outline", "solid", "mini"]},
{"name": "finger-print", "tags": ["security", "identity", "biometric", "auth"], "category": "security", "styles": ["outline", "solid", "mini"]},
{"name": "fire", "tags": ["flame", "hot", "trending", "popular"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "flag", "tags": ["report", "mark", "flag", "bookmark"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "folder", "tags": ["directory", "folder", "files", "organize"], "category": "files", "styles": ["outline", "solid", "mini"]},
{"name": "folder-arrow-down", "tags": ["download", "folder", "save"], "category": "files", "styles": ["outline", "solid", "mini"]},
{"name": "folder-minus", "tags": ["remove", "folder", "delete"], "category": "files", "styles": ["outline", "solid", "mini"]},
{"name": "folder-open", "tags": ["open", "folder", "browse"], "category": "files", "styles": ["outline", "solid", "mini"]},
{"name": "folder-plus", "tags": ["add", "folder", "new", "create"], "category": "files", "styles": ["outline", "solid", "mini"]},
{"name": "forward", "tags": ["media", "next", "skip"], "category": "media", "styles": ["outline", "solid", "mini"]},
{"name": "funnel", "tags": ["filter", "sort", "funnel"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "gif", "tags": ["image", "animation", "gif", "media"], "category": "media", "styles": ["outline", "solid", "mini"]},
{"name": "gift", "tags": ["present", "gift", "reward", "surprise"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "gift-top", "tags": ["present", "gift", "reward"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "globe-alt", "tags": ["world", "earth", "internet", "web"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "globe-americas", "tags": ["world", "earth", "americas"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "globe-asia-australia", "tags": ["world", "earth", "asia"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "globe-europe-africa", "tags": ["world", "earth", "europe"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "hand-raised", "tags": ["stop", "hand", "halt", "wait"], "category": "gestures", "styles": ["outline", "solid", "mini"]},
{"name": "hand-thumb-down", "tags": ["dislike", "thumbs down", "bad"], "category": "gestures", "styles": ["outline", "solid", "mini"]},
{"name": "hand-thumb-up", "tags": ["like", "thumbs up", "good", "approve"], "category": "gestures", "styles": ["outline", "solid", "mini"]},
{"name": "hashtag", "tags": ["tag", "hashtag", "topic", "social"], "category": "communication", "styles": ["outline", "solid", "mini"]},
{"name": "heart", "tags": ["love", "favorite", "like", "heart"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "home", "tags": ["house", "home", "main", "dashboard"], "category": "navigation", "styles": ["outline", "solid", "mini"]},
{"name": "home-modern", "tags": ["house", "home", "building"], "category": "buildings", "styles": ["outline", "solid", "mini"]},
{"name": "identification", "tags": ["id", "card", "identity", "badge"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "inbox", "tags": ["mail", "inbox", "messages"], "category": "communication", "styles": ["outline", "solid", "mini"]},
{"name": "inbox-arrow-down", "tags": ["receive", "inbox", "download"], "category": "communication", "styles": ["outline", "solid", "mini"]},
{"name": "inbox-stack", "tags": ["inbox", "messages", "stack"], "category": "communication", "styles": ["outline", "solid", "mini"]},
{"name": "information-circle", "tags": ["info", "help", "about", "details"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "key", "tags": ["password", "security", "access", "lock"], "category": "security", "styles": ["outline", "solid", "mini"]},
{"name": "language", "tags": ["translate", "language", "international"], "category": "communication", "styles": ["outline", "solid", "mini"]},
{"name": "lifebuoy", "tags": ["help", "support", "rescue", "assistance"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "light-bulb", "tags": ["idea", "light", "bulb", "creative"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "link", "tags": ["url", "link", "chain", "connect"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "list-bullet", "tags": ["list", "bullet", "items", "menu"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "lock-closed", "tags": ["security", "lock", "private", "protected"], "category": "security", "styles": ["outline", "solid", "mini"]},
{"name": "lock-open", "tags": ["unlock", "open", "access"], "category": "security", "styles": ["outline", "solid", "mini"]},
{"name": "magnifying-glass", "tags": ["search", "find", "zoom", "look"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "magnifying-glass-circle", "tags": ["search", "find", "explore"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "magnifying-glass-minus", "tags": ["zoom out", "search", "minus"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "magnifying-glass-plus", "tags": ["zoom in", "search", "plus"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "map", "tags": ["location", "map", "directions", "navigate"], "category": "maps", "styles": ["outline", "solid", "mini"]},
{"name": "map-pin", "tags": ["location", "pin", "marker", "place"], "category": "maps", "styles": ["outline", "solid", "mini"]},
{"name": "megaphone", "tags": ["announce", "broadcast", "marketing"], "category": "communication", "styles": ["outline", "solid", "mini"]},
{"name": "microphone", "tags": ["audio", "voice", "record", "speak"], "category": "media", "styles": ["outline", "solid", "mini"]},
{"name": "minus", "tags": ["subtract", "remove", "minus", "decrease"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "minus-circle", "tags": ["remove", "delete", "minus"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "moon", "tags": ["dark", "night", "mode", "theme"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "musical-note", "tags": ["music", "audio", "sound", "note"], "category": "media", "styles": ["outline", "solid", "mini"]},
{"name": "newspaper", "tags": ["news", "article", "blog", "press"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "no-symbol", "tags": ["ban", "prohibited", "forbidden", "block"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "paint-brush", "tags": ["design", "art", "paint", "brush"], "category": "design", "styles": ["outline", "solid", "mini"]},
{"name": "paper-airplane", "tags": ["send", "message", "email", "submit"], "category": "communication", "styles": ["outline", "solid", "mini"]},
{"name": "paper-clip", "tags": ["attachment", "clip", "attach", "file"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "pause", "tags": ["media", "pause", "stop", "wait"], "category": "media", "styles": ["outline", "solid", "mini"]},
{"name": "pause-circle", "tags": ["media", "pause", "stop"], "category": "media", "styles": ["outline", "solid", "mini"]},
{"name": "pencil", "tags": ["edit", "write", "pencil", "modify"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "pencil-square", "tags": ["edit", "write", "compose"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "phone", "tags": ["call", "phone", "contact", "mobile"], "category": "communication", "styles": ["outline", "solid", "mini"]},
{"name": "phone-arrow-down-left", "tags": ["incoming", "call", "receive"], "category": "communication", "styles": ["outline", "solid", "mini"]},
{"name": "phone-arrow-up-right", "tags": ["outgoing", "call", "dial"], "category": "communication", "styles": ["outline", "solid", "mini"]},
{"name": "phone-x-mark", "tags": ["hang up", "end call", "decline"], "category": "communication", "styles": ["outline", "solid", "mini"]},
{"name": "photo", "tags": ["image", "picture", "photo", "gallery"], "category": "media", "styles": ["outline", "solid", "mini"]},
{"name": "play", "tags": ["media", "play", "start", "video"], "category": "media", "styles": ["outline", "solid", "mini"]},
{"name": "play-circle", "tags": ["media", "play", "video"], "category": "media", "styles": ["outline", "solid", "mini"]},
{"name": "play-pause", "tags": ["media", "toggle", "play", "pause"], "category": "media", "styles": ["outline", "solid", "mini"]},
{"name": "plus", "tags": ["add", "create", "new", "plus"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "plus-circle", "tags": ["add", "create", "new"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "power", "tags": ["on", "off", "power", "shutdown"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "presentation-chart-bar", "tags": ["presentation", "chart", "analytics"], "category": "charts", "styles": ["outline", "solid", "mini"]},
{"name": "presentation-chart-line", "tags": ["presentation", "chart", "analytics"], "category": "charts", "styles": ["outline", "solid", "mini"]},
{"name": "printer", "tags": ["print", "printer", "document"], "category": "devices", "styles": ["outline", "solid", "mini"]},
{"name": "puzzle-piece", "tags": ["plugin", "extension", "puzzle", "addon"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "qr-code", "tags": ["qr", "code", "scan", "barcode"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "question-mark-circle", "tags": ["help", "question", "support", "faq"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "queue-list", "tags": ["list", "queue", "playlist"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "radio", "tags": ["radio", "audio", "broadcast"], "category": "media", "styles": ["outline", "solid", "mini"]},
{"name": "receipt-percent", "tags": ["discount", "sale", "coupon"], "category": "commerce", "styles": ["outline", "solid", "mini"]},
{"name": "receipt-refund", "tags": ["refund", "return", "money back"], "category": "commerce", "styles": ["outline", "solid", "mini"]},
{"name": "rectangle-group", "tags": ["layout", "grid", "components"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "rectangle-stack", "tags": ["layers", "stack", "cards"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "rocket-launch", "tags": ["launch", "startup", "rocket", "deploy"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "rss", "tags": ["feed", "rss", "subscribe", "blog"], "category": "communication", "styles": ["outline", "solid", "mini"]},
{"name": "scale", "tags": ["balance", "justice", "legal", "weight"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "scissors", "tags": ["cut", "scissors", "trim", "edit"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "server", "tags": ["server", "hosting", "database", "backend"], "category": "devices", "styles": ["outline", "solid", "mini"]},
{"name": "server-stack", "tags": ["servers", "hosting", "infrastructure"], "category": "devices", "styles": ["outline", "solid", "mini"]},
{"name": "share", "tags": ["share", "send", "social", "forward"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "shield-check", "tags": ["security", "protected", "verified", "safe"], "category": "security", "styles": ["outline", "solid", "mini"]},
{"name": "shield-exclamation", "tags": ["security", "warning", "alert"], "category": "security", "styles": ["outline", "solid", "mini"]},
{"name": "shopping-bag", "tags": ["shop", "bag", "purchase", "buy"], "category": "commerce", "styles": ["outline", "solid", "mini"]},
{"name": "shopping-cart", "tags": ["cart", "shop", "ecommerce", "buy"], "category": "commerce", "styles": ["outline", "solid", "mini"]},
{"name": "signal", "tags": ["wifi", "signal", "connection", "network"], "category": "devices", "styles": ["outline", "solid", "mini"]},
{"name": "signal-slash", "tags": ["no signal", "offline", "disconnected"], "category": "devices", "styles": ["outline", "solid", "mini"]},
{"name": "sparkles", "tags": ["magic", "sparkle", "new", "ai"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "speaker-wave", "tags": ["audio", "sound", "volume", "speaker"], "category": "media", "styles": ["outline", "solid", "mini"]},
{"name": "speaker-x-mark", "tags": ["mute", "silent", "no sound"], "category": "media", "styles": ["outline", "solid", "mini"]},
{"name": "square-2-stack", "tags": ["copy", "duplicate", "stack"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "square-3-stack-3d", "tags": ["layers", "3d", "stack"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "squares-2x2", "tags": ["grid", "layout", "dashboard"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "squares-plus", "tags": ["add", "widget", "new"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "star", "tags": ["favorite", "star", "rating", "bookmark"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "stop", "tags": ["media", "stop", "end"], "category": "media", "styles": ["outline", "solid", "mini"]},
{"name": "stop-circle", "tags": ["media", "stop", "end"], "category": "media", "styles": ["outline", "solid", "mini"]},
{"name": "sun", "tags": ["light", "day", "bright", "theme"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "swatch", "tags": ["color", "palette", "design", "theme"], "category": "design", "styles": ["outline", "solid", "mini"]},
{"name": "table-cells", "tags": ["table", "grid", "spreadsheet", "data"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "tag", "tags": ["label", "tag", "category", "price"], "category": "commerce", "styles": ["outline", "solid", "mini"]},
{"name": "ticket", "tags": ["ticket", "event", "pass", "coupon"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "trash", "tags": ["delete", "remove", "trash", "bin"], "category": "actions", "styles": ["outline", "solid", "mini"]},
{"name": "trophy", "tags": ["award", "winner", "achievement", "prize"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "truck", "tags": ["delivery", "shipping", "truck", "transport"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "tv", "tags": ["television", "tv", "screen", "display"], "category": "devices", "styles": ["outline", "solid", "mini"]},
{"name": "user", "tags": ["person", "user", "account", "profile"], "category": "users", "styles": ["outline", "solid", "mini"]},
{"name": "user-circle", "tags": ["avatar", "profile", "account"], "category": "users", "styles": ["outline", "solid", "mini"]},
{"name": "user-group", "tags": ["team", "group", "users", "people"], "category": "users", "styles": ["outline", "solid", "mini"]},
{"name": "user-minus", "tags": ["remove user", "unfriend", "delete"], "category": "users", "styles": ["outline", "solid", "mini"]},
{"name": "user-plus", "tags": ["add user", "invite", "new user"], "category": "users", "styles": ["outline", "solid", "mini"]},
{"name": "users", "tags": ["people", "team", "community"], "category": "users", "styles": ["outline", "solid", "mini"]},
{"name": "variable", "tags": ["code", "variable", "math", "programming"], "category": "development", "styles": ["outline", "solid", "mini"]},
{"name": "video-camera", "tags": ["video", "camera", "record", "film"], "category": "media", "styles": ["outline", "solid", "mini"]},
{"name": "video-camera-slash", "tags": ["video off", "camera off", "mute"], "category": "media", "styles": ["outline", "solid", "mini"]},
{"name": "view-columns", "tags": ["columns", "layout", "grid"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "view-finder-circle", "tags": ["focus", "target", "aim"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "wallet", "tags": ["money", "payment", "wallet", "finance"], "category": "commerce", "styles": ["outline", "solid", "mini"]},
{"name": "wifi", "tags": ["wireless", "internet", "connection", "network"], "category": "devices", "styles": ["outline", "solid", "mini"]},
{"name": "window", "tags": ["browser", "window", "app"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "wrench", "tags": ["tools", "settings", "repair", "fix"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "wrench-screwdriver", "tags": ["tools", "settings", "repair"], "category": "objects", "styles": ["outline", "solid", "mini"]},
{"name": "x-circle", "tags": ["close", "cancel", "remove", "delete"], "category": "ui", "styles": ["outline", "solid", "mini"]},
{"name": "x-mark", "tags": ["close", "cancel", "remove", "x"], "category": "ui", "styles": ["outline", "solid", "mini"]}
]
}

View file

@ -0,0 +1,5 @@
<?php
// Silence is golden.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}

View file

@ -0,0 +1,357 @@
{
"slug": "lucide",
"name": "Lucide",
"version": "0.303.0",
"icons": [
{"name": "accessibility", "tags": ["wheelchair", "disabled", "handicap"], "category": "accessibility", "styles": ["icons"]},
{"name": "activity", "tags": ["pulse", "health", "heart"], "category": "medical", "styles": ["icons"]},
{"name": "air-vent", "tags": ["ventilation", "hvac", "climate"], "category": "home", "styles": ["icons"]},
{"name": "airplay", "tags": ["stream", "cast", "screen"], "category": "devices", "styles": ["icons"]},
{"name": "alarm-clock", "tags": ["time", "wake", "alert"], "category": "time", "styles": ["icons"]},
{"name": "album", "tags": ["music", "photos", "collection"], "category": "media", "styles": ["icons"]},
{"name": "alert-circle", "tags": ["warning", "error", "danger"], "category": "alerts", "styles": ["icons"]},
{"name": "alert-octagon", "tags": ["warning", "stop", "error"], "category": "alerts", "styles": ["icons"]},
{"name": "alert-triangle", "tags": ["warning", "caution", "error"], "category": "alerts", "styles": ["icons"]},
{"name": "align-center", "tags": ["text", "format", "paragraph"], "category": "text", "styles": ["icons"]},
{"name": "align-center-horizontal", "tags": ["layout", "distribute"], "category": "layout", "styles": ["icons"]},
{"name": "align-center-vertical", "tags": ["layout", "distribute"], "category": "layout", "styles": ["icons"]},
{"name": "align-end-horizontal", "tags": ["layout", "distribute"], "category": "layout", "styles": ["icons"]},
{"name": "align-end-vertical", "tags": ["layout", "distribute"], "category": "layout", "styles": ["icons"]},
{"name": "align-horizontal-distribute-center", "tags": ["layout", "spacing"], "category": "layout", "styles": ["icons"]},
{"name": "align-horizontal-distribute-end", "tags": ["layout", "spacing"], "category": "layout", "styles": ["icons"]},
{"name": "align-horizontal-distribute-start", "tags": ["layout", "spacing"], "category": "layout", "styles": ["icons"]},
{"name": "align-horizontal-justify-center", "tags": ["layout", "justify"], "category": "layout", "styles": ["icons"]},
{"name": "align-horizontal-justify-end", "tags": ["layout", "justify"], "category": "layout", "styles": ["icons"]},
{"name": "align-horizontal-justify-start", "tags": ["layout", "justify"], "category": "layout", "styles": ["icons"]},
{"name": "align-horizontal-space-around", "tags": ["layout", "spacing"], "category": "layout", "styles": ["icons"]},
{"name": "align-horizontal-space-between", "tags": ["layout", "spacing"], "category": "layout", "styles": ["icons"]},
{"name": "align-justify", "tags": ["text", "format", "paragraph"], "category": "text", "styles": ["icons"]},
{"name": "align-left", "tags": ["text", "format", "paragraph"], "category": "text", "styles": ["icons"]},
{"name": "align-right", "tags": ["text", "format", "paragraph"], "category": "text", "styles": ["icons"]},
{"name": "align-start-horizontal", "tags": ["layout", "distribute"], "category": "layout", "styles": ["icons"]},
{"name": "align-start-vertical", "tags": ["layout", "distribute"], "category": "layout", "styles": ["icons"]},
{"name": "align-vertical-distribute-center", "tags": ["layout", "spacing"], "category": "layout", "styles": ["icons"]},
{"name": "align-vertical-distribute-end", "tags": ["layout", "spacing"], "category": "layout", "styles": ["icons"]},
{"name": "align-vertical-distribute-start", "tags": ["layout", "spacing"], "category": "layout", "styles": ["icons"]},
{"name": "align-vertical-justify-center", "tags": ["layout", "justify"], "category": "layout", "styles": ["icons"]},
{"name": "align-vertical-justify-end", "tags": ["layout", "justify"], "category": "layout", "styles": ["icons"]},
{"name": "align-vertical-justify-start", "tags": ["layout", "justify"], "category": "layout", "styles": ["icons"]},
{"name": "align-vertical-space-around", "tags": ["layout", "spacing"], "category": "layout", "styles": ["icons"]},
{"name": "align-vertical-space-between", "tags": ["layout", "spacing"], "category": "layout", "styles": ["icons"]},
{"name": "anchor", "tags": ["link", "marine", "ship"], "category": "navigation", "styles": ["icons"]},
{"name": "angry", "tags": ["emoji", "emotion", "face"], "category": "emoji", "styles": ["icons"]},
{"name": "annoyed", "tags": ["emoji", "emotion", "face"], "category": "emoji", "styles": ["icons"]},
{"name": "aperture", "tags": ["camera", "lens", "photo"], "category": "photography", "styles": ["icons"]},
{"name": "app-window", "tags": ["application", "program", "software"], "category": "development", "styles": ["icons"]},
{"name": "apple", "tags": ["fruit", "food", "healthy"], "category": "food", "styles": ["icons"]},
{"name": "archive", "tags": ["box", "storage", "files"], "category": "files", "styles": ["icons"]},
{"name": "archive-restore", "tags": ["unarchive", "restore", "files"], "category": "files", "styles": ["icons"]},
{"name": "archive-x", "tags": ["delete", "remove", "files"], "category": "files", "styles": ["icons"]},
{"name": "armchair", "tags": ["furniture", "seat", "chair"], "category": "furniture", "styles": ["icons"]},
{"name": "arrow-big-down", "tags": ["direction", "down", "navigation"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-big-down-dash", "tags": ["direction", "down", "end"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-big-left", "tags": ["direction", "left", "back"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-big-left-dash", "tags": ["direction", "left", "start"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-big-right", "tags": ["direction", "right", "forward"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-big-right-dash", "tags": ["direction", "right", "end"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-big-up", "tags": ["direction", "up", "navigation"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-big-up-dash", "tags": ["direction", "up", "start"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-down", "tags": ["direction", "down"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-down-circle", "tags": ["direction", "down", "circle"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-down-from-line", "tags": ["download", "export"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-down-left", "tags": ["direction", "diagonal"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-down-left-from-circle", "tags": ["direction", "exit"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-down-right", "tags": ["direction", "diagonal"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-down-right-from-circle", "tags": ["direction", "exit"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-down-to-dot", "tags": ["direction", "target"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-down-to-line", "tags": ["download", "bottom"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-left", "tags": ["direction", "back", "previous"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-left-circle", "tags": ["direction", "back", "circle"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-left-from-line", "tags": ["exit", "leave"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-left-right", "tags": ["swap", "exchange", "horizontal"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-left-to-line", "tags": ["start", "beginning"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-right", "tags": ["direction", "forward", "next"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-right-circle", "tags": ["direction", "forward", "circle"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-right-from-line", "tags": ["exit", "export"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-right-to-line", "tags": ["end", "finish"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-up", "tags": ["direction", "up"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-up-circle", "tags": ["direction", "up", "circle"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-up-down", "tags": ["sort", "reorder", "vertical"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-up-from-dot", "tags": ["direction", "source"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-up-from-line", "tags": ["upload", "export"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-up-left", "tags": ["direction", "diagonal"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-up-right", "tags": ["direction", "diagonal", "external"], "category": "arrows", "styles": ["icons"]},
{"name": "arrow-up-to-line", "tags": ["upload", "top"], "category": "arrows", "styles": ["icons"]},
{"name": "asterisk", "tags": ["star", "required", "note"], "category": "symbols", "styles": ["icons"]},
{"name": "at-sign", "tags": ["email", "mention", "at"], "category": "communication", "styles": ["icons"]},
{"name": "atom", "tags": ["science", "physics", "nuclear"], "category": "science", "styles": ["icons"]},
{"name": "award", "tags": ["achievement", "badge", "medal"], "category": "awards", "styles": ["icons"]},
{"name": "axe", "tags": ["tool", "chop", "wood"], "category": "tools", "styles": ["icons"]},
{"name": "baby", "tags": ["child", "infant", "kid"], "category": "people", "styles": ["icons"]},
{"name": "backpack", "tags": ["bag", "school", "travel"], "category": "objects", "styles": ["icons"]},
{"name": "badge", "tags": ["label", "tag", "verified"], "category": "ui", "styles": ["icons"]},
{"name": "badge-alert", "tags": ["warning", "notification"], "category": "ui", "styles": ["icons"]},
{"name": "badge-check", "tags": ["verified", "approved", "done"], "category": "ui", "styles": ["icons"]},
{"name": "badge-dollar-sign", "tags": ["money", "price", "cost"], "category": "commerce", "styles": ["icons"]},
{"name": "badge-help", "tags": ["question", "support", "info"], "category": "ui", "styles": ["icons"]},
{"name": "badge-info", "tags": ["information", "details", "about"], "category": "ui", "styles": ["icons"]},
{"name": "badge-minus", "tags": ["remove", "subtract", "delete"], "category": "ui", "styles": ["icons"]},
{"name": "badge-percent", "tags": ["discount", "sale", "offer"], "category": "commerce", "styles": ["icons"]},
{"name": "badge-plus", "tags": ["add", "new", "create"], "category": "ui", "styles": ["icons"]},
{"name": "badge-x", "tags": ["remove", "delete", "cancel"], "category": "ui", "styles": ["icons"]},
{"name": "baggage-claim", "tags": ["airport", "luggage", "travel"], "category": "travel", "styles": ["icons"]},
{"name": "ban", "tags": ["block", "forbidden", "prohibited"], "category": "ui", "styles": ["icons"]},
{"name": "banana", "tags": ["fruit", "food", "healthy"], "category": "food", "styles": ["icons"]},
{"name": "banknote", "tags": ["money", "cash", "currency"], "category": "commerce", "styles": ["icons"]},
{"name": "bar-chart", "tags": ["analytics", "statistics", "graph"], "category": "charts", "styles": ["icons"]},
{"name": "bar-chart-2", "tags": ["analytics", "statistics", "graph"], "category": "charts", "styles": ["icons"]},
{"name": "bar-chart-3", "tags": ["analytics", "statistics", "graph"], "category": "charts", "styles": ["icons"]},
{"name": "bar-chart-4", "tags": ["analytics", "statistics", "graph"], "category": "charts", "styles": ["icons"]},
{"name": "bar-chart-big", "tags": ["analytics", "statistics", "graph"], "category": "charts", "styles": ["icons"]},
{"name": "bar-chart-horizontal", "tags": ["analytics", "statistics", "graph"], "category": "charts", "styles": ["icons"]},
{"name": "bar-chart-horizontal-big", "tags": ["analytics", "statistics", "graph"], "category": "charts", "styles": ["icons"]},
{"name": "battery", "tags": ["power", "energy", "charge"], "category": "devices", "styles": ["icons"]},
{"name": "battery-charging", "tags": ["power", "energy", "charge"], "category": "devices", "styles": ["icons"]},
{"name": "battery-full", "tags": ["power", "energy", "full"], "category": "devices", "styles": ["icons"]},
{"name": "battery-low", "tags": ["power", "energy", "low"], "category": "devices", "styles": ["icons"]},
{"name": "battery-medium", "tags": ["power", "energy", "half"], "category": "devices", "styles": ["icons"]},
{"name": "battery-warning", "tags": ["power", "energy", "alert"], "category": "devices", "styles": ["icons"]},
{"name": "beaker", "tags": ["science", "lab", "chemistry"], "category": "science", "styles": ["icons"]},
{"name": "bean", "tags": ["food", "vegetable", "seed"], "category": "food", "styles": ["icons"]},
{"name": "bean-off", "tags": ["food", "allergy", "no"], "category": "food", "styles": ["icons"]},
{"name": "bed", "tags": ["sleep", "hotel", "rest"], "category": "furniture", "styles": ["icons"]},
{"name": "bed-double", "tags": ["sleep", "hotel", "bedroom"], "category": "furniture", "styles": ["icons"]},
{"name": "bed-single", "tags": ["sleep", "hotel", "bedroom"], "category": "furniture", "styles": ["icons"]},
{"name": "beer", "tags": ["drink", "alcohol", "bar"], "category": "food", "styles": ["icons"]},
{"name": "bell", "tags": ["notification", "alert", "alarm"], "category": "notifications", "styles": ["icons"]},
{"name": "bell-dot", "tags": ["notification", "alert", "new"], "category": "notifications", "styles": ["icons"]},
{"name": "bell-minus", "tags": ["notification", "remove", "mute"], "category": "notifications", "styles": ["icons"]},
{"name": "bell-off", "tags": ["notification", "mute", "silent"], "category": "notifications", "styles": ["icons"]},
{"name": "bell-plus", "tags": ["notification", "add", "subscribe"], "category": "notifications", "styles": ["icons"]},
{"name": "bell-ring", "tags": ["notification", "alert", "ringing"], "category": "notifications", "styles": ["icons"]},
{"name": "bike", "tags": ["bicycle", "cycling", "transport"], "category": "transportation", "styles": ["icons"]},
{"name": "binary", "tags": ["code", "programming", "data"], "category": "development", "styles": ["icons"]},
{"name": "bluetooth", "tags": ["wireless", "connection", "device"], "category": "connectivity", "styles": ["icons"]},
{"name": "bluetooth-connected", "tags": ["wireless", "paired", "device"], "category": "connectivity", "styles": ["icons"]},
{"name": "bluetooth-off", "tags": ["wireless", "disabled", "device"], "category": "connectivity", "styles": ["icons"]},
{"name": "bluetooth-searching", "tags": ["wireless", "scanning", "device"], "category": "connectivity", "styles": ["icons"]},
{"name": "bold", "tags": ["text", "format", "strong"], "category": "text", "styles": ["icons"]},
{"name": "bomb", "tags": ["explosive", "danger", "warning"], "category": "objects", "styles": ["icons"]},
{"name": "bone", "tags": ["skeleton", "body", "medical"], "category": "medical", "styles": ["icons"]},
{"name": "book", "tags": ["read", "library", "education"], "category": "education", "styles": ["icons"]},
{"name": "book-copy", "tags": ["duplicate", "clone", "library"], "category": "education", "styles": ["icons"]},
{"name": "book-down", "tags": ["download", "save", "library"], "category": "education", "styles": ["icons"]},
{"name": "book-key", "tags": ["password", "secret", "secure"], "category": "education", "styles": ["icons"]},
{"name": "book-lock", "tags": ["private", "secure", "protected"], "category": "education", "styles": ["icons"]},
{"name": "book-marked", "tags": ["bookmark", "saved", "reading"], "category": "education", "styles": ["icons"]},
{"name": "book-minus", "tags": ["remove", "delete", "library"], "category": "education", "styles": ["icons"]},
{"name": "book-open", "tags": ["read", "library", "education"], "category": "education", "styles": ["icons"]},
{"name": "book-open-check", "tags": ["read", "complete", "done"], "category": "education", "styles": ["icons"]},
{"name": "book-plus", "tags": ["add", "new", "library"], "category": "education", "styles": ["icons"]},
{"name": "book-up", "tags": ["upload", "export", "library"], "category": "education", "styles": ["icons"]},
{"name": "book-x", "tags": ["remove", "cancel", "library"], "category": "education", "styles": ["icons"]},
{"name": "bookmark", "tags": ["save", "favorite", "tag"], "category": "actions", "styles": ["icons"]},
{"name": "bookmark-minus", "tags": ["remove", "unsave", "tag"], "category": "actions", "styles": ["icons"]},
{"name": "bookmark-plus", "tags": ["add", "save", "tag"], "category": "actions", "styles": ["icons"]},
{"name": "bot", "tags": ["robot", "ai", "automation"], "category": "development", "styles": ["icons"]},
{"name": "box", "tags": ["package", "container", "storage"], "category": "objects", "styles": ["icons"]},
{"name": "box-select", "tags": ["selection", "area", "region"], "category": "ui", "styles": ["icons"]},
{"name": "boxes", "tags": ["packages", "inventory", "storage"], "category": "objects", "styles": ["icons"]},
{"name": "braces", "tags": ["code", "curly", "programming"], "category": "development", "styles": ["icons"]},
{"name": "brackets", "tags": ["code", "array", "programming"], "category": "development", "styles": ["icons"]},
{"name": "brain", "tags": ["mind", "thinking", "intelligence"], "category": "medical", "styles": ["icons"]},
{"name": "briefcase", "tags": ["work", "job", "business"], "category": "business", "styles": ["icons"]},
{"name": "brush", "tags": ["paint", "art", "design"], "category": "design", "styles": ["icons"]},
{"name": "bug", "tags": ["insect", "error", "debug"], "category": "development", "styles": ["icons"]},
{"name": "building", "tags": ["office", "company", "business"], "category": "buildings", "styles": ["icons"]},
{"name": "building-2", "tags": ["office", "company", "business"], "category": "buildings", "styles": ["icons"]},
{"name": "bus", "tags": ["transport", "vehicle", "travel"], "category": "transportation", "styles": ["icons"]},
{"name": "cable", "tags": ["wire", "connection", "plug"], "category": "devices", "styles": ["icons"]},
{"name": "cake", "tags": ["birthday", "celebration", "party"], "category": "food", "styles": ["icons"]},
{"name": "calculator", "tags": ["math", "calculate", "numbers"], "category": "tools", "styles": ["icons"]},
{"name": "calendar", "tags": ["date", "schedule", "event"], "category": "time", "styles": ["icons"]},
{"name": "calendar-check", "tags": ["date", "done", "confirmed"], "category": "time", "styles": ["icons"]},
{"name": "calendar-clock", "tags": ["date", "time", "schedule"], "category": "time", "styles": ["icons"]},
{"name": "calendar-days", "tags": ["date", "month", "schedule"], "category": "time", "styles": ["icons"]},
{"name": "camera", "tags": ["photo", "picture", "image"], "category": "devices", "styles": ["icons"]},
{"name": "camera-off", "tags": ["photo", "disabled", "no"], "category": "devices", "styles": ["icons"]},
{"name": "car", "tags": ["vehicle", "transport", "drive"], "category": "transportation", "styles": ["icons"]},
{"name": "check", "tags": ["done", "complete", "success"], "category": "ui", "styles": ["icons"]},
{"name": "check-check", "tags": ["done", "verified", "double"], "category": "ui", "styles": ["icons"]},
{"name": "check-circle", "tags": ["done", "complete", "success"], "category": "ui", "styles": ["icons"]},
{"name": "check-circle-2", "tags": ["done", "complete", "success"], "category": "ui", "styles": ["icons"]},
{"name": "check-square", "tags": ["done", "checkbox", "task"], "category": "ui", "styles": ["icons"]},
{"name": "chevron-down", "tags": ["arrow", "expand", "dropdown"], "category": "arrows", "styles": ["icons"]},
{"name": "chevron-left", "tags": ["arrow", "back", "previous"], "category": "arrows", "styles": ["icons"]},
{"name": "chevron-right", "tags": ["arrow", "forward", "next"], "category": "arrows", "styles": ["icons"]},
{"name": "chevron-up", "tags": ["arrow", "collapse", "up"], "category": "arrows", "styles": ["icons"]},
{"name": "chevrons-down", "tags": ["arrows", "expand", "more"], "category": "arrows", "styles": ["icons"]},
{"name": "chevrons-left", "tags": ["arrows", "back", "first"], "category": "arrows", "styles": ["icons"]},
{"name": "chevrons-right", "tags": ["arrows", "forward", "last"], "category": "arrows", "styles": ["icons"]},
{"name": "chevrons-up", "tags": ["arrows", "collapse", "less"], "category": "arrows", "styles": ["icons"]},
{"name": "circle", "tags": ["shape", "dot", "record"], "category": "shapes", "styles": ["icons"]},
{"name": "clipboard", "tags": ["copy", "paste", "board"], "category": "actions", "styles": ["icons"]},
{"name": "clipboard-check", "tags": ["done", "verified", "complete"], "category": "actions", "styles": ["icons"]},
{"name": "clipboard-copy", "tags": ["copy", "duplicate", "paste"], "category": "actions", "styles": ["icons"]},
{"name": "clipboard-list", "tags": ["tasks", "checklist", "todo"], "category": "actions", "styles": ["icons"]},
{"name": "clock", "tags": ["time", "watch", "schedule"], "category": "time", "styles": ["icons"]},
{"name": "cloud", "tags": ["weather", "storage", "sky"], "category": "weather", "styles": ["icons"]},
{"name": "cloud-download", "tags": ["download", "save", "sync"], "category": "actions", "styles": ["icons"]},
{"name": "cloud-off", "tags": ["offline", "disconnect", "no"], "category": "weather", "styles": ["icons"]},
{"name": "cloud-upload", "tags": ["upload", "sync", "backup"], "category": "actions", "styles": ["icons"]},
{"name": "code", "tags": ["programming", "html", "development"], "category": "development", "styles": ["icons"]},
{"name": "code-2", "tags": ["programming", "brackets", "development"], "category": "development", "styles": ["icons"]},
{"name": "coffee", "tags": ["drink", "cup", "cafe"], "category": "food", "styles": ["icons"]},
{"name": "cog", "tags": ["settings", "gear", "config"], "category": "ui", "styles": ["icons"]},
{"name": "command", "tags": ["keyboard", "mac", "shortcut"], "category": "devices", "styles": ["icons"]},
{"name": "compass", "tags": ["navigation", "direction", "explore"], "category": "navigation", "styles": ["icons"]},
{"name": "copy", "tags": ["duplicate", "clipboard", "clone"], "category": "actions", "styles": ["icons"]},
{"name": "credit-card", "tags": ["payment", "card", "money"], "category": "commerce", "styles": ["icons"]},
{"name": "crop", "tags": ["image", "edit", "resize"], "category": "editing", "styles": ["icons"]},
{"name": "database", "tags": ["storage", "data", "server"], "category": "development", "styles": ["icons"]},
{"name": "delete", "tags": ["remove", "trash", "erase"], "category": "actions", "styles": ["icons"]},
{"name": "download", "tags": ["save", "export", "arrow"], "category": "actions", "styles": ["icons"]},
{"name": "download-cloud", "tags": ["save", "sync", "backup"], "category": "actions", "styles": ["icons"]},
{"name": "edit", "tags": ["pencil", "write", "modify"], "category": "actions", "styles": ["icons"]},
{"name": "edit-2", "tags": ["pencil", "write", "modify"], "category": "actions", "styles": ["icons"]},
{"name": "edit-3", "tags": ["pencil", "write", "modify"], "category": "actions", "styles": ["icons"]},
{"name": "external-link", "tags": ["link", "open", "new window"], "category": "actions", "styles": ["icons"]},
{"name": "eye", "tags": ["view", "visible", "show"], "category": "actions", "styles": ["icons"]},
{"name": "eye-off", "tags": ["hide", "invisible", "hidden"], "category": "actions", "styles": ["icons"]},
{"name": "file", "tags": ["document", "page", "paper"], "category": "files", "styles": ["icons"]},
{"name": "file-plus", "tags": ["document", "add", "new"], "category": "files", "styles": ["icons"]},
{"name": "file-text", "tags": ["document", "text", "content"], "category": "files", "styles": ["icons"]},
{"name": "filter", "tags": ["funnel", "sort", "search"], "category": "actions", "styles": ["icons"]},
{"name": "flag", "tags": ["report", "mark", "country"], "category": "objects", "styles": ["icons"]},
{"name": "folder", "tags": ["directory", "files", "organize"], "category": "files", "styles": ["icons"]},
{"name": "folder-open", "tags": ["directory", "browse", "explore"], "category": "files", "styles": ["icons"]},
{"name": "folder-plus", "tags": ["directory", "add", "new"], "category": "files", "styles": ["icons"]},
{"name": "gift", "tags": ["present", "reward", "surprise"], "category": "objects", "styles": ["icons"]},
{"name": "globe", "tags": ["world", "earth", "web"], "category": "objects", "styles": ["icons"]},
{"name": "grid", "tags": ["layout", "squares", "dashboard"], "category": "layout", "styles": ["icons"]},
{"name": "hash", "tags": ["tag", "hashtag", "number"], "category": "communication", "styles": ["icons"]},
{"name": "heart", "tags": ["love", "favorite", "like"], "category": "objects", "styles": ["icons"]},
{"name": "help-circle", "tags": ["question", "help", "support"], "category": "ui", "styles": ["icons"]},
{"name": "home", "tags": ["house", "main", "dashboard"], "category": "navigation", "styles": ["icons"]},
{"name": "image", "tags": ["picture", "photo", "gallery"], "category": "media", "styles": ["icons"]},
{"name": "inbox", "tags": ["email", "messages", "mail"], "category": "communication", "styles": ["icons"]},
{"name": "info", "tags": ["information", "help", "about"], "category": "ui", "styles": ["icons"]},
{"name": "key", "tags": ["password", "security", "lock"], "category": "security", "styles": ["icons"]},
{"name": "layers", "tags": ["stack", "levels", "depth"], "category": "design", "styles": ["icons"]},
{"name": "layout", "tags": ["grid", "template", "design"], "category": "layout", "styles": ["icons"]},
{"name": "link", "tags": ["url", "chain", "connect"], "category": "actions", "styles": ["icons"]},
{"name": "list", "tags": ["menu", "items", "bullet"], "category": "text", "styles": ["icons"]},
{"name": "loader", "tags": ["loading", "spinner", "wait"], "category": "ui", "styles": ["icons"]},
{"name": "loader-2", "tags": ["loading", "spinner", "wait"], "category": "ui", "styles": ["icons"]},
{"name": "lock", "tags": ["security", "private", "password"], "category": "security", "styles": ["icons"]},
{"name": "log-in", "tags": ["login", "signin", "enter"], "category": "actions", "styles": ["icons"]},
{"name": "log-out", "tags": ["logout", "signout", "exit"], "category": "actions", "styles": ["icons"]},
{"name": "mail", "tags": ["email", "message", "envelope"], "category": "communication", "styles": ["icons"]},
{"name": "map", "tags": ["location", "navigation", "directions"], "category": "maps", "styles": ["icons"]},
{"name": "map-pin", "tags": ["location", "marker", "place"], "category": "maps", "styles": ["icons"]},
{"name": "maximize", "tags": ["fullscreen", "expand", "resize"], "category": "actions", "styles": ["icons"]},
{"name": "maximize-2", "tags": ["fullscreen", "expand", "resize"], "category": "actions", "styles": ["icons"]},
{"name": "menu", "tags": ["hamburger", "navigation", "bars"], "category": "navigation", "styles": ["icons"]},
{"name": "message-circle", "tags": ["chat", "comment", "conversation"], "category": "communication", "styles": ["icons"]},
{"name": "message-square", "tags": ["chat", "comment", "conversation"], "category": "communication", "styles": ["icons"]},
{"name": "mic", "tags": ["microphone", "audio", "record"], "category": "media", "styles": ["icons"]},
{"name": "mic-off", "tags": ["microphone", "mute", "silent"], "category": "media", "styles": ["icons"]},
{"name": "minimize", "tags": ["collapse", "shrink", "resize"], "category": "actions", "styles": ["icons"]},
{"name": "minimize-2", "tags": ["collapse", "shrink", "resize"], "category": "actions", "styles": ["icons"]},
{"name": "minus", "tags": ["subtract", "remove", "delete"], "category": "math", "styles": ["icons"]},
{"name": "minus-circle", "tags": ["subtract", "remove", "delete"], "category": "math", "styles": ["icons"]},
{"name": "monitor", "tags": ["screen", "display", "desktop"], "category": "devices", "styles": ["icons"]},
{"name": "moon", "tags": ["dark", "night", "theme"], "category": "weather", "styles": ["icons"]},
{"name": "more-horizontal", "tags": ["menu", "options", "dots"], "category": "ui", "styles": ["icons"]},
{"name": "more-vertical", "tags": ["menu", "options", "dots"], "category": "ui", "styles": ["icons"]},
{"name": "move", "tags": ["drag", "reorder", "arrows"], "category": "actions", "styles": ["icons"]},
{"name": "music", "tags": ["audio", "sound", "note"], "category": "media", "styles": ["icons"]},
{"name": "navigation", "tags": ["direction", "arrow", "location"], "category": "navigation", "styles": ["icons"]},
{"name": "package", "tags": ["box", "delivery", "shipping"], "category": "objects", "styles": ["icons"]},
{"name": "paperclip", "tags": ["attachment", "file", "clip"], "category": "actions", "styles": ["icons"]},
{"name": "pause", "tags": ["media", "stop", "wait"], "category": "media", "styles": ["icons"]},
{"name": "pencil", "tags": ["edit", "write", "draw"], "category": "actions", "styles": ["icons"]},
{"name": "phone", "tags": ["call", "contact", "mobile"], "category": "communication", "styles": ["icons"]},
{"name": "pie-chart", "tags": ["analytics", "statistics", "data"], "category": "charts", "styles": ["icons"]},
{"name": "play", "tags": ["media", "start", "video"], "category": "media", "styles": ["icons"]},
{"name": "play-circle", "tags": ["media", "start", "video"], "category": "media", "styles": ["icons"]},
{"name": "plus", "tags": ["add", "create", "new"], "category": "math", "styles": ["icons"]},
{"name": "plus-circle", "tags": ["add", "create", "new"], "category": "math", "styles": ["icons"]},
{"name": "power", "tags": ["on", "off", "shutdown"], "category": "devices", "styles": ["icons"]},
{"name": "printer", "tags": ["print", "document", "paper"], "category": "devices", "styles": ["icons"]},
{"name": "refresh-ccw", "tags": ["reload", "sync", "update"], "category": "actions", "styles": ["icons"]},
{"name": "refresh-cw", "tags": ["reload", "sync", "update"], "category": "actions", "styles": ["icons"]},
{"name": "repeat", "tags": ["loop", "replay", "refresh"], "category": "media", "styles": ["icons"]},
{"name": "rotate-ccw", "tags": ["undo", "rotate", "turn"], "category": "actions", "styles": ["icons"]},
{"name": "rotate-cw", "tags": ["redo", "rotate", "turn"], "category": "actions", "styles": ["icons"]},
{"name": "rss", "tags": ["feed", "subscribe", "blog"], "category": "communication", "styles": ["icons"]},
{"name": "save", "tags": ["disk", "floppy", "store"], "category": "actions", "styles": ["icons"]},
{"name": "scissors", "tags": ["cut", "trim", "edit"], "category": "actions", "styles": ["icons"]},
{"name": "search", "tags": ["find", "magnify", "look"], "category": "actions", "styles": ["icons"]},
{"name": "send", "tags": ["message", "email", "submit"], "category": "communication", "styles": ["icons"]},
{"name": "server", "tags": ["hosting", "database", "backend"], "category": "devices", "styles": ["icons"]},
{"name": "settings", "tags": ["gear", "cog", "preferences"], "category": "ui", "styles": ["icons"]},
{"name": "settings-2", "tags": ["sliders", "controls", "adjust"], "category": "ui", "styles": ["icons"]},
{"name": "share", "tags": ["social", "send", "forward"], "category": "actions", "styles": ["icons"]},
{"name": "share-2", "tags": ["social", "send", "network"], "category": "actions", "styles": ["icons"]},
{"name": "shield", "tags": ["security", "protection", "safe"], "category": "security", "styles": ["icons"]},
{"name": "shopping-bag", "tags": ["shop", "purchase", "buy"], "category": "commerce", "styles": ["icons"]},
{"name": "shopping-cart", "tags": ["shop", "cart", "ecommerce"], "category": "commerce", "styles": ["icons"]},
{"name": "shuffle", "tags": ["random", "mix", "music"], "category": "media", "styles": ["icons"]},
{"name": "skip-back", "tags": ["media", "previous", "rewind"], "category": "media", "styles": ["icons"]},
{"name": "skip-forward", "tags": ["media", "next", "forward"], "category": "media", "styles": ["icons"]},
{"name": "slash", "tags": ["ban", "cancel", "disabled"], "category": "ui", "styles": ["icons"]},
{"name": "sliders", "tags": ["settings", "controls", "adjust"], "category": "ui", "styles": ["icons"]},
{"name": "smartphone", "tags": ["phone", "mobile", "device"], "category": "devices", "styles": ["icons"]},
{"name": "smile", "tags": ["happy", "emoji", "face"], "category": "emoji", "styles": ["icons"]},
{"name": "sparkles", "tags": ["magic", "new", "ai"], "category": "objects", "styles": ["icons"]},
{"name": "speaker", "tags": ["audio", "sound", "volume"], "category": "media", "styles": ["icons"]},
{"name": "star", "tags": ["favorite", "rating", "bookmark"], "category": "objects", "styles": ["icons"]},
{"name": "stop-circle", "tags": ["media", "stop", "end"], "category": "media", "styles": ["icons"]},
{"name": "sun", "tags": ["light", "day", "brightness"], "category": "weather", "styles": ["icons"]},
{"name": "table", "tags": ["data", "grid", "spreadsheet"], "category": "data", "styles": ["icons"]},
{"name": "tablet", "tags": ["device", "ipad", "screen"], "category": "devices", "styles": ["icons"]},
{"name": "tag", "tags": ["label", "category", "price"], "category": "commerce", "styles": ["icons"]},
{"name": "target", "tags": ["goal", "aim", "focus"], "category": "ui", "styles": ["icons"]},
{"name": "terminal", "tags": ["console", "command", "code"], "category": "development", "styles": ["icons"]},
{"name": "thumbs-down", "tags": ["dislike", "bad", "negative"], "category": "actions", "styles": ["icons"]},
{"name": "thumbs-up", "tags": ["like", "good", "positive"], "category": "actions", "styles": ["icons"]},
{"name": "toggle-left", "tags": ["switch", "off", "disable"], "category": "ui", "styles": ["icons"]},
{"name": "toggle-right", "tags": ["switch", "on", "enable"], "category": "ui", "styles": ["icons"]},
{"name": "trash", "tags": ["delete", "remove", "bin"], "category": "actions", "styles": ["icons"]},
{"name": "trash-2", "tags": ["delete", "remove", "bin"], "category": "actions", "styles": ["icons"]},
{"name": "trending-down", "tags": ["chart", "decrease", "analytics"], "category": "charts", "styles": ["icons"]},
{"name": "trending-up", "tags": ["chart", "increase", "analytics"], "category": "charts", "styles": ["icons"]},
{"name": "triangle", "tags": ["shape", "polygon", "warning"], "category": "shapes", "styles": ["icons"]},
{"name": "truck", "tags": ["delivery", "shipping", "transport"], "category": "transportation", "styles": ["icons"]},
{"name": "tv", "tags": ["television", "screen", "display"], "category": "devices", "styles": ["icons"]},
{"name": "type", "tags": ["text", "font", "typography"], "category": "text", "styles": ["icons"]},
{"name": "umbrella", "tags": ["weather", "rain", "protection"], "category": "weather", "styles": ["icons"]},
{"name": "underline", "tags": ["text", "format", "style"], "category": "text", "styles": ["icons"]},
{"name": "undo", "tags": ["back", "reverse", "history"], "category": "actions", "styles": ["icons"]},
{"name": "unlock", "tags": ["security", "open", "access"], "category": "security", "styles": ["icons"]},
{"name": "upload", "tags": ["export", "send", "share"], "category": "actions", "styles": ["icons"]},
{"name": "upload-cloud", "tags": ["export", "sync", "backup"], "category": "actions", "styles": ["icons"]},
{"name": "user", "tags": ["person", "account", "profile"], "category": "users", "styles": ["icons"]},
{"name": "user-check", "tags": ["person", "verified", "approved"], "category": "users", "styles": ["icons"]},
{"name": "user-minus", "tags": ["person", "remove", "unfriend"], "category": "users", "styles": ["icons"]},
{"name": "user-plus", "tags": ["person", "add", "invite"], "category": "users", "styles": ["icons"]},
{"name": "user-x", "tags": ["person", "delete", "remove"], "category": "users", "styles": ["icons"]},
{"name": "users", "tags": ["people", "team", "group"], "category": "users", "styles": ["icons"]},
{"name": "video", "tags": ["camera", "film", "record"], "category": "media", "styles": ["icons"]},
{"name": "video-off", "tags": ["camera", "disabled", "mute"], "category": "media", "styles": ["icons"]},
{"name": "volume", "tags": ["sound", "audio", "speaker"], "category": "media", "styles": ["icons"]},
{"name": "volume-1", "tags": ["sound", "audio", "low"], "category": "media", "styles": ["icons"]},
{"name": "volume-2", "tags": ["sound", "audio", "high"], "category": "media", "styles": ["icons"]},
{"name": "volume-x", "tags": ["mute", "silent", "no sound"], "category": "media", "styles": ["icons"]},
{"name": "wallet", "tags": ["money", "payment", "finance"], "category": "commerce", "styles": ["icons"]},
{"name": "wifi", "tags": ["wireless", "internet", "connection"], "category": "connectivity", "styles": ["icons"]},
{"name": "wifi-off", "tags": ["wireless", "offline", "disconnect"], "category": "connectivity", "styles": ["icons"]},
{"name": "x", "tags": ["close", "cancel", "remove"], "category": "actions", "styles": ["icons"]},
{"name": "x-circle", "tags": ["close", "cancel", "error"], "category": "actions", "styles": ["icons"]},
{"name": "zap", "tags": ["lightning", "power", "energy"], "category": "objects", "styles": ["icons"]},
{"name": "zap-off", "tags": ["lightning", "disabled", "off"], "category": "objects", "styles": ["icons"]},
{"name": "zoom-in", "tags": ["magnify", "enlarge", "plus"], "category": "actions", "styles": ["icons"]},
{"name": "zoom-out", "tags": ["magnify", "shrink", "minus"], "category": "actions", "styles": ["icons"]}
]
}

View file

@ -0,0 +1,455 @@
{
"name": "Material Symbols",
"slug": "material",
"version": "latest",
"license": "Apache-2.0",
"url": "https://fonts.google.com/icons",
"styles": ["outlined", "rounded", "sharp"],
"defaultStyle": "outlined",
"viewBox": "0 0 24 24",
"cdn": {
"base": "https://cdn.jsdelivr.net/npm/@material-symbols/svg-400@0.14.5",
"pattern": "{style}/{name}.svg"
},
"icons": [
{"name": "home", "tags": ["house", "residence", "building"]},
{"name": "search", "tags": ["find", "magnify", "lookup"]},
{"name": "settings", "tags": ["cog", "gear", "preferences", "config"]},
{"name": "menu", "tags": ["hamburger", "navigation", "bars"]},
{"name": "close", "tags": ["x", "dismiss", "cancel", "remove"]},
{"name": "check", "tags": ["tick", "done", "complete", "success"]},
{"name": "add", "tags": ["plus", "new", "create"]},
{"name": "remove", "tags": ["minus", "subtract", "delete"]},
{"name": "edit", "tags": ["pencil", "modify", "change"]},
{"name": "delete", "tags": ["trash", "bin", "remove"]},
{"name": "favorite", "tags": ["heart", "love", "like"]},
{"name": "star", "tags": ["rating", "bookmark", "featured"]},
{"name": "share", "tags": ["social", "send", "distribute"]},
{"name": "person", "tags": ["user", "profile", "account"]},
{"name": "people", "tags": ["users", "group", "team"]},
{"name": "mail", "tags": ["email", "envelope", "message"]},
{"name": "call", "tags": ["phone", "telephone", "contact"]},
{"name": "chat", "tags": ["message", "conversation", "bubble"]},
{"name": "notifications", "tags": ["bell", "alert", "alarm"]},
{"name": "calendar_today", "tags": ["date", "schedule", "event"]},
{"name": "schedule", "tags": ["time", "clock", "watch"]},
{"name": "location_on", "tags": ["pin", "map", "place", "marker"]},
{"name": "directions", "tags": ["navigate", "route", "arrow"]},
{"name": "photo", "tags": ["image", "picture", "gallery"]},
{"name": "camera", "tags": ["photo", "capture", "snapshot"]},
{"name": "videocam", "tags": ["video", "movie", "record"]},
{"name": "mic", "tags": ["microphone", "audio", "voice"]},
{"name": "volume_up", "tags": ["sound", "speaker", "audio"]},
{"name": "volume_off", "tags": ["mute", "silent", "quiet"]},
{"name": "play_arrow", "tags": ["start", "begin", "media"]},
{"name": "pause", "tags": ["stop", "hold", "media"]},
{"name": "stop", "tags": ["end", "halt", "media"]},
{"name": "skip_next", "tags": ["forward", "next", "media"]},
{"name": "skip_previous", "tags": ["back", "previous", "media"]},
{"name": "replay", "tags": ["repeat", "refresh", "again"]},
{"name": "shuffle", "tags": ["random", "mix", "media"]},
{"name": "folder", "tags": ["directory", "file", "storage"]},
{"name": "folder_open", "tags": ["directory", "file", "open"]},
{"name": "file_copy", "tags": ["document", "duplicate", "clone"]},
{"name": "attach_file", "tags": ["attachment", "paperclip", "document"]},
{"name": "cloud", "tags": ["weather", "storage", "upload"]},
{"name": "cloud_upload", "tags": ["upload", "save", "storage"]},
{"name": "cloud_download", "tags": ["download", "save", "storage"]},
{"name": "download", "tags": ["save", "arrow", "get"]},
{"name": "upload", "tags": ["send", "arrow", "put"]},
{"name": "sync", "tags": ["refresh", "update", "reload"]},
{"name": "refresh", "tags": ["reload", "update", "sync"]},
{"name": "lock", "tags": ["security", "password", "secure"]},
{"name": "lock_open", "tags": ["unlock", "open", "access"]},
{"name": "visibility", "tags": ["eye", "view", "show"]},
{"name": "visibility_off", "tags": ["eye", "hide", "hidden"]},
{"name": "key", "tags": ["password", "access", "security"]},
{"name": "shield", "tags": ["security", "protect", "safe"]},
{"name": "verified", "tags": ["check", "approved", "trusted"]},
{"name": "warning", "tags": ["alert", "caution", "danger"]},
{"name": "error", "tags": ["alert", "problem", "issue"]},
{"name": "info", "tags": ["information", "help", "about"]},
{"name": "help", "tags": ["question", "support", "info"]},
{"name": "lightbulb", "tags": ["idea", "tip", "suggestion"]},
{"name": "thumb_up", "tags": ["like", "approve", "good"]},
{"name": "thumb_down", "tags": ["dislike", "reject", "bad"]},
{"name": "mood", "tags": ["emoji", "face", "happy"]},
{"name": "sentiment_satisfied", "tags": ["happy", "smile", "positive"]},
{"name": "sentiment_dissatisfied", "tags": ["sad", "unhappy", "negative"]},
{"name": "shopping_cart", "tags": ["cart", "buy", "purchase", "ecommerce"]},
{"name": "shopping_bag", "tags": ["bag", "buy", "purchase", "retail"]},
{"name": "store", "tags": ["shop", "retail", "business"]},
{"name": "payments", "tags": ["money", "card", "credit"]},
{"name": "credit_card", "tags": ["payment", "money", "bank"]},
{"name": "account_balance", "tags": ["bank", "finance", "money"]},
{"name": "receipt", "tags": ["invoice", "bill", "payment"]},
{"name": "local_shipping", "tags": ["delivery", "truck", "shipping"]},
{"name": "inventory", "tags": ["stock", "warehouse", "storage"]},
{"name": "work", "tags": ["briefcase", "job", "business"]},
{"name": "business", "tags": ["building", "company", "office"]},
{"name": "apartment", "tags": ["building", "home", "residence"]},
{"name": "school", "tags": ["education", "learning", "building"]},
{"name": "restaurant", "tags": ["food", "dining", "eat"]},
{"name": "local_cafe", "tags": ["coffee", "drink", "cup"]},
{"name": "local_bar", "tags": ["drink", "cocktail", "alcohol"]},
{"name": "fitness_center", "tags": ["gym", "exercise", "workout"]},
{"name": "sports", "tags": ["athletics", "game", "activity"]},
{"name": "directions_car", "tags": ["car", "vehicle", "auto", "drive"]},
{"name": "directions_bus", "tags": ["bus", "transit", "transport"]},
{"name": "directions_bike", "tags": ["bicycle", "cycling", "bike"]},
{"name": "directions_walk", "tags": ["walk", "pedestrian", "foot"]},
{"name": "flight", "tags": ["plane", "airplane", "travel"]},
{"name": "train", "tags": ["rail", "transit", "transport"]},
{"name": "subway", "tags": ["metro", "underground", "transit"]},
{"name": "keyboard", "tags": ["type", "input", "keys"]},
{"name": "mouse", "tags": ["cursor", "click", "input"]},
{"name": "computer", "tags": ["desktop", "pc", "monitor"]},
{"name": "laptop", "tags": ["computer", "notebook", "device"]},
{"name": "smartphone", "tags": ["phone", "mobile", "device"]},
{"name": "tablet", "tags": ["ipad", "device", "screen"]},
{"name": "watch", "tags": ["time", "wearable", "smartwatch"]},
{"name": "headphones", "tags": ["audio", "music", "listen"]},
{"name": "speaker", "tags": ["audio", "sound", "music"]},
{"name": "tv", "tags": ["television", "screen", "display"]},
{"name": "print", "tags": ["printer", "document", "paper"]},
{"name": "scanner", "tags": ["scan", "copy", "document"]},
{"name": "usb", "tags": ["cable", "port", "connect"]},
{"name": "bluetooth", "tags": ["wireless", "connect", "pair"]},
{"name": "wifi", "tags": ["wireless", "internet", "network"]},
{"name": "signal_cellular_alt", "tags": ["mobile", "network", "bars"]},
{"name": "battery_full", "tags": ["power", "charge", "energy"]},
{"name": "battery_low", "tags": ["power", "charge", "low"]},
{"name": "power", "tags": ["on", "off", "switch"]},
{"name": "bolt", "tags": ["lightning", "flash", "power"]},
{"name": "brightness_high", "tags": ["sun", "light", "display"]},
{"name": "brightness_low", "tags": ["dim", "dark", "display"]},
{"name": "dark_mode", "tags": ["night", "moon", "theme"]},
{"name": "light_mode", "tags": ["day", "sun", "theme"]},
{"name": "thermostat", "tags": ["temperature", "climate", "heat"]},
{"name": "ac_unit", "tags": ["cooling", "air", "snowflake"]},
{"name": "wb_sunny", "tags": ["sun", "weather", "bright"]},
{"name": "cloud_queue", "tags": ["weather", "cloudy", "sky"]},
{"name": "water_drop", "tags": ["rain", "liquid", "droplet"]},
{"name": "air", "tags": ["wind", "breeze", "weather"]},
{"name": "eco", "tags": ["leaf", "nature", "green"]},
{"name": "park", "tags": ["tree", "nature", "outdoor"]},
{"name": "pets", "tags": ["paw", "animal", "dog"]},
{"name": "bug_report", "tags": ["insect", "issue", "debug"]},
{"name": "code", "tags": ["programming", "developer", "brackets"]},
{"name": "terminal", "tags": ["console", "command", "shell"]},
{"name": "data_object", "tags": ["json", "braces", "code"]},
{"name": "database", "tags": ["storage", "data", "server"]},
{"name": "dns", "tags": ["server", "network", "domain"]},
{"name": "storage", "tags": ["disk", "save", "memory"]},
{"name": "memory", "tags": ["ram", "chip", "hardware"]},
{"name": "developer_board", "tags": ["circuit", "hardware", "tech"]},
{"name": "build", "tags": ["wrench", "tool", "construct"]},
{"name": "construction", "tags": ["tools", "work", "build"]},
{"name": "handyman", "tags": ["tools", "repair", "fix"]},
{"name": "science", "tags": ["flask", "chemistry", "lab"]},
{"name": "biotech", "tags": ["dna", "genetics", "science"]},
{"name": "psychology", "tags": ["brain", "mind", "mental"]},
{"name": "medical_services", "tags": ["health", "hospital", "doctor"]},
{"name": "healing", "tags": ["bandage", "medical", "health"]},
{"name": "medication", "tags": ["pill", "drug", "pharmacy"]},
{"name": "vaccines", "tags": ["syringe", "injection", "medical"]},
{"name": "accessibility", "tags": ["person", "handicap", "access"]},
{"name": "elderly", "tags": ["senior", "old", "person"]},
{"name": "child_care", "tags": ["baby", "kid", "infant"]},
{"name": "family", "tags": ["people", "group", "parents"]},
{"name": "wc", "tags": ["toilet", "bathroom", "restroom"]},
{"name": "smoking_rooms", "tags": ["cigarette", "smoke", "tobacco"]},
{"name": "no_smoking", "tags": ["prohibited", "ban", "cigarette"]},
{"name": "language", "tags": ["globe", "world", "international"]},
{"name": "translate", "tags": ["language", "convert", "localize"]},
{"name": "font_download", "tags": ["typography", "text", "typeface"]},
{"name": "format_bold", "tags": ["text", "typography", "b"]},
{"name": "format_italic", "tags": ["text", "typography", "i"]},
{"name": "format_underlined", "tags": ["text", "typography", "u"]},
{"name": "format_strikethrough", "tags": ["text", "delete", "cross"]},
{"name": "format_size", "tags": ["text", "font", "typography"]},
{"name": "format_color_text", "tags": ["color", "font", "typography"]},
{"name": "format_color_fill", "tags": ["bucket", "paint", "color"]},
{"name": "format_align_left", "tags": ["text", "paragraph", "align"]},
{"name": "format_align_center", "tags": ["text", "paragraph", "align"]},
{"name": "format_align_right", "tags": ["text", "paragraph", "align"]},
{"name": "format_align_justify", "tags": ["text", "paragraph", "align"]},
{"name": "format_list_bulleted", "tags": ["list", "ul", "bullets"]},
{"name": "format_list_numbered", "tags": ["list", "ol", "numbers"]},
{"name": "format_quote", "tags": ["quotation", "blockquote", "cite"]},
{"name": "format_indent_increase", "tags": ["indent", "tab", "spacing"]},
{"name": "format_indent_decrease", "tags": ["outdent", "tab", "spacing"]},
{"name": "link", "tags": ["url", "chain", "hyperlink"]},
{"name": "link_off", "tags": ["unlink", "broken", "disconnect"]},
{"name": "image", "tags": ["photo", "picture", "media"]},
{"name": "insert_photo", "tags": ["image", "picture", "add"]},
{"name": "add_photo_alternate", "tags": ["image", "upload", "new"]},
{"name": "crop", "tags": ["image", "resize", "cut"]},
{"name": "rotate_left", "tags": ["image", "turn", "ccw"]},
{"name": "rotate_right", "tags": ["image", "turn", "cw"]},
{"name": "flip", "tags": ["mirror", "image", "reflect"]},
{"name": "filter", "tags": ["image", "effect", "adjust"]},
{"name": "tune", "tags": ["adjust", "settings", "sliders"]},
{"name": "palette", "tags": ["color", "paint", "art"]},
{"name": "brush", "tags": ["paint", "draw", "art"]},
{"name": "create", "tags": ["pencil", "edit", "write"]},
{"name": "draw", "tags": ["pencil", "sketch", "art"]},
{"name": "gesture", "tags": ["hand", "draw", "touch"]},
{"name": "shape_line", "tags": ["line", "draw", "shape"]},
{"name": "rectangle", "tags": ["square", "shape", "box"]},
{"name": "circle", "tags": ["shape", "round", "ellipse"]},
{"name": "hexagon", "tags": ["shape", "polygon", "six"]},
{"name": "pentagon", "tags": ["shape", "polygon", "five"]},
{"name": "change_history", "tags": ["triangle", "shape", "arrow"]},
{"name": "category", "tags": ["shapes", "organize", "sort"]},
{"name": "grid_view", "tags": ["layout", "tiles", "gallery"]},
{"name": "view_list", "tags": ["layout", "rows", "lines"]},
{"name": "view_module", "tags": ["layout", "grid", "blocks"]},
{"name": "view_agenda", "tags": ["layout", "cards", "list"]},
{"name": "dashboard", "tags": ["layout", "widgets", "overview"]},
{"name": "widgets", "tags": ["components", "blocks", "modules"]},
{"name": "layers", "tags": ["stack", "overlap", "design"]},
{"name": "aspect_ratio", "tags": ["resize", "scale", "dimensions"]},
{"name": "zoom_in", "tags": ["magnify", "enlarge", "plus"]},
{"name": "zoom_out", "tags": ["shrink", "reduce", "minus"]},
{"name": "fullscreen", "tags": ["expand", "maximize", "enlarge"]},
{"name": "fullscreen_exit", "tags": ["shrink", "minimize", "reduce"]},
{"name": "center_focus_strong", "tags": ["target", "aim", "focus"]},
{"name": "filter_center_focus", "tags": ["camera", "focus", "center"]},
{"name": "panorama", "tags": ["wide", "image", "photo"]},
{"name": "photo_library", "tags": ["gallery", "images", "album"]},
{"name": "collections", "tags": ["gallery", "photos", "album"]},
{"name": "slideshow", "tags": ["presentation", "play", "images"]},
{"name": "movie", "tags": ["film", "video", "cinema"]},
{"name": "theaters", "tags": ["cinema", "film", "drama"]},
{"name": "music_note", "tags": ["audio", "song", "melody"]},
{"name": "album", "tags": ["music", "record", "disc"]},
{"name": "radio", "tags": ["music", "broadcast", "audio"]},
{"name": "equalizer", "tags": ["audio", "levels", "music"]},
{"name": "graphic_eq", "tags": ["audio", "visualizer", "music"]},
{"name": "mic_none", "tags": ["microphone", "record", "audio"]},
{"name": "record_voice_over", "tags": ["speak", "voice", "narrate"]},
{"name": "hearing", "tags": ["ear", "listen", "audio"]},
{"name": "closed_caption", "tags": ["subtitles", "cc", "text"]},
{"name": "subtitles", "tags": ["cc", "captions", "text"]},
{"name": "hd", "tags": ["quality", "high", "resolution"]},
{"name": "4k", "tags": ["quality", "ultra", "resolution"]},
{"name": "speed", "tags": ["fast", "gauge", "performance"]},
{"name": "timer", "tags": ["clock", "countdown", "stopwatch"]},
{"name": "hourglass_empty", "tags": ["time", "wait", "loading"]},
{"name": "hourglass_full", "tags": ["time", "complete", "done"]},
{"name": "access_time", "tags": ["clock", "time", "schedule"]},
{"name": "alarm", "tags": ["clock", "alert", "reminder"]},
{"name": "alarm_on", "tags": ["clock", "active", "set"]},
{"name": "alarm_off", "tags": ["clock", "disabled", "silent"]},
{"name": "snooze", "tags": ["sleep", "delay", "alarm"]},
{"name": "event", "tags": ["calendar", "date", "schedule"]},
{"name": "event_available", "tags": ["calendar", "free", "open"]},
{"name": "event_busy", "tags": ["calendar", "occupied", "booked"]},
{"name": "today", "tags": ["calendar", "now", "current"]},
{"name": "date_range", "tags": ["calendar", "period", "span"]},
{"name": "update", "tags": ["refresh", "sync", "reload"]},
{"name": "history", "tags": ["time", "past", "clock"]},
{"name": "restore", "tags": ["undo", "back", "recover"]},
{"name": "undo", "tags": ["back", "reverse", "arrow"]},
{"name": "redo", "tags": ["forward", "repeat", "arrow"]},
{"name": "content_copy", "tags": ["copy", "duplicate", "clipboard"]},
{"name": "content_cut", "tags": ["scissors", "cut", "clipboard"]},
{"name": "content_paste", "tags": ["clipboard", "paste", "insert"]},
{"name": "select_all", "tags": ["checkbox", "all", "select"]},
{"name": "save", "tags": ["disk", "floppy", "store"]},
{"name": "save_as", "tags": ["disk", "export", "store"]},
{"name": "draft", "tags": ["document", "edit", "paper"]},
{"name": "note", "tags": ["paper", "memo", "text"]},
{"name": "note_add", "tags": ["document", "new", "create"]},
{"name": "description", "tags": ["document", "file", "text"]},
{"name": "article", "tags": ["document", "text", "news"]},
{"name": "newspaper", "tags": ["news", "article", "media"]},
{"name": "feed", "tags": ["rss", "news", "stream"]},
{"name": "rss_feed", "tags": ["news", "subscribe", "blog"]},
{"name": "bookmark", "tags": ["save", "favorite", "tag"]},
{"name": "bookmark_border", "tags": ["save", "favorite", "outline"]},
{"name": "bookmarks", "tags": ["saved", "favorites", "collection"]},
{"name": "label", "tags": ["tag", "category", "badge"]},
{"name": "sell", "tags": ["tag", "price", "sale"]},
{"name": "loyalty", "tags": ["heart", "tag", "reward"]},
{"name": "new_releases", "tags": ["starburst", "badge", "new"]},
{"name": "stars", "tags": ["rating", "favorite", "best"]},
{"name": "grade", "tags": ["star", "rating", "review"]},
{"name": "military_tech", "tags": ["medal", "badge", "award"]},
{"name": "emoji_events", "tags": ["trophy", "award", "winner"]},
{"name": "workspace_premium", "tags": ["badge", "crown", "premium"]},
{"name": "verified_user", "tags": ["shield", "check", "secure"]},
{"name": "admin_panel_settings", "tags": ["shield", "gear", "admin"]},
{"name": "manage_accounts", "tags": ["user", "settings", "admin"]},
{"name": "supervised_user_circle", "tags": ["admin", "user", "manage"]},
{"name": "group", "tags": ["people", "team", "users"]},
{"name": "group_add", "tags": ["people", "team", "invite"]},
{"name": "group_remove", "tags": ["people", "team", "delete"]},
{"name": "person_add", "tags": ["user", "invite", "new"]},
{"name": "person_remove", "tags": ["user", "delete", "remove"]},
{"name": "face", "tags": ["emoji", "person", "avatar"]},
{"name": "sentiment_very_satisfied", "tags": ["happy", "joy", "smile"]},
{"name": "sentiment_neutral", "tags": ["meh", "okay", "face"]},
{"name": "sentiment_very_dissatisfied", "tags": ["angry", "upset", "mad"]},
{"name": "mood_bad", "tags": ["sad", "unhappy", "frown"]},
{"name": "sick", "tags": ["ill", "unwell", "face"]},
{"name": "masks", "tags": ["theater", "drama", "comedy"]},
{"name": "cake", "tags": ["birthday", "celebration", "party"]},
{"name": "celebration", "tags": ["party", "confetti", "event"]},
{"name": "card_giftcard", "tags": ["gift", "present", "voucher"]},
{"name": "redeem", "tags": ["gift", "box", "present"]},
{"name": "volunteer_activism", "tags": ["heart", "hand", "donate"]},
{"name": "handshake", "tags": ["deal", "agreement", "partner"]},
{"name": "diversity_3", "tags": ["people", "team", "group"]},
{"name": "public", "tags": ["globe", "world", "earth"]},
{"name": "travel_explore", "tags": ["globe", "search", "world"]},
{"name": "flag", "tags": ["country", "nation", "report"]},
{"name": "tour", "tags": ["flag", "marker", "location"]},
{"name": "place", "tags": ["pin", "location", "marker"]},
{"name": "my_location", "tags": ["gps", "target", "current"]},
{"name": "near_me", "tags": ["location", "arrow", "nearby"]},
{"name": "explore", "tags": ["compass", "navigate", "discover"]},
{"name": "navigation", "tags": ["arrow", "direction", "gps"]},
{"name": "map", "tags": ["location", "geography", "atlas"]},
{"name": "satellite", "tags": ["space", "orbit", "map"]},
{"name": "terrain", "tags": ["mountain", "landscape", "map"]},
{"name": "layers_clear", "tags": ["stack", "remove", "design"]},
{"name": "streetview", "tags": ["map", "360", "view"]},
{"name": "traffic", "tags": ["signal", "light", "road"]},
{"name": "alt_route", "tags": ["path", "alternative", "direction"]},
{"name": "merge", "tags": ["combine", "join", "arrows"]},
{"name": "call_split", "tags": ["fork", "divide", "arrows"]},
{"name": "compare_arrows", "tags": ["swap", "exchange", "switch"]},
{"name": "swap_horiz", "tags": ["exchange", "switch", "horizontal"]},
{"name": "swap_vert", "tags": ["exchange", "switch", "vertical"]},
{"name": "import_export", "tags": ["arrows", "transfer", "data"]},
{"name": "arrow_upward", "tags": ["up", "direction", "top"]},
{"name": "arrow_downward", "tags": ["down", "direction", "bottom"]},
{"name": "arrow_forward", "tags": ["right", "direction", "next"]},
{"name": "arrow_back", "tags": ["left", "direction", "previous"]},
{"name": "north", "tags": ["up", "direction", "compass"]},
{"name": "south", "tags": ["down", "direction", "compass"]},
{"name": "east", "tags": ["right", "direction", "compass"]},
{"name": "west", "tags": ["left", "direction", "compass"]},
{"name": "expand_more", "tags": ["down", "chevron", "dropdown"]},
{"name": "expand_less", "tags": ["up", "chevron", "collapse"]},
{"name": "chevron_right", "tags": ["arrow", "right", "next"]},
{"name": "chevron_left", "tags": ["arrow", "left", "previous"]},
{"name": "unfold_more", "tags": ["expand", "vertical", "arrows"]},
{"name": "unfold_less", "tags": ["collapse", "vertical", "arrows"]},
{"name": "first_page", "tags": ["start", "beginning", "arrow"]},
{"name": "last_page", "tags": ["end", "final", "arrow"]},
{"name": "subdirectory_arrow_right", "tags": ["nested", "child", "sub"]},
{"name": "subdirectory_arrow_left", "tags": ["nested", "parent", "back"]},
{"name": "trending_up", "tags": ["arrow", "growth", "increase"]},
{"name": "trending_down", "tags": ["arrow", "decline", "decrease"]},
{"name": "trending_flat", "tags": ["arrow", "stable", "neutral"]},
{"name": "show_chart", "tags": ["graph", "line", "analytics"]},
{"name": "bar_chart", "tags": ["graph", "statistics", "analytics"]},
{"name": "pie_chart", "tags": ["graph", "statistics", "donut"]},
{"name": "bubble_chart", "tags": ["graph", "data", "visualization"]},
{"name": "scatter_plot", "tags": ["graph", "data", "points"]},
{"name": "analytics", "tags": ["chart", "data", "statistics"]},
{"name": "insights", "tags": ["chart", "data", "analysis"]},
{"name": "leaderboard", "tags": ["chart", "ranking", "bars"]},
{"name": "table_chart", "tags": ["spreadsheet", "data", "grid"]},
{"name": "table_view", "tags": ["grid", "data", "spreadsheet"]},
{"name": "table_rows", "tags": ["grid", "horizontal", "data"]},
{"name": "view_column", "tags": ["grid", "vertical", "layout"]},
{"name": "view_week", "tags": ["calendar", "columns", "schedule"]},
{"name": "view_day", "tags": ["calendar", "single", "schedule"]},
{"name": "timeline", "tags": ["history", "time", "events"]},
{"name": "account_tree", "tags": ["hierarchy", "org", "structure"]},
{"name": "hub", "tags": ["network", "nodes", "connected"]},
{"name": "workspaces", "tags": ["circles", "venn", "overlap"]},
{"name": "token", "tags": ["chip", "badge", "tag"]},
{"name": "api", "tags": ["code", "integration", "connect"]},
{"name": "extension", "tags": ["puzzle", "plugin", "addon"]},
{"name": "integration_instructions", "tags": ["code", "embed", "api"]},
{"name": "webhook", "tags": ["api", "callback", "hook"]},
{"name": "javascript", "tags": ["code", "programming", "js"]},
{"name": "css", "tags": ["code", "style", "web"]},
{"name": "html", "tags": ["code", "web", "markup"]},
{"name": "php", "tags": ["code", "programming", "web"]},
{"name": "deployed_code", "tags": ["box", "package", "release"]},
{"name": "package_2", "tags": ["box", "delivery", "shipping"]},
{"name": "inventory_2", "tags": ["box", "storage", "stock"]},
{"name": "move_to_inbox", "tags": ["archive", "box", "download"]},
{"name": "outbox", "tags": ["send", "upload", "mail"]},
{"name": "inbox", "tags": ["mail", "receive", "box"]},
{"name": "all_inbox", "tags": ["mail", "messages", "combined"]},
{"name": "mark_email_read", "tags": ["mail", "open", "check"]},
{"name": "mark_email_unread", "tags": ["mail", "new", "dot"]},
{"name": "drafts", "tags": ["mail", "edit", "compose"]},
{"name": "send", "tags": ["mail", "submit", "arrow"]},
{"name": "forward_to_inbox", "tags": ["mail", "forward", "arrow"]},
{"name": "reply", "tags": ["mail", "respond", "arrow"]},
{"name": "reply_all", "tags": ["mail", "respond", "group"]},
{"name": "mark_as_unread", "tags": ["mail", "new", "notification"]},
{"name": "markunread_mailbox", "tags": ["mail", "mailbox", "new"]},
{"name": "contact_mail", "tags": ["email", "person", "address"]},
{"name": "contact_phone", "tags": ["call", "person", "number"]},
{"name": "contacts", "tags": ["people", "address", "book"]},
{"name": "perm_contact_calendar", "tags": ["person", "schedule", "date"]},
{"name": "badge", "tags": ["id", "name", "tag"]},
{"name": "assignment", "tags": ["clipboard", "task", "document"]},
{"name": "assignment_ind", "tags": ["person", "task", "assign"]},
{"name": "assignment_turned_in", "tags": ["task", "done", "check"]},
{"name": "assignment_late", "tags": ["task", "overdue", "warning"]},
{"name": "assignment_return", "tags": ["task", "back", "arrow"]},
{"name": "task", "tags": ["checkbox", "todo", "done"]},
{"name": "task_alt", "tags": ["check", "circle", "done"]},
{"name": "checklist", "tags": ["tasks", "todo", "list"]},
{"name": "fact_check", "tags": ["clipboard", "verify", "check"]},
{"name": "rule", "tags": ["check", "cross", "decision"]},
{"name": "done", "tags": ["check", "complete", "success"]},
{"name": "done_all", "tags": ["check", "double", "complete"]},
{"name": "done_outline", "tags": ["check", "circle", "complete"]},
{"name": "check_circle", "tags": ["done", "success", "complete"]},
{"name": "check_box", "tags": ["done", "tick", "checked"]},
{"name": "check_box_outline_blank", "tags": ["empty", "unchecked", "box"]},
{"name": "indeterminate_check_box", "tags": ["partial", "minus", "box"]},
{"name": "radio_button_checked", "tags": ["selected", "option", "circle"]},
{"name": "radio_button_unchecked", "tags": ["empty", "option", "circle"]},
{"name": "toggle_on", "tags": ["switch", "enabled", "active"]},
{"name": "toggle_off", "tags": ["switch", "disabled", "inactive"]},
{"name": "add_circle", "tags": ["plus", "new", "create"]},
{"name": "remove_circle", "tags": ["minus", "delete", "subtract"]},
{"name": "cancel", "tags": ["x", "close", "remove"]},
{"name": "highlight_off", "tags": ["x", "remove", "circle"]},
{"name": "do_not_disturb", "tags": ["minus", "block", "circle"]},
{"name": "block", "tags": ["prohibited", "ban", "stop"]},
{"name": "not_interested", "tags": ["circle", "slash", "disabled"]},
{"name": "report", "tags": ["octagon", "warning", "flag"]},
{"name": "report_problem", "tags": ["triangle", "warning", "alert"]},
{"name": "priority_high", "tags": ["exclamation", "important", "alert"]},
{"name": "new_label", "tags": ["tag", "badge", "add"]},
{"name": "fiber_new", "tags": ["badge", "new", "fresh"]},
{"name": "auto_awesome", "tags": ["sparkle", "magic", "ai"]},
{"name": "auto_fix_high", "tags": ["wand", "magic", "auto"]},
{"name": "flare", "tags": ["light", "sparkle", "shine"]},
{"name": "flash_on", "tags": ["lightning", "power", "camera"]},
{"name": "flash_off", "tags": ["lightning", "disabled", "camera"]},
{"name": "flash_auto", "tags": ["lightning", "auto", "camera"]},
{"name": "highlight", "tags": ["marker", "pen", "text"]},
{"name": "colorize", "tags": ["eyedropper", "pick", "color"]},
{"name": "opacity", "tags": ["transparent", "drop", "alpha"]},
{"name": "gradient", "tags": ["color", "blend", "fade"]},
{"name": "texture", "tags": ["pattern", "surface", "material"]},
{"name": "vignette", "tags": ["photo", "effect", "fade"]},
{"name": "blur_on", "tags": ["effect", "focus", "soft"]},
{"name": "blur_off", "tags": ["sharp", "clear", "focus"]},
{"name": "hdr_on", "tags": ["photo", "range", "dynamic"]},
{"name": "filter_vintage", "tags": ["flower", "retro", "effect"]},
{"name": "filter_drama", "tags": ["cloud", "weather", "sky"]},
{"name": "filter_frames", "tags": ["border", "photo", "frame"]},
{"name": "monochrome_photos", "tags": ["bw", "grayscale", "filter"]},
{"name": "photo_filter", "tags": ["star", "effect", "image"]},
{"name": "looks", "tags": ["sparkle", "enhance", "effect"]},
{"name": "straighten", "tags": ["rotate", "align", "level"]},
{"name": "transform", "tags": ["resize", "scale", "edit"]},
{"name": "style", "tags": ["format", "design", "appearance"]},
{"name": "format_paint", "tags": ["brush", "style", "copy"]}
]
}

View file

@ -0,0 +1,418 @@
{
"slug": "phosphor",
"name": "Phosphor",
"version": "2.1.1",
"icons": [
{"name": "activity", "tags": ["pulse", "health", "heart"], "category": "health", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "address-book", "tags": ["contacts", "directory", "people"], "category": "office", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "airplane", "tags": ["flight", "travel", "plane"], "category": "travel", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "airplane-in-flight", "tags": ["flight", "travel", "plane"], "category": "travel", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "airplane-landing", "tags": ["flight", "arrival", "plane"], "category": "travel", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "airplane-takeoff", "tags": ["flight", "departure", "plane"], "category": "travel", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "alarm", "tags": ["clock", "time", "alert"], "category": "time", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "alien", "tags": ["space", "ufo", "extraterrestrial"], "category": "objects", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "align-bottom", "tags": ["layout", "vertical", "align"], "category": "design", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "align-center-horizontal", "tags": ["layout", "horizontal", "align"], "category": "design", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "align-center-vertical", "tags": ["layout", "vertical", "align"], "category": "design", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "align-left", "tags": ["text", "format", "paragraph"], "category": "text", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "align-right", "tags": ["text", "format", "paragraph"], "category": "text", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "align-top", "tags": ["layout", "vertical", "align"], "category": "design", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "anchor", "tags": ["marine", "ship", "link"], "category": "objects", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "aperture", "tags": ["camera", "lens", "photo"], "category": "media", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "archive", "tags": ["box", "storage", "files"], "category": "files", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "archive-box", "tags": ["storage", "container", "files"], "category": "files", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "armchair", "tags": ["furniture", "seat", "chair"], "category": "furniture", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-arc-left", "tags": ["direction", "curved", "back"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-arc-right", "tags": ["direction", "curved", "forward"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-bend-double-up-left", "tags": ["direction", "turn", "back"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-bend-double-up-right", "tags": ["direction", "turn", "forward"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-bend-down-left", "tags": ["direction", "turn", "corner"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-bend-down-right", "tags": ["direction", "turn", "corner"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-bend-left-down", "tags": ["direction", "turn", "corner"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-bend-left-up", "tags": ["direction", "turn", "corner"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-bend-right-down", "tags": ["direction", "turn", "corner"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-bend-right-up", "tags": ["direction", "turn", "corner"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-bend-up-left", "tags": ["direction", "turn", "corner"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-bend-up-right", "tags": ["direction", "turn", "corner"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-circle-down", "tags": ["direction", "download", "circle"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-circle-down-left", "tags": ["direction", "diagonal", "circle"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-circle-down-right", "tags": ["direction", "diagonal", "circle"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-circle-left", "tags": ["direction", "back", "circle"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-circle-right", "tags": ["direction", "forward", "circle"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-circle-up", "tags": ["direction", "upload", "circle"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-circle-up-left", "tags": ["direction", "diagonal", "circle"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-circle-up-right", "tags": ["direction", "diagonal", "circle"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-clockwise", "tags": ["refresh", "reload", "sync"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-counter-clockwise", "tags": ["undo", "back", "history"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-down", "tags": ["direction", "down", "download"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-down-left", "tags": ["direction", "diagonal"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-down-right", "tags": ["direction", "diagonal"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-fat-down", "tags": ["direction", "down", "bold"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-fat-left", "tags": ["direction", "left", "bold"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-fat-right", "tags": ["direction", "right", "bold"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-fat-up", "tags": ["direction", "up", "bold"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-left", "tags": ["direction", "back", "previous"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-line-down", "tags": ["direction", "download", "end"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-line-left", "tags": ["direction", "start", "begin"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-line-right", "tags": ["direction", "end", "finish"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-line-up", "tags": ["direction", "upload", "top"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-right", "tags": ["direction", "forward", "next"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-square-down", "tags": ["direction", "download", "square"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-square-left", "tags": ["direction", "back", "square"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-square-right", "tags": ["direction", "forward", "square"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-square-up", "tags": ["direction", "upload", "square"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-u-down-left", "tags": ["direction", "turn", "uturn"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-u-down-right", "tags": ["direction", "turn", "uturn"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-u-left-down", "tags": ["direction", "turn", "uturn"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-u-left-up", "tags": ["direction", "turn", "uturn"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-u-right-down", "tags": ["direction", "turn", "uturn"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-u-right-up", "tags": ["direction", "turn", "uturn"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-u-up-left", "tags": ["direction", "turn", "uturn"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-u-up-right", "tags": ["direction", "turn", "uturn"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-up", "tags": ["direction", "up", "upload"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-up-left", "tags": ["direction", "diagonal"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrow-up-right", "tags": ["direction", "diagonal", "external"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrows-clockwise", "tags": ["refresh", "sync", "rotate"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrows-counter-clockwise", "tags": ["refresh", "sync", "rotate"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrows-down-up", "tags": ["sort", "swap", "vertical"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrows-horizontal", "tags": ["resize", "horizontal", "expand"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrows-in", "tags": ["collapse", "minimize", "shrink"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrows-in-cardinal", "tags": ["collapse", "center", "focus"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrows-in-line-horizontal", "tags": ["collapse", "horizontal"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrows-in-line-vertical", "tags": ["collapse", "vertical"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrows-in-simple", "tags": ["collapse", "minimize"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrows-left-right", "tags": ["swap", "horizontal", "exchange"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrows-merge", "tags": ["combine", "join", "merge"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrows-out", "tags": ["expand", "maximize", "fullscreen"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrows-out-cardinal", "tags": ["expand", "move", "directions"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrows-out-line-horizontal", "tags": ["expand", "horizontal"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrows-out-line-vertical", "tags": ["expand", "vertical"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrows-out-simple", "tags": ["expand", "maximize"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrows-split", "tags": ["fork", "divide", "branch"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "arrows-vertical", "tags": ["resize", "vertical", "expand"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "at", "tags": ["email", "mention", "at sign"], "category": "communication", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "atom", "tags": ["science", "physics", "nuclear"], "category": "science", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "baby", "tags": ["child", "infant", "kid"], "category": "people", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "backpack", "tags": ["bag", "school", "travel"], "category": "objects", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "bag", "tags": ["shopping", "tote", "carry"], "category": "commerce", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "bag-simple", "tags": ["shopping", "tote", "simple"], "category": "commerce", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "balloon", "tags": ["party", "celebration", "birthday"], "category": "objects", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "bank", "tags": ["finance", "money", "building"], "category": "buildings", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "barbell", "tags": ["gym", "fitness", "weight"], "category": "fitness", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "barcode", "tags": ["scan", "product", "code"], "category": "commerce", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "basket", "tags": ["shopping", "cart", "store"], "category": "commerce", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "basketball", "tags": ["sport", "ball", "game"], "category": "sports", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "bathtub", "tags": ["bathroom", "bath", "clean"], "category": "home", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "battery-charging", "tags": ["power", "energy", "charge"], "category": "devices", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "battery-charging-vertical", "tags": ["power", "energy", "charge"], "category": "devices", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "battery-empty", "tags": ["power", "energy", "empty"], "category": "devices", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "battery-full", "tags": ["power", "energy", "full"], "category": "devices", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "battery-high", "tags": ["power", "energy", "high"], "category": "devices", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "battery-low", "tags": ["power", "energy", "low"], "category": "devices", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "battery-medium", "tags": ["power", "energy", "half"], "category": "devices", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "battery-plus", "tags": ["power", "add", "charge"], "category": "devices", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "battery-vertical-empty", "tags": ["power", "energy", "empty"], "category": "devices", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "battery-vertical-full", "tags": ["power", "energy", "full"], "category": "devices", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "battery-vertical-high", "tags": ["power", "energy", "high"], "category": "devices", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "battery-vertical-low", "tags": ["power", "energy", "low"], "category": "devices", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "battery-vertical-medium", "tags": ["power", "energy", "half"], "category": "devices", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "battery-warning", "tags": ["power", "alert", "low"], "category": "devices", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "battery-warning-vertical", "tags": ["power", "alert", "low"], "category": "devices", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "bell", "tags": ["notification", "alert", "alarm"], "category": "notifications", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "bell-ringing", "tags": ["notification", "alert", "alarm"], "category": "notifications", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "bell-simple", "tags": ["notification", "alert", "simple"], "category": "notifications", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "bell-simple-ringing", "tags": ["notification", "alert", "alarm"], "category": "notifications", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "bell-simple-slash", "tags": ["notification", "mute", "off"], "category": "notifications", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "bell-simple-z", "tags": ["notification", "snooze", "sleep"], "category": "notifications", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "bell-slash", "tags": ["notification", "mute", "off"], "category": "notifications", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "bell-z", "tags": ["notification", "snooze", "sleep"], "category": "notifications", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "bluetooth", "tags": ["wireless", "connection", "device"], "category": "connectivity", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "bluetooth-connected", "tags": ["wireless", "paired", "device"], "category": "connectivity", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "bluetooth-slash", "tags": ["wireless", "disabled", "off"], "category": "connectivity", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "bluetooth-x", "tags": ["wireless", "disconnect", "error"], "category": "connectivity", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "book", "tags": ["read", "library", "education"], "category": "education", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "book-bookmark", "tags": ["saved", "favorite", "reading"], "category": "education", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "book-open", "tags": ["read", "library", "education"], "category": "education", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "book-open-text", "tags": ["read", "content", "article"], "category": "education", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "bookmark", "tags": ["save", "favorite", "tag"], "category": "actions", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "bookmark-simple", "tags": ["save", "favorite", "simple"], "category": "actions", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "bookmarks", "tags": ["saved", "favorites", "collection"], "category": "actions", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "bookmarks-simple", "tags": ["saved", "favorites", "simple"], "category": "actions", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "bounding-box", "tags": ["selection", "area", "crop"], "category": "design", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "briefcase", "tags": ["work", "job", "business"], "category": "business", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "briefcase-metal", "tags": ["work", "job", "metal"], "category": "business", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "browser", "tags": ["web", "internet", "window"], "category": "development", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "browsers", "tags": ["web", "windows", "tabs"], "category": "development", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "bug", "tags": ["insect", "error", "debug"], "category": "development", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "bug-beetle", "tags": ["insect", "beetle", "bug"], "category": "nature", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "bug-droid", "tags": ["android", "robot", "bug"], "category": "development", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "buildings", "tags": ["city", "office", "skyline"], "category": "buildings", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "calculator", "tags": ["math", "numbers", "compute"], "category": "tools", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "calendar", "tags": ["date", "schedule", "event"], "category": "time", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "calendar-blank", "tags": ["date", "schedule", "empty"], "category": "time", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "calendar-check", "tags": ["date", "done", "confirmed"], "category": "time", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "calendar-plus", "tags": ["date", "add", "new"], "category": "time", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "calendar-x", "tags": ["date", "cancel", "remove"], "category": "time", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "camera", "tags": ["photo", "picture", "image"], "category": "media", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "camera-slash", "tags": ["photo", "disabled", "off"], "category": "media", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "car", "tags": ["vehicle", "auto", "drive"], "category": "transportation", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "car-simple", "tags": ["vehicle", "auto", "simple"], "category": "transportation", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "caret-circle-down", "tags": ["arrow", "down", "circle"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "caret-circle-left", "tags": ["arrow", "left", "circle"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "caret-circle-right", "tags": ["arrow", "right", "circle"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "caret-circle-up", "tags": ["arrow", "up", "circle"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "caret-down", "tags": ["arrow", "down", "expand"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "caret-left", "tags": ["arrow", "left", "back"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "caret-right", "tags": ["arrow", "right", "forward"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "caret-up", "tags": ["arrow", "up", "collapse"], "category": "arrows", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "chart-bar", "tags": ["analytics", "graph", "data"], "category": "charts", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "chart-bar-horizontal", "tags": ["analytics", "graph", "horizontal"], "category": "charts", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "chart-line", "tags": ["analytics", "graph", "trend"], "category": "charts", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "chart-line-down", "tags": ["analytics", "decrease", "trend"], "category": "charts", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "chart-line-up", "tags": ["analytics", "increase", "trend"], "category": "charts", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "chart-pie", "tags": ["analytics", "graph", "pie"], "category": "charts", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "chart-pie-slice", "tags": ["analytics", "graph", "segment"], "category": "charts", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "chat", "tags": ["message", "conversation", "talk"], "category": "communication", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "chat-centered", "tags": ["message", "conversation", "center"], "category": "communication", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "chat-centered-dots", "tags": ["message", "typing", "dots"], "category": "communication", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "chat-centered-text", "tags": ["message", "text", "conversation"], "category": "communication", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "chat-circle", "tags": ["message", "conversation", "circle"], "category": "communication", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "chat-circle-dots", "tags": ["message", "typing", "circle"], "category": "communication", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "chat-circle-text", "tags": ["message", "text", "circle"], "category": "communication", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "chat-dots", "tags": ["message", "typing", "dots"], "category": "communication", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "chat-text", "tags": ["message", "text", "conversation"], "category": "communication", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "chats", "tags": ["messages", "conversations", "multiple"], "category": "communication", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "chats-circle", "tags": ["messages", "conversations", "circles"], "category": "communication", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "check", "tags": ["done", "complete", "success"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "check-circle", "tags": ["done", "complete", "circle"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "check-square", "tags": ["done", "checkbox", "complete"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "circle", "tags": ["shape", "dot", "round"], "category": "shapes", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "circle-dashed", "tags": ["shape", "dashed", "border"], "category": "shapes", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "circle-half", "tags": ["shape", "half", "contrast"], "category": "shapes", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "circle-half-tilt", "tags": ["shape", "half", "tilt"], "category": "shapes", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "circle-notch", "tags": ["loading", "spinner", "progress"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "clipboard", "tags": ["copy", "paste", "board"], "category": "actions", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "clipboard-text", "tags": ["copy", "paste", "text"], "category": "actions", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "clock", "tags": ["time", "watch", "schedule"], "category": "time", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "clock-afternoon", "tags": ["time", "pm", "afternoon"], "category": "time", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "clock-clockwise", "tags": ["time", "rotate", "forward"], "category": "time", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "clock-counter-clockwise", "tags": ["time", "rotate", "back"], "category": "time", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "cloud", "tags": ["weather", "storage", "sky"], "category": "weather", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "cloud-arrow-down", "tags": ["download", "cloud", "save"], "category": "actions", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "cloud-arrow-up", "tags": ["upload", "cloud", "sync"], "category": "actions", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "cloud-check", "tags": ["cloud", "done", "synced"], "category": "weather", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "cloud-fog", "tags": ["weather", "fog", "mist"], "category": "weather", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "cloud-lightning", "tags": ["weather", "storm", "thunder"], "category": "weather", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "cloud-moon", "tags": ["weather", "night", "cloudy"], "category": "weather", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "cloud-rain", "tags": ["weather", "rain", "precipitation"], "category": "weather", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "cloud-slash", "tags": ["cloud", "offline", "disconnect"], "category": "weather", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "cloud-snow", "tags": ["weather", "snow", "winter"], "category": "weather", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "cloud-sun", "tags": ["weather", "day", "partly cloudy"], "category": "weather", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "cloud-warning", "tags": ["cloud", "alert", "error"], "category": "weather", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "cloud-x", "tags": ["cloud", "error", "disconnect"], "category": "weather", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "code", "tags": ["programming", "html", "brackets"], "category": "development", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "code-block", "tags": ["programming", "snippet", "code"], "category": "development", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "code-simple", "tags": ["programming", "brackets", "simple"], "category": "development", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "coffee", "tags": ["drink", "cup", "cafe"], "category": "food", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "command", "tags": ["keyboard", "mac", "shortcut"], "category": "devices", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "compass", "tags": ["navigation", "direction", "explore"], "category": "navigation", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "copy", "tags": ["duplicate", "clipboard", "clone"], "category": "actions", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "copy-simple", "tags": ["duplicate", "clone", "simple"], "category": "actions", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "credit-card", "tags": ["payment", "card", "money"], "category": "commerce", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "crop", "tags": ["image", "edit", "resize"], "category": "editing", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "cube", "tags": ["3d", "box", "shape"], "category": "shapes", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "currency-btc", "tags": ["bitcoin", "crypto", "currency"], "category": "commerce", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "currency-dollar", "tags": ["money", "usd", "currency"], "category": "commerce", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "currency-eur", "tags": ["money", "euro", "currency"], "category": "commerce", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "currency-gbp", "tags": ["money", "pound", "currency"], "category": "commerce", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "currency-jpy", "tags": ["money", "yen", "currency"], "category": "commerce", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "cursor", "tags": ["pointer", "mouse", "click"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "cursor-click", "tags": ["pointer", "click", "select"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "database", "tags": ["storage", "data", "server"], "category": "development", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "desktop", "tags": ["computer", "monitor", "screen"], "category": "devices", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "desktop-tower", "tags": ["computer", "pc", "tower"], "category": "devices", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "device-mobile", "tags": ["phone", "mobile", "smartphone"], "category": "devices", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "device-tablet", "tags": ["tablet", "ipad", "device"], "category": "devices", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "download", "tags": ["save", "export", "arrow"], "category": "actions", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "download-simple", "tags": ["save", "export", "simple"], "category": "actions", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "envelope", "tags": ["email", "mail", "message"], "category": "communication", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "envelope-open", "tags": ["email", "mail", "read"], "category": "communication", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "envelope-simple", "tags": ["email", "mail", "simple"], "category": "communication", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "eye", "tags": ["view", "visible", "show"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "eye-closed", "tags": ["hide", "invisible", "hidden"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "eye-slash", "tags": ["hide", "invisible", "hidden"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "file", "tags": ["document", "page", "paper"], "category": "files", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "file-arrow-down", "tags": ["download", "document", "save"], "category": "files", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "file-arrow-up", "tags": ["upload", "document", "export"], "category": "files", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "file-code", "tags": ["code", "programming", "document"], "category": "files", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "file-plus", "tags": ["add", "new", "document"], "category": "files", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "file-text", "tags": ["document", "text", "content"], "category": "files", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "filter", "tags": ["funnel", "sort", "search"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "flag", "tags": ["report", "mark", "country"], "category": "objects", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "folder", "tags": ["directory", "files", "organize"], "category": "files", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "folder-open", "tags": ["directory", "browse", "explore"], "category": "files", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "folder-plus", "tags": ["directory", "add", "new"], "category": "files", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "funnel", "tags": ["filter", "sort", "funnel"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "gear", "tags": ["settings", "cog", "config"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "gear-six", "tags": ["settings", "cog", "config"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "gift", "tags": ["present", "reward", "surprise"], "category": "objects", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "globe", "tags": ["world", "earth", "web"], "category": "objects", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "globe-hemisphere-east", "tags": ["world", "earth", "asia"], "category": "objects", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "globe-hemisphere-west", "tags": ["world", "earth", "americas"], "category": "objects", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "grid-four", "tags": ["layout", "grid", "squares"], "category": "layout", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "hand", "tags": ["palm", "stop", "gesture"], "category": "gestures", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "hand-pointing", "tags": ["finger", "point", "gesture"], "category": "gestures", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "hash", "tags": ["tag", "hashtag", "number"], "category": "communication", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "heart", "tags": ["love", "favorite", "like"], "category": "objects", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "heart-break", "tags": ["love", "broken", "sad"], "category": "objects", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "house", "tags": ["home", "building", "main"], "category": "buildings", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "house-simple", "tags": ["home", "building", "simple"], "category": "buildings", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "image", "tags": ["picture", "photo", "gallery"], "category": "media", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "image-square", "tags": ["picture", "photo", "square"], "category": "media", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "info", "tags": ["information", "help", "about"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "key", "tags": ["password", "security", "lock"], "category": "security", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "keyboard", "tags": ["typing", "input", "keys"], "category": "devices", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "laptop", "tags": ["computer", "notebook", "device"], "category": "devices", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "link", "tags": ["url", "chain", "connect"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "link-break", "tags": ["unlink", "disconnect", "broken"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "list", "tags": ["menu", "items", "bullet"], "category": "text", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "list-bullets", "tags": ["menu", "items", "bullet"], "category": "text", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "list-checks", "tags": ["tasks", "checklist", "todo"], "category": "text", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "list-numbers", "tags": ["menu", "items", "ordered"], "category": "text", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "lock", "tags": ["security", "private", "password"], "category": "security", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "lock-key", "tags": ["security", "password", "key"], "category": "security", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "lock-key-open", "tags": ["security", "unlocked", "open"], "category": "security", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "lock-open", "tags": ["security", "unlocked", "open"], "category": "security", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "magnifying-glass", "tags": ["search", "find", "zoom"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "magnifying-glass-minus", "tags": ["zoom out", "search", "smaller"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "magnifying-glass-plus", "tags": ["zoom in", "search", "bigger"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "map-pin", "tags": ["location", "marker", "place"], "category": "maps", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "map-trifold", "tags": ["navigation", "directions", "map"], "category": "maps", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "megaphone", "tags": ["announce", "broadcast", "marketing"], "category": "communication", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "microphone", "tags": ["audio", "voice", "record"], "category": "media", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "microphone-slash", "tags": ["audio", "mute", "off"], "category": "media", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "minus", "tags": ["subtract", "remove", "delete"], "category": "math", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "minus-circle", "tags": ["subtract", "remove", "circle"], "category": "math", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "moon", "tags": ["dark", "night", "theme"], "category": "weather", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "moon-stars", "tags": ["night", "dark", "stars"], "category": "weather", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "music-note", "tags": ["audio", "sound", "melody"], "category": "media", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "music-notes", "tags": ["audio", "sound", "melody"], "category": "media", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "navigation-arrow", "tags": ["direction", "compass", "arrow"], "category": "navigation", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "paper-plane", "tags": ["send", "message", "mail"], "category": "communication", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "paper-plane-right", "tags": ["send", "submit", "forward"], "category": "communication", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "paper-plane-tilt", "tags": ["send", "message", "tilt"], "category": "communication", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "paperclip", "tags": ["attachment", "file", "clip"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "paperclip-horizontal", "tags": ["attachment", "file", "horizontal"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "pause", "tags": ["media", "stop", "wait"], "category": "media", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "pause-circle", "tags": ["media", "stop", "circle"], "category": "media", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "pencil", "tags": ["edit", "write", "draw"], "category": "actions", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "pencil-line", "tags": ["edit", "write", "underline"], "category": "actions", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "pencil-simple", "tags": ["edit", "write", "simple"], "category": "actions", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "phone", "tags": ["call", "contact", "mobile"], "category": "communication", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "phone-call", "tags": ["call", "ringing", "incoming"], "category": "communication", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "play", "tags": ["media", "start", "video"], "category": "media", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "play-circle", "tags": ["media", "start", "circle"], "category": "media", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "plus", "tags": ["add", "create", "new"], "category": "math", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "plus-circle", "tags": ["add", "create", "circle"], "category": "math", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "power", "tags": ["on", "off", "shutdown"], "category": "devices", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "printer", "tags": ["print", "document", "paper"], "category": "devices", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "question", "tags": ["help", "support", "faq"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "rocket", "tags": ["launch", "startup", "space"], "category": "objects", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "rocket-launch", "tags": ["launch", "startup", "deploy"], "category": "objects", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "rss", "tags": ["feed", "subscribe", "blog"], "category": "communication", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "rss-simple", "tags": ["feed", "subscribe", "simple"], "category": "communication", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "scissors", "tags": ["cut", "trim", "edit"], "category": "actions", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "share", "tags": ["social", "send", "forward"], "category": "actions", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "share-network", "tags": ["social", "connect", "network"], "category": "actions", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "shield", "tags": ["security", "protection", "safe"], "category": "security", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "shield-check", "tags": ["security", "verified", "safe"], "category": "security", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "shopping-bag", "tags": ["shop", "purchase", "buy"], "category": "commerce", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "shopping-cart", "tags": ["shop", "cart", "ecommerce"], "category": "commerce", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "sign-in", "tags": ["login", "enter", "access"], "category": "actions", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "sign-out", "tags": ["logout", "exit", "leave"], "category": "actions", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "sliders", "tags": ["settings", "controls", "adjust"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "sliders-horizontal", "tags": ["settings", "controls", "horizontal"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "smiley", "tags": ["emoji", "happy", "face"], "category": "emoji", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "smiley-sad", "tags": ["emoji", "sad", "face"], "category": "emoji", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "sparkle", "tags": ["magic", "new", "shine"], "category": "objects", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "speaker-high", "tags": ["audio", "volume", "loud"], "category": "media", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "speaker-low", "tags": ["audio", "volume", "quiet"], "category": "media", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "speaker-none", "tags": ["audio", "mute", "silent"], "category": "media", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "speaker-simple-high", "tags": ["audio", "volume", "loud"], "category": "media", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "speaker-simple-low", "tags": ["audio", "volume", "quiet"], "category": "media", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "speaker-simple-none", "tags": ["audio", "mute", "silent"], "category": "media", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "speaker-simple-slash", "tags": ["audio", "mute", "off"], "category": "media", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "speaker-simple-x", "tags": ["audio", "mute", "off"], "category": "media", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "speaker-slash", "tags": ["audio", "mute", "off"], "category": "media", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "speaker-x", "tags": ["audio", "mute", "off"], "category": "media", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "spinner", "tags": ["loading", "wait", "progress"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "spinner-gap", "tags": ["loading", "wait", "progress"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "star", "tags": ["favorite", "rating", "bookmark"], "category": "objects", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "star-four", "tags": ["sparkle", "magic", "star"], "category": "objects", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "star-half", "tags": ["rating", "half", "partial"], "category": "objects", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "stop", "tags": ["media", "stop", "end"], "category": "media", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "stop-circle", "tags": ["media", "stop", "circle"], "category": "media", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "sun", "tags": ["light", "day", "brightness"], "category": "weather", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "sun-dim", "tags": ["light", "dim", "brightness"], "category": "weather", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "tag", "tags": ["label", "category", "price"], "category": "commerce", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "target", "tags": ["goal", "aim", "focus"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "terminal", "tags": ["console", "command", "code"], "category": "development", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "terminal-window", "tags": ["console", "command", "window"], "category": "development", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "text-aa", "tags": ["font", "typography", "text"], "category": "text", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "text-align-center", "tags": ["text", "format", "center"], "category": "text", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "text-align-justify", "tags": ["text", "format", "justify"], "category": "text", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "text-align-left", "tags": ["text", "format", "left"], "category": "text", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "text-align-right", "tags": ["text", "format", "right"], "category": "text", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "text-b", "tags": ["bold", "format", "strong"], "category": "text", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "text-italic", "tags": ["italic", "format", "emphasis"], "category": "text", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "text-strikethrough", "tags": ["strikethrough", "format", "delete"], "category": "text", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "text-underline", "tags": ["underline", "format", "text"], "category": "text", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "thumbs-down", "tags": ["dislike", "bad", "negative"], "category": "gestures", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "thumbs-up", "tags": ["like", "good", "positive"], "category": "gestures", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "ticket", "tags": ["event", "pass", "coupon"], "category": "objects", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "timer", "tags": ["time", "countdown", "clock"], "category": "time", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "toggle-left", "tags": ["switch", "off", "disable"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "toggle-right", "tags": ["switch", "on", "enable"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "trash", "tags": ["delete", "remove", "bin"], "category": "actions", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "trash-simple", "tags": ["delete", "remove", "simple"], "category": "actions", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "trend-down", "tags": ["chart", "decrease", "analytics"], "category": "charts", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "trend-up", "tags": ["chart", "increase", "analytics"], "category": "charts", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "trophy", "tags": ["award", "winner", "achievement"], "category": "awards", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "truck", "tags": ["delivery", "shipping", "transport"], "category": "transportation", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "tv", "tags": ["television", "screen", "display"], "category": "devices", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "upload", "tags": ["export", "send", "share"], "category": "actions", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "upload-simple", "tags": ["export", "send", "simple"], "category": "actions", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "user", "tags": ["person", "account", "profile"], "category": "users", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "user-circle", "tags": ["avatar", "profile", "account"], "category": "users", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "user-circle-gear", "tags": ["settings", "account", "config"], "category": "users", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "user-circle-minus", "tags": ["remove", "unfriend", "user"], "category": "users", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "user-circle-plus", "tags": ["add", "invite", "user"], "category": "users", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "user-focus", "tags": ["target", "focus", "user"], "category": "users", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "user-gear", "tags": ["settings", "account", "config"], "category": "users", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "user-list", "tags": ["contacts", "directory", "users"], "category": "users", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "user-minus", "tags": ["remove", "unfriend", "delete"], "category": "users", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "user-plus", "tags": ["add", "invite", "new"], "category": "users", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "user-rectangle", "tags": ["id", "card", "profile"], "category": "users", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "user-square", "tags": ["avatar", "profile", "square"], "category": "users", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "users", "tags": ["people", "team", "group"], "category": "users", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "users-four", "tags": ["people", "team", "group"], "category": "users", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "users-three", "tags": ["people", "team", "group"], "category": "users", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "video-camera", "tags": ["video", "film", "record"], "category": "media", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "video-camera-slash", "tags": ["video", "off", "mute"], "category": "media", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "wallet", "tags": ["money", "payment", "finance"], "category": "commerce", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "warning", "tags": ["alert", "error", "caution"], "category": "alerts", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "warning-circle", "tags": ["alert", "error", "circle"], "category": "alerts", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "warning-diamond", "tags": ["alert", "error", "diamond"], "category": "alerts", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "warning-octagon", "tags": ["alert", "stop", "error"], "category": "alerts", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "wifi-high", "tags": ["wireless", "internet", "strong"], "category": "connectivity", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "wifi-low", "tags": ["wireless", "internet", "weak"], "category": "connectivity", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "wifi-medium", "tags": ["wireless", "internet", "medium"], "category": "connectivity", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "wifi-none", "tags": ["wireless", "offline", "disconnect"], "category": "connectivity", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "wifi-slash", "tags": ["wireless", "offline", "disconnect"], "category": "connectivity", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "wifi-x", "tags": ["wireless", "error", "disconnect"], "category": "connectivity", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "wrench", "tags": ["tool", "settings", "repair"], "category": "tools", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "x", "tags": ["close", "cancel", "remove"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "x-circle", "tags": ["close", "cancel", "error"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]},
{"name": "x-square", "tags": ["close", "cancel", "remove"], "category": "ui", "styles": ["regular", "bold", "light", "thin", "fill", "duotone"]}
]
}

View file

@ -0,0 +1,124 @@
=== Maple Icons ===
Contributors: jetrails
Tags: icons, svg, gutenberg, block, heroicons, lucide, feather, phosphor, material
Requires at least: 6.5
Tested up to: 6.7
Stable tag: 1.0.0
Requires PHP: 7.4
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Insert beautiful open-source icons into your content with a Gutenberg block. Download icon sets from CDN and serve locally.
== Description ==
Maple Icons provides a simple way to insert high-quality, open-source icons into your WordPress content using the Gutenberg block editor.
= Features =
* **Multiple Icon Sets** - Choose from popular open-source icon libraries:
* Heroicons (~290 icons) - MIT License
* Lucide (~1400 icons) - ISC License
* Feather (~287 icons) - MIT License
* Phosphor (~1200 icons per style) - MIT License
* Material Symbols (~400 icons) - Apache 2.0 License
* **Local Storage** - Icons are downloaded from CDN and stored locally in your WordPress installation. No external requests are made when displaying icons on your site.
* **Gutenberg Block** - Easy-to-use block with:
* Search and filter icons
* Multiple style variants per set
* Size control (12px - 256px)
* Custom icon color
* Background color
* Padding and margin controls
* Stroke width adjustment
* Drop shadow effect
* **Performance Optimized**
* Icons are inline SVG - no additional HTTP requests
* No frontend JavaScript or CSS
* Works in RSS feeds and email
* **Accessible**
* Decorative icons automatically hidden from screen readers
* Optional accessible labels for meaningful icons
* Follows WCAG guidelines
= How It Works =
1. Go to Settings → Maple Icons
2. Download one or more icon sets
3. Set one icon set as active
4. In the Gutenberg editor, add a "Maple Icon" block
5. Search and select an icon
6. Customize size, color, and other settings
= Icon Storage =
Downloaded icons are stored in `wp-content/maple-icons/`. Each icon set is stored in its own subdirectory with the SVG files organized by style.
= Compatibility =
* WordPress 6.5+
* PHP 7.4+
* WooCommerce (HPOS compatible)
* Works with all properly coded themes
* Compatible with popular page builders that support Gutenberg blocks
== Installation ==
1. Upload the `maple-icons` folder to the `/wp-content/plugins/` directory
2. Activate the plugin through the 'Plugins' menu in WordPress
3. Go to Settings → Maple Icons to download icon sets
4. Start using the "Maple Icon" block in your content
== Frequently Asked Questions ==
= Are the icons free to use? =
Yes! All icon sets included are open-source with permissive licenses (MIT, ISC, or Apache 2.0) that allow commercial use.
= Can I use multiple icon sets? =
You can download multiple icon sets, but only one can be active at a time. The active set is what appears in the Gutenberg block icon picker.
= Where are the icons stored? =
Icons are downloaded from CDN (jsdelivr.net) and stored locally in `wp-content/maple-icons/`. Once downloaded, no external requests are made to display icons.
= How do I change the icon color? =
Icons automatically inherit the text color from your theme. You can also set a custom color in the block settings panel.
= Will icons work in RSS feeds? =
Yes! Icons are saved as inline SVG in your post content, so they work in RSS feeds, email newsletters, and anywhere else your content is displayed.
= What happens if I uninstall the plugin? =
Existing icons in your content will remain as inline SVG. However, you won't be able to add new icons or use the block settings. The downloaded icon files in `wp-content/maple-icons/` will be removed on uninstall.
= Is this plugin GDPR compliant? =
Yes. Maple Icons does not collect any user data, set cookies, or make external requests after icons are downloaded. All icon files are stored locally on your server.
== Screenshots ==
1. Settings page - Download and manage icon sets
2. Icon picker modal in Gutenberg editor
3. Block settings panel with customization options
4. Icon block rendered on the frontend
== Changelog ==
= 1.0.0 =
* Initial release
* Support for Heroicons, Lucide, Feather, Phosphor, and Material Symbols
* Gutenberg block with full customization options
* Local icon storage
== Upgrade Notice ==
= 1.0.0 =
Initial release of Maple Icons.

View file

@ -0,0 +1,5 @@
<?php
// Silence is golden.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}

View file

@ -0,0 +1,62 @@
<?php
/**
* Maple Icons Uninstall
*
* Runs when the plugin is uninstalled (deleted).
* Cleans up all plugin data including downloaded icons and settings.
*
* @package MapleIcons
*/
// Exit if not called by WordPress.
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
exit;
}
/**
* Remove plugin options.
*/
delete_option( 'maple_icons_settings' );
/**
* Remove transients.
*/
global $wpdb;
$wpdb->query(
"DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_mi_%' OR option_name LIKE '_transient_timeout_mi_%'"
);
/**
* Remove downloaded icons directory.
*/
$icons_dir = WP_CONTENT_DIR . '/maple-icons/';
if ( is_dir( $icons_dir ) ) {
/**
* Recursively delete a directory and its contents.
*
* @param string $dir Directory path.
* @return bool True on success, false on failure.
*/
function mi_delete_directory( $dir ) {
if ( ! is_dir( $dir ) ) {
return false;
}
$files = array_diff( scandir( $dir ), array( '.', '..' ) );
foreach ( $files as $file ) {
$path = $dir . '/' . $file;
if ( is_dir( $path ) ) {
mi_delete_directory( $path );
} else {
unlink( $path );
}
}
return rmdir( $dir );
}
mi_delete_directory( $icons_dir );
}

View file

@ -0,0 +1,228 @@
# Maple Performance WP 🍁
A lightweight, privacy-focused WordPress performance plugin. No external dependencies, no tracking, no upsells.
**Built by [Maple Open Tech](https://mapleopentech.ca) for Canadian businesses who care about data sovereignty.**
## Features
### Page Caching
- Static HTML file generation
- Gzip pre-compression
- Brotli pre-compression (if PHP extension installed)
- Smart cache invalidation on content updates
- Automatic exclusion for logged-in users
### Asset Optimization
- **HTML**: Minification, comment removal
- **CSS**: Minification, aggregation, optional async loading
- **JavaScript**: Minification, aggregation (disabled by default for safety)
- **Google Fonts**: Combine multiple requests, add display:swap, optional deferred loading
### Lazy Loading
- Native `loading="lazy"` for images
- Native `loading="lazy"` for iframes
- Exclude LCP/hero images from lazy loading
### Extra Optimizations
- Remove WordPress emoji scripts
- Remove query strings from static resources
- DNS prefetch hints
- Preconnect to third-party domains
### Smart Plugin Compatibility
Maple Performance automatically detects these plugins and applies safe exclusions:
| Plugin | Automatic Protections |
|--------|----------------------|
| **WooCommerce** | Cart/checkout/account pages excluded from cache, WooCommerce cookies bypass cache, cart fragments and checkout scripts protected |
| **LearnDash** | Lesson/topic/quiz pages excluded, progress tracking AJAX protected, quiz scripts excluded from aggregation |
| **WPForms** | Form validation scripts excluded, AJAX submissions protected |
| **Wordfence** | Security scripts excluded, login pages not cached, firewall bypass cookies respected |
| **Gravity Forms** | Form scripts excluded from aggregation |
| **Contact Form 7** | Form scripts excluded from aggregation |
| **Elementor** | Builder scripts excluded from aggregation |
## Site Modes
Select your site type and Maple Performance automatically applies safe defaults:
| Site Type | JS Aggregate | CSS Defer | Expected Score |
|-----------|--------------|-----------|----------------|
| Brochure/Blog | ✅ Available | ✅ Available | 80-95 |
| WooCommerce | ❌ Disabled | ❌ Disabled | 70-85 |
| LearnDash | ❌ Disabled | ❌ Disabled | 70-85 |
| WooCommerce + LearnDash | ❌ Disabled | ❌ Disabled | 65-80 |
## Installation
### From GitHub
1. Download or clone this repository
2. Upload the `maple-performance-wp` folder to `/wp-content/plugins/`
3. Activate through WordPress admin
4. Go to Settings > Maple Performance
5. Select your site type and configure
### From WordPress Admin
1. Go to Plugins > Add New
2. Upload the zip file
3. Activate and configure
## Configuration
### Brochure Sites (Maximum Performance)
```
Site Mode: Brochure
Cache: Enabled
HTML Minify: On
CSS Minify: On
CSS Aggregate: On
CSS Defer: On (optional)
JS Minify: On
JS Aggregate: On
Lazy Load: On
```
### WooCommerce Sites (Safe Defaults)
```
Site Mode: WooCommerce
Cache: Enabled (cart/checkout excluded)
HTML Minify: On
CSS Minify: On
CSS Aggregate: On
CSS Defer: Off
JS Minify: On
JS Aggregate: Off (protects checkout)
Lazy Load: On
```
### LearnDash Sites (Safe Defaults)
```
Site Mode: LearnDash
Cache: Enabled (lessons excluded for logged-in)
HTML Minify: On
CSS Minify: On
CSS Aggregate: On
CSS Defer: Off
JS Minify: On
JS Aggregate: Off (protects tracking)
Lazy Load: On
```
## Privacy
This plugin:
- ✅ Processes everything locally on your server
- ✅ Makes zero external API calls
- ✅ Sends no data to third parties
- ✅ Has no tracking or analytics
- ✅ Has no premium upsells or nags
- ✅ Is fully open source (GPL-2.0)
## Privacy & GDPR Compliance
Maple Performance WP is designed with privacy as a core principle:
| Aspect | Status |
|--------|--------|
| Personal data collection | ❌ None |
| Cookies set by plugin | ❌ None |
| External connections | ❌ None |
| Tracking/analytics | ❌ None |
| Third-party services | ❌ None |
### What the plugin stores
- **Page cache**: Static HTML copies of publicly-visible pages (same content any visitor sees)
- **Asset cache**: Aggregated/minified CSS and JS files
- **Settings**: Your plugin configuration (no personal data)
### Cookie behavior
The plugin **reads** existing cookies (WordPress login, WooCommerce cart) only to determine whether to serve cached content. It never sets, modifies, or transmits cookie data.
### Privacy Policy
The plugin automatically registers suggested privacy policy text with WordPress (Settings → Privacy) that you can include in your site's privacy policy.
## Requirements
- WordPress 5.9+
- PHP 7.4+
- Write access to `wp-content/cache/`
## Cache Location
Cached files are stored in:
```
wp-content/cache/maple-performance/
├── assets/ # Aggregated CSS/JS files
├── {domain}/ # Page cache by domain
│ └── {path}/ # Page cache by URL path
│ ├── https-index.html
│ ├── https-index.html.gz
│ └── https-index.html.br
```
## Hooks & Filters
### Actions
```php
// Clear all cache
do_action( 'maple_performance_clear_cache' );
// After cache is cleared
do_action( 'maple_performance_cache_cleared' );
```
### Filters
```php
// Modify settings programmatically
add_filter( 'maple_performance_settings', function( $settings ) {
$settings['cache_enabled'] = false;
return $settings;
});
// Exclude specific URLs from caching
add_filter( 'maple_performance_exclude_url', function( $exclude, $url ) {
if ( strpos( $url, '/members/' ) !== false ) {
return true;
}
return $exclude;
}, 10, 2 );
```
## Comparison
| Feature | Maple Performance | Autoptimize | Cache Enabler | WP Rocket |
|---------|------------------|-------------|---------------|-----------|
| Page Cache | ✅ | ❌ | ✅ | ✅ |
| CSS/JS Minify | ✅ | ✅ | ❌ | ✅ |
| CSS/JS Aggregate | ✅ | ✅ | ❌ | ✅ |
| Google Fonts Optimization | ✅ | ✅ | ❌ | ✅ |
| External Dependencies | ❌ None | ⚠️ Optional | ⚠️ CDN promo | ✅ Required |
| Tracking | ❌ None | ⚠️ News feed | ❌ None | ✅ License |
| Upsells | ❌ None | ✅ Yes | ✅ Yes | N/A (paid) |
| WooCommerce Safe Mode | ✅ | ❌ | ❌ | ✅ |
| LearnDash Safe Mode | ✅ | ❌ | ❌ | ❌ |
| Price | Free | Free | Free | $59/year |
## Contributing
Contributions are welcome! Please feel free to submit issues and pull requests.
## License
GPL-2.0-or-later - see [LICENSE](LICENSE) for details.
## Credits
Built by [Maple Open Tech](https://mapleopentech.ca) 🍁
Inspired by the core concepts from Autoptimize and Cache Enabler, rebuilt from scratch with privacy and safety in mind.

View file

@ -0,0 +1,11 @@
<?php
/**
* Silence is golden.
*
* @package MaplePerformance
*/
// Prevent direct access
if ( ! defined( 'ABSPATH' ) ) {
exit;
}

View file

@ -0,0 +1,837 @@
<?php
/**
* Admin Settings Class
*
* Handles plugin settings page and admin functionality.
*
* @package MaplePerformance
*/
// Prevent direct access
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Admin class
*/
class Maple_Performance_Admin {
/**
* Single instance
*/
private static $instance = null;
/**
* Get instance
*/
public static function get_instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor
*/
private function __construct() {
$this->init_hooks();
}
/**
* Initialize hooks
*/
private function init_hooks() {
// Add admin menu
add_action( 'admin_menu', array( $this, 'add_menu' ) );
// Register settings
add_action( 'admin_init', array( $this, 'register_settings' ) );
// Admin bar cache clear button
add_action( 'admin_bar_menu', array( $this, 'add_admin_bar_button' ), 100 );
// Handle cache clear action
add_action( 'admin_init', array( $this, 'handle_cache_clear' ) );
// Admin notices
add_action( 'admin_notices', array( $this, 'admin_notices' ) );
// Admin styles
add_action( 'admin_enqueue_scripts', array( $this, 'admin_styles' ) );
// Plugin action links
add_filter( 'plugin_action_links_' . plugin_basename( MAPLE_PERF_FILE ), array( $this, 'action_links' ) );
// Privacy policy
add_action( 'admin_init', array( $this, 'add_privacy_policy_content' ) );
}
/**
* Add privacy policy content
*/
public function add_privacy_policy_content() {
if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) {
return;
}
$content = sprintf(
'<h2>%s</h2><p>%s</p><h3>%s</h3><p>%s</p><h3>%s</h3><p>%s</p><h3>%s</h3><p>%s</p>',
__( 'Maple Performance WP', 'maple-performance' ),
__( 'This site uses the Maple Performance WP plugin to improve page load times through caching and asset optimization.', 'maple-performance' ),
__( 'What personal data we collect and why', 'maple-performance' ),
__( 'Maple Performance WP does not collect, store, or process any personal data. The plugin caches publicly-visible page content to improve performance. By default, pages are not cached for logged-in users, ensuring that no user-specific content is stored in the cache.', 'maple-performance' ),
__( 'Cookies', 'maple-performance' ),
__( 'Maple Performance WP does not set any cookies. The plugin may read existing cookies (such as WordPress login cookies or WooCommerce cart cookies) solely to determine whether to serve a cached page or bypass the cache for dynamic content. No cookie data is stored or transmitted.', 'maple-performance' ),
__( 'Third-party services', 'maple-performance' ),
__( 'Maple Performance WP does not connect to any external services or transmit any data to third parties. All caching and optimization is performed locally on your web server.', 'maple-performance' )
);
wp_add_privacy_policy_content(
'Maple Performance WP',
wp_kses_post( $content )
);
}
/**
* Add admin menu
*/
public function add_menu() {
add_options_page(
__( 'Maple Performance', 'maple-performance' ),
__( 'Maple Performance', 'maple-performance' ),
'manage_options',
'maple-performance',
array( $this, 'settings_page' )
);
}
/**
* Register settings
*/
public function register_settings() {
register_setting(
'maple_performance_settings',
'maple_performance_settings',
array( $this, 'sanitize_settings' )
);
}
/**
* Sanitize settings
*/
public function sanitize_settings( $input ) {
$sanitized = array();
// Site mode
$sanitized['site_mode'] = sanitize_text_field( $input['site_mode'] ?? 'brochure' );
// Google Fonts mode
$valid_font_modes = array( 'leave', 'remove', 'combine', 'defer' );
$sanitized['google_fonts'] = in_array( $input['google_fonts'] ?? 'defer', $valid_font_modes )
? $input['google_fonts']
: 'defer';
// Plugin compatibility
$sanitized['compat_auto_detect'] = ! empty( $input['compat_auto_detect'] );
// Manual plugin selection
$valid_compat_plugins = array( 'woocommerce', 'learndash', 'wordfence', 'wpforms', 'gutenberg_fse' );
$sanitized['compat_plugins'] = array();
if ( ! empty( $input['compat_plugins'] ) && is_array( $input['compat_plugins'] ) ) {
foreach ( $input['compat_plugins'] as $plugin ) {
if ( in_array( $plugin, $valid_compat_plugins ) ) {
$sanitized['compat_plugins'][] = $plugin;
}
}
}
// Boolean settings
$booleans = array(
'cache_enabled', 'cache_logged_in', 'cache_gzip', 'cache_brotli',
'html_minify', 'html_remove_comments',
'css_minify', 'css_aggregate', 'css_inline_aggregate', 'css_defer',
'js_minify', 'js_aggregate', 'js_defer', 'js_exclude_jquery',
'remove_emojis', 'remove_query_strings', 'dns_prefetch',
'lazyload_images', 'lazyload_iframes',
'local_font_display',
);
foreach ( $booleans as $key ) {
$sanitized[ $key ] = ! empty( $input[ $key ] );
}
// Integer settings
$sanitized['cache_expiry'] = absint( $input['cache_expiry'] ?? 0 );
// Array settings (textarea, one per line)
$arrays = array(
'exclude_paths', 'exclude_cookies', 'exclude_js', 'exclude_css',
'preconnect_domains', 'lazyload_exclude', 'preload_fonts',
);
foreach ( $arrays as $key ) {
$value = $input[ $key ] ?? '';
if ( is_string( $value ) ) {
$lines = explode( "\n", $value );
$sanitized[ $key ] = array_filter( array_map( function( $line ) {
// Sanitize each line - remove potentially dangerous characters
$line = trim( $line );
$line = str_replace( array( "\0", "\r" ), '', $line );
// For URLs/paths, use esc_url_raw for domains, sanitize_text_field for others
if ( strpos( $line, 'http' ) === 0 || strpos( $line, '//' ) === 0 ) {
return esc_url_raw( $line );
}
return sanitize_text_field( $line );
}, $lines ) );
} else {
$sanitized[ $key ] = array();
}
}
// Clear cache when settings change
maple_performance()->clear_cache();
return $sanitized;
}
/**
* Add admin bar button
*/
public function add_admin_bar_button( $wp_admin_bar ) {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
$wp_admin_bar->add_node( array(
'id' => 'maple-performance',
'title' => '<span class="ab-icon dashicons dashicons-performance" style="font-family: dashicons; font-size: 20px; padding-top: 4px;"></span>' . __( 'Maple Cache', 'maple-performance' ),
'href' => '#',
) );
$wp_admin_bar->add_node( array(
'parent' => 'maple-performance',
'id' => 'maple-clear-cache',
'title' => __( 'Clear All Cache', 'maple-performance' ),
'href' => wp_nonce_url( admin_url( 'admin.php?action=maple_clear_cache' ), 'maple_clear_cache' ),
) );
$wp_admin_bar->add_node( array(
'parent' => 'maple-performance',
'id' => 'maple-settings',
'title' => __( 'Settings', 'maple-performance' ),
'href' => admin_url( 'options-general.php?page=maple-performance' ),
) );
// Show cache stats - use transient to avoid filesystem scan on every page load
$stats = get_transient( 'maple_perf_cache_stats' );
if ( false === $stats ) {
// Only calculate if not cached, with a 5-minute expiry
$stats = array(
'count' => Maple_Performance_Cache::get_cache_count(),
'size' => Maple_Performance_Cache::get_cache_size(),
);
set_transient( 'maple_perf_cache_stats', $stats, 5 * MINUTE_IN_SECONDS );
}
$wp_admin_bar->add_node( array(
'parent' => 'maple-performance',
'id' => 'maple-cache-stats',
'title' => sprintf( __( 'Cache: %d pages (%s)', 'maple-performance' ), $stats['count'], size_format( $stats['size'] ) ),
'href' => admin_url( 'options-general.php?page=maple-performance' ),
) );
}
/**
* Handle cache clear action
*/
public function handle_cache_clear() {
if ( ! isset( $_GET['action'] ) || $_GET['action'] !== 'maple_clear_cache' ) {
return;
}
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( __( 'Unauthorized', 'maple-performance' ) );
}
if ( ! wp_verify_nonce( $_GET['_wpnonce'] ?? '', 'maple_clear_cache' ) ) {
wp_die( __( 'Invalid nonce', 'maple-performance' ) );
}
maple_performance()->clear_cache();
// Redirect back with notice
$redirect = remove_query_arg( array( 'action', '_wpnonce' ), wp_get_referer() );
$redirect = add_query_arg( 'maple_cache_cleared', '1', $redirect );
wp_safe_redirect( $redirect );
exit;
}
/**
* Admin notices
*/
public function admin_notices() {
// Show activation conflict notice (only once, immediately after activation)
$activation_conflicts = get_transient( 'maple_perf_activation_conflict' );
if ( false !== $activation_conflicts && is_array( $activation_conflicts ) ) {
delete_transient( 'maple_perf_activation_conflict' );
echo '<div class="notice notice-error">';
echo '<p><strong>' . esc_html__( '⚠️ Maple Performance WP - Important!', 'maple-performance' ) . '</strong></p>';
echo '<p>' . sprintf(
esc_html__( 'Another caching plugin is already active: %s', 'maple-performance' ),
'<strong>' . esc_html( implode( ', ', $activation_conflicts ) ) . '</strong>'
) . '</p>';
echo '<p>' . esc_html__( 'Running multiple caching plugins can cause site errors, blank pages, or performance issues.', 'maple-performance' ) . '</p>';
echo '<p>';
echo '<a href="' . esc_url( admin_url( 'plugins.php' ) ) . '" class="button button-primary">' . esc_html__( 'Choose which plugin to keep', 'maple-performance' ) . '</a> ';
echo '<a href="' . esc_url( admin_url( 'options-general.php?page=maple-performance' ) ) . '" class="button">' . esc_html__( 'Go to Settings', 'maple-performance' ) . '</a>';
echo '</p>';
echo '</div>';
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display check
if ( isset( $_GET['maple_cache_cleared'] ) ) {
echo '<div class="notice notice-success is-dismissible">';
echo '<p>' . esc_html__( 'Maple Performance: Cache cleared successfully.', 'maple-performance' ) . '</p>';
echo '</div>';
}
// Check if settings were saved
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display check
$page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
if ( isset( $_GET['settings-updated'] ) && $page === 'maple-performance' ) {
echo '<div class="notice notice-success is-dismissible">';
echo '<p>' . esc_html__( 'Maple Performance: Settings saved and cache cleared.', 'maple-performance' ) . '</p>';
echo '</div>';
}
}
/**
* Admin styles
*/
public function admin_styles( $hook ) {
if ( $hook !== 'settings_page_maple-performance' ) {
return;
}
wp_add_inline_style( 'common', '
.maple-settings { max-width: 900px; }
.maple-settings h2 { border-bottom: 1px solid #ccc; padding-bottom: 10px; margin-top: 30px; }
.maple-settings h2:first-of-type { margin-top: 10px; }
.maple-settings table { margin-bottom: 20px; }
.maple-settings .description { color: #666; font-style: italic; }
.maple-settings textarea { width: 100%; max-width: 400px; }
.maple-settings .site-mode-card {
border: 2px solid #ddd;
padding: 15px;
margin: 10px 0;
border-radius: 5px;
cursor: pointer;
}
.maple-settings .site-mode-card:hover { border-color: #2271b1; }
.maple-settings .site-mode-card.selected { border-color: #2271b1; background: #f0f7fc; }
.maple-settings .site-mode-card h4 { margin: 0 0 5px; }
.maple-settings .warning { color: #d63638; }
.maple-settings .safe { color: #00a32a; }
.maple-cache-stats {
background: #f0f0f1;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.maple-cache-stats strong { font-size: 1.2em; }
.maple-detected-plugins {
background: #f9f9f9;
border: 1px solid #ddd;
border-radius: 5px;
padding: 15px 20px;
margin-bottom: 25px;
}
.maple-detected-plugins h3 { margin-top: 0; }
.maple-detected-plugins table { margin-top: 15px; }
.maple-detected-plugins ul li { font-size: 13px; color: #555; }
' );
}
/**
* Plugin action links
*/
public function action_links( $links ) {
$settings_link = '<a href="' . admin_url( 'options-general.php?page=maple-performance' ) . '">' . __( 'Settings', 'maple-performance' ) . '</a>';
$clear_link = '<a href="' . wp_nonce_url( admin_url( 'admin.php?action=maple_clear_cache' ), 'maple_clear_cache' ) . '">' . __( 'Clear Cache', 'maple-performance' ) . '</a>';
array_unshift( $links, $settings_link, $clear_link );
return $links;
}
/**
* Settings page
*/
public function settings_page() {
$settings = maple_performance()->settings;
// Get cache stats
$cache_count = Maple_Performance_Cache::get_cache_count();
$cache_size = Maple_Performance_Cache::get_cache_size();
?>
<div class="wrap maple-settings">
<h1><?php _e( 'Maple Performance WP', 'maple-performance' ); ?></h1>
<p><?php _e( 'A lightweight, privacy-focused performance plugin. No external dependencies, no tracking, no upsells.', 'maple-performance' ); ?></p>
<div class="maple-cache-stats">
<strong><?php _e( 'Cache Status:', 'maple-performance' ); ?></strong>
<?php printf( __( '%d cached pages (%s)', 'maple-performance' ), $cache_count, size_format( $cache_size ) ); ?>
&nbsp;&nbsp;
<a href="<?php echo wp_nonce_url( admin_url( 'admin.php?action=maple_clear_cache' ), 'maple_clear_cache' ); ?>" class="button button-secondary">
<?php _e( 'Clear All Cache', 'maple-performance' ); ?>
</a>
</div>
<?php
// Get compat class for detection info
$compat = class_exists( 'Maple_Performance_Compat' ) ? Maple_Performance_Compat::get_instance() : null;
$detected_plugins = $compat ? $compat->get_detected() : array();
?>
<form method="post" action="options.php">
<?php settings_fields( 'maple_performance_settings' ); ?>
<h2><?php _e( 'Plugin Compatibility', 'maple-performance' ); ?></h2>
<p class="description"><?php _e( 'Configure compatibility rules for common plugins. These rules protect critical functionality like checkout, form submissions, and course tracking.', 'maple-performance' ); ?></p>
<table class="form-table">
<tr>
<th scope="row"><?php _e( 'Detection Mode', 'maple-performance' ); ?></th>
<td>
<label>
<input type="checkbox" name="maple_performance_settings[compat_auto_detect]" value="1" <?php checked( $settings['compat_auto_detect'] ); ?> id="compat_auto_detect">
<?php _e( 'Auto-detect installed plugins', 'maple-performance' ); ?>
</label>
<p class="description">
<?php _e( 'When enabled, Maple Performance will attempt to detect plugins automatically. This can be fragile if plugins change their file structure. <strong>Manual selection below is recommended for reliability.</strong>', 'maple-performance' ); ?>
</p>
</td>
</tr>
<tr id="manual_compat_row">
<th scope="row"><?php _e( 'Enable Compatibility For', 'maple-performance' ); ?></th>
<td>
<fieldset>
<label style="display: block; margin-bottom: 12px;">
<input type="checkbox" name="maple_performance_settings[compat_plugins][]" value="woocommerce" <?php checked( in_array( 'woocommerce', $settings['compat_plugins'] ?? array() ) ); ?>>
<strong>WooCommerce</strong>
<?php if ( ! empty( $detected_plugins['woocommerce'] ) ) : ?>
<span class="safe" style="margin-left: 5px;"> Detected</span>
<?php endif; ?>
<br>
<span class="description" style="margin-left: 24px;">Excludes cart, checkout, account pages from cache. Protects payment gateway scripts. Bypasses cache when cart has items.</span>
</label>
<label style="display: block; margin-bottom: 12px;">
<input type="checkbox" name="maple_performance_settings[compat_plugins][]" value="learndash" <?php checked( in_array( 'learndash', $settings['compat_plugins'] ?? array() ) ); ?>>
<strong>LearnDash</strong>
<?php if ( ! empty( $detected_plugins['learndash'] ) ) : ?>
<span class="safe" style="margin-left: 5px;"> Detected</span>
<?php endif; ?>
<br>
<span class="description" style="margin-left: 24px;">Excludes lesson, topic, quiz pages from cache. Protects progress tracking and quiz submission scripts.</span>
</label>
<label style="display: block; margin-bottom: 12px;">
<input type="checkbox" name="maple_performance_settings[compat_plugins][]" value="wpforms" <?php checked( in_array( 'wpforms', $settings['compat_plugins'] ?? array() ) ); ?>>
<strong>WPForms</strong>
<?php if ( ! empty( $detected_plugins['wpforms'] ) ) : ?>
<span class="safe" style="margin-left: 5px;"> Detected</span>
<?php endif; ?>
<br>
<span class="description" style="margin-left: 24px;">Protects form validation and submission scripts. Ensures AJAX form submissions work correctly.</span>
</label>
<label style="display: block; margin-bottom: 12px;">
<input type="checkbox" name="maple_performance_settings[compat_plugins][]" value="wordfence" <?php checked( in_array( 'wordfence', $settings['compat_plugins'] ?? array() ) ); ?>>
<strong>Wordfence</strong>
<?php if ( ! empty( $detected_plugins['wordfence'] ) ) : ?>
<span class="safe" style="margin-left: 5px;"> Detected</span>
<?php endif; ?>
<br>
<span class="description" style="margin-left: 24px;">Excludes login pages from cache. Respects firewall bypass cookies. Protects security scripts.</span>
</label>
<label style="display: block; margin-bottom: 12px;">
<input type="checkbox" name="maple_performance_settings[compat_plugins][]" value="gutenberg_fse" <?php checked( in_array( 'gutenberg_fse', $settings['compat_plugins'] ?? array() ) ); ?>>
<strong>Gutenberg / Block Themes (FSE)</strong>
<?php if ( ! empty( $detected_plugins['gutenberg_fse'] ) ) : ?>
<span class="safe" style="margin-left: 5px;"> Detected</span>
<?php endif; ?>
<br>
<span class="description" style="margin-left: 24px;">Protects global styles and block CSS. Safe optimization for Full Site Editing themes (Twenty Twenty-Two, etc.).</span>
</label>
</fieldset>
<p class="description" style="margin-top: 10px;">
<?php _e( '<strong>Tip:</strong> Select plugins you have installed even if not detected. Detection can miss plugins due to naming changes between versions.', 'maple-performance' ); ?>
</p>
</td>
</tr>
</table>
<script>
(function() {
var autoDetect = document.getElementById('compat_auto_detect');
var manualRow = document.getElementById('manual_compat_row');
function toggleManual() {
if (autoDetect.checked) {
manualRow.style.opacity = '0.5';
manualRow.querySelectorAll('input').forEach(function(el) {
el.disabled = true;
});
} else {
manualRow.style.opacity = '1';
manualRow.querySelectorAll('input').forEach(function(el) {
el.disabled = false;
});
}
}
autoDetect.addEventListener('change', toggleManual);
toggleManual();
})();
</script>
<h2><?php _e( 'Site Mode', 'maple-performance' ); ?></h2>
<p class="description"><?php _e( 'Select your site type to apply safe default settings. This affects which optimizations are enabled.', 'maple-performance' ); ?></p>
<table class="form-table">
<tr>
<th scope="row"><?php _e( 'Site Type', 'maple-performance' ); ?></th>
<td>
<select name="maple_performance_settings[site_mode]" id="site_mode">
<option value="brochure" <?php selected( $settings['site_mode'], 'brochure' ); ?>>
<?php _e( 'Brochure / Blog (No ecommerce or LMS)', 'maple-performance' ); ?>
</option>
<option value="woocommerce" <?php selected( $settings['site_mode'], 'woocommerce' ); ?>>
<?php _e( 'WooCommerce Store', 'maple-performance' ); ?>
</option>
<option value="learndash" <?php selected( $settings['site_mode'], 'learndash' ); ?>>
<?php _e( 'LearnDash LMS', 'maple-performance' ); ?>
</option>
<option value="woo_learndash" <?php selected( $settings['site_mode'], 'woo_learndash' ); ?>>
<?php _e( 'WooCommerce + LearnDash', 'maple-performance' ); ?>
</option>
</select>
<p class="description">
<?php _e( '<strong>Brochure:</strong> Full optimization enabled. <strong>WooCommerce/LearnDash:</strong> Conservative settings to protect checkout and lesson tracking.', 'maple-performance' ); ?>
</p>
</td>
</tr>
</table>
<h2><?php esc_html_e( 'Page Caching', 'maple-performance' ); ?></h2>
<?php
// Check for caching conflicts
$compat = Maple_Performance_Compat::get_instance();
if ( method_exists( $compat, 'get_caching_conflicts' ) ) {
$caching_conflicts = $compat->get_caching_conflicts();
if ( ! empty( $caching_conflicts ) ) {
echo '<div class="notice notice-warning inline" style="margin: 10px 0;">';
echo '<p><strong>' . esc_html__( '⚠️ Another caching plugin detected:', 'maple-performance' ) . '</strong> ';
echo esc_html( implode( ', ', $caching_conflicts ) ) . '</p>';
echo '<p>' . esc_html__( 'Consider disabling Page Cache below and using only the CSS/JS/HTML optimizations to avoid conflicts.', 'maple-performance' ) . '</p>';
echo '</div>';
}
}
?>
<table class="form-table">
<tr>
<th scope="row"><?php esc_html_e( 'Enable Page Cache', 'maple-performance' ); ?></th>
<td>
<label>
<input type="checkbox" name="maple_performance_settings[cache_enabled]" value="1" <?php checked( $settings['cache_enabled'] ); ?>>
<?php esc_html_e( 'Create static HTML files for faster delivery', 'maple-performance' ); ?>
</label>
</td>
</tr>
<tr>
<th scope="row"><?php _e( 'Cache Expiry', 'maple-performance' ); ?></th>
<td>
<input type="number" name="maple_performance_settings[cache_expiry]" value="<?php echo esc_attr( $settings['cache_expiry'] ); ?>" min="0" step="1" class="small-text">
<?php _e( 'hours (0 = never expires, cleared on content updates)', 'maple-performance' ); ?>
</td>
</tr>
<tr>
<th scope="row"><?php _e( 'Pre-compression', 'maple-performance' ); ?></th>
<td>
<label>
<input type="checkbox" name="maple_performance_settings[cache_gzip]" value="1" <?php checked( $settings['cache_gzip'] ); ?>>
<?php _e( 'Create Gzip compressed versions', 'maple-performance' ); ?>
</label>
<br>
<label>
<input type="checkbox" name="maple_performance_settings[cache_brotli]" value="1" <?php checked( $settings['cache_brotli'] ); ?> <?php disabled( ! function_exists( 'brotli_compress' ) ); ?>>
<?php _e( 'Create Brotli compressed versions', 'maple-performance' ); ?>
<?php if ( ! function_exists( 'brotli_compress' ) ) : ?>
<span class="description">(<?php _e( 'PHP Brotli extension not installed', 'maple-performance' ); ?>)</span>
<?php endif; ?>
</label>
</td>
</tr>
</table>
<h2><?php _e( 'HTML Optimization', 'maple-performance' ); ?></h2>
<table class="form-table">
<tr>
<th scope="row"><?php _e( 'Minify HTML', 'maple-performance' ); ?></th>
<td>
<label>
<input type="checkbox" name="maple_performance_settings[html_minify]" value="1" <?php checked( $settings['html_minify'] ); ?>>
<?php _e( 'Remove unnecessary whitespace from HTML', 'maple-performance' ); ?>
</label>
</td>
</tr>
<tr>
<th scope="row"><?php _e( 'Remove Comments', 'maple-performance' ); ?></th>
<td>
<label>
<input type="checkbox" name="maple_performance_settings[html_remove_comments]" value="1" <?php checked( $settings['html_remove_comments'] ); ?>>
<?php _e( 'Remove HTML comments (except IE conditionals)', 'maple-performance' ); ?>
</label>
</td>
</tr>
</table>
<h2><?php _e( 'CSS Optimization', 'maple-performance' ); ?></h2>
<table class="form-table">
<tr>
<th scope="row"><?php _e( 'Minify CSS', 'maple-performance' ); ?></th>
<td>
<label>
<input type="checkbox" name="maple_performance_settings[css_minify]" value="1" <?php checked( $settings['css_minify'] ); ?>>
<?php _e( 'Remove whitespace and comments from CSS', 'maple-performance' ); ?>
</label>
</td>
</tr>
<tr>
<th scope="row"><?php _e( 'Aggregate CSS', 'maple-performance' ); ?></th>
<td>
<label>
<input type="checkbox" name="maple_performance_settings[css_aggregate]" value="1" <?php checked( $settings['css_aggregate'] ); ?>>
<?php _e( 'Combine CSS files into one', 'maple-performance' ); ?>
</label>
</td>
</tr>
<tr>
<th scope="row"><?php _e( 'Defer CSS', 'maple-performance' ); ?></th>
<td>
<label>
<input type="checkbox" name="maple_performance_settings[css_defer]" value="1" <?php checked( $settings['css_defer'] ); ?>>
<?php _e( 'Load CSS asynchronously (may cause flash of unstyled content)', 'maple-performance' ); ?>
</label>
<p class="description warning"><?php _e( '⚠️ Not recommended for WooCommerce/LearnDash sites', 'maple-performance' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><?php _e( 'Exclude CSS', 'maple-performance' ); ?></th>
<td>
<textarea name="maple_performance_settings[exclude_css]" rows="4" placeholder="handle-name&#10;another-handle"><?php echo esc_textarea( implode( "\n", $settings['exclude_css'] ) ); ?></textarea>
<p class="description"><?php _e( 'CSS handles to exclude from optimization (one per line)', 'maple-performance' ); ?></p>
</td>
</tr>
</table>
<h2><?php _e( 'JavaScript Optimization', 'maple-performance' ); ?></h2>
<table class="form-table">
<tr>
<th scope="row"><?php _e( 'Minify JavaScript', 'maple-performance' ); ?></th>
<td>
<label>
<input type="checkbox" name="maple_performance_settings[js_minify]" value="1" <?php checked( $settings['js_minify'] ); ?>>
<?php _e( 'Remove whitespace and comments from JavaScript', 'maple-performance' ); ?>
</label>
</td>
</tr>
<tr>
<th scope="row"><?php _e( 'Aggregate JavaScript', 'maple-performance' ); ?></th>
<td>
<label>
<input type="checkbox" name="maple_performance_settings[js_aggregate]" value="1" <?php checked( $settings['js_aggregate'] ); ?>>
<?php _e( 'Combine JavaScript files into one', 'maple-performance' ); ?>
</label>
<p class="description warning"><?php _e( '⚠️ Can break WooCommerce checkout and LearnDash tracking. Test thoroughly!', 'maple-performance' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><?php _e( 'Exclude jQuery', 'maple-performance' ); ?></th>
<td>
<label>
<input type="checkbox" name="maple_performance_settings[js_exclude_jquery]" value="1" <?php checked( $settings['js_exclude_jquery'] ); ?>>
<?php _e( 'Keep jQuery loading separately (recommended)', 'maple-performance' ); ?>
</label>
</td>
</tr>
<tr>
<th scope="row"><?php _e( 'Exclude JavaScript', 'maple-performance' ); ?></th>
<td>
<textarea name="maple_performance_settings[exclude_js]" rows="4" placeholder="handle-name&#10;woocommerce&#10;learndash"><?php echo esc_textarea( implode( "\n", $settings['exclude_js'] ) ); ?></textarea>
<p class="description"><?php _e( 'JavaScript handles/strings to exclude from optimization (one per line)', 'maple-performance' ); ?></p>
</td>
</tr>
</table>
<h2><?php _e( 'Lazy Loading', 'maple-performance' ); ?></h2>
<table class="form-table">
<tr>
<th scope="row"><?php _e( 'Lazy Load Images', 'maple-performance' ); ?></th>
<td>
<label>
<input type="checkbox" name="maple_performance_settings[lazyload_images]" value="1" <?php checked( $settings['lazyload_images'] ); ?>>
<?php _e( 'Add loading="lazy" to images', 'maple-performance' ); ?>
</label>
</td>
</tr>
<tr>
<th scope="row"><?php _e( 'Lazy Load Iframes', 'maple-performance' ); ?></th>
<td>
<label>
<input type="checkbox" name="maple_performance_settings[lazyload_iframes]" value="1" <?php checked( $settings['lazyload_iframes'] ); ?>>
<?php _e( 'Add loading="lazy" to iframes (YouTube, maps, etc.)', 'maple-performance' ); ?>
</label>
</td>
</tr>
<tr>
<th scope="row"><?php _e( 'Exclude from Lazy Load', 'maple-performance' ); ?></th>
<td>
<textarea name="maple_performance_settings[lazyload_exclude]" rows="4" placeholder="wp-image-12345&#10;hero-image&#10;logo"><?php echo esc_textarea( implode( "\n", $settings['lazyload_exclude'] ) ); ?></textarea>
<p class="description"><?php _e( 'Image class names or strings to exclude from lazy loading (one per line). Exclude your LCP/hero image.', 'maple-performance' ); ?></p>
</td>
</tr>
</table>
<h2><?php _e( 'Extra Optimizations', 'maple-performance' ); ?></h2>
<table class="form-table">
<tr>
<th scope="row"><?php _e( 'Google Fonts', 'maple-performance' ); ?></th>
<td>
<select name="maple_performance_settings[google_fonts]" id="google_fonts">
<option value="leave" <?php selected( $settings['google_fonts'], 'leave' ); ?>>
<?php _e( 'Leave as is', 'maple-performance' ); ?>
</option>
<option value="remove" <?php selected( $settings['google_fonts'], 'remove' ); ?>>
<?php _e( 'Remove Google Fonts', 'maple-performance' ); ?>
</option>
<option value="combine" <?php selected( $settings['google_fonts'], 'combine' ); ?>>
<?php _e( 'Combine and link in head (with display:swap)', 'maple-performance' ); ?>
</option>
<option value="defer" <?php selected( $settings['google_fonts'], 'defer' ); ?>>
<?php _e( 'Combine and load deferred (non-render-blocking, with display:swap)', 'maple-performance' ); ?>
</option>
</select>
<p class="description">
<?php _e( '<strong>Defer</strong> is recommended - loads fonts without blocking page render.', 'maple-performance' ); ?>
</p>
</td>
</tr>
<tr>
<th scope="row"><?php _e( 'Local Font Display', 'maple-performance' ); ?></th>
<td>
<label>
<input type="checkbox" name="maple_performance_settings[local_font_display]" value="1" <?php checked( $settings['local_font_display'] ?? true ); ?>>
<?php _e( 'Add font-display: swap to theme/plugin fonts', 'maple-performance' ); ?>
</label>
<p class="description">
<?php _e( 'Ensures text remains visible while local fonts load. Fixes "Ensure text remains visible during webfont load" warning.', 'maple-performance' ); ?>
</p>
</td>
</tr>
<tr>
<th scope="row"><?php _e( 'Preload Fonts', 'maple-performance' ); ?></th>
<td>
<textarea name="maple_performance_settings[preload_fonts]" rows="3" class="large-text code"><?php
echo esc_textarea( implode( "\n", $settings['preload_fonts'] ?? array() ) );
?></textarea>
<p class="description">
<?php _e( 'Enter font URLs to preload (one per line). This breaks the CSS → Font chain and improves LCP. Use .woff2 files for best results.', 'maple-performance' ); ?>
<br>
<?php _e( '<strong>Example:</strong> /wp-content/themes/yourtheme/fonts/font-name.woff2', 'maple-performance' ); ?>
</p>
</td>
</tr>
<tr>
<th scope="row"><?php _e( 'Remove Emojis', 'maple-performance' ); ?></th>
<td>
<label>
<input type="checkbox" name="maple_performance_settings[remove_emojis]" value="1" <?php checked( $settings['remove_emojis'] ); ?>>
<?php _e( 'Remove WordPress emoji CSS and JavaScript', 'maple-performance' ); ?>
</label>
</td>
</tr>
<tr>
<th scope="row"><?php _e( 'Remove Query Strings', 'maple-performance' ); ?></th>
<td>
<label>
<input type="checkbox" name="maple_performance_settings[remove_query_strings]" value="1" <?php checked( $settings['remove_query_strings'] ); ?>>
<?php _e( 'Remove ?ver= from static resource URLs', 'maple-performance' ); ?>
</label>
</td>
</tr>
<tr>
<th scope="row"><?php _e( 'DNS Prefetch', 'maple-performance' ); ?></th>
<td>
<label>
<input type="checkbox" name="maple_performance_settings[dns_prefetch]" value="1" <?php checked( $settings['dns_prefetch'] ); ?>>
<?php _e( 'Add DNS prefetch hints for external domains', 'maple-performance' ); ?>
</label>
</td>
</tr>
<tr>
<th scope="row"><?php _e( 'Preconnect Domains', 'maple-performance' ); ?></th>
<td>
<textarea name="maple_performance_settings[preconnect_domains]" rows="4" placeholder="https://www.googletagmanager.com&#10;https://fonts.googleapis.com"><?php echo esc_textarea( implode( "\n", $settings['preconnect_domains'] ) ); ?></textarea>
<p class="description"><?php _e( 'Third-party domains to preconnect to (one per line, include https://)', 'maple-performance' ); ?></p>
</td>
</tr>
</table>
<h2><?php _e( 'Cache Exclusions', 'maple-performance' ); ?></h2>
<table class="form-table">
<tr>
<th scope="row"><?php _e( 'Exclude Paths', 'maple-performance' ); ?></th>
<td>
<textarea name="maple_performance_settings[exclude_paths]" rows="4" placeholder="/cart/&#10;/checkout/&#10;/my-account/"><?php echo esc_textarea( implode( "\n", $settings['exclude_paths'] ) ); ?></textarea>
<p class="description"><?php _e( 'URL paths to exclude from caching (one per line)', 'maple-performance' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><?php _e( 'Exclude Cookies', 'maple-performance' ); ?></th>
<td>
<textarea name="maple_performance_settings[exclude_cookies]" rows="4" placeholder="woocommerce_items_in_cart&#10;woocommerce_cart_hash"><?php echo esc_textarea( implode( "\n", $settings['exclude_cookies'] ) ); ?></textarea>
<p class="description"><?php _e( 'Cookie names that bypass cache when present (one per line)', 'maple-performance' ); ?></p>
</td>
</tr>
</table>
<?php submit_button( __( 'Save Settings & Clear Cache', 'maple-performance' ) ); ?>
</form>
<hr>
<p>
<strong><?php _e( 'Maple Performance WP', 'maple-performance' ); ?></strong> v<?php echo MAPLE_PERF_VERSION; ?> |
<?php _e( 'by', 'maple-performance' ); ?> <a href="https://mapleopentech.ca" target="_blank">Maple Open Tech</a> |
<?php _e( 'No tracking. No external dependencies. No upsells.', 'maple-performance' ); ?> 🍁
</p>
</div>
<?php
}
}

View file

@ -0,0 +1,608 @@
<?php
/**
* Page Caching Class
*
* Handles static HTML file generation and serving.
*
* @package MaplePerformance
*/
// Prevent direct access
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Cache class
*/
class Maple_Performance_Cache {
/**
* Single instance
*/
private static $instance = null;
/**
* Plugin settings
*/
private $settings;
/**
* Get instance
*/
public static function get_instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor
*/
private function __construct() {
$this->settings = maple_performance()->settings;
$this->init_hooks();
}
/**
* Initialize hooks
*/
private function init_hooks() {
// Start output buffering early
add_action( 'template_redirect', array( $this, 'start_buffering' ), -999 );
// Cache clearing hooks
add_action( 'save_post', array( $this, 'clear_post_cache' ), 10, 1 );
add_action( 'delete_post', array( $this, 'clear_post_cache' ), 10, 1 );
add_action( 'wp_trash_post', array( $this, 'clear_post_cache' ), 10, 1 );
add_action( 'comment_post', array( $this, 'clear_post_cache_by_comment' ), 10, 2 );
add_action( 'edit_comment', array( $this, 'clear_post_cache_by_comment' ), 10, 1 );
add_action( 'switch_theme', array( __CLASS__, 'clear_all' ) );
add_action( 'activated_plugin', array( __CLASS__, 'clear_all' ) );
add_action( 'deactivated_plugin', array( __CLASS__, 'clear_all' ) );
add_action( 'update_option_permalink_structure', array( __CLASS__, 'clear_all' ) );
// WooCommerce hooks
if ( class_exists( 'WooCommerce' ) ) {
add_action( 'woocommerce_product_set_stock', array( $this, 'clear_product_cache' ) );
add_action( 'woocommerce_variation_set_stock', array( $this, 'clear_product_cache' ) );
}
}
/**
* Start output buffering
*/
public function start_buffering() {
// Check if we should cache this request
if ( maple_performance()->is_excluded() ) {
return;
}
// Check if cached version exists
$cache_file = $this->get_cache_file_path();
if ( $this->serve_cache( $cache_file ) ) {
exit;
}
// Start buffering for cache creation
ob_start( array( $this, 'process_buffer' ) );
}
/**
* Process output buffer and create cache
*/
public function process_buffer( $buffer ) {
// Don't cache empty or error pages
if ( empty( $buffer ) || http_response_code() !== 200 ) {
return $buffer;
}
// Don't cache if contains certain markers
if ( $this->should_skip_caching( $buffer ) ) {
return $buffer;
}
// Write cache file
$this->write_cache( $buffer );
return $buffer;
}
/**
* Check if page should skip caching based on content
*/
private function should_skip_caching( $buffer ) {
// Skip if contains no-cache markers
$skip_markers = array(
'<!-- no-cache -->',
'<!-- maple-no-cache -->',
'woocommerce-cart',
'woocommerce-checkout',
);
foreach ( $skip_markers as $marker ) {
if ( strpos( $buffer, $marker ) !== false ) {
return true;
}
}
return false;
}
/**
* Get cache file path for current request
*/
private function get_cache_file_path( $url = null ) {
if ( null === $url ) {
$url = $this->get_current_url();
}
$parsed = parse_url( $url );
$host = $parsed['host'] ?? '';
$path = $parsed['path'] ?? '/';
// Sanitize host - only allow alphanumeric, dots, and hyphens
$host = preg_replace( '/[^a-zA-Z0-9.-]/', '', $host );
// Sanitize path - remove any path traversal attempts
$path = str_replace( array( '..', "\0" ), '', $path );
$path = preg_replace( '/[^a-zA-Z0-9\/_-]/', '', $path );
$path = preg_replace( '#/+#', '/', $path ); // Collapse multiple slashes
$path = rtrim( $path, '/' );
if ( empty( $path ) ) {
$path = '/index';
}
// Limit path depth to prevent excessive directory creation
$path_parts = explode( '/', trim( $path, '/' ) );
if ( count( $path_parts ) > 10 ) {
$path_parts = array_slice( $path_parts, 0, 10 );
$path = '/' . implode( '/', $path_parts );
}
// Build cache directory structure
$cache_dir = MAPLE_PERF_CACHE_DIR . $host . $path . '/';
// Verify the resolved path is within cache directory
$real_cache_base = realpath( MAPLE_PERF_CACHE_DIR );
if ( $real_cache_base ) {
// Use dirname to check parent since $cache_dir may not exist yet
$parent_dir = dirname( $cache_dir );
while ( ! is_dir( $parent_dir ) && $parent_dir !== dirname( $parent_dir ) ) {
$parent_dir = dirname( $parent_dir );
}
if ( is_dir( $parent_dir ) ) {
$real_parent = realpath( $parent_dir );
if ( $real_parent && strpos( $real_parent, $real_cache_base ) !== 0 ) {
// Path escapes cache directory - return safe fallback
return MAPLE_PERF_CACHE_DIR . 'fallback/https-index.html';
}
}
}
// Determine cache file name
$is_https = is_ssl() ? 'https' : 'http';
$cache_file = $cache_dir . $is_https . '-index.html';
return $cache_file;
}
/**
* Get current URL
*/
private function get_current_url() {
$scheme = is_ssl() ? 'https' : 'http';
// Sanitize HTTP_HOST
$host = isset( $_SERVER['HTTP_HOST'] ) ? $_SERVER['HTTP_HOST'] : '';
$host = preg_replace( '/[^a-zA-Z0-9.-]/', '', $host );
// Sanitize REQUEST_URI
$uri = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : '/';
$uri = filter_var( $uri, FILTER_SANITIZE_URL );
// Remove query strings for cache key
$uri = strtok( $uri, '?' );
// Remove any null bytes or path traversal
$uri = str_replace( array( "\0", '..' ), '', $uri );
return $scheme . '://' . $host . $uri;
}
/**
* Serve cached file if exists
*/
private function serve_cache( $cache_file ) {
// Check for gzipped version first
if ( $this->settings['cache_gzip'] && $this->client_accepts_gzip() ) {
$gzip_file = $cache_file . '.gz';
if ( file_exists( $gzip_file ) && $this->is_cache_valid( $gzip_file ) ) {
header( 'Content-Encoding: gzip' );
header( 'Content-Type: text/html; charset=UTF-8' );
header( 'X-Maple-Cache: HIT (gzip)' );
readfile( $gzip_file );
return true;
}
}
// Check for brotli version
if ( $this->settings['cache_brotli'] && $this->client_accepts_brotli() ) {
$br_file = $cache_file . '.br';
if ( file_exists( $br_file ) && $this->is_cache_valid( $br_file ) ) {
header( 'Content-Encoding: br' );
header( 'Content-Type: text/html; charset=UTF-8' );
header( 'X-Maple-Cache: HIT (brotli)' );
readfile( $br_file );
return true;
}
}
// Serve uncompressed
if ( file_exists( $cache_file ) && $this->is_cache_valid( $cache_file ) ) {
header( 'Content-Type: text/html; charset=UTF-8' );
header( 'X-Maple-Cache: HIT' );
readfile( $cache_file );
return true;
}
return false;
}
/**
* Check if cache file is still valid
*/
private function is_cache_valid( $cache_file ) {
// No expiry set
if ( empty( $this->settings['cache_expiry'] ) || $this->settings['cache_expiry'] === 0 ) {
return true;
}
$file_age = time() - filemtime( $cache_file );
$max_age = $this->settings['cache_expiry'] * HOUR_IN_SECONDS;
return $file_age < $max_age;
}
/**
* Check if client accepts gzip
*/
private function client_accepts_gzip() {
$encoding = $_SERVER['HTTP_ACCEPT_ENCODING'] ?? '';
return strpos( $encoding, 'gzip' ) !== false;
}
/**
* Check if client accepts brotli
*/
private function client_accepts_brotli() {
$encoding = $_SERVER['HTTP_ACCEPT_ENCODING'] ?? '';
return strpos( $encoding, 'br' ) !== false;
}
/**
* Write cache file
*/
private function write_cache( $content ) {
$cache_file = $this->get_cache_file_path();
$cache_dir = dirname( $cache_file );
// Create directory if needed
if ( ! file_exists( $cache_dir ) ) {
wp_mkdir_p( $cache_dir );
}
// Verify we're still within cache directory (paranoid check)
$real_cache_base = realpath( MAPLE_PERF_CACHE_DIR );
$real_cache_dir = realpath( $cache_dir );
if ( false === $real_cache_base || false === $real_cache_dir ) {
return;
}
if ( strpos( $real_cache_dir, $real_cache_base ) !== 0 ) {
return;
}
// Add cache signature
$timestamp = gmdate( 'D, d M Y H:i:s' ) . ' GMT';
$signature = "\n<!-- Maple Performance WP @ {$timestamp} -->";
$content .= $signature;
// Write HTML file with proper permissions
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
file_put_contents( $cache_file, $content );
if ( file_exists( $cache_file ) ) {
chmod( $cache_file, 0644 );
}
// Create gzipped version
if ( $this->settings['cache_gzip'] && function_exists( 'gzencode' ) ) {
$gzip_content = gzencode( $content, 9 );
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
file_put_contents( $cache_file . '.gz', $gzip_content );
if ( file_exists( $cache_file . '.gz' ) ) {
chmod( $cache_file . '.gz', 0644 );
}
}
// Create brotli version
if ( $this->settings['cache_brotli'] && function_exists( 'brotli_compress' ) ) {
$br_content = brotli_compress( $content );
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
file_put_contents( $cache_file . '.br', $br_content );
if ( file_exists( $cache_file . '.br' ) ) {
chmod( $cache_file . '.br', 0644 );
}
}
}
/**
* Clear cache for a specific post
*/
public function clear_post_cache( $post_id ) {
// Don't clear for autosaves or revisions
if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
return;
}
$post = get_post( $post_id );
if ( ! $post || $post->post_status !== 'publish' ) {
return;
}
// Clear post URL
$url = get_permalink( $post_id );
$this->clear_url_cache( $url );
// Clear home page
$this->clear_url_cache( home_url( '/' ) );
// Clear archive pages
$post_type = get_post_type( $post_id );
$archive_url = get_post_type_archive_link( $post_type );
if ( $archive_url ) {
$this->clear_url_cache( $archive_url );
}
// Clear category/tag archives - limit to prevent performance issues
$taxonomies = get_object_taxonomies( $post_type );
$terms_cleared = 0;
$max_terms = 50; // Limit term cache clearing to prevent slowdown
foreach ( $taxonomies as $taxonomy ) {
if ( $terms_cleared >= $max_terms ) {
break;
}
$terms = get_the_terms( $post_id, $taxonomy );
if ( $terms && ! is_wp_error( $terms ) ) {
foreach ( $terms as $term ) {
if ( $terms_cleared >= $max_terms ) {
break;
}
$term_url = get_term_link( $term );
if ( ! is_wp_error( $term_url ) ) {
$this->clear_url_cache( $term_url );
$terms_cleared++;
}
}
}
}
// Clear stats transient since cache changed
delete_transient( 'maple_perf_cache_stats' );
}
/**
* Clear cache by comment
*/
public function clear_post_cache_by_comment( $comment_id, $comment_approved = null ) {
$comment = get_comment( $comment_id );
if ( $comment ) {
$this->clear_post_cache( $comment->comment_post_ID );
}
}
/**
* Clear product cache (WooCommerce)
*/
public function clear_product_cache( $product ) {
if ( is_numeric( $product ) ) {
$product_id = $product;
} else {
$product_id = $product->get_id();
}
$this->clear_post_cache( $product_id );
}
/**
* Clear cache for a specific URL
*/
public function clear_url_cache( $url ) {
$cache_file = $this->get_cache_file_path( $url );
if ( file_exists( $cache_file ) ) {
@unlink( $cache_file );
}
if ( file_exists( $cache_file . '.gz' ) ) {
@unlink( $cache_file . '.gz' );
}
if ( file_exists( $cache_file . '.br' ) ) {
@unlink( $cache_file . '.br' );
}
// Also clear https version if http was cleared and vice versa
$alt_file = str_replace(
array( '/http-index.html', '/https-index.html' ),
array( '/https-index.html', '/http-index.html' ),
$cache_file
);
if ( file_exists( $alt_file ) ) {
@unlink( $alt_file );
}
if ( file_exists( $alt_file . '.gz' ) ) {
@unlink( $alt_file . '.gz' );
}
if ( file_exists( $alt_file . '.br' ) ) {
@unlink( $alt_file . '.br' );
}
}
/**
* Clear all cache
*/
public static function clear_all() {
self::recursive_delete( MAPLE_PERF_CACHE_DIR );
// Recreate directories
wp_mkdir_p( MAPLE_PERF_CACHE_DIR );
wp_mkdir_p( MAPLE_PERF_CACHE_DIR . 'assets/' );
// Add index.php files with ABSPATH check
$index = "<?php\n// Silence is golden.\nif ( ! defined( 'ABSPATH' ) ) { exit; }";
file_put_contents( MAPLE_PERF_CACHE_DIR . 'index.php', $index );
file_put_contents( MAPLE_PERF_CACHE_DIR . 'assets/index.php', $index );
// Clear stats transient
delete_transient( 'maple_perf_cache_stats' );
}
/**
* Recursively delete directory
*/
private static function recursive_delete( $dir ) {
if ( ! is_dir( $dir ) ) {
return;
}
// Safety check - only delete within wp-content/cache/maple-performance
$real_dir = realpath( $dir );
$allowed_base = realpath( WP_CONTENT_DIR );
if ( false === $real_dir || false === $allowed_base ) {
return;
}
if ( strpos( $real_dir, $allowed_base ) !== 0 ) {
return;
}
// Additional safety - must contain 'maple-performance' in path
if ( strpos( $real_dir, 'maple-performance' ) === false ) {
return;
}
// Limit iterations to prevent runaway deletion
$max_iterations = 50000;
$iterations = 0;
try {
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator( $dir, RecursiveDirectoryIterator::SKIP_DOTS ),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ( $files as $file ) {
if ( ++$iterations > $max_iterations ) {
break; // Safety limit reached
}
$file_path = $file->getRealPath();
// Verify each file is within allowed directory
if ( strpos( $file_path, $real_dir ) !== 0 ) {
continue;
}
if ( $file->isDir() ) {
@rmdir( $file_path );
} else {
@unlink( $file_path );
}
}
} catch ( Exception $e ) {
// Handle iterator exceptions gracefully
return;
}
}
/**
* Get cache size
*/
public static function get_cache_size() {
$size = 0;
if ( ! is_dir( MAPLE_PERF_CACHE_DIR ) ) {
return $size;
}
// Limit iterations to prevent runaway on huge cache directories
$max_iterations = 10000;
$iterations = 0;
try {
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator( MAPLE_PERF_CACHE_DIR, RecursiveDirectoryIterator::SKIP_DOTS )
);
foreach ( $files as $file ) {
if ( ++$iterations > $max_iterations ) {
break; // Safety limit reached
}
if ( $file->isFile() ) {
$size += $file->getSize();
}
}
} catch ( Exception $e ) {
// Handle iterator exceptions gracefully
return $size;
}
return $size;
}
/**
* Get number of cached files
*/
public static function get_cache_count() {
$count = 0;
if ( ! is_dir( MAPLE_PERF_CACHE_DIR ) ) {
return $count;
}
// Limit iterations to prevent runaway on huge cache directories
$max_iterations = 10000;
$iterations = 0;
try {
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator( MAPLE_PERF_CACHE_DIR, RecursiveDirectoryIterator::SKIP_DOTS )
);
foreach ( $files as $file ) {
if ( ++$iterations > $max_iterations ) {
break; // Safety limit reached
}
if ( $file->isFile() && pathinfo( $file, PATHINFO_EXTENSION ) === 'html' ) {
$count++;
}
}
} catch ( Exception $e ) {
// Handle iterator exceptions gracefully
return $count;
}
return $count;
}
}

View file

@ -0,0 +1,801 @@
<?php
/**
* Plugin Compatibility Class
*
* Handles automatic detection and safe defaults for common plugins.
*
* @package MaplePerformance
*/
// Prevent direct access
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Compatibility class
*/
class Maple_Performance_Compat {
/**
* Single instance
*/
private static $instance = null;
/**
* Detected plugins (via auto-detect)
*/
private $detected = array();
/**
* Enabled plugins (manual selection or auto-detected)
*/
private $enabled = array();
/**
* Plugin settings
*/
private $settings = array();
/**
* Get instance
*/
public static function get_instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor
*/
private function __construct() {
$this->settings = maple_performance()->settings;
$this->determine_enabled_plugins();
$this->init_hooks();
}
/**
* Determine which plugins should have compatibility rules applied
*/
private function determine_enabled_plugins() {
// If auto-detect is enabled, detect plugins
if ( ! empty( $this->settings['compat_auto_detect'] ) ) {
$this->detect_plugins();
$this->enabled = $this->detected;
} else {
// Use manual selection
$manual = $this->settings['compat_plugins'] ?? array();
foreach ( $manual as $plugin ) {
$this->enabled[ $plugin ] = true;
}
}
}
/**
* Detect active plugins (used when auto-detect is enabled)
*/
private function detect_plugins() {
// WooCommerce
if ( class_exists( 'WooCommerce' ) || defined( 'WC_PLUGIN_FILE' ) ) {
$this->detected['woocommerce'] = true;
}
// LearnDash
if ( defined( 'LEARNDASH_VERSION' ) || class_exists( 'SFWD_LMS' ) ) {
$this->detected['learndash'] = true;
}
// WPForms
if ( defined( 'WPFORMS_VERSION' ) || class_exists( 'WPForms' ) ) {
$this->detected['wpforms'] = true;
}
// Wordfence
if ( defined( 'WORDFENCE_VERSION' ) || class_exists( 'wordfence' ) ) {
$this->detected['wordfence'] = true;
}
// Gutenberg FSE / Block Themes
if ( function_exists( 'wp_is_block_theme' ) && wp_is_block_theme() ) {
$this->detected['gutenberg_fse'] = true;
}
// Gravity Forms
if ( class_exists( 'GFForms' ) || defined( 'GF_MIN_WP_VERSION' ) ) {
$this->detected['gravityforms'] = true;
}
// Contact Form 7
if ( defined( 'WPCF7_VERSION' ) || class_exists( 'WPCF7' ) ) {
$this->detected['cf7'] = true;
}
// Elementor
if ( defined( 'ELEMENTOR_VERSION' ) ) {
$this->detected['elementor'] = true;
}
// === Caching plugin conflict detection ===
// WP Rocket
if ( defined( 'WP_ROCKET_VERSION' ) ) {
$this->detected['wp_rocket'] = true;
}
// W3 Total Cache
if ( defined( 'W3TC' ) || class_exists( 'W3_Plugin_TotalCache' ) ) {
$this->detected['w3tc'] = true;
}
// LiteSpeed Cache
if ( defined( 'LSCWP_V' ) || class_exists( 'LiteSpeed_Cache' ) ) {
$this->detected['litespeed'] = true;
}
// WP Super Cache
if ( defined( 'WPCACHEHOME' ) || function_exists( 'wp_cache_phase2' ) ) {
$this->detected['wp_super_cache'] = true;
}
// WP Fastest Cache
if ( class_exists( 'WpFastestCache' ) || defined( 'WPFC_WP_CONTENT_BASENAME' ) ) {
$this->detected['wp_fastest_cache'] = true;
}
// Autoptimize
if ( class_exists( 'autoptimizeMain' ) || defined( 'AUTOPTIMIZE_PLUGIN_VERSION' ) ) {
$this->detected['autoptimize'] = true;
}
// Cache Enabler
if ( class_exists( 'Cache_Enabler' ) || defined( 'CACHE_ENABLER_VERSION' ) ) {
$this->detected['cache_enabler'] = true;
}
// Hummingbird
if ( class_exists( 'WP_Hummingbird' ) || defined( 'WPHB_VERSION' ) ) {
$this->detected['hummingbird'] = true;
}
// Breeze (Cloudways)
if ( class_exists( 'Breeze_Admin' ) || defined( 'BREEZE_VERSION' ) ) {
$this->detected['breeze'] = true;
}
// SG Optimizer (SiteGround)
if ( class_exists( 'SiteGround_Optimizer' ) || defined( 'SG_OPTIMIZER_VERSION' ) ) {
$this->detected['sg_optimizer'] = true;
}
// Swift Performance
if ( class_exists( 'Swift_Performance' ) || defined( 'FLAVOR_FLAVOR' ) ) {
$this->detected['swift_performance'] = true;
}
// Comet Cache
if ( class_exists( 'WebSharks\\CometCache\\Classes\\Plugin' ) || defined( 'COMET_CACHE_VERSION' ) ) {
$this->detected['comet_cache'] = true;
}
// Powered Cache
if ( defined( 'POWERED_CACHE_VERSION' ) ) {
$this->detected['powered_cache'] = true;
}
// Perfmatters
if ( class_exists( 'Perfmatters' ) || defined( 'PERFMATTERS_VERSION' ) ) {
$this->detected['perfmatters'] = true;
}
}
/**
* Initialize hooks
*/
private function init_hooks() {
// Only apply filters if at least one plugin is enabled
if ( empty( $this->enabled ) ) {
// Still check for caching conflicts in admin
add_action( 'admin_notices', array( $this, 'conflict_notices' ) );
return;
}
// Filter exclusions based on enabled plugins
add_filter( 'maple_performance_js_exclusions', array( $this, 'add_js_exclusions' ) );
add_filter( 'maple_performance_css_exclusions', array( $this, 'add_css_exclusions' ) );
add_filter( 'maple_performance_path_exclusions', array( $this, 'add_path_exclusions' ) );
add_filter( 'maple_performance_cookie_exclusions', array( $this, 'add_cookie_exclusions' ) );
// Add admin notice for conflicts
add_action( 'admin_notices', array( $this, 'conflict_notices' ) );
// WooCommerce specific hooks
if ( $this->is_enabled( 'woocommerce' ) ) {
$this->init_woocommerce_compat();
}
// LearnDash specific hooks
if ( $this->is_enabled( 'learndash' ) ) {
$this->init_learndash_compat();
}
// WPForms specific hooks
if ( $this->is_enabled( 'wpforms' ) ) {
$this->init_wpforms_compat();
}
// Wordfence specific hooks
if ( $this->is_enabled( 'wordfence' ) ) {
$this->init_wordfence_compat();
}
// Gutenberg FSE specific hooks
if ( $this->is_enabled( 'gutenberg_fse' ) ) {
$this->init_gutenberg_fse_compat();
}
}
/**
* Check if plugin compatibility is enabled (either via auto-detect or manual)
*/
public function is_enabled( $plugin ) {
return ! empty( $this->enabled[ $plugin ] );
}
/**
* Check if plugin was detected (for display purposes)
*/
public function is_detected( $plugin ) {
return ! empty( $this->detected[ $plugin ] );
}
/**
* Get all detected plugins
*/
public function get_detected() {
// Always run detection for display in admin
if ( empty( $this->detected ) ) {
$this->detect_plugins();
}
return $this->detected;
}
/**
* Get all enabled plugins
*/
public function get_enabled() {
return $this->enabled;
}
/**
* Add JS exclusions for enabled plugins
*/
public function add_js_exclusions( $exclusions ) {
// WooCommerce
if ( $this->is_enabled( 'woocommerce' ) ) {
$exclusions = array_merge( $exclusions, array(
'woocommerce',
'wc-',
'wc_',
'jquery-blockui',
'selectWoo',
'select2',
'js-cookie',
'cart-fragments',
'checkout',
'add-to-cart',
'payment',
'stripe',
'paypal',
'square',
'braintree',
) );
}
// LearnDash
if ( $this->is_enabled( 'learndash' ) ) {
$exclusions = array_merge( $exclusions, array(
'learndash',
'sfwd-',
'sfwd_',
'ld-',
'ld_',
'ldlms',
'quiz',
'wpProQuiz',
) );
}
// WPForms
if ( $this->is_enabled( 'wpforms' ) ) {
$exclusions = array_merge( $exclusions, array(
'wpforms',
'wpforms-',
'jquery-validation',
'mailcheck',
'inputmask',
) );
}
// Wordfence
if ( $this->is_enabled( 'wordfence' ) ) {
$exclusions = array_merge( $exclusions, array(
'wordfence',
'wf-',
'wfls-',
) );
}
// Gutenberg FSE / Block Themes
if ( $this->is_enabled( 'gutenberg_fse' ) ) {
$exclusions = array_merge( $exclusions, array(
'wp-block-',
'wp-edit-',
) );
}
// Gravity Forms
if ( $this->is_enabled( 'gravityforms' ) ) {
$exclusions = array_merge( $exclusions, array(
'gform',
'gravityforms',
'gf_',
) );
}
// Contact Form 7
if ( $this->is_enabled( 'cf7' ) ) {
$exclusions = array_merge( $exclusions, array(
'contact-form-7',
'wpcf7',
) );
}
// Elementor
if ( $this->is_enabled( 'elementor' ) ) {
$exclusions = array_merge( $exclusions, array(
'elementor-',
'elementor_',
) );
}
return array_unique( $exclusions );
}
/**
* Add CSS exclusions for enabled plugins
*/
public function add_css_exclusions( $exclusions ) {
// LearnDash - Focus Mode CSS should load normally
if ( $this->is_enabled( 'learndash' ) ) {
$exclusions = array_merge( $exclusions, array(
'learndash-front',
'sfwd-',
) );
}
// WPForms
if ( $this->is_enabled( 'wpforms' ) ) {
$exclusions = array_merge( $exclusions, array(
'wpforms',
) );
}
// Gutenberg FSE / Block Themes - protect global styles
if ( $this->is_enabled( 'gutenberg_fse' ) ) {
$exclusions = array_merge( $exclusions, array(
'global-styles',
'wp-block-',
'core-block-',
) );
}
return array_unique( $exclusions );
}
/**
* Add path exclusions for enabled plugins
*/
public function add_path_exclusions( $exclusions ) {
// WooCommerce
if ( $this->is_enabled( 'woocommerce' ) ) {
$exclusions = array_merge( $exclusions, array(
'/cart/',
'/cart',
'/checkout/',
'/checkout',
'/my-account/',
'/my-account',
'/wc-api/',
'/order-received/',
'/order-pay/',
'/view-order/',
'/add-to-cart=',
'?add-to-cart=',
'?remove_item=',
'?removed_item=',
) );
}
// LearnDash
if ( $this->is_enabled( 'learndash' ) ) {
$exclusions = array_merge( $exclusions, array(
'/lessons/',
'/topic/',
'/quiz/',
'/quizzes/',
'/certificates/',
'/sfwd-',
) );
}
// Wordfence
if ( $this->is_enabled( 'wordfence' ) ) {
$exclusions = array_merge( $exclusions, array(
'/wp-login.php',
'?wfls-',
) );
}
return array_unique( $exclusions );
}
/**
* Add cookie exclusions for enabled plugins
*/
public function add_cookie_exclusions( $exclusions ) {
// WooCommerce
if ( $this->is_enabled( 'woocommerce' ) ) {
$exclusions = array_merge( $exclusions, array(
'woocommerce_items_in_cart',
'woocommerce_cart_hash',
'wp_woocommerce_session_',
'woocommerce_recently_viewed',
) );
}
// Wordfence
if ( $this->is_enabled( 'wordfence' ) ) {
$exclusions = array_merge( $exclusions, array(
'wfCBLBypass',
'wf_loginalerted_',
) );
}
return array_unique( $exclusions );
}
/**
* Initialize WooCommerce compatibility
*/
private function init_woocommerce_compat() {
// Don't cache cart fragments AJAX
add_action( 'wc_ajax_get_refreshed_fragments', array( $this, 'disable_caching' ), 1 );
add_action( 'wc_ajax_add_to_cart', array( $this, 'disable_caching' ), 1 );
add_action( 'wc_ajax_remove_from_cart', array( $this, 'disable_caching' ), 1 );
add_action( 'wc_ajax_apply_coupon', array( $this, 'disable_caching' ), 1 );
add_action( 'wc_ajax_remove_coupon', array( $this, 'disable_caching' ), 1 );
add_action( 'wc_ajax_update_shipping_method', array( $this, 'disable_caching' ), 1 );
add_action( 'wc_ajax_checkout', array( $this, 'disable_caching' ), 1 );
// Clear cache on stock changes
add_action( 'woocommerce_product_set_stock', array( $this, 'clear_product_cache' ) );
add_action( 'woocommerce_variation_set_stock', array( $this, 'clear_product_cache' ) );
// Clear cache on order status changes (affects stock)
add_action( 'woocommerce_order_status_changed', array( $this, 'clear_on_order_status' ), 10, 3 );
// Exclude WooCommerce pages from optimization
add_filter( 'maple_performance_exclude_optimization', array( $this, 'exclude_woo_pages' ) );
}
/**
* Initialize LearnDash compatibility
*/
private function init_learndash_compat() {
// Don't cache any LearnDash AJAX
add_action( 'wp_ajax_learndash_mark_complete', array( $this, 'disable_caching' ), 1 );
add_action( 'wp_ajax_ld_adv_quiz_result', array( $this, 'disable_caching' ), 1 );
add_action( 'wp_ajax_wpProQuiz_admin_ajax', array( $this, 'disable_caching' ), 1 );
// Clear cache on course enrollment/completion
add_action( 'learndash_course_completed', array( $this, 'clear_learndash_user_cache' ), 10, 1 );
add_action( 'learndash_lesson_completed', array( $this, 'clear_learndash_user_cache' ), 10, 1 );
add_action( 'learndash_topic_completed', array( $this, 'clear_learndash_user_cache' ), 10, 1 );
add_action( 'learndash_quiz_completed', array( $this, 'clear_learndash_user_cache' ), 10, 2 );
// Clear cache when user is enrolled
add_action( 'learndash_update_course_access', array( $this, 'clear_course_cache' ), 10, 4 );
}
/**
* Initialize WPForms compatibility
*/
private function init_wpforms_compat() {
// WPForms AJAX is generally fine, but ensure it's not cached
add_action( 'wp_ajax_wpforms_submit', array( $this, 'disable_caching' ), 1 );
add_action( 'wp_ajax_nopriv_wpforms_submit', array( $this, 'disable_caching' ), 1 );
add_action( 'wp_ajax_wpforms_file_upload_speed_test', array( $this, 'disable_caching' ), 1 );
add_action( 'wp_ajax_nopriv_wpforms_file_upload_speed_test', array( $this, 'disable_caching' ), 1 );
add_action( 'wp_ajax_wpforms_restricted_email', array( $this, 'disable_caching' ), 1 );
add_action( 'wp_ajax_nopriv_wpforms_restricted_email', array( $this, 'disable_caching' ), 1 );
}
/**
* Initialize Wordfence compatibility
*/
private function init_wordfence_compat() {
// Don't interfere with Wordfence login security
add_action( 'wp_ajax_nopriv_wordfence_ls_authenticate', array( $this, 'disable_caching' ), 1 );
add_action( 'wp_ajax_wordfence_ls_authenticate', array( $this, 'disable_caching' ), 1 );
// Don't cache Wordfence blocked pages
if ( defined( 'WORDFENCE_BLOCKED' ) && WORDFENCE_BLOCKED ) {
add_filter( 'maple_performance_should_cache', '__return_false' );
}
// Respect Wordfence's caching headers
add_filter( 'maple_performance_should_cache', array( $this, 'check_wordfence_bypass' ) );
}
/**
* Initialize Gutenberg FSE / Block Theme compatibility
*/
private function init_gutenberg_fse_compat() {
// Don't aggregate global styles inline CSS
add_filter( 'maple_performance_aggregate_inline_css', '__return_false' );
// Protect block editor assets on frontend
add_filter( 'maple_performance_exclude_optimization', array( $this, 'exclude_block_editor_frontend' ) );
}
/**
* Exclude block editor frontend from aggressive optimization
*/
public function exclude_block_editor_frontend( $exclude ) {
// If viewing a page with blocks that need JS interaction
// This is conservative - most block content is static
return $exclude;
}
/**
* Disable caching for current request
*/
public function disable_caching() {
if ( ! defined( 'DONOTCACHEPAGE' ) ) {
define( 'DONOTCACHEPAGE', true );
}
}
/**
* Clear product cache
*/
public function clear_product_cache( $product ) {
if ( ! class_exists( 'Maple_Performance_Cache' ) ) {
return;
}
$product_id = is_numeric( $product ) ? $product : $product->get_id();
$url = get_permalink( $product_id );
if ( $url ) {
$cache = Maple_Performance_Cache::get_instance();
$cache->clear_url_cache( $url );
}
// Also clear shop page
$shop_url = function_exists( 'wc_get_page_permalink' ) ? wc_get_page_permalink( 'shop' ) : '';
if ( $shop_url ) {
$cache->clear_url_cache( $shop_url );
}
}
/**
* Clear cache on order status change
*/
public function clear_on_order_status( $order_id, $old_status, $new_status ) {
// Only clear when status changes might affect stock
$stock_statuses = array( 'completed', 'processing', 'cancelled', 'refunded' );
if ( in_array( $new_status, $stock_statuses ) || in_array( $old_status, $stock_statuses ) ) {
$order = wc_get_order( $order_id );
if ( $order ) {
foreach ( $order->get_items() as $item ) {
$product_id = $item->get_product_id();
$this->clear_product_cache( $product_id );
}
}
}
}
/**
* Exclude WooCommerce pages from JS/CSS optimization
*/
public function exclude_woo_pages( $exclude ) {
if ( function_exists( 'is_cart' ) && is_cart() ) {
return true;
}
if ( function_exists( 'is_checkout' ) && is_checkout() ) {
return true;
}
if ( function_exists( 'is_account_page' ) && is_account_page() ) {
return true;
}
return $exclude;
}
/**
* Clear LearnDash user-related cache
*/
public function clear_learndash_user_cache( $data ) {
// LearnDash pages are user-specific and already excluded for logged-in users
// But we can clear the course archive pages
if ( ! class_exists( 'Maple_Performance_Cache' ) ) {
return;
}
$cache = Maple_Performance_Cache::get_instance();
// Clear course archive
$course_archive = get_post_type_archive_link( 'sfwd-courses' );
if ( $course_archive ) {
$cache->clear_url_cache( $course_archive );
}
// Clear home page (might show course counts)
$cache->clear_url_cache( home_url( '/' ) );
}
/**
* Clear course cache when enrollment changes
*/
public function clear_course_cache( $user_id, $course_id, $access_list, $remove ) {
if ( ! class_exists( 'Maple_Performance_Cache' ) ) {
return;
}
$cache = Maple_Performance_Cache::get_instance();
// Clear the course page (might show enrollment count)
$course_url = get_permalink( $course_id );
if ( $course_url ) {
$cache->clear_url_cache( $course_url );
}
}
/**
* Check Wordfence bypass
*/
public function check_wordfence_bypass( $should_cache ) {
// If Wordfence has set bypass cookie, don't cache
if ( isset( $_COOKIE['wfCBLBypass'] ) ) {
return false;
}
return $should_cache;
}
/**
* Show admin notices for plugin conflicts
*/
public function conflict_notices() {
// Build list of conflicting caching plugins
$caching_conflicts = $this->get_caching_conflicts();
// Show caching conflict warning on all admin pages (dismissible)
if ( ! empty( $caching_conflicts ) && ! get_transient( 'maple_perf_conflict_dismissed' ) ) {
$this->show_conflict_warning( $caching_conflicts );
}
// Show detailed info only on Maple Performance settings page
$screen = get_current_screen();
if ( ! $screen || $screen->id !== 'settings_page_maple-performance' ) {
return;
}
// If conflicts exist but were dismissed, show a subtle reminder on settings page
if ( ! empty( $caching_conflicts ) && get_transient( 'maple_perf_conflict_dismissed' ) ) {
echo '<div class="notice notice-warning">';
echo '<p><strong>' . esc_html__( 'Reminder:', 'maple-performance' ) . '</strong> ';
echo sprintf(
esc_html__( 'Other caching plugin(s) detected: %s. For best results, use only one caching solution.', 'maple-performance' ),
'<code>' . implode( '</code>, <code>', array_map( 'esc_html', $caching_conflicts ) ) . '</code>'
);
echo '</p></div>';
}
// Show detected compatible plugins (info)
$compatible = array();
if ( $this->is_detected( 'woocommerce' ) ) $compatible[] = 'WooCommerce';
if ( $this->is_detected( 'learndash' ) ) $compatible[] = 'LearnDash';
if ( $this->is_detected( 'wpforms' ) ) $compatible[] = 'WPForms';
if ( $this->is_detected( 'wordfence' ) ) $compatible[] = 'Wordfence';
if ( $this->is_detected( 'gravityforms' ) ) $compatible[] = 'Gravity Forms';
if ( $this->is_detected( 'cf7' ) ) $compatible[] = 'Contact Form 7';
if ( $this->is_detected( 'elementor' ) ) $compatible[] = 'Elementor';
if ( ! empty( $compatible ) ) {
echo '<div class="notice notice-info">';
echo '<p><strong>' . esc_html__( 'Maple Performance - Detected Plugins:', 'maple-performance' ) . '</strong> ';
echo sprintf(
esc_html__( 'Compatibility rules available for: %s. Enable them in the Plugin Compatibility section below.', 'maple-performance' ),
'<code>' . implode( '</code>, <code>', array_map( 'esc_html', $compatible ) ) . '</code>'
);
echo '</p></div>';
}
}
/**
* Get list of detected caching plugin conflicts
*/
public function get_caching_conflicts() {
$conflicts = array();
$caching_plugins = array(
'wp_rocket' => 'WP Rocket',
'w3tc' => 'W3 Total Cache',
'litespeed' => 'LiteSpeed Cache',
'wp_super_cache' => 'WP Super Cache',
'wp_fastest_cache' => 'WP Fastest Cache',
'autoptimize' => 'Autoptimize',
'cache_enabler' => 'Cache Enabler',
'hummingbird' => 'Hummingbird',
'breeze' => 'Breeze',
'sg_optimizer' => 'SG Optimizer',
'swift_performance'=> 'Swift Performance',
'comet_cache' => 'Comet Cache',
'powered_cache' => 'Powered Cache',
'perfmatters' => 'Perfmatters',
);
foreach ( $caching_plugins as $key => $name ) {
if ( $this->is_detected( $key ) ) {
$conflicts[] = $name;
}
}
return $conflicts;
}
/**
* Check if any caching conflicts exist
*/
public function has_caching_conflicts() {
return ! empty( $this->get_caching_conflicts() );
}
/**
* Show conflict warning notice
*/
private function show_conflict_warning( $conflicts ) {
// Handle dismiss action
if ( isset( $_GET['maple_dismiss_conflict'] ) && wp_verify_nonce( $_GET['_wpnonce'] ?? '', 'maple_dismiss_conflict' ) ) {
set_transient( 'maple_perf_conflict_dismissed', true, 7 * DAY_IN_SECONDS );
return;
}
$dismiss_url = wp_nonce_url( add_query_arg( 'maple_dismiss_conflict', '1' ), 'maple_dismiss_conflict' );
echo '<div class="notice notice-error">';
echo '<p><strong>' . esc_html__( '⚠️ Maple Performance - Caching Conflict Detected', 'maple-performance' ) . '</strong></p>';
echo '<p>' . sprintf(
esc_html__( 'The following caching/optimization plugin(s) are also active: %s', 'maple-performance' ),
'<strong>' . implode( ', ', array_map( 'esc_html', $conflicts ) ) . '</strong>'
) . '</p>';
echo '<p>' . esc_html__( 'Running multiple caching plugins simultaneously can cause:', 'maple-performance' ) . '</p>';
echo '<ul style="list-style: disc; margin-left: 20px;">';
echo '<li>' . esc_html__( 'Blank pages or site errors', 'maple-performance' ) . '</li>';
echo '<li>' . esc_html__( 'Stale/outdated content being served', 'maple-performance' ) . '</li>';
echo '<li>' . esc_html__( 'Slower performance (double processing)', 'maple-performance' ) . '</li>';
echo '<li>' . esc_html__( 'Cache invalidation failures', 'maple-performance' ) . '</li>';
echo '</ul>';
echo '<p><strong>' . esc_html__( 'Recommended action:', 'maple-performance' ) . '</strong> ';
echo esc_html__( 'Deactivate the other caching plugin(s), or deactivate Maple Performance if you prefer to keep your existing solution.', 'maple-performance' );
echo '</p>';
echo '<p>';
echo '<a href="' . esc_url( admin_url( 'plugins.php' ) ) . '" class="button button-primary">' . esc_html__( 'Go to Plugins', 'maple-performance' ) . '</a> ';
echo '<a href="' . esc_url( $dismiss_url ) . '" class="button">' . esc_html__( 'Dismiss for 7 days', 'maple-performance' ) . '</a>';
echo '</p>';
echo '</div>';
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,11 @@
<?php
/**
* Silence is golden.
*
* @package MaplePerformance
*/
// Prevent direct access
if ( ! defined( 'ABSPATH' ) ) {
exit;
}

View file

@ -0,0 +1,11 @@
<?php
/**
* Silence is golden.
*
* @package MaplePerformance
*/
// Prevent direct access
if ( ! defined( 'ABSPATH' ) ) {
exit;
}

View file

@ -0,0 +1,11 @@
<?php
/**
* Silence is golden.
*
* @package MaplePerformance
*/
// Prevent direct access
if ( ! defined( 'ABSPATH' ) ) {
exit;
}

View file

@ -0,0 +1,539 @@
<?php
/**
* Plugin Name: Maple Performance WP
* Plugin URI: https://mapleopentech.ca/maple-performance-wp
* Description: A lightweight, privacy-focused WordPress performance plugin. No external dependencies, no tracking, no upsells.
* Version: 1.0.0
* Author: Maple Open Tech
* Author URI: https://mapleopentech.ca
* License: GPL-2.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: maple-performance
* Domain Path: /languages
* Requires at least: 5.9
* Requires PHP: 7.4
*
* @package MaplePerformance
*/
// Prevent direct access
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// Plugin constants
define( 'MAPLE_PERF_VERSION', '1.0.0' );
define( 'MAPLE_PERF_FILE', __FILE__ );
define( 'MAPLE_PERF_PATH', plugin_dir_path( __FILE__ ) );
define( 'MAPLE_PERF_URL', plugin_dir_url( __FILE__ ) );
define( 'MAPLE_PERF_CACHE_DIR', WP_CONTENT_DIR . '/cache/maple-performance/' );
define( 'MAPLE_PERF_CACHE_URL', content_url( '/cache/maple-performance/' ) );
/**
* Autoloader for plugin classes
*/
spl_autoload_register( function( $class ) {
$prefix = 'Maple_Performance_';
if ( strpos( $class, $prefix ) !== 0 ) {
return;
}
$class_name = str_replace( $prefix, '', $class );
$class_name = strtolower( str_replace( '_', '-', $class_name ) );
$file = MAPLE_PERF_PATH . 'inc/class-maple-' . $class_name . '.php';
if ( file_exists( $file ) ) {
require_once $file;
}
});
/**
* Main plugin class
*/
final class Maple_Performance {
/**
* Single instance
*/
private static $instance = null;
/**
* Plugin settings
*/
public $settings = array();
/**
* Get instance
*/
public static function get_instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor
*/
private function __construct() {
$this->load_settings();
$this->init_hooks();
}
/**
* Load settings from database
*/
private function load_settings() {
$defaults = array(
// Site mode
'site_mode' => 'brochure', // brochure, woocommerce, learndash, woo_learndash
// Page caching
'cache_enabled' => true,
'cache_expiry' => 0, // 0 = never expires
'cache_logged_in' => false,
'cache_gzip' => true,
'cache_brotli' => false,
// HTML optimization
'html_minify' => true,
'html_remove_comments' => true,
// CSS optimization
'css_minify' => true,
'css_aggregate' => true,
'css_inline_aggregate' => false,
'css_defer' => false, // Disabled by default for safety
// JS optimization
'js_minify' => true,
'js_aggregate' => false, // Disabled by default for safety
'js_defer' => false,
'js_exclude_jquery' => true,
// Exclusions
'exclude_paths' => array(),
'exclude_cookies' => array(),
'exclude_js' => array(),
'exclude_css' => array(),
// Extra
'remove_emojis' => true,
'remove_query_strings' => true,
'preconnect_domains' => array(),
'dns_prefetch' => true,
// Lazy loading
'lazyload_images' => true,
'lazyload_iframes' => true,
'lazyload_exclude' => array(),
// Google Fonts
'google_fonts' => 'defer', // 'leave', 'remove', 'combine', 'defer'
// Local Font Optimization
'local_font_display' => true, // Add font-display: swap to all @font-face rules
'preload_fonts' => array(), // Font URLs to preload (woff2 recommended)
// Plugin Compatibility
'compat_auto_detect' => false, // Auto-detect plugins (can be fragile)
'compat_plugins' => array(), // Manually selected: woocommerce, learndash, wordfence, wpforms, gutenberg_fse
);
$saved = get_option( 'maple_performance_settings', array() );
$this->settings = wp_parse_args( $saved, $defaults );
// Apply site mode presets
$this->apply_site_mode();
}
/**
* Apply safe defaults based on site mode
*/
private function apply_site_mode() {
switch ( $this->settings['site_mode'] ) {
case 'woocommerce':
$this->settings['js_aggregate'] = false;
$this->settings['css_defer'] = false;
$this->settings['cache_logged_in'] = false;
break;
case 'learndash':
$this->settings['js_aggregate'] = false;
$this->settings['css_defer'] = false;
$this->settings['cache_logged_in'] = false;
break;
case 'woo_learndash':
$this->settings['js_aggregate'] = false;
$this->settings['js_defer'] = false;
$this->settings['css_aggregate'] = true;
$this->settings['css_inline_aggregate'] = false;
$this->settings['css_defer'] = false;
$this->settings['cache_logged_in'] = false;
break;
}
// Ensure arrays are unique
$this->settings['exclude_paths'] = array_unique( array_filter( $this->settings['exclude_paths'] ) );
$this->settings['exclude_cookies'] = array_unique( array_filter( $this->settings['exclude_cookies'] ) );
$this->settings['exclude_js'] = array_unique( array_filter( $this->settings['exclude_js'] ) );
$this->settings['exclude_css'] = array_unique( array_filter( $this->settings['exclude_css'] ) );
}
/**
* Initialize hooks
*/
private function init_hooks() {
// Activation/Deactivation
register_activation_hook( MAPLE_PERF_FILE, array( $this, 'activate' ) );
register_deactivation_hook( MAPLE_PERF_FILE, array( $this, 'deactivate' ) );
// Load components
add_action( 'plugins_loaded', array( $this, 'load_components' ) );
// Admin
if ( is_admin() ) {
add_action( 'plugins_loaded', array( $this, 'load_admin' ) );
}
}
/**
* Load plugin components
*/
public function load_components() {
// Compatibility layer (always load to detect plugins)
require_once MAPLE_PERF_PATH . 'inc/class-maple-compat.php';
Maple_Performance_Compat::get_instance();
// Cache component
if ( $this->settings['cache_enabled'] && ! is_admin() ) {
require_once MAPLE_PERF_PATH . 'inc/class-maple-cache.php';
Maple_Performance_Cache::get_instance();
}
// Optimize component (CSS/JS/HTML)
if ( ! is_admin() ) {
require_once MAPLE_PERF_PATH . 'inc/class-maple-optimize.php';
Maple_Performance_Optimize::get_instance();
}
// Extra optimizations
if ( $this->settings['remove_emojis'] ) {
$this->disable_emojis();
}
}
/**
* Load admin component
*/
public function load_admin() {
require_once MAPLE_PERF_PATH . 'inc/class-maple-admin.php';
Maple_Performance_Admin::get_instance();
}
/**
* Disable WordPress emojis
*/
private function disable_emojis() {
remove_action( 'wp_head', 'print_emoji_detection_script', 7 );
remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );
remove_action( 'wp_print_styles', 'print_emoji_styles' );
remove_action( 'admin_print_styles', 'print_emoji_styles' );
remove_filter( 'the_content_feed', 'wp_staticize_emoji' );
remove_filter( 'comment_text_rss', 'wp_staticize_emoji' );
remove_filter( 'wp_mail', 'wp_staticize_emoji_for_email' );
add_filter( 'tiny_mce_plugins', function( $plugins ) {
return is_array( $plugins ) ? array_diff( $plugins, array( 'wpemoji' ) ) : array();
});
add_filter( 'wp_resource_hints', function( $urls, $relation_type ) {
if ( 'dns-prefetch' === $relation_type ) {
$urls = array_filter( $urls, function( $url ) {
return strpos( $url, 'https://s.w.org/images/core/emoji/' ) === false;
});
}
return $urls;
}, 10, 2 );
}
/**
* Plugin activation
*/
public function activate() {
// Create cache directory
if ( ! file_exists( MAPLE_PERF_CACHE_DIR ) ) {
wp_mkdir_p( MAPLE_PERF_CACHE_DIR );
}
// Create assets cache directory
if ( ! file_exists( MAPLE_PERF_CACHE_DIR . 'assets/' ) ) {
wp_mkdir_p( MAPLE_PERF_CACHE_DIR . 'assets/' );
}
// Add index.php for security
$index_content = "<?php\n// Silence is golden.\nif ( ! defined( 'ABSPATH' ) ) { exit; }";
if ( ! file_exists( MAPLE_PERF_CACHE_DIR . 'index.php' ) ) {
file_put_contents( MAPLE_PERF_CACHE_DIR . 'index.php', $index_content );
}
if ( ! file_exists( MAPLE_PERF_CACHE_DIR . 'assets/index.php' ) ) {
file_put_contents( MAPLE_PERF_CACHE_DIR . 'assets/index.php', $index_content );
}
// Set default settings
if ( ! get_option( 'maple_performance_settings' ) ) {
add_option( 'maple_performance_settings', array() );
}
// Check for caching conflicts and set flag to show notice
$conflicts = $this->detect_caching_conflicts();
if ( ! empty( $conflicts ) ) {
set_transient( 'maple_perf_activation_conflict', $conflicts, 60 );
}
// Clear any existing cache
$this->clear_cache();
// Clear any previously dismissed conflict notice
delete_transient( 'maple_perf_conflict_dismissed' );
flush_rewrite_rules();
}
/**
* Detect caching plugin conflicts at activation time
*/
private function detect_caching_conflicts() {
$conflicts = array();
// Check for common caching plugins
if ( defined( 'WP_ROCKET_VERSION' ) ) {
$conflicts[] = 'WP Rocket';
}
if ( defined( 'W3TC' ) || class_exists( 'W3_Plugin_TotalCache' ) ) {
$conflicts[] = 'W3 Total Cache';
}
if ( defined( 'LSCWP_V' ) || class_exists( 'LiteSpeed_Cache' ) ) {
$conflicts[] = 'LiteSpeed Cache';
}
if ( defined( 'WPCACHEHOME' ) || function_exists( 'wp_cache_phase2' ) ) {
$conflicts[] = 'WP Super Cache';
}
if ( class_exists( 'WpFastestCache' ) || defined( 'WPFC_WP_CONTENT_BASENAME' ) ) {
$conflicts[] = 'WP Fastest Cache';
}
if ( class_exists( 'autoptimizeMain' ) || defined( 'AUTOPTIMIZE_PLUGIN_VERSION' ) ) {
$conflicts[] = 'Autoptimize';
}
if ( class_exists( 'Cache_Enabler' ) || defined( 'CACHE_ENABLER_VERSION' ) ) {
$conflicts[] = 'Cache Enabler';
}
if ( class_exists( 'WP_Hummingbird' ) || defined( 'WPHB_VERSION' ) ) {
$conflicts[] = 'Hummingbird';
}
if ( class_exists( 'Breeze_Admin' ) || defined( 'BREEZE_VERSION' ) ) {
$conflicts[] = 'Breeze';
}
if ( class_exists( 'SiteGround_Optimizer' ) || defined( 'SG_OPTIMIZER_VERSION' ) ) {
$conflicts[] = 'SG Optimizer';
}
return $conflicts;
}
/**
* Plugin deactivation
*/
public function deactivate() {
$this->clear_cache();
flush_rewrite_rules();
}
/**
* Clear all caches
*/
public function clear_cache() {
// Clear page cache
if ( class_exists( 'Maple_Performance_Cache' ) ) {
Maple_Performance_Cache::clear_all();
} else {
$this->recursive_delete( MAPLE_PERF_CACHE_DIR );
}
// Recreate directories
if ( ! file_exists( MAPLE_PERF_CACHE_DIR ) ) {
wp_mkdir_p( MAPLE_PERF_CACHE_DIR );
}
if ( ! file_exists( MAPLE_PERF_CACHE_DIR . 'assets/' ) ) {
wp_mkdir_p( MAPLE_PERF_CACHE_DIR . 'assets/' );
}
}
/**
* Recursively delete directory contents
*/
private function recursive_delete( $dir ) {
if ( ! is_dir( $dir ) ) {
return;
}
// Safety check - only delete within wp-content/cache/maple-performance
$real_dir = realpath( $dir );
$allowed_base = realpath( WP_CONTENT_DIR );
if ( false === $real_dir || false === $allowed_base ) {
return;
}
if ( strpos( $real_dir, $allowed_base ) !== 0 ) {
return;
}
// Additional safety - must contain 'maple-performance' in path
if ( strpos( $real_dir, 'maple-performance' ) === false ) {
return;
}
// Limit iterations to prevent runaway deletion
$max_iterations = 50000;
$iterations = 0;
try {
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator( $dir, RecursiveDirectoryIterator::SKIP_DOTS ),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ( $files as $file ) {
if ( ++$iterations > $max_iterations ) {
break; // Safety limit reached
}
$file_path = $file->getRealPath();
// Verify each file is within allowed directory
if ( strpos( $file_path, $real_dir ) !== 0 ) {
continue;
}
if ( $file->isDir() ) {
@rmdir( $file_path );
} else {
@unlink( $file_path );
}
}
} catch ( Exception $e ) {
// Handle iterator exceptions gracefully
return;
}
}
/**
* Get setting value
*/
public function get_setting( $key, $default = null ) {
return isset( $this->settings[ $key ] ) ? $this->settings[ $key ] : $default;
}
/**
* Check if current request should be excluded from caching
*/
public function is_excluded() {
// Admin requests
if ( is_admin() ) {
return true;
}
// AJAX requests
if ( wp_doing_ajax() ) {
return true;
}
// Cron requests
if ( wp_doing_cron() ) {
return true;
}
// REST API
if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
return true;
}
// POST requests
if ( 'GET' !== $_SERVER['REQUEST_METHOD'] ) {
return true;
}
// DONOTCACHEPAGE constant (set by compat class or other plugins)
if ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) {
return true;
}
// Logged-in users (unless enabled)
if ( ! $this->settings['cache_logged_in'] && is_user_logged_in() ) {
return true;
}
// Get cookie exclusions with compat filter
$exclude_cookies = apply_filters( 'maple_performance_cookie_exclusions', $this->settings['exclude_cookies'] );
// Check excluded cookies
foreach ( $exclude_cookies as $cookie ) {
if ( ! empty( $cookie ) ) {
// Support partial cookie name matching (for session cookies with dynamic suffixes)
foreach ( $_COOKIE as $cookie_name => $cookie_value ) {
if ( strpos( $cookie_name, $cookie ) !== false ) {
return true;
}
}
}
}
// Get path exclusions with compat filter
$exclude_paths = apply_filters( 'maple_performance_path_exclusions', $this->settings['exclude_paths'] );
// Check excluded paths
$request_uri = $_SERVER['REQUEST_URI'] ?? '';
foreach ( $exclude_paths as $path ) {
if ( ! empty( $path ) && strpos( $request_uri, $path ) !== false ) {
return true;
}
}
// Allow other plugins/code to exclude
if ( apply_filters( 'maple_performance_exclude_optimization', false ) ) {
return true;
}
// WooCommerce specific
if ( function_exists( 'is_cart' ) && ( is_cart() || is_checkout() || is_account_page() ) ) {
return true;
}
// LearnDash specific - always exclude when user is enrolled
if ( function_exists( 'learndash_user_get_enrolled_courses' ) && is_user_logged_in() ) {
return true;
}
return false;
}
}
/**
* Initialize plugin
*/
function maple_performance() {
return Maple_Performance::get_instance();
}
// Start the plugin
maple_performance();
/**
* Template tag to clear cache
*/
function maple_clear_cache() {
maple_performance()->clear_cache();
}

View file

@ -0,0 +1,142 @@
=== Maple Performance WP ===
Contributors: mapleopentech
Tags: cache, performance, speed, optimization, minify
Requires at least: 5.9
Tested up to: 6.4
Requires PHP: 7.4
Stable tag: 1.0.0
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
A lightweight, privacy-focused WordPress performance plugin. No external dependencies, no tracking, no upsells.
== Description ==
Maple Performance WP is a Canadian-made, privacy-focused performance optimization plugin that speeds up your WordPress site without sending any data to external servers.
**Key Features:**
* **Page Caching** - Creates static HTML files with optional Gzip/Brotli pre-compression
* **HTML Minification** - Removes unnecessary whitespace and comments
* **CSS Optimization** - Minify and aggregate CSS files
* **JavaScript Optimization** - Minify and aggregate JS files (with safe defaults)
* **Google Fonts Optimization** - Combine, add display:swap, and optionally defer loading
* **Lazy Loading** - Native lazy loading for images and iframes
* **Extra Optimizations** - Remove emoji scripts, query strings, add preconnect hints
* **Smart Plugin Compatibility** - Auto-detects WooCommerce, LearnDash, WPForms, Wordfence and applies safe exclusions
**Site Mode Presets:**
Choose your site type and Maple Performance automatically applies safe default settings:
* **Brochure/Blog** - Full optimization enabled
* **WooCommerce** - Conservative JS settings to protect checkout
* **LearnDash** - Safe settings to protect lesson tracking and quizzes
* **WooCommerce + LearnDash** - Maximum safety for course selling sites
**Privacy First:**
* No external API calls
* No tracking or analytics
* No CDN dependencies
* No upsells or premium nags
* All processing happens on your server
**Canadian Data Sovereignty:**
Built by [Maple Open Tech](https://mapleopentech.ca) for Canadian businesses who care about keeping their data in Canada.
== Installation ==
1. Upload the `maple-performance-wp` folder to `/wp-content/plugins/`
2. Activate the plugin through the 'Plugins' menu in WordPress
3. Go to Settings > Maple Performance
4. Select your site type (Brochure, WooCommerce, LearnDash, or both)
5. Adjust settings as needed
6. Save and test your site
== Frequently Asked Questions ==
= Is this safe for WooCommerce? =
Yes! Select "WooCommerce Store" as your site type and Maple Performance automatically disables aggressive optimizations that could break checkout. Cart and checkout pages are excluded from caching automatically.
= Is this safe for LearnDash? =
Yes! Select "LearnDash LMS" as your site type and JavaScript aggregation is disabled to protect lesson completion tracking and quiz functionality.
= Does this require any external service? =
No. Everything runs on your server. No API calls, no CDN requirements, no external dependencies.
= How do I clear the cache? =
Click "Clear All Cache" in the admin bar, or go to Settings > Maple Performance and click the clear cache button.
= Can I use this with other caching plugins? =
Not recommended. Deactivate other caching plugins (W3 Total Cache, WP Rocket, LiteSpeed Cache, etc.) before activating Maple Performance to avoid conflicts.
= What plugins are automatically compatible? =
Maple Performance automatically detects and applies safe defaults for:
* **WooCommerce** - Cart, checkout, account pages excluded from cache. Cart/checkout scripts protected.
* **LearnDash** - Lesson, topic, quiz pages excluded. Progress tracking and quiz scripts protected.
* **WPForms** - Form scripts excluded from aggregation. AJAX submissions protected.
* **Wordfence** - Security scripts excluded. Login pages not cached. Firewall cookies respected.
* **Gravity Forms** - Form scripts excluded from aggregation.
* **Contact Form 7** - Form scripts excluded from aggregation.
* **Elementor** - Builder scripts excluded from aggregation.
You'll see a list of detected plugins and applied protections on the settings page.
= Is Maple Performance GDPR compliant? =
Yes. Maple Performance WP is designed with privacy in mind:
* **No personal data collection** - The plugin does not collect, store, or process any personal data such as IP addresses, email addresses, names, or user identifiers.
* **No cookies set** - The plugin does not set any cookies. It only reads existing cookies (like WordPress login or WooCommerce cart cookies) to determine whether to serve cached content.
* **No external connections** - The plugin does not connect to any external services or transmit any data to third parties. All processing happens locally on your server.
* **No tracking or analytics** - There is no usage tracking, telemetry, or analytics of any kind.
* **Logged-in users excluded** - By default, pages are not cached for logged-in users, ensuring no user-specific content is stored.
The plugin automatically adds suggested text to your Privacy Policy page (Settings → Privacy) explaining its data practices.
= What data does the cache store? =
The cache stores only:
1. **Static HTML files** - Copies of your publicly-visible pages (the same content any visitor would see)
2. **Aggregated CSS/JS files** - Combined and minified versions of your theme/plugin assets
No personal data, user information, or tracking data is ever stored in the cache.
= What PageSpeed scores can I expect? =
* Brochure sites: 80-95
* WooCommerce sites: 70-85
* LearnDash sites: 70-85
* WooCommerce + LearnDash: 65-80
The conservative settings for ecommerce and LMS sites prioritize functionality over scores.
== Changelog ==
= 1.0.0 =
* Initial release
* Page caching with Gzip/Brotli support
* HTML, CSS, and JS minification
* CSS and JS aggregation
* Google Fonts optimization (combine, display:swap, defer)
* Lazy loading for images and iframes
* Site mode presets for WooCommerce and LearnDash
* Automatic plugin compatibility for WooCommerce, LearnDash, WPForms, Wordfence, Gravity Forms, Contact Form 7, Elementor
* DNS prefetch and preconnect support
* Emoji removal
* Query string removal
== Upgrade Notice ==
= 1.0.0 =
Initial release of Maple Performance WP.

View file

@ -0,0 +1,68 @@
<?php
/**
* Uninstall Maple Performance WP
*
* Removes all plugin data when uninstalled.
*
* @package MaplePerformance
*/
// Exit if not called by WordPress
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
exit;
}
// Delete options
delete_option( 'maple_performance_settings' );
// Delete cache directory
$cache_dir = WP_CONTENT_DIR . '/cache/maple-performance/';
// Verify path is valid and within wp-content
$real_cache_dir = realpath( $cache_dir );
$real_content_dir = realpath( WP_CONTENT_DIR );
if ( $real_cache_dir && $real_content_dir && strpos( $real_cache_dir, $real_content_dir ) === 0 ) {
if ( is_dir( $cache_dir ) ) {
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator( $cache_dir, RecursiveDirectoryIterator::SKIP_DOTS ),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ( $files as $file ) {
$file_path = $file->getRealPath();
// Verify each file is within cache directory
if ( strpos( $file_path, $real_cache_dir ) !== 0 ) {
continue;
}
if ( $file->isDir() ) {
@rmdir( $file_path );
} else {
@unlink( $file_path );
}
}
@rmdir( $cache_dir );
}
}
// Clear any transients using proper LIKE escaping
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
$wpdb->esc_like( '_transient_maple_perf_' ) . '%'
)
);
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
$wpdb->esc_like( '_transient_timeout_maple_perf_' ) . '%'
)
);

View file

@ -0,0 +1,12 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

View file

@ -0,0 +1,33 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
export default [
{ ignores: ['dist'] },
{
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
]

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View file

@ -0,0 +1 @@
../acorn/bin/acorn

View file

@ -0,0 +1 @@
../autoprefixer/bin/autoprefixer

Some files were not shown because too many files have changed in this diff Show more