diff options
author | João Augusto Costa Branco Marado Torres <torres.dev@disroot.org> | 2025-06-24 12:08:41 -0300 |
---|---|---|
committer | João Augusto Costa Branco Marado Torres <torres.dev@disroot.org> | 2025-06-24 12:50:43 -0300 |
commit | f9a77c5c27aede4e5978eb55d9b7af781b680a1d (patch) | |
tree | d545e325ba1ae756fc2eac66fac1001b6753c40d |
feat!: initial commit
Signed-off-by: João Augusto Costa Branco Marado Torres <torres.dev@disroot.org>
80 files changed, 9901 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7cf1bf7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# build output +dist/ +# generated types +.astro/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + + +# environment variables +.env +.env.production + +# macOS-specific files +.DS_Store + +# jetbrains setting folder +.idea/ + +/coverage/ +/report.junit.xml +/docs/ + +# I guess if this was a template repo I woudn't want to share the blog posts +# and the "trusted" keys. +# +# /public/blog/* +# !/public/blog/TEMPLATE +# /public/keys/ @@ -0,0 +1 @@ +João Augusto Costa Branco Marado Torres <torres.dev@disroot.org> (https://cravodeabril.pt) diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6e6b13b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +### Changed + +### Deprecated + +### Removed + +### Fixed + +### Security diff --git a/CODE_OF_CONDUCT.adoc b/CODE_OF_CONDUCT.adoc new file mode 100644 index 0000000..24c8f58 --- /dev/null +++ b/CODE_OF_CONDUCT.adoc @@ -0,0 +1,133 @@ += Splikan Code of Conduct +:toc: + +== Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +== Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances + of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +== Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for +moderation decisions when appropriate. + +== Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +== Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +mailto:torres.dev@disroot.org[,"Splikan CoC Enforcement"]. All complaints will +be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +== Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +=== 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +=== 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +=== 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +=== 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +== Attribution + +This Code of Conduct is adapted from the +https://www.contributor-covenant.org[Contributor Covenant], version 2.1, +available at +https://www.contributor-covenant.org/version/2/1/code_of_conduct.html[]. + +Community Impact Guidelines were inspired by +https://github.com/mozilla/diversity[Mozilla's code of conduct enforcement ladder]. + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq[]. Translations are available at +https://www.contributor-covenant.org/translations[]. + +//// +https://www.djangoproject.com/conduct/ +https://www.caseiq.com/resources/18-of-the-best-code-of-conduct-examples/ +//// diff --git a/CONTRIBUTORS b/CONTRIBUTORS new file mode 100644 index 0000000..441474b --- /dev/null +++ b/CONTRIBUTORS @@ -0,0 +1 @@ +João Augusto Costa Branco Marado Torres <torres.dev@disroot.org> (https://github.com/torres-engineer) @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +<https://www.gnu.org/licenses/>. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a205558 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# <cravodeabril.pt> + +# Signing and verifying blog posts (incomplete) + +Posts don't have to be signed. + +Assuming you have the repo cloned. + +1. Write a blog post in [`./src/content/blog/`][blog dir] using the + [`TEMPLATE`][blog template] in that directory by duplicating it and renaming + it to what will be displayed on the URL (I'd like to keep the format of the + slug only ASCII, lowercase letters, numbers and hyphens) plus the `.md` + extension for markdown: + + cp ./src/content/blog/TEMPLATE ./src/content/blog/<slug>.md + +2. Write the blog post. Addicionally, you will have to fill the _frontmatter_. + Commented lines in the _frontmatter_ with `#` are optional. But it's a good + idea to fill some of them, namely: + + - `signer.name` - Can be used to find your public key in Keyserver for + example; + - `signer.email` - Can be used to find your public key in Keyserver, or from + a signed e-mail that the user might have recieved; + - `signer.website[]` - Can be used to get your public key from the website's + certificate assuming it uses TLS, or from your GitHub's profile, + <https://keybase.io/>, or by reading the DNS records, the website uses WKD + protocol. Basically for signature statements; + - `signer.publickey.armor` - Your public key in ASCII armor format so that + they can import it; + - `signer.publickey.url` - URL to your public key for people to download from + and import it; + - `signer.publickey.keyID` - Your public key ID to find your public key in + Keyserver for example; + - `signer.publickey.fingerprint` - Your public key fingerprint; + - `signer.publickey.keyserver[]` - Key servers where we can find your public + key. + + The _frontmatters_ are either in [TOML or YAML format][md in astro]. + +3. Sign that blog post with OpenPGP creating a detached signature with the file + extension `.sig` for binary signatures and `.asc` for ASCII armored + signatures, on the same directory as the blog `.md` file: + + gpg -b ./src/content/blog/<slug>.md + + Perfer [these][web crypto algs] algorithms. + +4. Commit the new blog post plus the signature. + +5. The `.md` blog post file, its signature, and your public key are now + available to the website user. We can download them, import the key and + verify themselves: + + gpg --import publickey.asc + gpg --search-keys <name> + gpg --recv-keys 0x<keyID> + gpg --auto-key-locate wkd,keyserver --locate-keys <name> + + gpg --verify ./src/content/blog/<slug>.md.sig ./src/content/blog/<slug>.md + + The UI will show all the `signer` information from the _frontmatter_ plus, + the commit who created the signature plus the signature of that commit, the + QR code for the public key, download everything as an archive option and a + label based on this: + + - good: signed + verified signature + trust level + - warning: signed + verified signature + untrusted + - warning: signed + verified signature + key revoked + when time of + revocation after time of signature + backdate fake signatures + - error: signed + verified signature + key revoked + date of revocation + + when time of revocation before time of signature + - error: signed + unverifiable + - warning: signed + not recognized + - error: unsigned + +[blog dir]: /src/content/blog "Blogs directory" +[blog template]: /src/content/blog/TEMPLATE "Blog Markdown template" +[web crypto algs]: https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/sign#algorithm "Supported signing algorithms by the Web Crypto API" +[md in astro]: https://docs.astro.build/en/guides/markdown-content/#importing-markdown "Importing Markdown in Astro" diff --git a/astro.config.ts b/astro.config.ts new file mode 100644 index 0000000..499b7ec --- /dev/null +++ b/astro.config.ts @@ -0,0 +1,98 @@ +// @ts-check +import { defineConfig } from "astro/config"; +import sitemap from "@astrojs/sitemap"; +import { parseFrontmatter } from "@astrojs/markdown-remark"; +import remarkGfm from "remark-gfm"; +import remarkSmartypants from "remark-smartypants"; +import rehypeExternalLinks from "rehype-external-links"; +import type { Root } from "mdast"; +import type { VFile } from "vfile"; +import type { Plugin } from "unified"; +import type { Options } from "retext-smartypants"; +import { visit } from "unist-util-visit"; +import rehypeSanitize from "rehype-sanitize"; +import remarkToc from "remark-toc"; +import { get } from "./src/utils/anonymous.ts"; + +// https://astro.build/config +export default defineConfig({ + site: "https://cravodeabril.pt", + integrations: [sitemap({ + serialize: async (item) => { + const match = item.url.match(/\/blog\/read\/([^/]+)\/$/); + if (match === null) { + return item; + } + const slug = match[1]; + + let frontmatter; + try { + frontmatter = await Deno.readTextFile( + `${Deno.cwd()}/public/blog/${slug}.md`, + ).then(parseFrontmatter).then(get("frontmatter")); + } catch { + return item; + } + + item.lastmod = (frontmatter.dateUpdated ?? frontmatter.dateCreated) + .toISOString(); + for await ( + const { name, isFile } of Deno.readDir(`${Deno.cwd()}/public/blog/`) + ) { + if (!name.endsWith(".md") || !isFile || name === `${slug}.md`) { + continue; + } + + let frontmatter; + try { + frontmatter = await Deno.readTextFile( + `${Deno.cwd()}/public/blog/${name}`, + ).then(parseFrontmatter).then(get("frontmatter")); + } catch { + continue; + } + + if (frontmatter.translationOf !== slug) { + continue; + } + + item.links ??= []; + item.links.push({ + url: `https://cravodeabril.pt/blog/${name}`, + lang: frontmatter.lang, + hreflang: frontmatter.lang, + }); + } + return item; + }, + xslURL: "/sitemap.xsl", + })], + server: ({ command }) => ({ + host: command === "dev", + }), + prefetch: true, + markdown: { + remarkPlugins: [ + remarkGfm, + remarkSmartypants as Plugin<[(Options | undefined)?], Root>, + [remarkToc, { ordered: true }], + () => (tree: Root, _file: VFile): void => { + visit(tree, function (node) { + if (node.type === "heading") { + node.depth++; + } + }); + }, + ], + rehypePlugins: [ + [ + rehypeExternalLinks, + { + target: "_blank", + }, + ], + rehypeSanitize, + ], + remarkRehype: { clobberPrefix: "" }, + }, +}); diff --git a/bin/sign.sh b/bin/sign.sh new file mode 100755 index 0000000..92fd207 --- /dev/null +++ b/bin/sign.sh @@ -0,0 +1,44 @@ +#!/bin/sh +# Exit codes: +# 0 - Success +# 1 - Missing args +# 2 - File not found + +set -e + +usage() { + echo "Usage: $(basename "$0") [-u KEY_ID] <file-to-sign>" >&2 + echo "\t-u KEY_ID\tUse a specific GPG key to sign with (optional)" >&2 + exit 1 +} + +USER_KEY="" + +while getopts ":u:" opt; do + case "$opt" in + u) USER_KEY="$OPTARG" ;; + *) usage ;; + esac +done + +shift $((OPTIND - 1)) + +if [ $# -ne 1 ]; then + echo "Error: Missing file to sign." >&2 + usage +fi + +FILE="$1" + +if [ ! -f "$FILE" ]; then + echo "Error: File '$FILE' not found." >&2 + exit 2 +fi + +GPG_CMD="gpg --include-key-block --openpgp -b" + +if [ -n "$USER_KEY" ]; then + GPG_CMD="$GPG_CMD --local-user $USER_KEY" +fi + +eval "$GPG_CMD \"$FILE\"" && echo "Signature created: ${FILE}.sig" diff --git a/deno.jsonc b/deno.jsonc new file mode 100644 index 0000000..f53c068 --- /dev/null +++ b/deno.jsonc @@ -0,0 +1,93 @@ +{ + "$schema": "https://deno.land/x/deno/cli/schemas/config-file.v1.json", + "tasks": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "build:preview": { + "command": "deno task preview", + "dependencies": [ + "build" + ] + }, + "astro": "astro", + "test:unit": "deno test --doc --junit-path=report.junit.xml --permit-no-files --shuffle --trace-leaks src", + "test:unit:coverage": "deno task test:unit --coverage", + "test:unit:inspect": "deno task test:unit --inspect-brk", + "test:unit:watch": "deno task test:unit --watch --no-clear-screen", + "test:unit:watch:coverage": "deno task test:unit:coverage --watch --no-clear-screen", + "test:unit:watch:inspect": "deno task test:unit:inspect --watch --no-clear-screen", + "test:check": "deno --doc", + "coverage": { + "command": "deno coverage --html --exclude=\"node_modules/,dist/\"", + "dependencies": [ + "test:unit" + ] + }, + "doc": "deno doc --html --name=\"<cravodeabril.pt>\"", + "doc:lint": "deno doc --lint --html --name=\"<cravodeabril.pt>\"" + }, + "license": "AGPL-3.0-or-later", + "fmt": { + "useTabs": false, + "lineWidth": 80, + "semiColons": true, + "indentWidth": 2, + "singleQuote": false + }, + "lint": { + "rules": { + "tags": [ + "jsr", + "jsx", + "recommended" + ], + "include": [ + "ban-untagged-todo", + "camelcase", + "default-param-last", + "eqeqeq", + // "explicit-function-return-type", + "explicit-module-boundary-types", + "guard-for-in", + "jsx-boolean-value", + "no-await-in-loop", + "no-boolean-literal-for-arguments", + "no-const-assign", + "no-eval", + // "no-external-import", + "no-implicit-declare-namespace-export", + "no-inferrable-types", + "no-non-null-asserted-optional-chain", + "no-non-null-assertion", + "no-self-compare", + "no-sparse-arrays", + "no-sync-fn-in-async-fn", + "no-throw-literal", + "no-top-level-await", + "no-undef", + "no-useless-rename", + // "prefer-ascii", + "single-var-declarator" + ] + }, + "report": "pretty" + }, + "unstable": [ + "worker-options", + "cron", + "kv" + ], + "nodeModulesDir": "auto", + "imports": { + "@std/assert": "jsr:@std/assert@^1.0.13", + "@std/async": "jsr:@std/async@^1.0.13", + "@std/encoding": "jsr:@std/encoding@^1.0.10", + "@std/expect": "jsr:@std/expect@^1.0.16", + "@std/fs": "jsr:@std/fs@^1.0.17", + "@std/path": "jsr:@std/path@^1.0.9", + "@std/testing": "jsr:@std/testing@^1.0.12", + "@std/toml": "jsr:@std/toml@^1.0.7", + "mdast": "npm:@types/mdast" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..5a6bff5 --- /dev/null +++ b/deno.lock @@ -0,0 +1,2972 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/assert@^1.0.13": "1.0.13", + "jsr:@std/async@^1.0.13": "1.0.13", + "jsr:@std/collections@^1.1.1": "1.1.1", + "jsr:@std/data-structures@^1.0.8": "1.0.8", + "jsr:@std/encoding@^1.0.10": "1.0.10", + "jsr:@std/expect@^1.0.16": "1.0.16", + "jsr:@std/fs@^1.0.17": "1.0.18", + "jsr:@std/fs@^1.0.18": "1.0.18", + "jsr:@std/internal@^1.0.6": "1.0.8", + "jsr:@std/internal@^1.0.7": "1.0.8", + "jsr:@std/internal@^1.0.8": "1.0.8", + "jsr:@std/path@^1.0.9": "1.1.0", + "jsr:@std/path@^1.1.0": "1.1.0", + "jsr:@std/testing@^1.0.12": "1.0.14", + "jsr:@std/toml@^1.0.7": "1.0.8", + "npm:@astrojs/check@~0.9.4": "0.9.4_typescript@5.8.3", + "npm:@astrojs/markdown-remark@^6.3.2": "6.3.2", + "npm:@astrojs/rss@4.0.12": "4.0.12", + "npm:@astrojs/sitemap@3.4.1": "3.4.1", + "npm:@openpgp/web-stream-tools@~0.1.3": "0.1.3_typescript@5.8.3", + "npm:@types/mdast@*": "4.0.4", + "npm:@types/mdast@^4.0.4": "4.0.4", + "npm:@types/unist@*": "3.0.3", + "npm:@types/unist@^3.0.3": "3.0.3", + "npm:astro@5.9.3": "5.9.3_typescript@5.8.3_vite@6.3.5__picomatch@4.0.2_zod@3.25.64", + "npm:openpgp@^6.1.1": "6.1.1", + "npm:reading-time@^1.5.0": "1.5.0", + "npm:rehype-external-links@3": "3.0.0", + "npm:rehype-sanitize@6": "6.0.0", + "npm:remark-gfm@^4.0.1": "4.0.1", + "npm:remark-smartypants@^3.0.2": "3.0.2", + "npm:remark-toc@9": "9.0.0", + "npm:retext-smartypants@^6.2.0": "6.2.0", + "npm:toml@3": "3.0.0", + "npm:typescript@^5.8.3": "5.8.3", + "npm:unified@^11.0.5": "11.0.5", + "npm:unist-util-visit@5": "5.0.0", + "npm:vfile@^6.0.3": "6.0.3", + "npm:yaqrcode@~0.2.1": "0.2.1" + }, + "jsr": { + "@std/assert@1.0.13": { + "integrity": "ae0d31e41919b12c656c742b22522c32fb26ed0cba32975cb0de2a273cb68b29", + "dependencies": [ + "jsr:@std/internal@^1.0.6" + ] + }, + "@std/async@1.0.13": { + "integrity": "1d76ca5d324aef249908f7f7fe0d39aaf53198e5420604a59ab5c035adc97c96" + }, + "@std/collections@1.1.1": { + "integrity": "eff6443fbd9d5a6697018fb39c5d13d5f662f0045f21392d640693d0008ab2af" + }, + "@std/data-structures@1.0.8": { + "integrity": "2fb7219247e044c8fcd51341788547575653c82ae2c759ff209e0263ba7d9b66" + }, + "@std/encoding@1.0.10": { + "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" + }, + "@std/expect@1.0.16": { + "integrity": "ceeef6dda21f256a5f0f083fcc0eaca175428b523359a9b1d9b3a1df11cc7391", + "dependencies": [ + "jsr:@std/assert", + "jsr:@std/internal@^1.0.7" + ] + }, + "@std/fs@1.0.18": { + "integrity": "24bcad99eab1af4fde75e05da6e9ed0e0dce5edb71b7e34baacf86ffe3969f3a", + "dependencies": [ + "jsr:@std/path@^1.1.0" + ] + }, + "@std/internal@1.0.8": { + "integrity": "fc66e846d8d38a47cffd274d80d2ca3f0de71040f855783724bb6b87f60891f5" + }, + "@std/path@1.1.0": { + "integrity": "ddc94f8e3c275627281cbc23341df6b8bcc874d70374f75fec2533521e3d6886" + }, + "@std/testing@1.0.14": { + "integrity": "144b3737105b9071cb50c957681f58a1b8ec0f3e5b19ad830f401c5fa931e8f0", + "dependencies": [ + "jsr:@std/assert", + "jsr:@std/async", + "jsr:@std/data-structures", + "jsr:@std/fs@^1.0.18", + "jsr:@std/internal@^1.0.8", + "jsr:@std/path@^1.1.0" + ] + }, + "@std/toml@1.0.8": { + "integrity": "eb8ae76b4cc1c6c13f2a91123675823adbec2380de75cd3748c628960d952164", + "dependencies": [ + "jsr:@std/collections" + ] + } + }, + "npm": { + "@astrojs/check@0.9.4_typescript@5.8.3": { + "integrity": "sha512-IOheHwCtpUfvogHHsvu0AbeRZEnjJg3MopdLddkJE70mULItS/Vh37BHcI00mcOJcH1vhD3odbpvWokpxam7xA==", + "dependencies": [ + "@astrojs/language-server", + "chokidar", + "kleur@4.1.5", + "typescript", + "yargs" + ], + "bin": true + }, + "@astrojs/compiler@2.12.2": { + "integrity": "sha512-w2zfvhjNCkNMmMMOn5b0J8+OmUaBL1o40ipMvqcG6NRpdC+lKxmTi48DT8Xw0SzJ3AfmeFLB45zXZXtmbsjcgw==" + }, + "@astrojs/internal-helpers@0.6.1": { + "integrity": "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A==" + }, + "@astrojs/language-server@2.15.4_typescript@5.8.3_@volar+language-service@2.4.14": { + "integrity": "sha512-JivzASqTPR2bao9BWsSc/woPHH7OGSGc9aMxXL4U6egVTqBycB3ZHdBJPuOCVtcGLrzdWTosAqVPz1BVoxE0+A==", + "dependencies": [ + "@astrojs/compiler", + "@astrojs/yaml2ts", + "@jridgewell/sourcemap-codec", + "@volar/kit", + "@volar/language-core", + "@volar/language-server", + "@volar/language-service", + "fast-glob", + "muggle-string", + "volar-service-css", + "volar-service-emmet", + "volar-service-html", + "volar-service-prettier", + "volar-service-typescript", + "volar-service-typescript-twoslash-queries", + "volar-service-yaml", + "vscode-html-languageservice", + "vscode-uri" + ], + "bin": true + }, + "@astrojs/markdown-remark@6.3.2": { + "integrity": "sha512-bO35JbWpVvyKRl7cmSJD822e8YA8ThR/YbUsciWNA7yTcqpIAL2hJDToWP5KcZBWxGT6IOdOkHSXARSNZc4l/Q==", + "dependencies": [ + "@astrojs/internal-helpers", + "@astrojs/prism", + "github-slugger", + "hast-util-from-html", + "hast-util-to-text", + "import-meta-resolve", + "js-yaml", + "mdast-util-definitions", + "rehype-raw", + "rehype-stringify", + "remark-gfm", + "remark-parse", + "remark-rehype", + "remark-smartypants", + "shiki", + "smol-toml", + "unified", + "unist-util-remove-position", + "unist-util-visit", + "unist-util-visit-parents", + "vfile" + ] + }, + "@astrojs/prism@3.3.0": { + "integrity": "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==", + "dependencies": [ + "prismjs" + ] + }, + "@astrojs/rss@4.0.12": { + "integrity": "sha512-O5yyxHuDVb6DQ6VLOrbUVFSm+NpObulPxjs6XT9q3tC+RoKbN4HXMZLpv0LvXd1qdAjzVgJ1NFD+zKHJNDXikw==", + "dependencies": [ + "fast-xml-parser", + "kleur@4.1.5" + ] + }, + "@astrojs/sitemap@3.4.1": { + "integrity": "sha512-VjZvr1e4FH6NHyyHXOiQgLiw94LnCVY4v06wN/D0gZKchTMkg71GrAHJz81/huafcmavtLkIv26HnpfDq6/h/Q==", + "dependencies": [ + "sitemap", + "stream-replace-string", + "zod" + ] + }, + "@astrojs/telemetry@3.3.0": { + "integrity": "sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==", + "dependencies": [ + "ci-info", + "debug", + "dlv", + "dset", + "is-docker", + "is-wsl", + "which-pm-runs" + ] + }, + "@astrojs/yaml2ts@0.2.2": { + "integrity": "sha512-GOfvSr5Nqy2z5XiwqTouBBpy5FyI6DEe+/g/Mk5am9SjILN1S5fOEvYK0GuWHg98yS/dobP4m8qyqw/URW35fQ==", + "dependencies": [ + "yaml@2.8.0" + ] + }, + "@babel/helper-string-parser@7.27.1": { + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==" + }, + "@babel/helper-validator-identifier@7.27.1": { + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==" + }, + "@babel/parser@7.27.5": { + "integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==", + "dependencies": [ + "@babel/types" + ], + "bin": true + }, + "@babel/types@7.27.6": { + "integrity": "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==", + "dependencies": [ + "@babel/helper-string-parser", + "@babel/helper-validator-identifier" + ] + }, + "@capsizecss/unpack@2.4.0": { + "integrity": "sha512-GrSU71meACqcmIUxPYOJvGKF0yryjN/L1aCuE9DViCTJI7bfkjgYDPD1zbNDcINJwSSP6UaBZY9GAbYDO7re0Q==", + "dependencies": [ + "blob-to-buffer", + "cross-fetch", + "fontkit" + ] + }, + "@emmetio/abbreviation@2.3.3": { + "integrity": "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==", + "dependencies": [ + "@emmetio/scanner" + ] + }, + "@emmetio/css-abbreviation@2.1.8": { + "integrity": "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==", + "dependencies": [ + "@emmetio/scanner" + ] + }, + "@emmetio/css-parser@0.4.0": { + "integrity": "sha512-z7wkxRSZgrQHXVzObGkXG+Vmj3uRlpM11oCZ9pbaz0nFejvCDmAiNDpY75+wgXOcffKpj4rzGtwGaZxfJKsJxw==", + "dependencies": [ + "@emmetio/stream-reader", + "@emmetio/stream-reader-utils" + ] + }, + "@emmetio/html-matcher@1.3.0": { + "integrity": "sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==", + "dependencies": [ + "@emmetio/scanner" + ] + }, + "@emmetio/scanner@1.0.4": { + "integrity": "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==" + }, + "@emmetio/stream-reader-utils@0.1.0": { + "integrity": "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==" + }, + "@emmetio/stream-reader@2.2.0": { + "integrity": "sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==" + }, + "@emnapi/runtime@1.4.3": { + "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", + "dependencies": [ + "tslib" + ] + }, + "@esbuild/aix-ppc64@0.25.5": { + "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "os": ["aix"], + "cpu": ["ppc64"] + }, + "@esbuild/android-arm64@0.25.5": { + "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@esbuild/android-arm@0.25.5": { + "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", + "os": ["android"], + "cpu": ["arm"] + }, + "@esbuild/android-x64@0.25.5": { + "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", + "os": ["android"], + "cpu": ["x64"] + }, + "@esbuild/darwin-arm64@0.25.5": { + "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@esbuild/darwin-x64@0.25.5": { + "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@esbuild/freebsd-arm64@0.25.5": { + "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", + "os": ["freebsd"], + "cpu": ["arm64"] + }, + "@esbuild/freebsd-x64@0.25.5": { + "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@esbuild/linux-arm64@0.25.5": { + "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@esbuild/linux-arm@0.25.5": { + "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@esbuild/linux-ia32@0.25.5": { + "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", + "os": ["linux"], + "cpu": ["ia32"] + }, + "@esbuild/linux-loong64@0.25.5": { + "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@esbuild/linux-mips64el@0.25.5": { + "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", + "os": ["linux"], + "cpu": ["mips64el"] + }, + "@esbuild/linux-ppc64@0.25.5": { + "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@esbuild/linux-riscv64@0.25.5": { + "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@esbuild/linux-s390x@0.25.5": { + "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@esbuild/linux-x64@0.25.5": { + "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@esbuild/netbsd-arm64@0.25.5": { + "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "os": ["netbsd"], + "cpu": ["arm64"] + }, + "@esbuild/netbsd-x64@0.25.5": { + "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", + "os": ["netbsd"], + "cpu": ["x64"] + }, + "@esbuild/openbsd-arm64@0.25.5": { + "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "os": ["openbsd"], + "cpu": ["arm64"] + }, + "@esbuild/openbsd-x64@0.25.5": { + "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", + "os": ["openbsd"], + "cpu": ["x64"] + }, + "@esbuild/sunos-x64@0.25.5": { + "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", + "os": ["sunos"], + "cpu": ["x64"] + }, + "@esbuild/win32-arm64@0.25.5": { + "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@esbuild/win32-ia32@0.25.5": { + "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@esbuild/win32-x64@0.25.5": { + "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@img/sharp-darwin-arm64@0.33.5": { + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "optionalDependencies": [ + "@img/sharp-libvips-darwin-arm64" + ], + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@img/sharp-darwin-x64@0.33.5": { + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "optionalDependencies": [ + "@img/sharp-libvips-darwin-x64" + ], + "os": ["darwin"], + "cpu": ["x64"] + }, + "@img/sharp-libvips-darwin-arm64@1.0.4": { + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@img/sharp-libvips-darwin-x64@1.0.4": { + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@img/sharp-libvips-linux-arm64@1.0.4": { + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@img/sharp-libvips-linux-arm@1.0.5": { + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@img/sharp-libvips-linux-s390x@1.0.4": { + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@img/sharp-libvips-linux-x64@1.0.4": { + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@img/sharp-libvips-linuxmusl-arm64@1.0.4": { + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@img/sharp-libvips-linuxmusl-x64@1.0.4": { + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@img/sharp-linux-arm64@0.33.5": { + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-arm64" + ], + "os": ["linux"], + "cpu": ["arm64"] + }, + "@img/sharp-linux-arm@0.33.5": { + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-arm" + ], + "os": ["linux"], + "cpu": ["arm"] + }, + "@img/sharp-linux-s390x@0.33.5": { + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-s390x" + ], + "os": ["linux"], + "cpu": ["s390x"] + }, + "@img/sharp-linux-x64@0.33.5": { + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-x64" + ], + "os": ["linux"], + "cpu": ["x64"] + }, + "@img/sharp-linuxmusl-arm64@0.33.5": { + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "optionalDependencies": [ + "@img/sharp-libvips-linuxmusl-arm64" + ], + "os": ["linux"], + "cpu": ["arm64"] + }, + "@img/sharp-linuxmusl-x64@0.33.5": { + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "optionalDependencies": [ + "@img/sharp-libvips-linuxmusl-x64" + ], + "os": ["linux"], + "cpu": ["x64"] + }, + "@img/sharp-wasm32@0.33.5": { + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "dependencies": [ + "@emnapi/runtime" + ], + "cpu": ["wasm32"] + }, + "@img/sharp-win32-ia32@0.33.5": { + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@img/sharp-win32-x64@0.33.5": { + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@jridgewell/sourcemap-codec@1.5.0": { + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==" + }, + "@nodelib/fs.scandir@2.1.5": { + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dependencies": [ + "@nodelib/fs.stat", + "run-parallel" + ] + }, + "@nodelib/fs.stat@2.0.5": { + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==" + }, + "@nodelib/fs.walk@1.2.8": { + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dependencies": [ + "@nodelib/fs.scandir", + "fastq" + ] + }, + "@openpgp/web-stream-tools@0.1.3_typescript@5.8.3": { + "integrity": "sha512-mT/ds43cH6c+AO5RFpxs+LkACr7KjC3/dZWHrP6KPrWJu4uJ/XJ+p7telaoYiqUfdjiiIvdNSOfhezW9fkmboQ==", + "dependencies": [ + "typescript" + ], + "optionalPeers": [ + "typescript" + ] + }, + "@oslojs/encoding@1.1.0": { + "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==" + }, + "@rollup/pluginutils@5.1.4": { + "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", + "dependencies": [ + "@types/estree@1.0.8", + "estree-walker@2.0.2", + "picomatch@4.0.2" + ] + }, + "@rollup/rollup-android-arm-eabi@4.43.0": { + "integrity": "sha512-Krjy9awJl6rKbruhQDgivNbD1WuLb8xAclM4IR4cN5pHGAs2oIMMQJEiC3IC/9TZJ+QZkmZhlMO/6MBGxPidpw==", + "os": ["android"], + "cpu": ["arm"] + }, + "@rollup/rollup-android-arm64@4.43.0": { + "integrity": "sha512-ss4YJwRt5I63454Rpj+mXCXicakdFmKnUNxr1dLK+5rv5FJgAxnN7s31a5VchRYxCFWdmnDWKd0wbAdTr0J5EA==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@rollup/rollup-darwin-arm64@4.43.0": { + "integrity": "sha512-eKoL8ykZ7zz8MjgBenEF2OoTNFAPFz1/lyJ5UmmFSz5jW+7XbH1+MAgCVHy72aG59rbuQLcJeiMrP8qP5d/N0A==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@rollup/rollup-darwin-x64@4.43.0": { + "integrity": "sha512-SYwXJgaBYW33Wi/q4ubN+ldWC4DzQY62S4Ll2dgfr/dbPoF50dlQwEaEHSKrQdSjC6oIe1WgzosoaNoHCdNuMg==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@rollup/rollup-freebsd-arm64@4.43.0": { + "integrity": "sha512-SV+U5sSo0yujrjzBF7/YidieK2iF6E7MdF6EbYxNz94lA+R0wKl3SiixGyG/9Klab6uNBIqsN7j4Y/Fya7wAjQ==", + "os": ["freebsd"], + "cpu": ["arm64"] + }, + "@rollup/rollup-freebsd-x64@4.43.0": { + "integrity": "sha512-J7uCsiV13L/VOeHJBo5SjasKiGxJ0g+nQTrBkAsmQBIdil3KhPnSE9GnRon4ejX1XDdsmK/l30IYLiAaQEO0Cg==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@rollup/rollup-linux-arm-gnueabihf@4.43.0": { + "integrity": "sha512-gTJ/JnnjCMc15uwB10TTATBEhK9meBIY+gXP4s0sHD1zHOaIh4Dmy1X9wup18IiY9tTNk5gJc4yx9ctj/fjrIw==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@rollup/rollup-linux-arm-musleabihf@4.43.0": { + "integrity": "sha512-ZJ3gZynL1LDSIvRfz0qXtTNs56n5DI2Mq+WACWZ7yGHFUEirHBRt7fyIk0NsCKhmRhn7WAcjgSkSVVxKlPNFFw==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@rollup/rollup-linux-arm64-gnu@4.43.0": { + "integrity": "sha512-8FnkipasmOOSSlfucGYEu58U8cxEdhziKjPD2FIa0ONVMxvl/hmONtX/7y4vGjdUhjcTHlKlDhw3H9t98fPvyA==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@rollup/rollup-linux-arm64-musl@4.43.0": { + "integrity": "sha512-KPPyAdlcIZ6S9C3S2cndXDkV0Bb1OSMsX0Eelr2Bay4EsF9yi9u9uzc9RniK3mcUGCLhWY9oLr6er80P5DE6XA==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@rollup/rollup-linux-loongarch64-gnu@4.43.0": { + "integrity": "sha512-HPGDIH0/ZzAZjvtlXj6g+KDQ9ZMHfSP553za7o2Odegb/BEfwJcR0Sw0RLNpQ9nC6Gy8s+3mSS9xjZ0n3rhcYg==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@rollup/rollup-linux-powerpc64le-gnu@4.43.0": { + "integrity": "sha512-gEmwbOws4U4GLAJDhhtSPWPXUzDfMRedT3hFMyRAvM9Mrnj+dJIFIeL7otsv2WF3D7GrV0GIewW0y28dOYWkmw==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@rollup/rollup-linux-riscv64-gnu@4.43.0": { + "integrity": "sha512-XXKvo2e+wFtXZF/9xoWohHg+MuRnvO29TI5Hqe9xwN5uN8NKUYy7tXUG3EZAlfchufNCTHNGjEx7uN78KsBo0g==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@rollup/rollup-linux-riscv64-musl@4.43.0": { + "integrity": "sha512-ruf3hPWhjw6uDFsOAzmbNIvlXFXlBQ4nk57Sec8E8rUxs/AI4HD6xmiiasOOx/3QxS2f5eQMKTAwk7KHwpzr/Q==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@rollup/rollup-linux-s390x-gnu@4.43.0": { + "integrity": "sha512-QmNIAqDiEMEvFV15rsSnjoSmO0+eJLoKRD9EAa9rrYNwO/XRCtOGM3A5A0X+wmG+XRrw9Fxdsw+LnyYiZWWcVw==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@rollup/rollup-linux-x64-gnu@4.43.0": { + "integrity": "sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@rollup/rollup-linux-x64-musl@4.43.0": { + "integrity": "sha512-3yATWgdeXyuHtBhrLt98w+5fKurdqvs8B53LaoKD7P7H7FKOONLsBVMNl9ghPQZQuYcceV5CDyPfyfGpMWD9mQ==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@rollup/rollup-win32-arm64-msvc@4.43.0": { + "integrity": "sha512-wVzXp2qDSCOpcBCT5WRWLmpJRIzv23valvcTwMHEobkjippNf+C3ys/+wf07poPkeNix0paTNemB2XrHr2TnGw==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@rollup/rollup-win32-ia32-msvc@4.43.0": { + "integrity": "sha512-fYCTEyzf8d+7diCw8b+asvWDCLMjsCEA8alvtAutqJOJp/wL5hs1rWSqJ1vkjgW0L2NB4bsYJrpKkiIPRR9dvw==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@rollup/rollup-win32-x64-msvc@4.43.0": { + "integrity": "sha512-SnGhLiE5rlK0ofq8kzuDkM0g7FN1s5VYY+YSMTibP7CqShxCQvqtNxTARS4xX4PFJfHjG0ZQYX9iGzI3FQh5Aw==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@shikijs/core@3.6.0": { + "integrity": "sha512-9By7Xb3olEX0o6UeJyPLI1PE1scC4d3wcVepvtv2xbuN9/IThYN4Wcwh24rcFeASzPam11MCq8yQpwwzCgSBRw==", + "dependencies": [ + "@shikijs/types", + "@shikijs/vscode-textmate", + "@types/hast", + "hast-util-to-html" + ] + }, + "@shikijs/engine-javascript@3.6.0": { + "integrity": "sha512-7YnLhZG/TU05IHMG14QaLvTW/9WiK8SEYafceccHUSXs2Qr5vJibUwsDfXDLmRi0zHdzsxrGKpSX6hnqe0k8nA==", + "dependencies": [ + "@shikijs/types", + "@shikijs/vscode-textmate", + "oniguruma-to-es" + ] + }, + "@shikijs/engine-oniguruma@3.6.0": { + "integrity": "sha512-nmOhIZ9yT3Grd+2plmW/d8+vZ2pcQmo/UnVwXMUXAKTXdi+LK0S08Ancrz5tQQPkxvjBalpMW2aKvwXfelauvA==", + "dependencies": [ + "@shikijs/types", + "@shikijs/vscode-textmate" + ] + }, + "@shikijs/langs@3.6.0": { + "integrity": "sha512-IdZkQJaLBu1LCYCwkr30hNuSDfllOT8RWYVZK1tD2J03DkiagYKRxj/pDSl8Didml3xxuyzUjgtioInwEQM/TA==", + "dependencies": [ + "@shikijs/types" + ] + }, + "@shikijs/themes@3.6.0": { + "integrity": "sha512-Fq2j4nWr1DF4drvmhqKq8x5vVQ27VncF8XZMBuHuQMZvUSS3NBgpqfwz/FoGe36+W6PvniZ1yDlg2d4kmYDU6w==", + "dependencies": [ + "@shikijs/types" + ] + }, + "@shikijs/types@3.6.0": { + "integrity": "sha512-cLWFiToxYu0aAzJqhXTQsFiJRTFDAGl93IrMSBNaGSzs7ixkLfdG6pH11HipuWFGW5vyx4X47W8HDQ7eSrmBUg==", + "dependencies": [ + "@shikijs/vscode-textmate", + "@types/hast" + ] + }, + "@shikijs/vscode-textmate@10.0.2": { + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==" + }, + "@swc/helpers@0.5.17": { + "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "dependencies": [ + "tslib" + ] + }, + "@types/debug@4.1.12": { + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dependencies": [ + "@types/ms" + ] + }, + "@types/estree@1.0.7": { + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==" + }, + "@types/estree@1.0.8": { + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" + }, + "@types/fontkit@2.0.8": { + "integrity": "sha512-wN+8bYxIpJf+5oZdrdtaX04qUuWHcKxcDEgRS9Qm9ZClSHjzEn13SxUC+5eRM+4yXIeTYk8mTzLAWGF64847ew==", + "dependencies": [ + "@types/node@22.15.15" + ] + }, + "@types/hast@3.0.4": { + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dependencies": [ + "@types/unist" + ] + }, + "@types/mdast@4.0.4": { + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dependencies": [ + "@types/unist" + ] + }, + "@types/ms@2.1.0": { + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" + }, + "@types/nlcst@2.0.3": { + "integrity": "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==", + "dependencies": [ + "@types/unist" + ] + }, + "@types/node@17.0.45": { + "integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==" + }, + "@types/node@22.15.15": { + "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", + "dependencies": [ + "undici-types" + ] + }, + "@types/sax@1.2.7": { + "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==", + "dependencies": [ + "@types/node@22.15.15" + ] + }, + "@types/ungap__structured-clone@1.2.0": { + "integrity": "sha512-ZoaihZNLeZSxESbk9PUAPZOlSpcKx81I1+4emtULDVmBLkYutTcMlCj2K9VNlf9EWODxdO6gkAqEaLorXwZQVA==" + }, + "@types/unist@3.0.3": { + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==" + }, + "@ungap/structured-clone@1.3.0": { + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==" + }, + "@volar/kit@2.4.14_typescript@5.8.3": { + "integrity": "sha512-kBcmHjEodtmYGJELHePZd2JdeYm4ZGOd9F/pQ1YETYIzAwy4Z491EkJ1nRSo/GTxwKt0XYwYA/dHSEgXecVHRA==", + "dependencies": [ + "@volar/language-service", + "@volar/typescript", + "typesafe-path", + "typescript", + "vscode-languageserver-textdocument", + "vscode-uri" + ] + }, + "@volar/language-core@2.4.14": { + "integrity": "sha512-X6beusV0DvuVseaOEy7GoagS4rYHgDHnTrdOj5jeUb49fW5ceQyP9Ej5rBhqgz2wJggl+2fDbbojq1XKaxDi6w==", + "dependencies": [ + "@volar/source-map" + ] + }, + "@volar/language-server@2.4.14": { + "integrity": "sha512-P3mGbQbW0v40UYBnb3DAaNtRYx6/MGOVKzdOWmBCGwjUkCR2xBkGrCFt05XnPDwFS/cTWDh2U6Mc9lpZ8Aecfw==", + "dependencies": [ + "@volar/language-core", + "@volar/language-service", + "@volar/typescript", + "path-browserify", + "request-light@0.7.0", + "vscode-languageserver@9.0.1", + "vscode-languageserver-protocol@3.17.5", + "vscode-languageserver-textdocument", + "vscode-uri" + ] + }, + "@volar/language-service@2.4.14": { + "integrity": "sha512-vNC3823EJohdzLTyjZoCMPwoWCfINB5emusniCkW5CGoGHQov4VVmT6yI5ncgP/NpgAIUv2NEkJooXvLHA4VeQ==", + "dependencies": [ + "@volar/language-core", + "vscode-languageserver-protocol@3.17.5", + "vscode-languageserver-textdocument", + "vscode-uri" + ] + }, + "@volar/source-map@2.4.14": { + "integrity": "sha512-5TeKKMh7Sfxo8021cJfmBzcjfY1SsXsPMMjMvjY7ivesdnybqqS+GxGAoXHAOUawQTwtdUxgP65Im+dEmvWtYQ==" + }, + "@volar/typescript@2.4.14": { + "integrity": "sha512-p8Z6f/bZM3/HyCdRNFZOEEzts51uV8WHeN8Tnfnm2EBv6FDB2TQLzfVx7aJvnl8ofKAOnS64B2O8bImBFaauRw==", + "dependencies": [ + "@volar/language-core", + "path-browserify", + "vscode-uri" + ] + }, + "@vscode/emmet-helper@2.11.0": { + "integrity": "sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==", + "dependencies": [ + "emmet", + "jsonc-parser@2.3.1", + "vscode-languageserver-textdocument", + "vscode-languageserver-types@3.17.5", + "vscode-uri" + ] + }, + "@vscode/l10n@0.0.18": { + "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==" + }, + "acorn@8.15.0": { + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "bin": true + }, + "ajv@8.17.1": { + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dependencies": [ + "fast-deep-equal", + "fast-uri", + "json-schema-traverse", + "require-from-string" + ] + }, + "ansi-align@3.0.1": { + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "dependencies": [ + "string-width@4.2.3" + ] + }, + "ansi-regex@5.0.1": { + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, + "ansi-regex@6.1.0": { + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==" + }, + "ansi-styles@4.3.0": { + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": [ + "color-convert" + ] + }, + "ansi-styles@6.2.1": { + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==" + }, + "anymatch@3.1.3": { + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dependencies": [ + "normalize-path", + "picomatch@2.3.1" + ] + }, + "arg@5.0.2": { + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" + }, + "argparse@2.0.1": { + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "aria-query@5.3.2": { + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==" + }, + "array-iterate@2.0.1": { + "integrity": "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==" + }, + "astro@5.9.3_typescript@5.8.3_vite@6.3.5__picomatch@4.0.2_zod@3.25.64": { + "integrity": "sha512-VReZrpUa/3rfeiVvsQ1A2M3ujDPI+pDGIYOMtXPEZwut8tZoEyealXXLjitgCsJ+3dunKGZbg4Eak6i+r0vniw==", + "dependencies": [ + "@astrojs/compiler", + "@astrojs/internal-helpers", + "@astrojs/markdown-remark", + "@astrojs/telemetry", + "@capsizecss/unpack", + "@oslojs/encoding", + "@rollup/pluginutils", + "acorn", + "aria-query", + "axobject-query", + "boxen", + "ci-info", + "clsx", + "common-ancestor-path", + "cookie", + "cssesc", + "debug", + "deterministic-object-hash", + "devalue", + "diff", + "dlv", + "dset", + "es-module-lexer", + "esbuild", + "estree-walker@3.0.3", + "flattie", + "fontace", + "github-slugger", + "html-escaper", + "http-cache-semantics", + "import-meta-resolve", + "js-yaml", + "kleur@4.1.5", + "magic-string", + "magicast", + "mrmime", + "neotraverse", + "p-limit", + "p-queue", + "package-manager-detector", + "picomatch@4.0.2", + "prompts", + "rehype", + "semver", + "shiki", + "tinyexec", + "tinyglobby", + "tsconfck", + "ultrahtml", + "unifont", + "unist-util-visit", + "unstorage", + "vfile", + "vite", + "vitefu", + "xxhash-wasm", + "yargs-parser", + "yocto-spinner", + "zod", + "zod-to-json-schema", + "zod-to-ts" + ], + "optionalDependencies": [ + "sharp" + ], + "bin": true + }, + "axobject-query@4.1.0": { + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==" + }, + "bail@2.0.2": { + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==" + }, + "base-64@1.0.0": { + "integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==" + }, + "base64-js@1.5.1": { + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, + "blob-to-buffer@1.2.9": { + "integrity": "sha512-BF033y5fN6OCofD3vgHmNtwZWRcq9NLyyxyILx9hfMy1sXYy4ojFl765hJ2lP0YaN2fuxPaLO2Vzzoxy0FLFFA==" + }, + "boxen@8.0.1": { + "integrity": "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==", + "dependencies": [ + "ansi-align", + "camelcase", + "chalk", + "cli-boxes", + "string-width@7.2.0", + "type-fest", + "widest-line", + "wrap-ansi@9.0.0" + ] + }, + "braces@3.0.3": { + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dependencies": [ + "fill-range" + ] + }, + "brotli@1.3.3": { + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "dependencies": [ + "base64-js" + ] + }, + "camelcase@8.0.0": { + "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==" + }, + "ccount@2.0.1": { + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==" + }, + "chalk@5.4.1": { + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==" + }, + "character-entities-html4@2.1.0": { + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==" + }, + "character-entities-legacy@3.0.0": { + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==" + }, + "character-entities@2.0.2": { + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==" + }, + "chokidar@4.0.3": { + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dependencies": [ + "readdirp" + ] + }, + "ci-info@4.2.0": { + "integrity": "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==" + }, + "cli-boxes@3.0.0": { + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==" + }, + "cliui@8.0.1": { + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dependencies": [ + "string-width@4.2.3", + "strip-ansi@6.0.1", + "wrap-ansi@7.0.0" + ] + }, + "clone@2.1.2": { + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==" + }, + "clsx@2.1.1": { + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" + }, + "color-convert@2.0.1": { + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": [ + "color-name" + ] + }, + "color-name@1.1.4": { + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "color-string@1.9.1": { + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "dependencies": [ + "color-name", + "simple-swizzle" + ] + }, + "color@4.2.3": { + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "dependencies": [ + "color-convert", + "color-string" + ] + }, + "comma-separated-tokens@2.0.3": { + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==" + }, + "common-ancestor-path@1.0.1": { + "integrity": "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==" + }, + "cookie-es@1.2.2": { + "integrity": "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==" + }, + "cookie@1.0.2": { + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==" + }, + "cross-fetch@3.2.0": { + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "dependencies": [ + "node-fetch" + ] + }, + "crossws@0.3.5": { + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "dependencies": [ + "uncrypto" + ] + }, + "css-tree@3.1.0": { + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dependencies": [ + "mdn-data", + "source-map-js" + ] + }, + "cssesc@3.0.0": { + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "bin": true + }, + "debug@4.4.1": { + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dependencies": [ + "ms" + ] + }, + "decode-named-character-reference@1.2.0": { + "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", + "dependencies": [ + "character-entities" + ] + }, + "defu@6.1.4": { + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==" + }, + "dequal@2.0.3": { + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==" + }, + "destr@2.0.5": { + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==" + }, + "detect-libc@2.0.4": { + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==" + }, + "deterministic-object-hash@2.0.2": { + "integrity": "sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==", + "dependencies": [ + "base-64" + ] + }, + "devalue@5.1.1": { + "integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==" + }, + "devlop@1.1.0": { + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dependencies": [ + "dequal" + ] + }, + "dfa@1.2.0": { + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==" + }, + "diff@5.2.0": { + "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==" + }, + "dlv@1.1.3": { + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" + }, + "dset@3.1.4": { + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==" + }, + "emmet@2.4.11": { + "integrity": "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==", + "dependencies": [ + "@emmetio/abbreviation", + "@emmetio/css-abbreviation" + ] + }, + "emoji-regex@10.4.0": { + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==" + }, + "emoji-regex@8.0.0": { + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "entities@6.0.1": { + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==" + }, + "es-module-lexer@1.7.0": { + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==" + }, + "esbuild@0.25.5": { + "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "optionalDependencies": [ + "@esbuild/aix-ppc64", + "@esbuild/android-arm", + "@esbuild/android-arm64", + "@esbuild/android-x64", + "@esbuild/darwin-arm64", + "@esbuild/darwin-x64", + "@esbuild/freebsd-arm64", + "@esbuild/freebsd-x64", + "@esbuild/linux-arm", + "@esbuild/linux-arm64", + "@esbuild/linux-ia32", + "@esbuild/linux-loong64", + "@esbuild/linux-mips64el", + "@esbuild/linux-ppc64", + "@esbuild/linux-riscv64", + "@esbuild/linux-s390x", + "@esbuild/linux-x64", + "@esbuild/netbsd-arm64", + "@esbuild/netbsd-x64", + "@esbuild/openbsd-arm64", + "@esbuild/openbsd-x64", + "@esbuild/sunos-x64", + "@esbuild/win32-arm64", + "@esbuild/win32-ia32", + "@esbuild/win32-x64" + ], + "scripts": true, + "bin": true + }, + "escalade@3.2.0": { + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==" + }, + "escape-string-regexp@5.0.0": { + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==" + }, + "estree-walker@2.0.2": { + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "estree-walker@3.0.3": { + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dependencies": [ + "@types/estree@1.0.8" + ] + }, + "eventemitter3@5.0.1": { + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" + }, + "extend@3.0.2": { + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "fast-deep-equal@3.1.3": { + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "fast-glob@3.3.3": { + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dependencies": [ + "@nodelib/fs.stat", + "@nodelib/fs.walk", + "glob-parent", + "merge2", + "micromatch" + ] + }, + "fast-uri@3.0.6": { + "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==" + }, + "fast-xml-parser@5.2.5": { + "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "dependencies": [ + "strnum" + ], + "bin": true + }, + "fastq@1.19.1": { + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dependencies": [ + "reusify" + ] + }, + "fdir@6.4.6_picomatch@4.0.2": { + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "dependencies": [ + "picomatch@4.0.2" + ], + "optionalPeers": [ + "picomatch@4.0.2" + ] + }, + "fill-range@7.1.1": { + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dependencies": [ + "to-regex-range" + ] + }, + "flattie@1.1.1": { + "integrity": "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==" + }, + "fontace@0.3.0": { + "integrity": "sha512-czoqATrcnxgWb/nAkfyIrRp6Q8biYj7nGnL6zfhTcX+JKKpWHFBnb8uNMw/kZr7u++3Y3wYSYoZgHkCcsuBpBg==", + "dependencies": [ + "@types/fontkit", + "fontkit" + ] + }, + "fontkit@2.0.4": { + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "dependencies": [ + "@swc/helpers", + "brotli", + "clone", + "dfa", + "fast-deep-equal", + "restructure", + "tiny-inflate", + "unicode-properties", + "unicode-trie" + ] + }, + "fsevents@2.3.3": { + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "os": ["darwin"], + "scripts": true + }, + "get-caller-file@2.0.5": { + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" + }, + "get-east-asian-width@1.3.0": { + "integrity": "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==" + }, + "github-slugger@2.0.0": { + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==" + }, + "glob-parent@5.1.2": { + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": [ + "is-glob" + ] + }, + "h3@1.15.3": { + "integrity": "sha512-z6GknHqyX0h9aQaTx22VZDf6QyZn+0Nh+Ym8O/u0SGSkyF5cuTJYKlc8MkzW3Nzf9LE1ivcpmYC3FUGpywhuUQ==", + "dependencies": [ + "cookie-es", + "crossws", + "defu", + "destr", + "iron-webcrypto", + "node-mock-http", + "radix3", + "ufo", + "uncrypto" + ] + }, + "hast-util-from-html@2.0.3": { + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "dependencies": [ + "@types/hast", + "devlop", + "hast-util-from-parse5", + "parse5", + "vfile", + "vfile-message" + ] + }, + "hast-util-from-parse5@8.0.3": { + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "dependencies": [ + "@types/hast", + "@types/unist", + "devlop", + "hastscript", + "property-information@7.1.0", + "vfile", + "vfile-location", + "web-namespaces" + ] + }, + "hast-util-is-element@3.0.0": { + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "dependencies": [ + "@types/hast" + ] + }, + "hast-util-parse-selector@4.0.0": { + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "dependencies": [ + "@types/hast" + ] + }, + "hast-util-raw@9.1.0": { + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "dependencies": [ + "@types/hast", + "@types/unist", + "@ungap/structured-clone", + "hast-util-from-parse5", + "hast-util-to-parse5", + "html-void-elements", + "mdast-util-to-hast", + "parse5", + "unist-util-position", + "unist-util-visit", + "vfile", + "web-namespaces", + "zwitch" + ] + }, + "hast-util-sanitize@5.0.2": { + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "dependencies": [ + "@types/hast", + "@ungap/structured-clone", + "unist-util-position" + ] + }, + "hast-util-to-html@9.0.5": { + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "dependencies": [ + "@types/hast", + "@types/unist", + "ccount", + "comma-separated-tokens", + "hast-util-whitespace", + "html-void-elements", + "mdast-util-to-hast", + "property-information@7.1.0", + "space-separated-tokens", + "stringify-entities", + "zwitch" + ] + }, + "hast-util-to-parse5@8.0.0": { + "integrity": "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==", + "dependencies": [ + "@types/hast", + "comma-separated-tokens", + "devlop", + "property-information@6.5.0", + "space-separated-tokens", + "web-namespaces", + "zwitch" + ] + }, + "hast-util-to-text@4.0.2": { + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "dependencies": [ + "@types/hast", + "@types/unist", + "hast-util-is-element", + "unist-util-find-after" + ] + }, + "hast-util-whitespace@3.0.0": { + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dependencies": [ + "@types/hast" + ] + }, + "hastscript@9.0.1": { + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "dependencies": [ + "@types/hast", + "comma-separated-tokens", + "hast-util-parse-selector", + "property-information@7.1.0", + "space-separated-tokens" + ] + }, + "html-escaper@3.0.3": { + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==" + }, + "html-void-elements@3.0.0": { + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==" + }, + "http-cache-semantics@4.2.0": { + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==" + }, + "import-meta-resolve@4.1.0": { + "integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==" + }, + "iron-webcrypto@1.2.1": { + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==" + }, + "is-absolute-url@4.0.1": { + "integrity": "sha512-/51/TKE88Lmm7Gc4/8btclNXWS+g50wXhYJq8HWIBAGUBnoAdRu1aXeh364t/O7wXDAcTJDP8PNuNKWUDWie+A==" + }, + "is-arrayish@0.3.2": { + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" + }, + "is-docker@3.0.0": { + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "bin": true + }, + "is-extglob@2.1.1": { + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==" + }, + "is-fullwidth-code-point@3.0.0": { + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + }, + "is-glob@4.0.3": { + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": [ + "is-extglob" + ] + }, + "is-inside-container@1.0.0": { + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dependencies": [ + "is-docker" + ], + "bin": true + }, + "is-number@7.0.0": { + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" + }, + "is-plain-obj@4.1.0": { + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==" + }, + "is-wsl@3.1.0": { + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "dependencies": [ + "is-inside-container" + ] + }, + "js-yaml@4.1.0": { + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dependencies": [ + "argparse" + ], + "bin": true + }, + "json-schema-traverse@1.0.0": { + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "jsonc-parser@2.3.1": { + "integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==" + }, + "jsonc-parser@3.3.1": { + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==" + }, + "kleur@3.0.3": { + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==" + }, + "kleur@4.1.5": { + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==" + }, + "lodash@4.17.21": { + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "longest-streak@3.1.0": { + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==" + }, + "lru-cache@10.4.3": { + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + }, + "magic-string@0.30.17": { + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "dependencies": [ + "@jridgewell/sourcemap-codec" + ] + }, + "magicast@0.3.5": { + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dependencies": [ + "@babel/parser", + "@babel/types", + "source-map-js" + ] + }, + "markdown-table@3.0.4": { + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==" + }, + "mdast-util-definitions@6.0.0": { + "integrity": "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==", + "dependencies": [ + "@types/mdast", + "@types/unist", + "unist-util-visit" + ] + }, + "mdast-util-find-and-replace@3.0.2": { + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "dependencies": [ + "@types/mdast", + "escape-string-regexp", + "unist-util-is", + "unist-util-visit-parents" + ] + }, + "mdast-util-from-markdown@2.0.2": { + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "dependencies": [ + "@types/mdast", + "@types/unist", + "decode-named-character-reference", + "devlop", + "mdast-util-to-string", + "micromark", + "micromark-util-decode-numeric-character-reference", + "micromark-util-decode-string", + "micromark-util-normalize-identifier", + "micromark-util-symbol", + "micromark-util-types", + "unist-util-stringify-position" + ] + }, + "mdast-util-gfm-autolink-literal@2.0.1": { + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "dependencies": [ + "@types/mdast", + "ccount", + "devlop", + "mdast-util-find-and-replace", + "micromark-util-character" + ] + }, + "mdast-util-gfm-footnote@2.1.0": { + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "dependencies": [ + "@types/mdast", + "devlop", + "mdast-util-from-markdown", + "mdast-util-to-markdown", + "micromark-util-normalize-identifier" + ] + }, + "mdast-util-gfm-strikethrough@2.0.0": { + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "dependencies": [ + "@types/mdast", + "mdast-util-from-markdown", + "mdast-util-to-markdown" + ] + }, + "mdast-util-gfm-table@2.0.0": { + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "dependencies": [ + "@types/mdast", + "devlop", + "markdown-table", + "mdast-util-from-markdown", + "mdast-util-to-markdown" + ] + }, + "mdast-util-gfm-task-list-item@2.0.0": { + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "dependencies": [ + "@types/mdast", + "devlop", + "mdast-util-from-markdown", + "mdast-util-to-markdown" + ] + }, + "mdast-util-gfm@3.1.0": { + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "dependencies": [ + "mdast-util-from-markdown", + "mdast-util-gfm-autolink-literal", + "mdast-util-gfm-footnote", + "mdast-util-gfm-strikethrough", + "mdast-util-gfm-table", + "mdast-util-gfm-task-list-item", + "mdast-util-to-markdown" + ] + }, + "mdast-util-phrasing@4.1.0": { + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "dependencies": [ + "@types/mdast", + "unist-util-is" + ] + }, + "mdast-util-to-hast@13.2.0": { + "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "dependencies": [ + "@types/hast", + "@types/mdast", + "@ungap/structured-clone", + "devlop", + "micromark-util-sanitize-uri", + "trim-lines", + "unist-util-position", + "unist-util-visit", + "vfile" + ] + }, + "mdast-util-to-markdown@2.1.2": { + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "dependencies": [ + "@types/mdast", + "@types/unist", + "longest-streak", + "mdast-util-phrasing", + "mdast-util-to-string", + "micromark-util-classify-character", + "micromark-util-decode-string", + "unist-util-visit", + "zwitch" + ] + }, + "mdast-util-to-string@4.0.0": { + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "dependencies": [ + "@types/mdast" + ] + }, + "mdast-util-toc@7.1.0": { + "integrity": "sha512-2TVKotOQzqdY7THOdn2gGzS9d1Sdd66bvxUyw3aNpWfcPXCLYSJCCgfPy30sEtuzkDraJgqF35dzgmz6xlvH/w==", + "dependencies": [ + "@types/mdast", + "@types/ungap__structured-clone", + "@ungap/structured-clone", + "github-slugger", + "mdast-util-to-string", + "unist-util-is", + "unist-util-visit" + ] + }, + "mdn-data@2.12.2": { + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==" + }, + "merge2@1.4.1": { + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==" + }, + "micromark-core-commonmark@2.0.3": { + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "dependencies": [ + "decode-named-character-reference", + "devlop", + "micromark-factory-destination", + "micromark-factory-label", + "micromark-factory-space", + "micromark-factory-title", + "micromark-factory-whitespace", + "micromark-util-character", + "micromark-util-chunked", + "micromark-util-classify-character", + "micromark-util-html-tag-name", + "micromark-util-normalize-identifier", + "micromark-util-resolve-all", + "micromark-util-subtokenize", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-extension-gfm-autolink-literal@2.1.0": { + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "dependencies": [ + "micromark-util-character", + "micromark-util-sanitize-uri", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-extension-gfm-footnote@2.1.0": { + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "dependencies": [ + "devlop", + "micromark-core-commonmark", + "micromark-factory-space", + "micromark-util-character", + "micromark-util-normalize-identifier", + "micromark-util-sanitize-uri", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-extension-gfm-strikethrough@2.1.0": { + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "dependencies": [ + "devlop", + "micromark-util-chunked", + "micromark-util-classify-character", + "micromark-util-resolve-all", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-extension-gfm-table@2.1.1": { + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "dependencies": [ + "devlop", + "micromark-factory-space", + "micromark-util-character", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-extension-gfm-tagfilter@2.0.0": { + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "dependencies": [ + "micromark-util-types" + ] + }, + "micromark-extension-gfm-task-list-item@2.1.0": { + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "dependencies": [ + "devlop", + "micromark-factory-space", + "micromark-util-character", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-extension-gfm@3.0.0": { + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "dependencies": [ + "micromark-extension-gfm-autolink-literal", + "micromark-extension-gfm-footnote", + "micromark-extension-gfm-strikethrough", + "micromark-extension-gfm-table", + "micromark-extension-gfm-tagfilter", + "micromark-extension-gfm-task-list-item", + "micromark-util-combine-extensions", + "micromark-util-types" + ] + }, + "micromark-factory-destination@2.0.1": { + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "dependencies": [ + "micromark-util-character", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-factory-label@2.0.1": { + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "dependencies": [ + "devlop", + "micromark-util-character", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-factory-space@2.0.1": { + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "dependencies": [ + "micromark-util-character", + "micromark-util-types" + ] + }, + "micromark-factory-title@2.0.1": { + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "dependencies": [ + "micromark-factory-space", + "micromark-util-character", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-factory-whitespace@2.0.1": { + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "dependencies": [ + "micromark-factory-space", + "micromark-util-character", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-util-character@2.1.1": { + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dependencies": [ + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-util-chunked@2.0.1": { + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "dependencies": [ + "micromark-util-symbol" + ] + }, + "micromark-util-classify-character@2.0.1": { + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "dependencies": [ + "micromark-util-character", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-util-combine-extensions@2.0.1": { + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "dependencies": [ + "micromark-util-chunked", + "micromark-util-types" + ] + }, + "micromark-util-decode-numeric-character-reference@2.0.2": { + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "dependencies": [ + "micromark-util-symbol" + ] + }, + "micromark-util-decode-string@2.0.1": { + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "dependencies": [ + "decode-named-character-reference", + "micromark-util-character", + "micromark-util-decode-numeric-character-reference", + "micromark-util-symbol" + ] + }, + "micromark-util-encode@2.0.1": { + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==" + }, + "micromark-util-html-tag-name@2.0.1": { + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==" + }, + "micromark-util-normalize-identifier@2.0.1": { + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "dependencies": [ + "micromark-util-symbol" + ] + }, + "micromark-util-resolve-all@2.0.1": { + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "dependencies": [ + "micromark-util-types" + ] + }, + "micromark-util-sanitize-uri@2.0.1": { + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dependencies": [ + "micromark-util-character", + "micromark-util-encode", + "micromark-util-symbol" + ] + }, + "micromark-util-subtokenize@2.1.0": { + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "dependencies": [ + "devlop", + "micromark-util-chunked", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromark-util-symbol@2.0.1": { + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==" + }, + "micromark-util-types@2.0.2": { + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==" + }, + "micromark@4.0.2": { + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "dependencies": [ + "@types/debug", + "debug", + "decode-named-character-reference", + "devlop", + "micromark-core-commonmark", + "micromark-factory-space", + "micromark-util-character", + "micromark-util-chunked", + "micromark-util-combine-extensions", + "micromark-util-decode-numeric-character-reference", + "micromark-util-encode", + "micromark-util-normalize-identifier", + "micromark-util-resolve-all", + "micromark-util-sanitize-uri", + "micromark-util-subtokenize", + "micromark-util-symbol", + "micromark-util-types" + ] + }, + "micromatch@4.0.8": { + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dependencies": [ + "braces", + "picomatch@2.3.1" + ] + }, + "mrmime@2.0.1": { + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==" + }, + "ms@2.1.3": { + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "muggle-string@0.4.1": { + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==" + }, + "nanoid@3.3.11": { + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "bin": true + }, + "neotraverse@0.6.18": { + "integrity": "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==" + }, + "nlcst-to-string@4.0.0": { + "integrity": "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==", + "dependencies": [ + "@types/nlcst" + ] + }, + "node-fetch-native@1.6.6": { + "integrity": "sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ==" + }, + "node-fetch@2.7.0": { + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": [ + "whatwg-url" + ] + }, + "node-mock-http@1.0.0": { + "integrity": "sha512-0uGYQ1WQL1M5kKvGRXWQ3uZCHtLTO8hln3oBjIusM75WoesZ909uQJs/Hb946i2SS+Gsrhkaa6iAO17jRIv6DQ==" + }, + "normalize-path@3.0.0": { + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==" + }, + "ofetch@1.4.1": { + "integrity": "sha512-QZj2DfGplQAr2oj9KzceK9Hwz6Whxazmn85yYeVuS3u9XTMOGMRx0kO95MQ+vLsj/S/NwBDMMLU5hpxvI6Tklw==", + "dependencies": [ + "destr", + "node-fetch-native", + "ufo" + ] + }, + "ohash@2.0.11": { + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==" + }, + "oniguruma-parser@0.12.1": { + "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==" + }, + "oniguruma-to-es@4.3.3": { + "integrity": "sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==", + "dependencies": [ + "oniguruma-parser", + "regex", + "regex-recursion" + ] + }, + "openpgp@6.1.1": { + "integrity": "sha512-V/DXZ5AGCz3q4X8psUSc3q4SxnH/bfICaTSpNcla7wvBFhrxa9/ajm31rtMwZ1qj7Fu2oMpfX6ZcxKmTBlb6Yg==" + }, + "p-limit@6.2.0": { + "integrity": "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==", + "dependencies": [ + "yocto-queue" + ] + }, + "p-queue@8.1.0": { + "integrity": "sha512-mxLDbbGIBEXTJL0zEx8JIylaj3xQ7Z/7eEVjcF9fJX4DBiH9oqe+oahYnlKKxm0Ci9TlWTyhSHgygxMxjIB2jw==", + "dependencies": [ + "eventemitter3", + "p-timeout" + ] + }, + "p-timeout@6.1.4": { + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==" + }, + "package-manager-detector@1.3.0": { + "integrity": "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==" + }, + "pako@0.2.9": { + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==" + }, + "parse-latin@7.0.0": { + "integrity": "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==", + "dependencies": [ + "@types/nlcst", + "@types/unist", + "nlcst-to-string", + "unist-util-modify-children", + "unist-util-visit-children", + "vfile" + ] + }, + "parse5@7.3.0": { + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dependencies": [ + "entities" + ] + }, + "path-browserify@1.0.1": { + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==" + }, + "picocolors@1.1.1": { + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "picomatch@2.3.1": { + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" + }, + "picomatch@4.0.2": { + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==" + }, + "postcss@8.5.5": { + "integrity": "sha512-d/jtm+rdNT8tpXuHY5MMtcbJFBkhXE6593XVR9UoGCH8jSFGci7jGvMGH5RYd5PBJW+00NZQt6gf7CbagJCrhg==", + "dependencies": [ + "nanoid", + "picocolors", + "source-map-js" + ] + }, + "prettier@2.8.7": { + "integrity": "sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw==", + "bin": true + }, + "prismjs@1.30.0": { + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==" + }, + "prompts@2.4.2": { + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dependencies": [ + "kleur@3.0.3", + "sisteransi" + ] + }, + "property-information@6.5.0": { + "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==" + }, + "property-information@7.1.0": { + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==" + }, + "queue-microtask@1.2.3": { + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" + }, + "radix3@1.1.2": { + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==" + }, + "readdirp@4.1.2": { + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==" + }, + "reading-time@1.5.0": { + "integrity": "sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg==" + }, + "regex-recursion@6.0.2": { + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "dependencies": [ + "regex-utilities" + ] + }, + "regex-utilities@2.3.0": { + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==" + }, + "regex@6.0.1": { + "integrity": "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==", + "dependencies": [ + "regex-utilities" + ] + }, + "rehype-external-links@3.0.0": { + "integrity": "sha512-yp+e5N9V3C6bwBeAC4n796kc86M4gJCdlVhiMTxIrJG5UHDMh+PJANf9heqORJbt1nrCbDwIlAZKjANIaVBbvw==", + "dependencies": [ + "@types/hast", + "@ungap/structured-clone", + "hast-util-is-element", + "is-absolute-url", + "space-separated-tokens", + "unist-util-visit" + ] + }, + "rehype-parse@9.0.1": { + "integrity": "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==", + "dependencies": [ + "@types/hast", + "hast-util-from-html", + "unified" + ] + }, + "rehype-raw@7.0.0": { + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "dependencies": [ + "@types/hast", + "hast-util-raw", + "vfile" + ] + }, + "rehype-sanitize@6.0.0": { + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "dependencies": [ + "@types/hast", + "hast-util-sanitize" + ] + }, + "rehype-stringify@10.0.1": { + "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "dependencies": [ + "@types/hast", + "hast-util-to-html", + "unified" + ] + }, + "rehype@13.0.2": { + "integrity": "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==", + "dependencies": [ + "@types/hast", + "rehype-parse", + "rehype-stringify", + "unified" + ] + }, + "remark-gfm@4.0.1": { + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "dependencies": [ + "@types/mdast", + "mdast-util-gfm", + "micromark-extension-gfm", + "remark-parse", + "remark-stringify", + "unified" + ] + }, + "remark-parse@11.0.0": { + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "dependencies": [ + "@types/mdast", + "mdast-util-from-markdown", + "micromark-util-types", + "unified" + ] + }, + "remark-rehype@11.1.2": { + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "dependencies": [ + "@types/hast", + "@types/mdast", + "mdast-util-to-hast", + "unified", + "vfile" + ] + }, + "remark-smartypants@3.0.2": { + "integrity": "sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==", + "dependencies": [ + "retext", + "retext-smartypants", + "unified", + "unist-util-visit" + ] + }, + "remark-stringify@11.0.0": { + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "dependencies": [ + "@types/mdast", + "mdast-util-to-markdown", + "unified" + ] + }, + "remark-toc@9.0.0": { + "integrity": "sha512-KJ9txbo33GjDAV1baHFze7ij4G8c7SGYoY8Kzsm2gzFpbhL/bSoVpMMzGa3vrNDSWASNd/3ppAqL7cP2zD6JIA==", + "dependencies": [ + "@types/mdast", + "mdast-util-toc" + ] + }, + "request-light@0.5.8": { + "integrity": "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==" + }, + "request-light@0.7.0": { + "integrity": "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==" + }, + "require-directory@2.1.1": { + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==" + }, + "require-from-string@2.0.2": { + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" + }, + "restructure@3.0.2": { + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==" + }, + "retext-latin@4.0.0": { + "integrity": "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==", + "dependencies": [ + "@types/nlcst", + "parse-latin", + "unified" + ] + }, + "retext-smartypants@6.2.0": { + "integrity": "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==", + "dependencies": [ + "@types/nlcst", + "nlcst-to-string", + "unist-util-visit" + ] + }, + "retext-stringify@4.0.0": { + "integrity": "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==", + "dependencies": [ + "@types/nlcst", + "nlcst-to-string", + "unified" + ] + }, + "retext@9.0.0": { + "integrity": "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==", + "dependencies": [ + "@types/nlcst", + "retext-latin", + "retext-stringify", + "unified" + ] + }, + "reusify@1.1.0": { + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==" + }, + "rollup@4.43.0": { + "integrity": "sha512-wdN2Kd3Twh8MAEOEJZsuxuLKCsBEo4PVNLK6tQWAn10VhsVewQLzcucMgLolRlhFybGxfclbPeEYBaP6RvUFGg==", + "dependencies": [ + "@types/estree@1.0.7" + ], + "optionalDependencies": [ + "@rollup/rollup-android-arm-eabi", + "@rollup/rollup-android-arm64", + "@rollup/rollup-darwin-arm64", + "@rollup/rollup-darwin-x64", + "@rollup/rollup-freebsd-arm64", + "@rollup/rollup-freebsd-x64", + "@rollup/rollup-linux-arm-gnueabihf", + "@rollup/rollup-linux-arm-musleabihf", + "@rollup/rollup-linux-arm64-gnu", + "@rollup/rollup-linux-arm64-musl", + "@rollup/rollup-linux-loongarch64-gnu", + "@rollup/rollup-linux-powerpc64le-gnu", + "@rollup/rollup-linux-riscv64-gnu", + "@rollup/rollup-linux-riscv64-musl", + "@rollup/rollup-linux-s390x-gnu", + "@rollup/rollup-linux-x64-gnu", + "@rollup/rollup-linux-x64-musl", + "@rollup/rollup-win32-arm64-msvc", + "@rollup/rollup-win32-ia32-msvc", + "@rollup/rollup-win32-x64-msvc", + "fsevents" + ], + "bin": true + }, + "run-parallel@1.2.0": { + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dependencies": [ + "queue-microtask" + ] + }, + "sax@1.4.1": { + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==" + }, + "semver@7.7.2": { + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "bin": true + }, + "sharp@0.33.5": { + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "dependencies": [ + "color", + "detect-libc", + "semver" + ], + "optionalDependencies": [ + "@img/sharp-darwin-arm64", + "@img/sharp-darwin-x64", + "@img/sharp-libvips-darwin-arm64", + "@img/sharp-libvips-darwin-x64", + "@img/sharp-libvips-linux-arm", + "@img/sharp-libvips-linux-arm64", + "@img/sharp-libvips-linux-s390x", + "@img/sharp-libvips-linux-x64", + "@img/sharp-libvips-linuxmusl-arm64", + "@img/sharp-libvips-linuxmusl-x64", + "@img/sharp-linux-arm", + "@img/sharp-linux-arm64", + "@img/sharp-linux-s390x", + "@img/sharp-linux-x64", + "@img/sharp-linuxmusl-arm64", + "@img/sharp-linuxmusl-x64", + "@img/sharp-wasm32", + "@img/sharp-win32-ia32", + "@img/sharp-win32-x64" + ], + "scripts": true + }, + "shiki@3.6.0": { + "integrity": "sha512-tKn/Y0MGBTffQoklaATXmTqDU02zx8NYBGQ+F6gy87/YjKbizcLd+Cybh/0ZtOBX9r1NEnAy/GTRDKtOsc1L9w==", + "dependencies": [ + "@shikijs/core", + "@shikijs/engine-javascript", + "@shikijs/engine-oniguruma", + "@shikijs/langs", + "@shikijs/themes", + "@shikijs/types", + "@shikijs/vscode-textmate", + "@types/hast" + ] + }, + "simple-swizzle@0.2.2": { + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "dependencies": [ + "is-arrayish" + ] + }, + "sisteransi@1.0.5": { + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==" + }, + "sitemap@8.0.0": { + "integrity": "sha512-+AbdxhM9kJsHtruUF39bwS/B0Fytw6Fr1o4ZAIAEqA6cke2xcoO2GleBw9Zw7nRzILVEgz7zBM5GiTJjie1G9A==", + "dependencies": [ + "@types/node@17.0.45", + "@types/sax", + "arg", + "sax" + ], + "bin": true + }, + "smol-toml@1.3.4": { + "integrity": "sha512-UOPtVuYkzYGee0Bd2Szz8d2G3RfMfJ2t3qVdZUAozZyAk+a0Sxa+QKix0YCwjL/A1RR0ar44nCxaoN9FxdJGwA==" + }, + "source-map-js@1.2.1": { + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" + }, + "space-separated-tokens@2.0.2": { + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==" + }, + "stream-replace-string@2.0.0": { + "integrity": "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==" + }, + "string-width@4.2.3": { + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": [ + "emoji-regex@8.0.0", + "is-fullwidth-code-point", + "strip-ansi@6.0.1" + ] + }, + "string-width@7.2.0": { + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dependencies": [ + "emoji-regex@10.4.0", + "get-east-asian-width", + "strip-ansi@7.1.0" + ] + }, + "stringify-entities@4.0.4": { + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dependencies": [ + "character-entities-html4", + "character-entities-legacy" + ] + }, + "strip-ansi@6.0.1": { + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": [ + "ansi-regex@5.0.1" + ] + }, + "strip-ansi@7.1.0": { + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dependencies": [ + "ansi-regex@6.1.0" + ] + }, + "strnum@2.1.1": { + "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==" + }, + "tiny-inflate@1.0.3": { + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==" + }, + "tinyexec@0.3.2": { + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==" + }, + "tinyglobby@0.2.14_picomatch@4.0.2": { + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dependencies": [ + "fdir", + "picomatch@4.0.2" + ] + }, + "to-regex-range@5.0.1": { + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": [ + "is-number" + ] + }, + "toml@3.0.0": { + "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==" + }, + "tr46@0.0.3": { + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "trim-lines@3.0.1": { + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==" + }, + "trough@2.2.0": { + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==" + }, + "tsconfck@3.1.6_typescript@5.8.3": { + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", + "dependencies": [ + "typescript" + ], + "optionalPeers": [ + "typescript" + ], + "bin": true + }, + "tslib@2.8.1": { + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "type-fest@4.41.0": { + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==" + }, + "typesafe-path@0.2.2": { + "integrity": "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==" + }, + "typescript-auto-import-cache@0.3.6": { + "integrity": "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==", + "dependencies": [ + "semver" + ] + }, + "typescript@5.8.3": { + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "bin": true + }, + "ufo@1.6.1": { + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==" + }, + "ultrahtml@1.6.0": { + "integrity": "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==" + }, + "uncrypto@0.1.3": { + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==" + }, + "undici-types@6.21.0": { + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + }, + "unicode-properties@1.4.1": { + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "dependencies": [ + "base64-js", + "unicode-trie" + ] + }, + "unicode-trie@2.0.0": { + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "dependencies": [ + "pako", + "tiny-inflate" + ] + }, + "unified@11.0.5": { + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "dependencies": [ + "@types/unist", + "bail", + "devlop", + "extend", + "is-plain-obj", + "trough", + "vfile" + ] + }, + "unifont@0.5.0": { + "integrity": "sha512-4DueXMP5Hy4n607sh+vJ+rajoLu778aU3GzqeTCqsD/EaUcvqZT9wPC8kgK6Vjh22ZskrxyRCR71FwNOaYn6jA==", + "dependencies": [ + "css-tree", + "ohash" + ] + }, + "unist-util-find-after@5.0.0": { + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "dependencies": [ + "@types/unist", + "unist-util-is" + ] + }, + "unist-util-is@6.0.0": { + "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", + "dependencies": [ + "@types/unist" + ] + }, + "unist-util-modify-children@4.0.0": { + "integrity": "sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==", + "dependencies": [ + "@types/unist", + "array-iterate" + ] + }, + "unist-util-position@5.0.0": { + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dependencies": [ + "@types/unist" + ] + }, + "unist-util-remove-position@5.0.0": { + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "dependencies": [ + "@types/unist", + "unist-util-visit" + ] + }, + "unist-util-stringify-position@4.0.0": { + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dependencies": [ + "@types/unist" + ] + }, + "unist-util-visit-children@3.0.0": { + "integrity": "sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==", + "dependencies": [ + "@types/unist" + ] + }, + "unist-util-visit-parents@6.0.1": { + "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", + "dependencies": [ + "@types/unist", + "unist-util-is" + ] + }, + "unist-util-visit@5.0.0": { + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "dependencies": [ + "@types/unist", + "unist-util-is", + "unist-util-visit-parents" + ] + }, + "unstorage@1.16.0": { + "integrity": "sha512-WQ37/H5A7LcRPWfYOrDa1Ys02xAbpPJq6q5GkO88FBXVSQzHd7+BjEwfRqyaSWCv9MbsJy058GWjjPjcJ16GGA==", + "dependencies": [ + "anymatch", + "chokidar", + "destr", + "h3", + "lru-cache", + "node-fetch-native", + "ofetch", + "ufo" + ] + }, + "vfile-location@5.0.3": { + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "dependencies": [ + "@types/unist", + "vfile" + ] + }, + "vfile-message@4.0.2": { + "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", + "dependencies": [ + "@types/unist", + "unist-util-stringify-position" + ] + }, + "vfile@6.0.3": { + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dependencies": [ + "@types/unist", + "vfile-message" + ] + }, + "vite@6.3.5_picomatch@4.0.2": { + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "dependencies": [ + "esbuild", + "fdir", + "picomatch@4.0.2", + "postcss", + "rollup", + "tinyglobby" + ], + "optionalDependencies": [ + "fsevents" + ], + "bin": true + }, + "vitefu@1.0.6_vite@6.3.5__picomatch@4.0.2": { + "integrity": "sha512-+Rex1GlappUyNN6UfwbVZne/9cYC4+R2XDk9xkNXBKMw6HQagdX9PgZ8V2v1WUSK1wfBLp7qbI1+XSNIlB1xmA==", + "dependencies": [ + "vite" + ], + "optionalPeers": [ + "vite" + ] + }, + "volar-service-css@0.0.62_@volar+language-service@2.4.14": { + "integrity": "sha512-JwNyKsH3F8PuzZYuqPf+2e+4CTU8YoyUHEHVnoXNlrLe7wy9U3biomZ56llN69Ris7TTy/+DEX41yVxQpM4qvg==", + "dependencies": [ + "@volar/language-service", + "vscode-css-languageservice", + "vscode-languageserver-textdocument", + "vscode-uri" + ], + "optionalPeers": [ + "@volar/language-service" + ] + }, + "volar-service-emmet@0.0.62_@volar+language-service@2.4.14": { + "integrity": "sha512-U4dxWDBWz7Pi4plpbXf4J4Z/ss6kBO3TYrACxWNsE29abu75QzVS0paxDDhI6bhqpbDFXlpsDhZ9aXVFpnfGRQ==", + "dependencies": [ + "@emmetio/css-parser", + "@emmetio/html-matcher", + "@volar/language-service", + "@vscode/emmet-helper", + "vscode-uri" + ], + "optionalPeers": [ + "@volar/language-service" + ] + }, + "volar-service-html@0.0.62_@volar+language-service@2.4.14": { + "integrity": "sha512-Zw01aJsZRh4GTGUjveyfEzEqpULQUdQH79KNEiKVYHZyuGtdBRYCHlrus1sueSNMxwwkuF5WnOHfvBzafs8yyQ==", + "dependencies": [ + "@volar/language-service", + "vscode-html-languageservice", + "vscode-languageserver-textdocument", + "vscode-uri" + ], + "optionalPeers": [ + "@volar/language-service" + ] + }, + "volar-service-prettier@0.0.62_@volar+language-service@2.4.14": { + "integrity": "sha512-h2yk1RqRTE+vkYZaI9KYuwpDfOQRrTEMvoHol0yW4GFKc75wWQRrb5n/5abDrzMPrkQbSip8JH2AXbvrRtYh4w==", + "dependencies": [ + "@volar/language-service", + "vscode-uri" + ], + "optionalPeers": [ + "@volar/language-service" + ] + }, + "volar-service-typescript-twoslash-queries@0.0.62_@volar+language-service@2.4.14": { + "integrity": "sha512-KxFt4zydyJYYI0kFAcWPTh4u0Ha36TASPZkAnNY784GtgajerUqM80nX/W1d0wVhmcOFfAxkVsf/Ed+tiYU7ng==", + "dependencies": [ + "@volar/language-service", + "vscode-uri" + ], + "optionalPeers": [ + "@volar/language-service" + ] + }, + "volar-service-typescript@0.0.62_@volar+language-service@2.4.14": { + "integrity": "sha512-p7MPi71q7KOsH0eAbZwPBiKPp9B2+qrdHAd6VY5oTo9BUXatsOAdakTm9Yf0DUj6uWBAaOT01BSeVOPwucMV1g==", + "dependencies": [ + "@volar/language-service", + "path-browserify", + "semver", + "typescript-auto-import-cache", + "vscode-languageserver-textdocument", + "vscode-nls", + "vscode-uri" + ], + "optionalPeers": [ + "@volar/language-service" + ] + }, + "volar-service-yaml@0.0.62_@volar+language-service@2.4.14": { + "integrity": "sha512-k7gvv7sk3wa+nGll3MaSKyjwQsJjIGCHFjVkl3wjaSP2nouKyn9aokGmqjrl39mi88Oy49giog2GkZH526wjig==", + "dependencies": [ + "@volar/language-service", + "vscode-uri", + "yaml-language-server" + ], + "optionalPeers": [ + "@volar/language-service" + ] + }, + "vscode-css-languageservice@6.3.6": { + "integrity": "sha512-fU4h8mT3KlvfRcbF74v/M+Gzbligav6QMx4AD/7CbclWPYOpGb9kgIswfpZVJbIcOEJJACI9iYizkNwdiAqlHw==", + "dependencies": [ + "@vscode/l10n", + "vscode-languageserver-textdocument", + "vscode-languageserver-types@3.17.5", + "vscode-uri" + ] + }, + "vscode-html-languageservice@5.5.0": { + "integrity": "sha512-No6Er2P2L8IsXDnUFlp0bP4f2sdkJv+zJLZYFhtEQIp+2xNfxY8WYkhSxLJ/7bZhuV/aU55lmGSSHBVxSGer3Q==", + "dependencies": [ + "@vscode/l10n", + "vscode-languageserver-textdocument", + "vscode-languageserver-types@3.17.5", + "vscode-uri" + ] + }, + "vscode-json-languageservice@4.1.8": { + "integrity": "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==", + "dependencies": [ + "jsonc-parser@3.3.1", + "vscode-languageserver-textdocument", + "vscode-languageserver-types@3.17.5", + "vscode-nls", + "vscode-uri" + ] + }, + "vscode-jsonrpc@6.0.0": { + "integrity": "sha512-wnJA4BnEjOSyFMvjZdpiOwhSq9uDoK8e/kpRJDTaMYzwlkrhG1fwDIZI94CLsLzlCK5cIbMMtFlJlfR57Lavmg==" + }, + "vscode-jsonrpc@8.2.0": { + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==" + }, + "vscode-languageserver-protocol@3.16.0": { + "integrity": "sha512-sdeUoAawceQdgIfTI+sdcwkiK2KU+2cbEYA0agzM2uqaUy2UpnnGHtWTHVEtS0ES4zHU0eMFRGN+oQgDxlD66A==", + "dependencies": [ + "vscode-jsonrpc@6.0.0", + "vscode-languageserver-types@3.16.0" + ] + }, + "vscode-languageserver-protocol@3.17.5": { + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "dependencies": [ + "vscode-jsonrpc@8.2.0", + "vscode-languageserver-types@3.17.5" + ] + }, + "vscode-languageserver-textdocument@1.0.12": { + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==" + }, + "vscode-languageserver-types@3.16.0": { + "integrity": "sha512-k8luDIWJWyenLc5ToFQQMaSrqCHiLwyKPHKPQZ5zz21vM+vIVUSvsRpcbiECH4WR88K2XZqc4ScRcZ7nk/jbeA==" + }, + "vscode-languageserver-types@3.17.5": { + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==" + }, + "vscode-languageserver@7.0.0": { + "integrity": "sha512-60HTx5ID+fLRcgdHfmz0LDZAXYEV68fzwG0JWwEPBode9NuMYTIxuYXPg4ngO8i8+Ou0lM7y6GzaYWbiDL0drw==", + "dependencies": [ + "vscode-languageserver-protocol@3.16.0" + ], + "bin": true + }, + "vscode-languageserver@9.0.1": { + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "dependencies": [ + "vscode-languageserver-protocol@3.17.5" + ], + "bin": true + }, + "vscode-nls@5.2.0": { + "integrity": "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==" + }, + "vscode-uri@3.1.0": { + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==" + }, + "web-namespaces@2.0.1": { + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==" + }, + "webidl-conversions@3.0.1": { + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "whatwg-url@5.0.0": { + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": [ + "tr46", + "webidl-conversions" + ] + }, + "which-pm-runs@1.1.0": { + "integrity": "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==" + }, + "widest-line@5.0.0": { + "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "dependencies": [ + "string-width@7.2.0" + ] + }, + "wrap-ansi@7.0.0": { + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": [ + "ansi-styles@4.3.0", + "string-width@4.2.3", + "strip-ansi@6.0.1" + ] + }, + "wrap-ansi@9.0.0": { + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dependencies": [ + "ansi-styles@6.2.1", + "string-width@7.2.0", + "strip-ansi@7.1.0" + ] + }, + "xxhash-wasm@1.1.0": { + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==" + }, + "y18n@5.0.8": { + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==" + }, + "yaml-language-server@1.15.0": { + "integrity": "sha512-N47AqBDCMQmh6mBLmI6oqxryHRzi33aPFPsJhYy3VTUGCdLHYjGh4FZzpUjRlphaADBBkDmnkM/++KNIOHi5Rw==", + "dependencies": [ + "ajv", + "lodash", + "request-light@0.5.8", + "vscode-json-languageservice", + "vscode-languageserver@7.0.0", + "vscode-languageserver-textdocument", + "vscode-languageserver-types@3.17.5", + "vscode-nls", + "vscode-uri", + "yaml@2.2.2" + ], + "optionalDependencies": [ + "prettier" + ], + "bin": true + }, + "yaml@2.2.2": { + "integrity": "sha512-CBKFWExMn46Foo4cldiChEzn7S7SRV+wqiluAb6xmueD/fGyRHIhX8m14vVGgeFWjN540nKCNVj6P21eQjgTuA==" + }, + "yaml@2.8.0": { + "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "bin": true + }, + "yaqrcode@0.2.1": { + "integrity": "sha512-J7VoOePCNP//W6ImhDAOPxZO55u7xgt0s7dJkhlKTzbRAZX9MNljOsBOR4Xghoh9BsXww6tKZNW9VZuSnUSicA==" + }, + "yargs-parser@21.1.1": { + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==" + }, + "yargs@17.7.2": { + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dependencies": [ + "cliui", + "escalade", + "get-caller-file", + "require-directory", + "string-width@4.2.3", + "y18n", + "yargs-parser" + ] + }, + "yocto-queue@1.2.1": { + "integrity": "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==" + }, + "yocto-spinner@0.2.3": { + "integrity": "sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ==", + "dependencies": [ + "yoctocolors" + ] + }, + "yoctocolors@2.1.1": { + "integrity": "sha512-GQHQqAopRhwU8Kt1DDM8NjibDXHC8eoh1erhGAJPEyveY9qqVeXvVikNKrDz69sHowPMorbPUrH/mx8c50eiBQ==" + }, + "zod-to-json-schema@3.24.5_zod@3.25.64": { + "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", + "dependencies": [ + "zod" + ] + }, + "zod-to-ts@1.2.0_typescript@5.8.3_zod@3.25.64": { + "integrity": "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==", + "dependencies": [ + "typescript", + "zod" + ] + }, + "zod@3.25.64": { + "integrity": "sha512-hbP9FpSZf7pkS7hRVUrOjhwKJNyampPgtXKc3AN6DsWtoHsg2Sb4SQaS4Tcay380zSwd2VPo9G9180emBACp5g==" + }, + "zwitch@2.0.4": { + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==" + } + }, + "redirects": { + "https://esm.sh/@types/mdast@latest": "https://esm.sh/@types/mdast@4.0.4/index.d.ts" + }, + "workspace": { + "dependencies": [ + "jsr:@std/assert@^1.0.13", + "jsr:@std/async@^1.0.13", + "jsr:@std/encoding@^1.0.10", + "jsr:@std/expect@^1.0.16", + "jsr:@std/fs@^1.0.17", + "jsr:@std/path@^1.0.9", + "jsr:@std/testing@^1.0.12", + "jsr:@std/toml@^1.0.7", + "npm:@types/mdast@*" + ], + "packageJson": { + "dependencies": [ + "npm:@astrojs/check@~0.9.4", + "npm:@astrojs/markdown-remark@^6.3.2", + "npm:@astrojs/rss@4.0.12", + "npm:@astrojs/sitemap@3.4.1", + "npm:@openpgp/web-stream-tools@~0.1.3", + "npm:@types/mdast@^4.0.4", + "npm:astro@5.9.3", + "npm:openpgp@^6.1.1", + "npm:reading-time@^1.5.0", + "npm:rehype-external-links@3", + "npm:rehype-sanitize@6", + "npm:remark-gfm@^4.0.1", + "npm:remark-smartypants@^3.0.2", + "npm:remark-toc@9", + "npm:retext-smartypants@^6.2.0", + "npm:toml@3", + "npm:typescript@^5.8.3", + "npm:unified@^11.0.5", + "npm:unist-util-visit@5", + "npm:vfile@^6.0.3", + "npm:yaqrcode@~0.2.1" + ] + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5bcaeec --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "cravodeabril.pt", + "type": "module", + "version": "0.1.0", + "dependencies": { + "@astrojs/check": "^0.9.4", + "@astrojs/markdown-remark": "^6.3.2", + "@astrojs/rss": "4.0.12", + "@astrojs/sitemap": "3.4.1", + "@types/mdast": "^4.0.4", + "astro": "5.9.3", + "openpgp": "^6.1.1", + "reading-time": "^1.5.0", + "rehype-external-links": "^3.0.0", + "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.1", + "remark-smartypants": "^3.0.2", + "remark-toc": "^9.0.0", + "retext-smartypants": "^6.2.0", + "toml": "^3.0.0", + "typescript": "^5.8.3", + "unified": "^11.0.5", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.3", + "yaqrcode": "^0.2.1" + }, + "devDependencies": { + "@openpgp/web-stream-tools": "^0.1.3" + } +} diff --git a/public/blog/TEMPLATE b/public/blog/TEMPLATE new file mode 100644 index 0000000..a21288e --- /dev/null +++ b/public/blog/TEMPLATE @@ -0,0 +1,46 @@ ++++ +title = '' +# subtitle = '' +# description = ''' +# +# ''' +keywords = [] # list of available keywords in `src/consts.ts` +dateCreated = 1974-04-25T00:21:00+01:00 +# dateUpdated = 2025-04-25 +# locationCreated = '' +# relatedPosts = [] +lang = 'pt-PT' +# translationOf = '' +# license = '' +# +# [[signer]] +# entity = '' +# role = '' +# +# [[signer]] +# entity = '' +# role = '' ++++ + +<!-- +https://github.github.com/gfm/ +https://daringfireball.net/projects/markdown/basics +https://daringfireball.net/projects/markdown/syntax +https://html.spec.whatwg.org/multipage/text-level-semantics.html#usage-summary +--> + +paragraph after title header + +# (Sub)Header + +paragraph. + +## (Subsub)Header + +paragraph. + +# (Sub)Header + +paragraph. + +paragraph. diff --git a/public/blog/legislativas-2025.md b/public/blog/legislativas-2025.md new file mode 100644 index 0000000..89c48ff --- /dev/null +++ b/public/blog/legislativas-2025.md @@ -0,0 +1,40 @@ ++++ +title = 'Eleições para a Assembleia da República 2025' +subtitle = 'A minha opinião acerca dos resultados' +description = ''' +Dia 18 de maio de 2025 houve eleições legislativas em Portugal. + +A direita e o conservadorismo ganharam mais poder do que já tinham. +''' +keywords = ['democracy', 'Portugal'] +dateCreated = 2025-05-18T21:31:00-03:00 +dateUpdated = 2025-05-19T00:00:00-03:00 +locationCreated = 'RJ, Brasil' +lang = 'pt-PT' +license = "CC-BY-NC-SA" + +[[signer]] +entity = 'cravodeabril' +role = 'author' ++++ + +Hoje eu vi uma esquerda derrotada[^1]. Vou para a cama, organizar as minhas +ideias, dormir, logo volto. + +# Quem sou eu (brevemente) + +Acabou que este será o primeiro <span lang="en">blog post</span>, e ainda nem me +apresentei, ainda não expliquei o porquê de eu ter decidido comprar este domínio +e para quê que o quero usar. + +O meu nome é João Augusto Costa Branco Marado Torres, nasci em Abril de 2005, +sou atualmente estudante no <abbr>IPBeja</abbr>, 2.º ano da Licenciatura de +Engenharia Informática, apesar de já não pisar em Beja vai fazer 1 ano. Estive +no semestre passado em intercâmbio em <span lang="tr">İstanbul, Türkiye</span>, +e neste semestre estou no <abbr>RJ</abbr>, Brasil. + +Deve fazer cerca de 2 anos que tive um <span lang="en">"woke awakening"</span>. +Vá lá, talvez eu sempre tenha sido <span lang="en">"woke"</span>, mas antes eu +não me importava tanto + +[^1]: <https://www.legislativas2025.mai.gov.pt/resultados/globais> diff --git a/public/blog/legislativas-2025.md.sig b/public/blog/legislativas-2025.md.sig Binary files differnew file mode 100644 index 0000000..8ad85c9 --- /dev/null +++ b/public/blog/legislativas-2025.md.sig diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..682932f --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg version="1.0" xmlns="http://www.w3.org/2000/svg" width="32" height="32"> + <rect width="42" height="32" fill="#d00"/> +</svg> diff --git a/public/keys/cravodeabril.gpg b/public/keys/cravodeabril.gpg Binary files differnew file mode 100644 index 0000000..9921f2e --- /dev/null +++ b/public/keys/cravodeabril.gpg diff --git a/src/components/BaseHead.astro b/src/components/BaseHead.astro new file mode 100644 index 0000000..5ac0410 --- /dev/null +++ b/src/components/BaseHead.astro @@ -0,0 +1,79 @@ +--- +// Import the global.css file here so that it is included on +// all pages through the use of the <BaseHead /> component. +import "../styles/global.css"; +import { SITE_AUTHOR, SITE_DESCRIPTION, SITE_TITLE } from "../consts"; +import { ClientRouter } from "astro:transitions"; + +export interface Props { + title: string; + description?: string; + image?: string; + keywords?: string[]; +} + +const canonicalURL = new URL(Astro.url.pathname, Astro.site); + +const { title, description = SITE_DESCRIPTION, image, keywords = [] } = + Astro.props; +// const socialImage = image ?? Astro.site.href + 'assets/social.png' +--- + +<!-- Global Metadata --> +<meta charset="utf-8" /> +<meta name="viewport" content="width=device-width,initial-scale=1" /> + +<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> +<link rel="sitemap" href="/sitemap-index.xml" /> +<link + rel="alternate" + type="application/rss+xml" + title={SITE_TITLE} + href={new URL("rss.xml", Astro.site)} +/> +<meta name="generator" content={Astro.generator} /> + +<!-- Canonical URL --> +<link rel="canonical" href={canonicalURL} /> + +<!-- Primary Meta Tags --> +<title>{title}</title> +<meta name="title" content={title} /> +<meta name="description" content={description} /> +<meta name="author" content={SITE_AUTHOR} /> +{keywords.length > 0 && <meta name="keywords" content={keywords.join(",")} />} +<meta name="theme-color" content="#a50026" /> +<meta + name="theme-color" + content="#f46d43" + media="(prefers-color-scheme: dark)" +/> + +<!-- Open Graph / Facebook --> +<meta property="og:type" content="website" /> +<meta property="og:url" content={Astro.url} /> +<meta property="og:title" content={title} /> +<meta property="og:description" content={description} /> +{image && <meta property="og:image" content={new URL(image, Astro.url)} />} + +<!-- Twitter --> +<meta property="twitter:card" content="summary_large_image" /> +<meta property="twitter:url" content={Astro.url} /> +<meta property="twitter:title" content={title} /> +<meta property="twitter:description" content={description} /> +{image && <meta property="twitter:image" content={new URL(image, Astro.url)} />} + +<ClientRouter /> + +<script is:inline> + const root = document.documentElement; + const theme = localStorage.getItem("theme"); + if ( + theme === "dark" || + (!theme && window.matchMedia("(prefers-color-scheme: dark)").matches) + ) { + root.classList.add("theme-dark"); + } else { + root.classList.remove("theme-dark"); + } +</script> diff --git a/src/components/BlogCard.astro b/src/components/BlogCard.astro new file mode 100644 index 0000000..7ab42d7 --- /dev/null +++ b/src/components/BlogCard.astro @@ -0,0 +1,38 @@ +--- +import type { CollectionEntry } from "astro:content"; + +interface Props extends CollectionEntry<"blog"> {} + +const { id, data } = Astro.props; +const { title, description, dateCreated, lang } = data; + +const href = `/blog/read/${id}`; +--- + +<article> + <h2> + <a {href}>{title}</a> + </h2> + <p>{description}</p> + <footer> + <span><time datetime={(dateCreated as Date).toISOString()}>{ + new Intl.DateTimeFormat(lang, { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "long", + }).format(dateCreated) + }</time></span> + </footer> +</article> + +<style> + article { + border-block-end: 1px solid #181818; + padding-block-end: 1rem; + margin-block: 0.5rem; + } +</style> diff --git a/src/components/Citations.astro b/src/components/Citations.astro new file mode 100644 index 0000000..cc82eda --- /dev/null +++ b/src/components/Citations.astro @@ -0,0 +1,39 @@ +--- +import type { CollectionEntry } from "astro:content"; +import { getEntries } from "astro:content"; + +type Props = { citations: CollectionEntry<"blog">["data"]["relatedPosts"] }; +const citations = await getEntries(Astro.props.citations ?? []); +--- +{ + citations.length > 0 && + ( + <aside> + <p>O autor recomenda ler também:</p> + <ul> + { + citations.map(({ collection, id, data }) => ( + <li + itemprop="citation" + itemscope + itemtype="http://schema.org/BlogPosting" + itemid={Astro.url.href.replace(/[^\/]*\/?$/, id)} + > + <a href={`/${collection}/read/${id}`}> + <cite itemprop="headline">{data.title}</cite> + </a> + </li> + )) + } + </ul> + </aside> + ) +} + +<style> + @media print { + aside { + display: none; + } + } +</style> diff --git a/src/components/Commit.astro b/src/components/Commit.astro new file mode 100644 index 0000000..3ee284a --- /dev/null +++ b/src/components/Commit.astro @@ -0,0 +1,49 @@ +--- +import type { Commit } from "@lib/git/types"; +import { gitDir } from "@lib/git"; + +type Props = Commit; + +const { hash, files, author, signature } = Astro.props; + +const git = await gitDir; +--- +<p>Git commit info:</p> +<dl> + <dt>Hash</dt> + <dd>{hash}</dd> + <dt>Files</dt> + {files.map((file) => <dd>{file.pathname.replace(git, "")}</dd>)} + <dt>Author</dt> + <dd>{author.name} <{author.email}></dd> + { + signature && ( + <dt>Commit Signature</dt> + <dd> + <dl> + <dt>Type</dt> + <dd>{signature.type}</dd> + <dt>Signer</dt> + <dd>{signature.signerName}</dd> + <dt>Key fingerprint</dt> + <dd>{signature.keyFingerPrint}</dd> + </dl> + </dd> + ) + } +</dl> + +<style> + dl { + display: grid; + grid-template-columns: 1fr 1fr; + } + + dl > dt, dd { + display: inline-block; + } + + dt::after { + content: ": "; + } +</style> diff --git a/src/components/CopyrightNotice.astro b/src/components/CopyrightNotice.astro new file mode 100644 index 0000000..2aa72ad --- /dev/null +++ b/src/components/CopyrightNotice.astro @@ -0,0 +1,66 @@ +--- +import CC from "./licenses/CC.astro"; +import WTFPL from "./licenses/WTFPL.astro"; +import { CREATIVE_COMMONS_LICENSES, LICENSES } from "../consts.ts"; + +export interface Props { + title: string; + author: string; + email?: string; + website?: string; + dateCreated: Date; + license?: typeof LICENSES[number]; +} + +let { license = "public domain" } = Astro.props; + +let Notice = undefined; +if (license === "WTFPL") { + Notice = WTFPL; +} else if ( + CREATIVE_COMMONS_LICENSES.some((x) => license.localeCompare(x) === 0) +) { + Notice = CC; +} +--- + +{Notice && <div lang="en"><Notice {...Astro.props} /></div>} + +{ + /* +https://spdx.org/licenses/WTFPL.html +https://spdx.org/licenses/GFDL-1.3-or-later.html +https://spdx.org/licenses/FSFAP.html +https://artlibre.org/licence/lal/en/ +https://harmful.cat-v.org/software/ + +IPL-1.0 +IPA +Intel +HPND +EUPL-1.2 +EUPL-1.1 +EUDatagrid +EPL-2.0 +EPL-1.0 +EFL-2.0 +ECL-2.0 +CPL-1.0 +CPAL-1.0 +CDDL-1.0 +BSL-1.0 +BSD-3-Clause +BSD-2-Clause +Artistic-2.0 +APSL-2.0 +Apache-2.0 +Apache-1.1 +AGPL-3.0-or-later +AGPL-3.0-only +AFL-3.0 +AFL-2.1 +AFL-2.0 +AFL-1.2 +AFL-1.1 + */ +} diff --git a/src/components/DateSelector.astro b/src/components/DateSelector.astro new file mode 100644 index 0000000..324bc41 --- /dev/null +++ b/src/components/DateSelector.astro @@ -0,0 +1,141 @@ +--- +interface Props { + date: Date; + years: number[]; + months: number[]; + days?: number[]; +} + +const { date, years, months, days } = Astro.props; + +const y = date.getFullYear(); +const m = date.getMonth() + 1; +const d = date.getDate(); +let yI = 0; +let mI = 0; +let dI = 0; + +const list = new Intl.ListFormat("pt-PT", { type: "unit", style: "narrow" }); + +const pad = (n: number) => String(n).padStart(2, "0"); +--- +<nav> + <span role="list"> + Anos:{" "} + { + list.formatToParts(years.map((y) => + new Intl.DateTimeFormat("pt-PT", { year: "2-digit" }).format( + new Date( + Date.UTC( + y, + 0, + 1, + date.getTimezoneOffset() / 60, + date.getTimezoneOffset() % 60, + ), + ), + ) + )).map(({ type, value }: { type: string; value: string }) => { + switch (type) { + case "element": { + const year = years[yI++]; + return ( + <span role="listitem"><a + class:list={[{ active: year === y }]} + href={`/blog/${year}`} + >{value}</a></span> + ); + } + case "literal": { + return ( + <span>{value}</span> + ); + } + } + }) + } + </span> + <br /> + <span role="list"> + Meses:{" "} + { + list.formatToParts(months.map((m) => + new Intl.DateTimeFormat("pt-PT", { month: "short" }).format( + new Date( + Date.UTC( + y, + m - 1, + 1, + date.getTimezoneOffset() / 60, + date.getTimezoneOffset() % 60, + ), + ), + ) + )).map(({ type, value }: { type: string; value: string }) => { + switch (type) { + case "element": { + const month = months[mI++]; + return ( + <span role="listitem"><a + class:list={[{ active: month === m }]} + href={`/blog/${y}/${pad(month)}`} + >{value}</a></span> + ); + } + case "literal": { + return ( + <span>{value}</span> + ); + } + } + }) + } + </span> + { + days && + ( + <><br /><span role="list"> + Dias:{" "} + { + list.formatToParts(days.map((d) => { + return new Intl.DateTimeFormat("pt-PT", { day: "numeric" }) + .format( + new Date( + Date.UTC( + y, + m - 1, + d, + date.getTimezoneOffset() / 60, + date.getTimezoneOffset() % 60, + ), + ), + ); + })).map(({ type, value }: { type: string; value: string }) => { + switch (type) { + case "element": { + const day = days[dI++]; + return ( + <span role="listitem"><a + class:list={[{ active: day === d }]} + href={`/blog/${y}/${pad(m)}/${pad(d)}`} + >{value}</a></span> + ); + } + case "literal": { + return ( + <span>{value}</span> + ); + } + } + }) + } + </span></> + ) + } +</nav> + +<style> + a.active { + font-weight: bolder; + } +</style> diff --git a/src/components/Footer.astro b/src/components/Footer.astro new file mode 100644 index 0000000..11c62c4 --- /dev/null +++ b/src/components/Footer.astro @@ -0,0 +1,62 @@ +--- + +--- + +<footer> + <address> + Sítio web de <a href={Astro.site} target="_blank" rel="author" + >João Augusto Costa Branco Marado Torres</a> + </address> + <section id="copying"> + <h2>Licença de <span lang="en">Software</span></h2> + <div lang="en"> + <p> + <small> + <<a href="/" hreflang="pt-PT">cravodeabril.pt</a>> Copyright + © 2025 João Augusto Costa Branco Marado Torres + </small> + </p> + <p> + <small> + This program is free software: you can redistribute it and/or modify + it under the terms of the <a + href="https://www.gnu.org/licenses/agpl-3.0.html" + target="_blank" + rel="external license" + >GNU Affero General Public License</a> as published by the Free + Software Foundation, either version 3 of the License, or (at your + option) any later version. + </small> + </p> + <p> + <small> + This program is distributed in the hope that it will be useful, but + WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Affero General Public License for more details. + </small> + </p> + <p> + <small> + You should have received a copy of the GNU Affero General Public + License along with this program. If not, see <a + href="https://www.gnu.org/licenses/" + target="_blank" + rel="external" + >https://www.gnu.org/licenses</a> + </small> + </p> + </div> + </section> + <nav> + <ul> + <li><a>Código de Conduta</a></li> + <li><a>Declaração de Exoneração de Responsabilidade</a></li> + <li><a>Aviso sobre cookies</a></li> + <li><a>Declaração de acessibilidade</a></li> + <li><a>Apoio</a></li> + <li><a>Contacto</a></li> + <li><a>Código fonte</a></li> + </ul> + </nav> +</footer> diff --git a/src/components/Header.astro b/src/components/Header.astro new file mode 100644 index 0000000..874a496 --- /dev/null +++ b/src/components/Header.astro @@ -0,0 +1,41 @@ +--- +import HeaderLink from "./HeaderLink.astro"; +--- + +<header> + <h1><<a href="/">cravodeabril.pt</a>></h1> + <search> + <form + action="https://www.google.com/search" + target="_blank" + rel="external noreferrer search" + role="search" + autocomplete="on" + name="search" + > + <p> + <label>Barra de pesquisa: <input + name="q" + type="search" + placeholder={`site:${Astro.site} consulta de pesquisa`} + value={`site:${Astro.site} `} + required + title={`"site:${Astro.site} " é usado para que os resultados da pesquisa fiquem restritos a este website`} + pattern={`site:${Astro.site} .+`} + size={`site:${Astro.site} .+`.length} + /></label> + </p> + <p><button type="submit">Pesquisar</button></p> + <p> + <small>Esta pesquisa é efectuada pelo Google e utiliza software + proprietário.</small> + </p> + </form> + </search> + <nav> + <ul> + <li><HeaderLink href="/blog">Publicações</HeaderLink></li> + <li><HeaderLink href="/blog/keywords">Palavras-Chave</HeaderLink></li> + </ul> + </nav> +</header> diff --git a/src/components/HeaderLink.astro b/src/components/HeaderLink.astro new file mode 100644 index 0000000..8c01f92 --- /dev/null +++ b/src/components/HeaderLink.astro @@ -0,0 +1,18 @@ +--- +import type { HTMLAttributes } from "astro/types"; + +type Props = HTMLAttributes<"a">; + +const { href, class: className, ...props } = Astro.props; +const pathname = Astro.url.pathname; +const isActive = href === pathname; +--- + +<a {href} class:list={[className, { current: isActive }]} {...props}> + <slot /> +</a> +<style> + a.current { + font-weight: bolder; + } +</style> diff --git a/src/components/Keywords.astro b/src/components/Keywords.astro new file mode 100644 index 0000000..1800d5a --- /dev/null +++ b/src/components/Keywords.astro @@ -0,0 +1,52 @@ +--- +import type { CollectionEntry } from "astro:content"; + +interface Props { + keywords: CollectionEntry<"blog">["data"]["keywords"]; +} + +const { keywords } = Astro.props; +--- +<aside> + <ul> + { + keywords.map((x) => ( + <li> + <a rel="tag" itemprop="keywords" href={`/blog/keywords/${x}`}><b>{ + x + }</b></a> + </li> + )) + } + </ul> +</aside> + +<style> + ul { + list-style-type: none; + padding-inline-start: 0; + max-width: 40ch; + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 1em; + margin-inline: auto; + } + + ul > li { + font-size: smaller; + display: inline-block; + } + + ul > li::before { + content: "#"; + color: var(--color-active); + font-weight: bolder; + } + + @media print { + aside { + display: none; + } + } +</style> diff --git a/src/components/ReadingTime.astro b/src/components/ReadingTime.astro new file mode 100644 index 0000000..2c8c676 --- /dev/null +++ b/src/components/ReadingTime.astro @@ -0,0 +1,26 @@ +--- +import type { CollectionEntry } from "astro:content"; +import { default as readingTime } from "reading-time"; + +type Props = { + body: CollectionEntry<"blog">["body"]; + lang: CollectionEntry<"blog">["data"]["lang"]; +}; + +const { body, lang } = Astro.props; + +const reading = readingTime(body ?? "", {}); +const minutes = Math.ceil(reading.minutes); +const estimative = new Intl.DurationFormat(lang, { + style: "long", +}).format({ minutes }); +const duration = `PT${ + Math.floor(minutes / 60) > 0 ? Math.floor(minutes / 60) + "H" : "" +}${minutes % 60 > 0 ? minutes % 60 + "M" : ""}`; +--- +<p> + <data itemprop="timeRequired" value={duration}><bdi>Tempo de leitura + estimado</bdi>: ~ {estimative}</data> + <data itemprop="wordCount" value={reading.words} + >(<bdi>palavras</bdi>: {reading.words})</data> +</p> diff --git a/src/components/SignaturesTableRows.astro b/src/components/SignaturesTableRows.astro new file mode 100644 index 0000000..eafd4de --- /dev/null +++ b/src/components/SignaturesTableRows.astro @@ -0,0 +1,51 @@ +--- +import { type Summary, VerificationResult } from "@lib/pgp/summary"; +import { PublicKey } from "openpgp"; + +type Props = { summary: Summary; rowspan?: number }; + +const { summary, rowspan } = Astro.props; +const [type, _, info] = summary; + +let name: string = ""; +let email: string = ""; +let fingerprint: string = ""; +let trust: number | undefined = NaN; +let commiter: boolean | undefined = undefined; +let revoked: boolean | undefined = undefined; +let keyType: "primary" | "sub" | "" = ""; + +switch (type) { + case VerificationResult.MISSING_KEY: + fingerprint = typeof info.keyID === "string" + ? info.keyID + : info.keyID.toHex(); + break; + case VerificationResult.TRUSTED_KEY: + const match = info.userID[0].match(/^(.*?)\s*(?:\((.*?)\))?\s*<(.+?)>$/); + + if (match) { + name = match[1]; + email = match[3]; + } + + fingerprint = info.key.getFingerprint(); + trust = info.trust; + keyType = info.key instanceof PublicKey ? "primary" : "sub"; + break; +} + +const names = name.split(/\s/); +const firstName = names[0]; +const lastName = names.length > 1 ? ` ${names[names.length - 1]}` : ""; +--- +<td {rowspan}><span title={name}>{firstName}{lastName}</span></td> +<td {rowspan}>{email}</td> +<td {rowspan}> + <span title={fingerprint.replace(/(....)/g, "$1 ").trim()}> + {`0x${fingerprint.slice(-8)}`} + </span> +</td> +<td {rowspan}>{trust}</td> +<td {rowspan}>{commiter}</td> +<td {rowspan}>{revoked}</td> diff --git a/src/components/Translations.astro b/src/components/Translations.astro new file mode 100644 index 0000000..b0164bb --- /dev/null +++ b/src/components/Translations.astro @@ -0,0 +1,107 @@ +--- +import type { CollectionEntry } from "astro:content"; +import { + getFlagEmojiFromLocale, + getLanguageNameFromLocale, +} from "../utils/lang"; +import { getEntries } from "astro:content"; + +interface Props { + lang: string; + translations?: CollectionEntry<"blog">["data"]["translations"]; +} + +const { lang } = Astro.props; + +const translations = await getEntries(Astro.props.translations ?? []).then( + (translations) => + translations.sort((x, y) => x.data.lang.localeCompare(y.data.lang)), +); +--- + +{ + /* TODO: What about <https://schema.org/translationOfWork> and <https://schema.org/translator>? */ +} + +{ + translations.length > 0 && ( + <aside> + <nav> + <p>Traduções:</p> + <ul class="translations"> + { + translations.map(async ( + { data, collection, id }, + ) => { + const active = lang.localeCompare(data.lang) === 0; + return ( + <li + itemprop={active ? undefined : "workTranslation"} + itemscope={!active} + itemtype={active ? undefined : "http://schema.org/BlogPosting"} + itemid={active + ? undefined + : new URL(`${collection}/read/${id}`, Astro.site).href} + > + <a + href={`/${collection}/read/${id}`} + class:list={[{ active }]} + rel={active ? undefined : "alternate"} + hreflang={active ? undefined : data.lang} + type="text/html" + title={data.title} + ><span class="emoji">{getFlagEmojiFromLocale(data.lang)}</span> + {getLanguageNameFromLocale(data.lang)} (<span + itemprop="inLanguage" + >{data.lang}</span>)</a> + </li> + ); + }) + } + </ul> + </nav> + </aside> + ) +} + +<style> + .translations { + list-style-type: none; + padding-inline-start: 0; + } + + .translations > li { + display: inline; + } + + .translations > li > a > .emoji { + text-decoration: none; + font-family: var(--ff-icons); + } + + .translations > li > a.active { + font-weight: bolder; + text-decoration: underline; + color: var(--color-active); + } + + nav:has(.translations) { + display: flex; + gap: 1rem; + } + + nav:has(.translations) > * { + font-size: smaller; + } + + .translations > li:not(:first-child)::before { + content: "|"; + margin-inline: 0.5em; + } + + @media print { + aside { + display: none; + } + } +</style> diff --git a/src/components/licenses/CC.astro b/src/components/licenses/CC.astro new file mode 100644 index 0000000..61f9114 --- /dev/null +++ b/src/components/licenses/CC.astro @@ -0,0 +1,120 @@ +--- +import type { Props as BaseProps } from "../CopyRightNotice.astro"; +interface Props extends BaseProps {} + +let { title, website, author, dateCreated, license } = Astro.props; +const publicdomain = license === "CC0"; +const sa = /SA/.test(license); +const nd = /ND/.test(license); +const nc = /NC/.test(license); +const licenseURL = `https://creativecommons.org/licenses/${ + license.slice(3).toLowerCase() +}/4.0/`; +--- + +<footer itemprop="copyrightNotice"> + { + publicdomain ? ( + <p> + <small> + <a href={Astro.url}>{title}</a> by <span + itemprop="copyrightholder" + itemscope + itemtype="https://schema.org/Person" + >{ + website ? ( + <a + itemprop="url" + rel="author external noreferrer" + target="_blank" + href={website} + content={website} + ><span itemprop="name">{author}</span></a> + ) : author + }</span> is marked <a + itemprop="license" + rel="license noreferrer" + target="_blank" + href="https://creativecommons.org/publicdomain/zero/1.0/" + content="https://creativecommons.org/publicdomain/zero/1.0/" + >CC0 1.0</a> + <img + alt="" + src="https://mirrors.creativecommons.org/presskit/icons/cc.svg" + style="max-width: 1em; max-height: 1em; margin-left: 0.2em" + > + <img + alt="" + src="https://mirrors.creativecommons.org/presskit/icons/zero.svg" + style="max-width: 1em; max-height: 1em; margin-left: 0.2em" + > + </small> + </p> + ) : ( + <p> + <small> + <a href={Astro.url}>{title}</a> © <span itemprop="copyrightYear">{ + dateCreated.getFullYear() + }</span> by <span + itemprop="copyrightholder" + itemscope + itemtype="https://schema.org/Person" + >{ + website ? ( + <a + itemprop="url" + href={website} + target="_blank" + rel="author external noreferrer" + content={website} + ><span itemprop="name">{author}</span></a> + ) : author + }</span> is licensed under <a + itemprop="license" + rel="license noreferrer" + target="_blank" + href={licenseURL} + content={licenseURL} + >{license.replace("CC-", "CC ")} 4.0</a> + <img + alt="" + src="https://mirrors.creativecommons.org/presskit/icons/cc.svg" + style="max-width: 1em; max-height: 1em; margin-left: 0.2em" + > + <img + alt="" + src="https://mirrors.creativecommons.org/presskit/icons/by.svg" + style="max-width: 1em; max-height: 1em; margin-left: 0.2em" + > + { + nc && ( + <img + alt="" + src="https://mirrors.creativecommons.org/presskit/icons/nc.svg" + style="max-width: 1em; max-height: 1em; margin-left: 0.2em" + > + ) + } + { + sa && ( + <>{" "}<img + alt="" + src="https://mirrors.creativecommons.org/presskit/icons/sa.svg" + style="max-width: 1em; max-height: 1em; margin-left: 0.2em" + ></> + ) + } + { + nd && ( + <>{" "}<img + alt="" + src="https://mirrors.creativecommons.org/presskit/icons/nd.svg" + style="max-width: 1em; max-height: 1em; margin-left: 0.2em" + ></> + ) + } + </small> + </p> + ) + } +</footer> diff --git a/src/components/licenses/WTFPL.astro b/src/components/licenses/WTFPL.astro new file mode 100644 index 0000000..feab7ec --- /dev/null +++ b/src/components/licenses/WTFPL.astro @@ -0,0 +1,53 @@ +--- +import type { Props as BaseProps } from "../CopyrightNotice.astro"; +interface Props extends BaseProps {} + +let { website, author, email, dateCreated } = Astro.props; +--- + +<footer itemprop="copyrightNotice"> + <p> + <small> + Copyright © <span itemprop="copyrightYear">{ + dateCreated.getFullYear() + }</span> + <span + itemprop="copyrightholder" + itemscope + itemtype="https://schema.org/Person" + >{ + website ? ( + <a + itemprop="url" + rel="author external noreferrer" + target="_blank" + href={website} + content={website} + ><span itemprop="name">{author}</span></a> + ) : author + } + { + email && ( + <><<a + itemprop="email" + rel="author external noreferrer" + target="_blank" + href={`mailto:${email}`} + >{email}</a>></> + ) + }</span> + </small> + </p> + <p> + <small> + This work is free. You can redistribute it and/or modify it under the + terms of the Do What The Fuck You Want To Public License, Version 2, as + published by Sam Hocevar. See <a + itemprop="license" + href="http://www.wtfpl.net/" + rel="license noreferrer" + target="_blank" + >http://www.wtfpl.net/</a> for more details. + </small> + </p> +</footer> diff --git a/src/components/signature/Authors.astro b/src/components/signature/Authors.astro new file mode 100644 index 0000000..43a2b36 --- /dev/null +++ b/src/components/signature/Authors.astro @@ -0,0 +1,281 @@ +--- +import { toPK } from "@lib/pgp"; +import { createKeyFromArmor } from "@lib/pgp/create"; +import type { Verification } from "@lib/pgp/verify"; +import { defined, get, instanciate } from "@utils/anonymous"; +import { type CollectionEntry, z } from "astro:content"; +import type { EntityTypesEnum } from "src/consts"; +import qrcode from "yaqrcode"; + +interface Props { + verifications: NonNullable<Verification["verifications"]>; + expectedSigners: { + entity: CollectionEntry<"entity">; + role: z.infer<typeof EntityTypesEnum>; + }[]; + commitSignerKey?: string; +} + +const { + verifications: verificationsPromise, + expectedSigners, + commitSignerKey, +} = Astro.props; + +const fingerprintToData = new Map< + string, + { websites: URL[]; role: z.infer<typeof EntityTypesEnum> } +>(); + +for (const { entity, role } of expectedSigners) { + const key = await createKeyFromArmor(entity.data.publickey.armor); + const fingerprint = key.getFingerprint(); + fingerprintToData.set(fingerprint, { + websites: entity.data.websites?.map(instanciate(URL)) ?? [], + role, + }); +} + +let verifications = await Promise.all( + verificationsPromise.map(async ({ key, keyID, userID, verified }) => { + return { + key: await key, + keyID, + userID: await userID, + verified: await verified.catch(() => false), + }; + }), +); + +const expectedKeys = await Promise.all( + expectedSigners.map(get("entity")).map(({ data }) => + createKeyFromArmor(data.publickey.armor) + ), +); + +const expectedFingerprints = new Set( + expectedKeys.map((key) => key.getFingerprint()), +); + +const verifiedFingerprints = new Set( + verifications.map((v) => v.key).filter(defined).map(toPK).map((key) => + key.getFingerprint() + ), +); + +if (!expectedFingerprints.isSubsetOf(verifiedFingerprints)) { + throw new Error( + `Missing signature from expected signers: ${[ + ...expectedFingerprints.difference(verifiedFingerprints).values(), + ]}`, + ); +} +--- + +<div> + <table> + <caption> + <strong>Assinaturas</strong> + <p> + Para verificar uma assinatura é necessário a <a href="#message" + >mensagem</a>, a <a href="#signature">assinatura digital</a> e as <em + >chaves públicas</em> dos assinantes. Esta tabela mostra algumas + informações sobre os assinantes e as suas chaves públicas. + </p> + </caption> + <colgroup> + <col /> + <col /> + </colgroup> + <colgroup> + <col /> + <col /> + <col /> + </colgroup> + <thead> + <tr> + <th scope="col">Assinante</th> + <th scope="col">Função</th> + <th scope="col">Fingerprint</th> + <th scope="col">Válido</th> + <th scope="col">Commiter</th> + </tr> + </thead> + <tbody> + { + verifications.map(({ userID, key, keyID, verified }) => { + const fingerprint = key + ? toPK(key).getFingerprint() + : undefined; + const info = fingerprint + ? fingerprintToData.get(fingerprint) + : undefined; + const primary = userID?.[0]; + let role = ""; + switch (info?.role) { + case "author": { + role = "Autor"; + break; + } + case "co-author": { + role = "Co-autor"; + break; + } + case "translator": { + role = "Tradutor"; + break; + } + } + return ( + <tr> + <th scope="row"> + <address + itemprop="author" + itemscope + itemtype="https://schema.org/Person" + > + { + primary?.name + ? info?.websites[0] ? ( + <a + itemprop="url" + rel="author external noreferrer" + target="_blank" + href={info.websites[0]} + ><span itemprop="name">{primary.name}</span></a> + ) : ( + <span itemprop="name">{primary.name}</span> + ) + : primary?.email + ? ( + <><<a + itemprop="email" + rel="author external noreferrer" + target="_blank" + href={primary?.email && `mailto:${primary.email}`} + >{primary?.email}</a>></> + ) + : "" + } + { + primary && ( + <> + <button + popovertarget={`user-id-${fingerprint}`} + class="emoji" + > + ➕ + </button> + <section + class="user-id" + popover + id={`user-id-${fingerprint}`} + > + { + userID && ( + <><p><code>UserID</code>s</p><ul> + {userID.map((x) => <li>{x.userID}</li>)} + </ul></> + ) + } + { + info?.websites && ( + <><p>Websites</p><ul> + { + info.websites.map(( + x, + ) => ( + <li><a href={x}>{x}</a></li> + )) + } + </ul></> + ) + } + </section> + </> + ) + } + </address> + </th> + <td>{role}</td> + <td> + <><span title={fingerprint?.replace(/(....)/g, "$1 ")}>{ + key + ? "0x" + toPK(key).getKeyID().toHex() + : "0x" + keyID.toHex() + }</span> + { + key && false && ( + <img + src={qrcode(toPK(key).armor(), { + typeNumber: 40, + errorCorrectLevel: "L", + })} + /> + ) + } + { + key && + ( + <button popovertarget={`armor-${fingerprint}`}> + Armor + </button> + <section class="armor" popover id={`armor-${fingerprint}`}> + <pre><code>{toPK(key).armor()}</code></pre> + </section> + ) + } + </> + </td> + <td>{verified ? "✅" : "❌"}</td> + <td> + { + commitSignerKey && + key?.getFingerprint().toUpperCase()?.endsWith( + commitSignerKey.toUpperCase(), + ) && "✅" + } + </td> + </tr> + ); + }) + } + </tbody> + </table> +</div> + +<style> + div { + overflow-x: auto; + } + + table { + table-layout: fixed; + border-collapse: collapse; + border: 3px solid; + margin-inline: auto; + max-width: 90svw; + } + + th, + td { + padding: 1rem; + text-align: center; + } + + tbody tr:nth-child(odd) { + background-color: #e7e7e7; + } + + section[popover] { + text-align: initial; + } + section[popover].armor { + max-height: calc(200dvh / 3); + } + @media (prefers-color-scheme: dark) { + tbody tr:nth-child(odd) { + background-color: #181818; + } + } +</style> diff --git a/src/components/signature/Commit.astro b/src/components/signature/Commit.astro new file mode 100644 index 0000000..9cc997a --- /dev/null +++ b/src/components/signature/Commit.astro @@ -0,0 +1,87 @@ +--- +import { gitDir } from "@lib/git"; +import type { Commit } from "@lib/git/types"; +import { toIso8601Full } from "@utils/datetime"; + +type Props = { commit: Commit; lang: string }; + +const dir = await gitDir(); +const { hash, files, author, committer, signature } = + Astro.props.commit; + +const formatter = new Intl.DateTimeFormat([Astro.props.lang], { + dateStyle: "short", + timeStyle: "short", +}); +--- + +<section> + <details> + <summary> + Informações sobre o último commit que modificou ficheiros relacionados a + este blog post: + </summary> + <dl class="divider"> + <dt>Hash</dt> + <dd><samp title={hash.long}>0x{hash.short.toUpperCase()}</samp></dd> + <dt>Ficheiros modificados</dt> + { + files.length > 0 + ? files.map((file) => ( + <dd><samp>{file.path.pathname.replace(dir.pathname, "")}</samp></dd> + )) + : <dd>Nenhum ficheiro modificado</dd> + } + <dt> + Autor (<time datetime={toIso8601Full(author.date)}>{ + formatter.format(author.date) + }</time>) + </dt> + <dd> + {author.name} <<a href={`mailto:${author.email}`}>{ + author.email + }</a>> + </dd> + <dt> + Commiter (<time datetime={toIso8601Full(committer.date)}>{ + formatter.format(committer.date) + }</time>) + </dt> + <dd> + {committer.name} <<a href={`mailto:${committer.email}`}>{ + committer.email + }</a>> + </dd> + { + signature && + ( + <dt>Assinatura do commit</dt> + <dd> + <dl> + <dt>Tipo</dt> + <dd><samp>{signature.type}</samp></dd> + <dt>Assinante</dt> + <dd>{signature.signer}</dd> + <dt>Fingerprint da chave</dt> + <dd><samp>0x{signature.key.short}</samp></dd> + </dl> + </dd> + ) + } + </dl> + </details> +</section> + +<style> + section { + font-size: smaller; + } + + dl { + margin-block: 0; + } + + details { + padding-block: 1rem; + } +</style> diff --git a/src/components/signature/Downloads.astro b/src/components/signature/Downloads.astro new file mode 100644 index 0000000..ac8215f --- /dev/null +++ b/src/components/signature/Downloads.astro @@ -0,0 +1,63 @@ +--- +import { gitDir } from "@lib/git"; +import { get } from "@utils/anonymous"; + +interface Props { + lang: string; +} + +const { lang } = Astro.props; + +let source = new URL( + `${Astro.url.href.replace("read/", "").replace(/\/$/, "")}.md`, +); + +const dir = await gitDir(); + +const format: Intl.NumberFormatOptions = { + notation: "compact", + style: "unit", + unit: "byte", + unitDisplay: "narrow", +}; + +const formatter = new Intl.NumberFormat(lang, format); + +const sourceSize = formatter.format( + await Deno.stat( + new URL("public" + source.pathname, dir), + ).then(get("size")), +); +const sig = await Deno.stat( + new URL("public" + source.pathname + ".sig", dir), +).then(get("size")).catch(() => undefined); +const sigSize = formatter.format(sig); +--- + +<section> + <p>Ficheiros para descarregar:</p> + <dl> + <dt>Blog post</dt> + <dd> + <a + id="message" + href={source} + download + type="text/markdown; charset=utf-8" + ><samp>text/markdown</samp>, <samp>{sourceSize}</samp></a> + </dd> + { + sig && ( + <dt>Assinatura digital</dt> + <dd> + <a + id="signature" + href={`${source}.sig`} + download + type="application/pgp-signature" + ><samp>application/pgp-signature</samp>, <samp>{sigSize}</samp></a> + </dd> + ) + } + </dl> +</section> diff --git a/src/components/signature/Signature.astro b/src/components/signature/Signature.astro new file mode 100644 index 0000000..57e9902 --- /dev/null +++ b/src/components/signature/Signature.astro @@ -0,0 +1,44 @@ +--- +import type { Verification } from "@lib/pgp/verify"; +import Summary from "./Summary.astro"; +import Downloads from "./Downloads.astro"; +import Commit from "./Commit.astro"; + +interface Props { + verification: Verification; + lang: string; +} + +const { verification, lang } = Astro.props; +const commit = await verification.commit; +--- + +<aside id="signatures"> + <p><strong>Verificação da assinatura digital</strong></p> + <Summary {...verification} /> + <Downloads {lang} /> + {commit && <Commit {commit} {lang} />} +</aside> + +<style is:global> + #signatures > section > p:first-child { + font-weight: bolder; + } +</style> +<style> + #signatures { + margin-inline: 1.5rem; + margin-block-end: 1.5rem; + box-shadow: 0 0 calc(1em) #e7e7e7; + border-radius: calc(1rem / 3); + padding: 1rem; + } + + #signatures > p:first-child { + font-size: larger; + + & > strong { + font-weight: bolder; + } + } +</style> diff --git a/src/components/signature/Summary.astro b/src/components/signature/Summary.astro new file mode 100644 index 0000000..6ab6bf5 --- /dev/null +++ b/src/components/signature/Summary.astro @@ -0,0 +1,279 @@ +--- +import { + createVerificationSummary, + logLevel, + type Summary, + VerificationResult, +} from "@lib/pgp/summary"; +import type { Verification } from "@lib/pgp/verify"; +import { Level } from "@utils/index"; +import type { NonEmptyArray } from "@utils/iterator"; + +interface Props extends Verification {} + +let [errors, keys] = await createVerificationSummary(Astro.props); +const failed = errors.filter((summary) => "reason" in summary); + +if (failed.length > 0) { + errors = failed as NonEmptyArray<Summary>; +} + +let worst; + +for (const summary of errors) { + if (worst === undefined) { + worst = summary; + } + + const { result } = summary; + const a = logLevel(worst.result); + const b = logLevel(result); + if (a[0] === b[0] && !a[1] && b[1]) { + worst = summary; + } else if (b[0] === Level.ERROR) { + worst = summary; + } else if (a[0] === Level.OK && b[0] === Level.WARN) { + worst = summary; + } +} + +let lvl: [Level, boolean] | undefined = undefined; + +let label; + +let title = ""; +let content; +const error = worst && "reason" in worst ? worst.reason : undefined; + +if (worst) { + lvl = logLevel(worst.result); + switch (lvl[0]) { + case Level.OK: { + label = "OK"; + break; + } + case Level.WARN: { + label = "Aviso"; + break; + } + case Level.ERROR: { + label = "Erro"; + break; + } + default: { + throw new Error("Unreachable"); + } + } + + switch (worst.result) { + case VerificationResult.NO_SIGNATURE: { + title = "Assinatura não encontrada"; + content = `<p> +Este blog post não foi assinado. +</p> +<p> +<strong>Não existe forma de verificar a autentacidade do autor ou a integridade do texto escrito</strong>. +</p> +`; + break; + } + case VerificationResult.MISSING_KEY: { + title = "Chave não encontrada"; + content = `<p> +Este blog post está assinado digitalmente, porém a chave pública com <code>KeyID</code> <samp>0x${worst.keyID}</samp> com que foi assinado não foi encontrada no chaveiro sendo <strong>impossível verificar a assinatura, quer dizer, não existe forma de verificar a autentacidade do autor ou a integridade do texto escrito</strong>. +</p> +<p> +Procure a chave noutro sítio da internet para conseguir fazer a verificação manualmente. +</p> +`; + break; + } + case VerificationResult.SIGNATURE_CORRUPTED: { + title = "Assinatura corrumpida"; + content = `<p> +Exite um ficheiro que supostamente é a assinatura, mas ele está corrompido ou com um formato inválido. +</p> +<p> +<strong>Não existe forma de verificar a autentacidade do autor ou a integridade do texto escrito</strong>. +</p> +`; + break; + } + case VerificationResult.SIGNATURE_COULD_NOT_BE_CHECKED: { + title = "Erro desconhecido"; + content = `<p> +A assinatura foi encontrada mas ocorreu um erro inesperado durante a verificação. +</p> +<p> +<strong>Não existe forma de verificar a autentacidade do autor ou a integridade do texto escrito</strong>. +</p> +`; + break; + } + case VerificationResult.BAD_SIGNATURE: { + title = "Assinatura inválida"; + content = `<p> +Existe uma assinatura digital porém o conteúdo da blog post não corresponde à assinatura. Talvez o texto tenha sido alterado sem ter sido criada uma nova assinatura. +</p> +<p> +Pode tentar verificar a assinatura com versões antigas do blog post, mas esta versão <strong> não pode ser verificada quanto à autentacidade do autor ou à integridade do texto escrito</strong>. +</p> +`; + break; + } + case VerificationResult.UNTRUSTED_KEY: { + title = "Assinatura válida (chave não confiada)"; + content = `<p> +A assinatura digital é criptograficamente válida, porém a chave utilizada não é suficientemente confiada pelo servidor. Mas podes ter a certeza que <strong>o dono da chave pública é a mesma pessoa que assinou este blog post</strong>. +</p> +`; + break; + } + case VerificationResult.TRUSTED_KEY: { + title = "Assinatura válida"; + content = `<p> +A assinatura digital é criptograficamente válida. <strong>O dono da chave pública é a mesma pessoa que assinou este blog post exatamente como ele está, sem alterações</strong>. +</p> +`; + break; + } + case VerificationResult.EXPIRATION_AFTER_SIGNATURE: { + break; + } + case VerificationResult.EXPIRATION_BEFORE_SIGNATURE: { + break; + } + case VerificationResult.REVOCATION_AFTER_SIGNATURE: { + break; + } + case VerificationResult.REVOCATION_BEFORE_SIGNATURE: { + break; + } + case VerificationResult.KEY_DOES_NOT_SIGN: { + break; + } + default: { + throw new Error("Unreachable"); + } + } +} +--- + +{ + lvl && + ( + <details + class:list={{ + ok: lvl[0] === Level.OK, + warn: lvl[0] === Level.WARN, + error: lvl[0] === Level.ERROR, + super: lvl[1], + }} + > + <summary>{label?.toUpperCase()}: {title.toUpperCase()}</summary> + <Fragment set:html={content} /> + {error && <pre><samp>{error}</samp></pre>} + </details> + ) +} + +<style> + pre { + overflow-x: auto; + } + details { + &.error { + --bg: #fff; + --fg: var(--color-active); + + &.super { + --bg: var(--color-active); + --fg: #fff; + } + } + + &.warn { + --bg: #fff; + --fg: #f46d43; + + &.super { + --bg: #f46d43; + --fg: #fff; + } + } + + &.ok { + --bg: #fff; + --fg: var(--color-visited); + + &.super { + --bg: var(--color-visited); + --fg: #fff; + } + } + + padding-inline: 0.5em; + padding-block: 0.5em; + + & > summary { + background-color: var(--bg); + padding-inline: 0.5em; + padding-block: calc(1em / 3); + color: var(--fg); + border-color: var(--fg); + border-width: 1px; + border-style: solid; + border-radius: calc(1em / 3); + font-weight: bolder; + + &:focus { + outline-color: var(--fg); + } + + &::marker { + color: var(--fg); + } + } + + & > :not(summary) { + padding-inline: 1em; + /* font-size: smaller; */ + } + + & > summary + * { + margin-block: 0.5em; + padding-block-start: 1em; + border-block-start: 1px solid var(--fg); + } + } + + @media (prefers-color-scheme: dark) { + details { + &.error { + --bg: #000; + + &.super { + --fg: #000; + } + } + + &.warn { + --bg: #000; + --fg: #f46d43; + + &.super { + --bg: #f46d43; + --fg: #000; + } + } + + &.ok { + --bg: #000; + + &.super { + --fg: #000; + } + } + } + } +</style> diff --git a/src/consts.ts b/src/consts.ts new file mode 100644 index 0000000..ee6c580 --- /dev/null +++ b/src/consts.ts @@ -0,0 +1,29 @@ +import { z } from "astro/zod"; + +export const SITE_TITLE = "Cravo de Abril"; +export const SITE_DESCRIPTION = "Um domínio da liberdade!"; +export const SITE_AUTHOR = "João Augusto Costa Branco Marado Torres"; + +export const KEYWORDS = ["Portugal", "democracy"] as const; +export const KeywordsEnum = z.enum(KEYWORDS); + +export const ENTITY_TYPES = ["author", "co-author", "translator"] as const; +export const EntityTypesEnum = z.enum(ENTITY_TYPES); + +export const CREATIVE_COMMONS_LICENSES = [ + "CC0", + "CC-BY", + "CC-BY-SA", + "CC-BY-ND", + "CC-BY-NC", + "CC-BY-NC-SA", + "CC-BY-NC-ND", +] as const; +export const LICENSES = [ + ...CREATIVE_COMMONS_LICENSES, + "WTFPL", + "public domain", +] as const; +export const LicensesEnum = z.enum(LICENSES); + +export const TRUSTED_KEYS_DIR = new URL(`file://${Deno.cwd()}/public/keys/`); diff --git a/src/content.config.ts b/src/content.config.ts new file mode 100644 index 0000000..f652cc3 --- /dev/null +++ b/src/content.config.ts @@ -0,0 +1,116 @@ +import { file, glob } from "astro/loaders"; +import { defineCollection, reference, z } from "astro:content"; +//import { parse } from "@std/toml"; +import { parse } from "toml"; +import { EntityTypesEnum, KeywordsEnum, LicensesEnum } from "./consts.ts"; +import { get, instanciate } from "./utils/anonymous.ts"; +import { isValidLocale } from "./utils/lang.ts"; + +const Blog = z.object({ + title: z.string().trim(), + subtitle: z.string().trim().optional(), + description: z.string().trim().optional(), + keywords: z.array(KeywordsEnum).optional().refine( + (keywords) => new Set(keywords).size === (keywords?.length ?? 0), + { + message: "Keywords must be unique", + }, + ).transform((keywords) => + keywords !== undefined ? new Set(keywords).values().toArray() : undefined + ), + dateCreated: z.coerce.date(), + dateUpdated: z.coerce.date().optional(), + locationCreated: z.string().trim().optional(), + relatedPosts: z.array(reference("blog")).default([]).refine( + (posts) => new Set(posts).size === (posts?.length ?? 0), + { + message: "Related posts referenced multiple times", + }, + ).transform((x) => new Set(x)).transform((set) => set.values().toArray()), + lang: z.string().trim().refine(isValidLocale), + translationOf: reference("blog").optional(), + signers: z.array( + z.object({ entity: reference("entity"), role: EntityTypesEnum }), + ).optional().refine( + (signers) => { + if (signers === undefined) return true; + return signers.filter((s) => s.role === "author").length <= 1; + }, + { + message: "There can only be one author", + }, + ).refine( + (signers) => { + const ids = signers?.map(get("entity")) ?? []; + return new Set(ids).size === ids.length; + }, + { + message: "Reusing signers", + }, + //).transform((signers) => + // Object.fromEntries(new Map(signers?.map(({ entity, ...rest }) => [entity, rest]) ?? [])) + ), + license: LicensesEnum, +}).refine( + ({ dateCreated, dateUpdated }) => + dateUpdated === undefined || dateCreated.getTime() <= dateUpdated.getTime(), + { message: "Update before creation" }, +).refine( + ({ translationOf, keywords }) => + translationOf !== undefined || (keywords?.length ?? 0) > 0, + { + message: "Originals must include at least one keyword", + path: ["keywords"], + }, +).refine( + ({ translationOf, keywords }) => + (translationOf === undefined) !== ((keywords?.length ?? 0) <= 0), + { + message: "we will use this information from the original, " + + "so no need to specify it for translations", + path: ["keywords"], + }, +).refine( + ({ translationOf, relatedPosts }) => + (translationOf === undefined) || (relatedPosts.length <= 0), + { + message: "we will use this information from the original, " + + "so no need to specify it for translations", + path: ["relatedPosts"], + }, +).refine( + ({ translationOf, signers = [] }) => + (translationOf === undefined) || + (signers.values().every(({ role }) => role !== "translator")), + { + message: "There can't be translator signers on non translated work", + path: ["signers"], + }, +); + +const blog = defineCollection({ + loader: glob({ base: "./public/blog", pattern: "+([0-9a-z-]).md" }), + schema: Blog, +}); + +export type Blog = z.infer<typeof Blog>; + +const Entity = z.object({ + websites: z.array(z.string().url().trim()).default([]).transform((websites) => + websites.map(instanciate(URL)) + ), + publickey: z.object({ + armor: z.string().trim(), + }), +}); + +type Entity = z.infer<typeof Entity>; + +const entity = defineCollection({ + loader: file("./src/content/entities.toml", { + parser: (text) => parse(text).entities as Entity[], + }), + schema: Entity, +}); + +export const collections = { blog, entity }; diff --git a/src/content/entities.toml b/src/content/entities.toml new file mode 100644 index 0000000..a05749a --- /dev/null +++ b/src/content/entities.toml @@ -0,0 +1,85 @@ +# [[entities]] +# id = '' +# website = [''] +# [entities.publickey] +# armor = ''' +# -----BEGIN PGP PUBLIC KEY BLOCK----- +# ... +# -----END PGP PUBLIC KEY BLOCK----- +# ''' + +[[entities]] +id = 'cravodeabril' +website = ['https://cravodeabril.pt'] +[entities.publickey] +armor = ''' +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGL8N9ABEAC7M2bDtOMYFzzj6CvxsD94aBilharzXYvGTw9GbZqfEl8G3xit +ShGES3LLhOe2lKSSDGCXbYoQ9eVm+gt3riTayFmGsDWaIEmCRobtUZ5AVjl95ds+ +NMOI5AG6fx5DcG1Kg5x5ULHBdD4OFUtG1uM2WsviGVVroZl9PJLXW1jEiTbBGKjM +KHyhydjYJ5ZEjRNWhKlxplO83P6CwREQwCX7yliSqpiGIHH1h8Lf+2pmv1AzWelL +2RXyX7qekcaTpihpGgA5luUCCKk2C8mV9QMnRSbKFKT/r3FWmHX8X8TYnRUYaoYL +M/FkWeUCJoYQEjQMFKgWTBnZiW29FtwYX3streO7/+abWUzsAWG0euDeSuNm1VCR +jafMegr+ihpWqB9YL6aAYcmO7vDQ1sqMKALjt2bHrtJsGjM/qzSFH1IK38koQ9IR +8Or6/sPStS9t5ug4968NPAC17j+I7nUqE6AAdkP8T9FTn/m6mdZOKxcMPTgrPM6a +s3LTRKwueMZ4SA+Gs7ZfFGYsl9uG5g5v85N+/abdC3oNkS4ikVSTp0M18w5Ywgdy +JXsnAPM1mz/XTCNu6vYT8JpRw5xsfNgc8cL/h6vY317DScABIlZ5uLTXMaqlEiZb +L3QyPnNKyLM+D/2Vh0j7nNzzydREpaCLSPFzfi9T6bLHCFzPOMGtSThCvQARAQAB +tFtKb8OjbyBBdWd1c3RvIENvc3RhIEJyYW5jbyBNYXJhZG8gVG9ycmVzIChodHRw +czovL2NyYXZvZGVhYnJpbC5wdCkgPHRvcnJlcy5kZXZAZGlzcm9vdC5vcmc+iQJa +BBMBCABEAhsDBQkGMQ/6BQsJCAcCAiICBhUKCQgLAgQWAgMBAh4HAheAFiEE0vD/ +OlpfIlcPSxI48oBRM0M48CEFAmgtFK8CGQEACgkQ8oBRM0M48CHvdRAAjPnDFuXY +oGiDHEjHaLemBwL6WC6ct7e/20V70uzmyRspl24boJS8e+1YpLi7yjKiGidq3VqB +YhZkR/8mPJ+2/zY6+4Qt1Nj9rJd4+GiWW3jd2sXlUwh7OEHZY4yS4bcrGSXnhLS2 +NHz+bQ1ICMzYbhNZTFTIaO4hEB/9IANejSCJI+OKjW8v0Ae+W47kqt4ZT/g31YiI +0HNGnlQB6ZRV89fcHXpTR1E6Yr6XT+EBr22ziaAZlhBPdYaiP7WZUTub6fE4bkkW +etuGCQxqLWtZ7BxLgurtbSU9z0O5HEYwp7wBNdPALV+XsOxIuL96jum0+ZsSSTQT +Rf6ks8g/sDiMKaWlIWNqsiKjpPoL76YDq8M0yka6Hvb0Wc1ebjQigpkWP6H3eC/v +Gq3lERuJ1bo6oyG8EkHBv3No9IF994NIf4Dq8X9jQ+aHFKfgHR8nUmZPH44GMVk0 +KsLgs0syUch3ArassRXOZlHSnNLuhy9aymP1o1pMlqCeS9K4+r/9vIbQhzTqMKOh +UcLKj+YGaTKnZ4Iu8W4d3JiY17GentV8h+btZIPHXHxsVMhrbdr+jU/PIJeOksNz +nQvua9lvhUueILU8Dx7Ql/lQXbnGT1Zz/jw6dKFooif9PIfRgZ+wJZx8X4/RbU/g +JqYQSWouGyz7U/1GGgkb/1o7+um99O74a3i5Ag0EYvw30AEQAMUJ/2T2T9c0OAET +PktIqUQn2xtfrhqvWpzSJdwkruya2zKRKHmQYLfFOZ2rUCTdQauWicmcnWb48cT4 +sWMrQFAVenZe6Ml7jB8q0BVA/i1lvraK7xLhjGTPhun1mmVys43xSwqzv+aqTUyI +8/SerRYfF3rCClYDJHn/F0FfutbqJYDw7NHRSEp2s3ly1wGQVlKl9ZMO4KTIO6QT +vXo0/0WzvDn5kqBPijpgujDW/zPA+rNtmD7wDaUw1AEK9fE9K/pIGlaWLNm6LbpY +gXHCujuikTXG2oUdlwgy/4PqiA79o2D21GfVY6YWChco6CZd2cUjwYQjJUnYLLDw +sB/N4GzWa35hURyYZH3jITfa3U1nDn9TTOJL8x6aAsKQhJ31Pxhbtnd4KeC6KhXK +0GqnH6aMXfLZEZLy/QSH/QedqhuuwlU+aQ3DUqZj7VBFGBFrUnDt0K5UfCIv1TCf +vTOufJgKe6G9wh2RHs2nD/sqYTdb/zQwHCnnzEEFFer5PXq32PZZlXjrB1AfXNIL +YbASSXNybaQHBifwPIXy0HvExmcUZCSLNQ3kuJuwg2RzjUjXfurY6YDiwd0IZTLn +gCD0DqmNgDB/sEuZYu+JuzuSX/NRYJNNXpIl8aqZ7VTLDgKesyWcrhMlg5s1fbm0 +cHiV7Lhjmof0DJr5QG11OgYoyJSTABEBAAGJAjwEGAEIACYCGwwWIQTS8P86Wl8i +Vw9LEjjygFEzQzjwIQUCZWp9RAUJBE949AAKCRDygFEzQzjwIeu4D/95P+zuO9tC +v109Fx9EXiFDOwsjUlMsYWRrFU8V4gL4J47gi6UrqNAxge9XCzLkF+YGxr9oc1+r +f3yJol/unO/41BP9BAztC5Oxo58XPgOvnDmt+LDGSTr2HxTPDOkNuI3uWPjkgFVB +6+cUqu06bZb2zcgnh7gjIJeXhtSiuxRXw0hxHY8WS9KcMx/9HqtmCFQAt2twwEFy +Wud2XspQOdZKw3E9Yp3TxyZAJ03t3cIHVrmXQpJAEb15z8uIXHCei0R6rsVfmKFW +6CJjZFDDGCEqRUbLvf03AjkC4CnWQcB6ItkdGSvoq8WOzglVD0MxgygNz8nlmS94 +mMvMm+62aUeM9vaBJwM2Mm9qNwNAKXMYJFX0DdJZuZwVTVXHYoTOZldLrKFPn263 +I/wwG+9iZ/3NcDoY6AY15EjMx9NK7MHLrBLcAybVK5aOZCmVlxVNP/9fS+5tKdbq +Wfm0YkefGv87spBJUGWcqd7x4cyhYeDwipvesK5cQIJ9JYG3pWBhrJDSTTdQldED +djUxlAshVLNF0wu5rrEblBZMMwjG3qwwtSINHu2t5DX5jlulHOkCe1ulYo0OXOf4 +Tiiw/edcZfu4SSzZwu3rI3IpzOdqk78KzWWvsCvY+z4h7Zd71wdzw4ppPnHaCzeO +R5WLoVaRvrrEV9/bd01FW52j5kYCL75B07gzBGgtFeMWCSsGAQQB2kcPAQEHQPGB +GyQNil22vLUIfTCJdRCvWKTYsWg7REVnZfr5aAjbiQKzBBgBCAAmFiEE0vD/Olpf +IlcPSxI48oBRM0M48CEFAmgtFeMCGwIFCQHhM4AAgQkQ8oBRM0M48CF2IAQZFgoA +HRYhBGi/gzHbs32zOXAWxt8BM0HJQlLHBQJoLRXjAAoJEN8BM0HJQlLHJeQBALbS +9yGXeHMJLGC/dFU6khu8jfh58a6jxvL2jHXo3CLmAQDurWnP1I6HNNJUnFQnv1mX +5Q+uQtxtSQfxXWS/2otmBcTTD/9EUdeyntQZG5X6cBJTmYSqgd56SmakH2AjyD57 +hPwxMOUfbalHKCv5qH6g/2YXyDoNjbwPegIVY5tpFYxyPQcHUfFONzEeFjf1/TjW +nP2JeU5vu+inH2rrE4KvMXNlhyzzce8MpYIetbWXPslMGaFTBctP/FjkA62I0NBs +cf/2g75yuvu8MoSbFRJK70oTQdIroVSvb+AfbQgRrthsslYLj7iBsIOuqQpeZY85 +riVBgY9pwqiZ9cytH817nkEhlHH75eXjJK9Ay4jwxh7li6zhPEGLfSFGMnHe3Z8Z +jHcUEk5vTabu+ZBDJ6h+8BcQTufNOwcQjhjTkFYpW6B839B1bUeFFBkgrw/bnRso +00LYbaqMCVaPlCHHFh6L6/A093jZibLTO/13wUSmoSyyQuYwBvpnZlx85s8IHh1s +4YTnL1uZRhtFEuNWfmTFR3bONAgPX16SGzUqx9XT5XZcFcycFmnghIU5poGuJb0Z +dM0FspCBGOupfUVT6tQ2RC9zCV898hTCgHDwuBVnK7LLl+t8Y85PNh3COh7ovyU4 +fkfaNmWmhY41xBoTexcSAtU3q8j/6TklstRRX4F0gY0KbEoVwX8wt1OKA/v79U4T +ifGqldD4RjPQDIetyHzU7MmjxY6pmvJJSrNY6RynBRy8GR5k901TBGjTFk5dXvvF +zUSvXg== +=6+uu +-----END PGP PUBLIC KEY BLOCK----- +''' diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro new file mode 100644 index 0000000..60a9d4f --- /dev/null +++ b/src/layouts/Base.astro @@ -0,0 +1,35 @@ +--- +import BaseHead, { type Props } from "@components/BaseHead.astro"; +import Footer from "@components/Footer.astro"; +import Header from "@components/Header.astro"; +--- + +<!DOCTYPE html> +<!-- + <cravodeabril.pt> - Personal website + Copyright (C) 2024 João Augusto Costa Branco Marado Torres + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. +--> +<html dir="ltr" lang="pt-PT"> + <head> + <BaseHead {...Astro.props} /> + </head> + <body> + <Header /> + <slot /> + <Footer /> + <noscript>I see, a man of culture :)</noscript> + </body> +</html> diff --git a/src/lib/git/index.test.ts b/src/lib/git/index.test.ts new file mode 100644 index 0000000..4eedaca --- /dev/null +++ b/src/lib/git/index.test.ts @@ -0,0 +1,40 @@ +import { describe, it } from "@std/testing/bdd"; +import { assertEquals } from "@std/assert"; +import { + assertSpyCall, + assertSpyCalls, + returnsNext, + stub, +} from "@std/testing/mock"; + +// IMPORTANT: Delay the import of `gitDir` to after the stub +let gitDir: typeof import("./index.ts").gitDir; + +describe("gitDir", () => { + it("resolves with trimmed decoded stdout", async () => { + const encoded = new TextEncoder().encode( + " /home/user/project \n", + ) as Uint8Array<ArrayBuffer>; + const fakeOutput = Promise.resolve({ + success: true, + code: 0, + stdout: encoded, + stderr: new Uint8Array(), + signal: null, + }); + + using outputStub = stub( + Deno.Command.prototype, + "output", + returnsNext([fakeOutput]), + ); + + // Now import gitDir AFTER stubbing + ({ gitDir } = await import("./index.ts")); + + const result = await gitDir(); + assertEquals(result.pathname, "/home/user/project"); + assertSpyCall(outputStub, 0, { args: [], returned: fakeOutput }); + assertSpyCalls(outputStub, 1); + }); +}); diff --git a/src/lib/git/index.ts b/src/lib/git/index.ts new file mode 100644 index 0000000..23a13eb --- /dev/null +++ b/src/lib/git/index.ts @@ -0,0 +1,16 @@ +import { get, instanciate } from "../../utils/anonymous.ts"; + +let cachedGitDir: Promise<URL> | undefined; + +export function gitDir(): Promise<URL> { + if (!cachedGitDir) { + cachedGitDir = new Deno.Command("git", { + args: ["rev-parse", "--show-toplevel"], + }).output() + .then(get("stdout")) + .then((x) => `file://${new TextDecoder().decode(x).trim()}/`) + .then(instanciate(URL)); + } + + return cachedGitDir; +} diff --git a/src/lib/git/log.test.ts b/src/lib/git/log.test.ts new file mode 100644 index 0000000..09acb1c --- /dev/null +++ b/src/lib/git/log.test.ts @@ -0,0 +1,71 @@ +import { describe, it } from "@std/testing/bdd"; +import { assertEquals, assertExists } from "@std/assert"; +import { + assertSpyCall, + assertSpyCalls, + returnsNext, + stub, +} from "@std/testing/mock"; +import { getLastCommitForOneOfFiles } from "./log.ts"; +import { + emptyCommandOutput, + gitDiffTreeCommandOutput, + gitDir, + gitLogPrettyCommandOutput, + gitRevParseCommandOutput, +} from "../../../tests/fixtures/test_data.ts"; + +describe("getLastCommitForOneOfFiles", () => { + it("returns parsed commit with signature and file info", async () => { + const outputs = [ + gitLogPrettyCommandOutput, + gitDiffTreeCommandOutput, + gitRevParseCommandOutput, + ]; + using logStub = stub( + Deno.Command.prototype, + "output", + returnsNext(outputs), + ); + + const file = new URL("file.ts", gitDir); + const result = await getLastCommitForOneOfFiles(file); + + assertExists(result); + assertEquals(result.hash.short, "abcdef1"); + assertEquals(result.hash.long, "abcdef1234567890abcdef1234567890abcdef12"); + + assertEquals(result.author.name, "Alice"); + assertEquals(result.committer.email, "bob@example.com"); + + assertEquals(result.files.length, 1); + assertEquals(result.files[0], { + path: file, + status: "modified", + }); + + assertEquals(result.signature?.type, "gpg"); + assertEquals(result.signature?.signer, "bob@example.com"); + + for (let i = 0; i < outputs.length; i++) { + assertSpyCall(logStub, i, { args: [], returned: outputs[i] }); + } + assertSpyCalls(logStub, outputs.length); + }); + + it("returns undefined for empty commit output", async () => { + using logStub = stub( + Deno.Command.prototype, + "output", + returnsNext([emptyCommandOutput]), + ); + + const result = await getLastCommitForOneOfFiles( + [new URL("nonexistent.ts", gitDir)], + ); + + assertEquals(result, undefined); + assertSpyCall(logStub, 0, { args: [], returned: emptyCommandOutput }); + assertSpyCalls(logStub, 1); + }); +}); diff --git a/src/lib/git/log.ts b/src/lib/git/log.ts new file mode 100644 index 0000000..86bbe7b --- /dev/null +++ b/src/lib/git/log.ts @@ -0,0 +1,131 @@ +import { defined } from "../../utils/anonymous.ts"; +import { type MaybeIterable, surelyIterable } from "../../utils/iterator.ts"; +import { gitDir } from "./index.ts"; +import type { Commit, CommitFile } from "./types.ts"; + +const format = [ + "H", + "h", + "aI", + "aN", + "aE", + "cI", + "cN", + "cE", + // "G?", + "GS", + "GK", + "GF", + "GG", +]; + +export async function getLastCommitForOneOfFiles( + sources: MaybeIterable<URL>, +): Promise<Commit | undefined> { + const files = surelyIterable(sources); + const gitLog = new Deno.Command("git", { + args: [ + "log", + "-1", + `--pretty=format:${format.map((x) => `%${x}`).join("%n")}`, + "--", + ...Iterator.from(files).map((x) => x.pathname), + ], + }); + + const { stdout } = await gitLog.output(); + const result = new TextDecoder().decode(stdout).trim(); + + if (result.length <= 0) { + return undefined; + } + + const [ + hash, + abbrHash, + authorDate, + authorName, + authorEmail, + committerDate, + committerName, + committerEmail, + // signatureValidation, + signer, + key, + keyFingerPrint, + ...rawLines + ] = result.split("\n"); + + const raw = rawLines.join("\n").trim(); + + const commit: Commit = { + files: await fileStatusFromCommit(hash, Iterator.from(files)), + hash: { long: hash, short: abbrHash }, + author: { + date: new Date(authorDate), + name: authorName, + email: authorEmail, + }, + committer: { + date: new Date(committerDate), + name: committerName, + email: committerEmail, + }, + }; + + if (raw.length > 0) { + commit.signature = { + type: raw.startsWith("gpgsm:") + ? "x509" + : raw.startsWith("gpg:") + ? "gpg" + : "ssh", + signer, + key: { long: keyFingerPrint, short: key }, + rawMessage: raw, + }; + } + + return commit; +} + +async function fileStatusFromCommit( + hash: string, + files: Iterable<URL>, +): Promise<CommitFile[]> { + const gitDiffTree = new Deno.Command("git", { + args: [ + "diff-tree", + "--no-commit-id", + "--name-status", + "-r", + hash, + ], + }); + + const { stdout } = await gitDiffTree.output(); + const result = new TextDecoder().decode(stdout).trim().split("\n").filter( + defined, + ); + + const dir = await gitDir(); + return result.map((line) => { + const [status, path] = line.split("\t"); + if ( + Iterator.from(files).some((file) => + file.pathname.replace(dir.pathname, "").includes(path) + ) + ) { + return { + path: new URL(path, dir), + status: status === "A" + ? "added" + : status === "D" + ? "deleted" + : "modified", + } as const; + } + + return undefined; + }).filter(defined); +} diff --git a/src/lib/git/types.ts b/src/lib/git/types.ts new file mode 100644 index 0000000..672d242 --- /dev/null +++ b/src/lib/git/types.ts @@ -0,0 +1,27 @@ +export type CommitFile = { + path: URL; + status: "added" | "modified" | "deleted"; +}; + +export type Hash = { long: string; short: string }; + +export type Contributor = { + name: string; + email: string; + date: Date; +}; + +export type SignatureType = "ssh" | "gpg" | "x509"; + +export type Commit = { + files: CommitFile[]; + hash: Hash; + author: Contributor; + committer: Contributor; + signature?: { + type: SignatureType; + signer: string; + key: Hash; + rawMessage: string; + }; +}; diff --git a/src/lib/pgp/create.test.ts b/src/lib/pgp/create.test.ts new file mode 100644 index 0000000..e9e9f41 --- /dev/null +++ b/src/lib/pgp/create.test.ts @@ -0,0 +1,130 @@ +import { beforeEach, describe, it } from "@std/testing/bdd"; +import { + createInMemoryFile, + generateKeyPair, + startMockFs, +} from "../../../tests/fixtures/setup.ts"; +import { + armored, + binary, + createKeysFromFs, + DEFAULT_KEY_DISCOVERY_RULES, +} from "./create.ts"; +import { assertEquals, assertRejects } from "@std/assert"; +import { stub } from "@std/testing/mock"; + +startMockFs(); + +describe("createKeysFromFs", () => { + let keyPair: Awaited<ReturnType<typeof generateKeyPair>>; + + beforeEach(async () => { + keyPair = await generateKeyPair("Alice"); + }); + + it("loads a single armored key file", async () => { + const url = createInMemoryFile( + new URL("file:///mock/alice.asc"), + keyPair.privateKey.armor(), + ); + + const keys = []; + for await (const key of createKeysFromFs(url)) { + keys.push(key); + } + + assertEquals(keys.length, 1); + }); + + it("loads a single binary key file", async () => { + const binaryData = keyPair.privateKey.write(); + const url = createInMemoryFile( + new URL("file:///mock/alice.gpg"), + binaryData as Uint8Array<ArrayBuffer>, + ); + + const keys = []; + for await (const key of createKeysFromFs(url)) { + keys.push(key); + } + + assertEquals(keys.length, 1); + }); + + it("ignores unsupported file extensions", async () => { + const url = createInMemoryFile( + new URL("file:///mock/ignored.txt"), + "This is not a key", + ); + + const keys = []; + for await (const key of createKeysFromFs(url)) { + keys.push(key); + } + + assertEquals(keys.length, 0); + }); + + it("throws on overlapping discovery formats", async () => { + const rules = { + formats: { + [armored]: new Set(["asc", "gpg"]), + [binary]: new Set(["gpg"]), + }, + }; + + const url = new URL("file:///mock/bogus.gpg"); + + await assertRejects(() => createKeysFromFs(url, rules).next()); + }); + + it("handles recursive directory traversal", async () => { + const aliceURL = new URL("file:///mock/keys/alice.asc"); + const bobURL = new URL("file:///mock/keys/sub/bob.asc"); + + createInMemoryFile(aliceURL, keyPair.privateKey.armor()); + createInMemoryFile(bobURL, keyPair.privateKey.armor()); + + const mockedDirTree = { + "file:///mock/keys/": [ + { name: "alice.asc", isFile: true, isDirectory: false }, + { name: "sub", isFile: false, isDirectory: true }, + ], + "file:///mock/keys/sub/": [ + { name: "bob.asc", isFile: true, isDirectory: false }, + ], + }; + + stub(Deno, "stat", (url: URL | string) => { + const href = new URL(url).href; + return Promise.resolve({ + isDirectory: href.endsWith("/") || href.includes("/sub"), + isFile: href.endsWith(".asc"), + isSymlink: false, + } as Deno.FileInfo); + }); + + stub(Deno, "readDir", async function* (url: URL | string) { + const href = new URL(url).href; + for ( + const entry of mockedDirTree[href as keyof typeof mockedDirTree] ?? [] + ) { + yield entry as Deno.DirEntry; + } + }); + + const root = new URL("file:///mock/keys/"); + const keys = []; + + for await ( + const key of createKeysFromFs( + root, + { ...DEFAULT_KEY_DISCOVERY_RULES, recursive: true }, + ) + ) { + keys.push(key); + } + + assertEquals(keys.length, 2); + }); +}); diff --git a/src/lib/pgp/create.ts b/src/lib/pgp/create.ts new file mode 100644 index 0000000..fb45954 --- /dev/null +++ b/src/lib/pgp/create.ts @@ -0,0 +1,183 @@ +import { readKey } from "openpgp"; + +export const armored: unique symbol = Symbol(); +export const binary: unique symbol = Symbol(); +export type KeyFileFormat = typeof armored | typeof binary; + +export interface KeyDiscoveryRules { + formats?: Partial<Record<KeyFileFormat, Set<string> | undefined>>; + recursive?: boolean | number; +} +export const DEFAULT_KEY_DISCOVERY_RULES = { + formats: { + [armored]: new Set(["asc"]), + [binary]: new Set(["gpg"]), + }, +} satisfies KeyDiscoveryRules; + +export async function* createKeysFromFs( + key: string | URL, + rules: KeyDiscoveryRules = DEFAULT_KEY_DISCOVERY_RULES, + coders: { decoder?: TextDecoder; encoder?: TextEncoder } = {}, +): AsyncGenerator<Awaited<ReturnType<typeof readKey>>, void, void> { + key = new URL(key); + + validateKeyDiscoveryRules(rules); + + const stat = await Deno.stat(key); + + if (stat.isDirectory) { + const generator = createKeysFromDir(key, rules, coders); + yield* generator; + } else if (stat.isFile) { + const period = key.pathname.lastIndexOf("."); + const ext = period === -1 ? "" : key.pathname.slice(period + 1); + if ( + rules.formats?.[armored] !== undefined && rules.formats[armored].has(ext) + ) { + yield createKeyFromFile( + key, + armored, + coders?.decoder, + ); + } else if ( + rules.formats?.[binary] !== undefined && rules.formats[binary].has(ext) + ) { + yield createKeyFromFile( + key, + binary, + coders?.encoder, + ); + } + } +} + +export async function* createKeysFromDir( + key: string | URL, + rules: KeyDiscoveryRules = DEFAULT_KEY_DISCOVERY_RULES, + coders: { decoder?: TextDecoder; encoder?: TextEncoder } = {}, +): AsyncGenerator<Awaited<ReturnType<typeof readKey>>, void, void> { + key = new URL(key); + + validateKeyDiscoveryRules(rules); + + for await (const dirEntry of Deno.readDir(key)) { + const filePath = new URL(dirEntry.name, key); + if (dirEntry.isFile) { + const period = filePath.pathname.lastIndexOf("."); + const ext = period === -1 ? "" : filePath.pathname.slice(period + 1); + if ( + rules.formats?.[armored] !== undefined && + rules.formats[armored].has(ext) + ) { + yield createKeyFromFile( + filePath, + armored, + coders?.decoder, + ); + } else if ( + rules.formats?.[binary] !== undefined && rules.formats[binary].has(ext) + ) { + yield createKeyFromFile( + filePath, + binary, + coders?.encoder, + ); + } + } else if (dirEntry.isDirectory) { + const depth = typeof rules.recursive === "number" + ? rules.recursive + : rules.recursive + ? Infinity + : 0; + if (depth > 0) { + yield* createKeysFromDir(filePath, { + ...rules, + recursive: depth - 1, + }, coders); + } + } + } +} + +export async function createKeyFromFile( + key: string | URL, + type: typeof armored, + coder?: TextDecoder, +): ReturnType<typeof readKey>; +export async function createKeyFromFile( + key: string | URL, + type: typeof binary, + coder?: TextEncoder, +): ReturnType<typeof readKey>; +export async function createKeyFromFile( + key: string | URL, + type: typeof armored | typeof binary, + coder?: TextDecoder | TextEncoder, +): ReturnType<typeof readKey> { + switch (type) { + case armored: + return await Deno.readTextFile(key).then((key) => + createKeyFromArmor(key, coder as TextDecoder) + ); + case binary: + return await Deno.readFile(key).then((key) => + createKeyFromBinary(key, coder as TextEncoder) + ); + } +} + +export function createKeyFromArmor( + key: string | Uint8Array, + decoder?: TextDecoder, +): ReturnType<typeof readKey> { + return readKey({ + armoredKey: typeof key === "string" + ? key + : (decoder ?? new TextDecoder()).decode(key), + }); +} +export function createKeyFromBinary( + key: string | Uint8Array, + encoder?: TextEncoder, +): ReturnType<typeof readKey> { + return readKey({ + binaryKey: typeof key === "string" + ? (encoder ?? new TextEncoder()).encode(key) + : key, + }); +} + +function validateKeyDiscoveryRules(rules: KeyDiscoveryRules) { + let disjoint = true; + let union: Set<string> | undefined = undefined; + const keys = rules.formats !== undefined + ? Object.getOwnPropertySymbols(rules.formats) as KeyFileFormat[] + : []; + + for (const i of keys) { + const set = rules.formats?.[i]; + + if (union === undefined) { + union = set; + continue; + } + + if (set === undefined) { + continue; + } + + disjoint &&= union.isDisjointFrom(set); + union = union.union(set); + + if (!disjoint) { + break; + } + } + + if (!disjoint) { + throw new Error( + `\`Set\`s from \`rules.formats\` aren't disjoint`, + ); + } +} diff --git a/src/lib/pgp/index.ts b/src/lib/pgp/index.ts new file mode 100644 index 0000000..8142732 --- /dev/null +++ b/src/lib/pgp/index.ts @@ -0,0 +1,63 @@ +import { enums, PublicKey, type Subkey } from "openpgp"; + +export async function isKeyExpired( + key: PublicKey | Subkey, +): Promise<Date | null> { + const keyExpiration = await key.getExpirationTime(); + + return typeof keyExpiration === "number" + ? new Date(keyExpiration) + : keyExpiration; +} + +export type RevocationReason = { flag?: string; msg?: string }; +export type Revocation = { date: Date; reason: RevocationReason }; +export function isKeyRevoked( + key: PublicKey | Subkey, +): Revocation | undefined { + const revokes = key.revocationSignatures.map(( + { created, reasonForRevocationFlag, reasonForRevocationString }, + ) => ({ created, reasonForRevocationFlag, reasonForRevocationString })); + let keyRevocation: Revocation | undefined = undefined; + for (const i of revokes) { + const unix = i.created?.getTime(); + if (unix === undefined) { + continue; + } + const date = new Date(unix); + if (keyRevocation === undefined || unix < keyRevocation.date.getTime()) { + let flag = undefined; + switch (i.reasonForRevocationFlag) { + case enums.reasonForRevocation.noReason: { + flag = "No reason specified (key revocations or cert revocations)"; + break; + } + case enums.reasonForRevocation.keySuperseded: { + flag = "Key is superseded (key revocations)"; + break; + } + case enums.reasonForRevocation.keyCompromised: { + flag = "Key material has been compromised (key revocations)"; + break; + } + case enums.reasonForRevocation.keyRetired: { + flag = "Key is retired and no longer used (key revocations)"; + break; + } + case enums.reasonForRevocation.userIDInvalid: { + flag = "User ID information is no longer valid (cert revocations)"; + break; + } + } + keyRevocation = { + date, + reason: { msg: i.reasonForRevocationString ?? undefined, flag }, + }; + } + } + + return keyRevocation; +} + +export const toPK = (key: PublicKey | Subkey): PublicKey => + key instanceof PublicKey ? key : key.mainKey; diff --git a/src/lib/pgp/sign.test.ts b/src/lib/pgp/sign.test.ts new file mode 100644 index 0000000..1f9c4db --- /dev/null +++ b/src/lib/pgp/sign.test.ts @@ -0,0 +1,121 @@ +import { + assert, + assertAlmostEquals, + assertArrayIncludes, + assertEquals, + assertExists, +} from "@std/assert"; +import { describe, it } from "@std/testing/bdd"; +import { createMessage, enums, readSignature, sign } from "openpgp"; +import { Signature } from "./sign.ts"; +import { get, instanciate } from "../../utils/anonymous.ts"; +import { bufferToBase } from "../../utils/bases.ts"; +import { generateKeyPair } from "../../../tests/fixtures/setup.ts"; + +describe("Signature wrapper", () => { + const now = new Date(); + const aliceKeyPair = generateKeyPair("Alice"); + const signature = Promise.all([ + aliceKeyPair.then(get("privateKey")), + createMessage({ text: "Hello world" }), + ]).then(([privateKey, message]) => + sign({ + message, + signingKeys: privateKey, + detached: true, + format: "object", + }) + ).then((x) => readSignature({ armoredSignature: x.armor() })).then( + instanciate(Signature), + ); + + describe("Single signer", () => { + it("signingKeyIDs", async () => { + const { publicKey } = await aliceKeyPair; + const sig = await signature; + + assertEquals(sig.signingKeyIDs.length, 1); + assert(sig.signingKeyIDs[0].equals(publicKey.getKeyID())); + }); + + it("getPackets", async () => { + const sig = await signature; + + assertEquals(sig.getPackets().length, 1); + assertEquals(sig.getPackets(sig.signingKeyIDs[0]).length, 1); + }); + + describe("Packet wrapper", () => { + const packet = signature.then((x) => x.getPackets()[0]); + + it("created", async () => { + const p = await packet; + + assertExists(p.created); + assertAlmostEquals(p.created.getTime(), now.getTime()); + }); + + it("issuerKeyID and issuerFingerprint", async () => { + const { privateKey } = await aliceKeyPair; + const p = await packet; + + assertEquals(p.issuerKeyID, privateKey.getKeyID()); + assertExists(p.issuerFingerprint); + assertEquals( + bufferToBase(p.issuerFingerprint, 16), + privateKey.getFingerprint(), + ); + }); + + it("signatureType", async () => { + const p = await packet; + + assertEquals(p.signatureType, enums.signature.text); + }); + }); + }); + + const bobKeyPair = generateKeyPair("Bob"); + const multiSignature = Promise.all([ + Promise.all([ + aliceKeyPair.then(get("privateKey")), + bobKeyPair.then(get("privateKey")), + ]), + createMessage({ text: "Hello world" }), + ]).then(([signingKeys, message]) => + sign({ + message, + signingKeys, + detached: true, + format: "object", + }) + ).then((x) => readSignature({ armoredSignature: x.armor() })).then( + instanciate(Signature), + ); + + describe("with multiple signers", () => { + it("signingKeyIDs", async () => { + const { publicKey: alice } = await aliceKeyPair; + const { publicKey: bob } = await bobKeyPair; + const sig = await multiSignature; + + assertEquals(sig.signingKeyIDs.length, 2); + assertArrayIncludes(sig.signingKeyIDs, [ + alice.getKeyID(), + bob.getKeyID(), + ]); + }); + + it("getPackets", async () => { + const sig = await multiSignature; + const { publicKey: alice } = await aliceKeyPair; + const { publicKey: bob } = await bobKeyPair; + + assertEquals(sig.getPackets().length, 2); + + assertEquals(sig.getPackets(alice.getKeyID()).length, 1); + + assertEquals(sig.getPackets(bob.getKeyID()).length, 1); + }); + }); +}); diff --git a/src/lib/pgp/sign.ts b/src/lib/pgp/sign.ts new file mode 100644 index 0000000..5f7f5a8 --- /dev/null +++ b/src/lib/pgp/sign.ts @@ -0,0 +1,82 @@ +import type { + KeyID, + Signature as InnerSignature, + SignaturePacket, +} from "openpgp"; +import { defined, identity } from "../../utils/anonymous.ts"; +import { type MaybeIterable, surelyIterable } from "../../utils/iterator.ts"; + +export class Signature { + private signature!: InnerSignature; + #packets!: Map<string, Packet[]>; + + constructor(signature: InnerSignature) { + this.signature = signature; + this.#packets = new Map(); + for (const packet of this.signature.packets) { + const key = packet.issuerKeyID.bytes; + const keyPackets = this.#packets.get(key); + if (keyPackets !== undefined) { + keyPackets.push(new Packet(packet)); + } else { + this.#packets.set(key, [new Packet(packet)]); + } + } + } + + getPackets(key?: MaybeIterable<KeyID>): Packet[] { + key ??= this.signingKeyIDs; + const iterator = Iterator.from(surelyIterable(key)); + return iterator.map((key) => this.#packets.get(key.bytes)).filter(defined) + .flatMap(identity).toArray(); + } + + get signingKeyIDs(): ReturnType< + InstanceType<typeof InnerSignature>["getSigningKeyIDs"] + > { + return this.signature.getSigningKeyIDs(); + } + + get inner(): InnerSignature { + return this.signature; + } +} + +export class Packet { + private packet!: SignaturePacket; + + constructor(packet: SignaturePacket) { + this.packet = packet; + } + + get signersUserID(): SignaturePacket["signersUserID"] { + return this.packet.signersUserID; + } + + get issuerKeyID(): SignaturePacket["issuerKeyID"] { + return this.packet.issuerKeyID; + } + + get issuerFingerprint(): SignaturePacket["issuerFingerprint"] { + return this.packet.issuerFingerprint; + } + + get created(): SignaturePacket["created"] { + return this.packet.created; + } + + get signatureType(): SignaturePacket["signatureType"] { + return this.packet.signatureType; + } + + get trustLevel(): SignaturePacket["trustLevel"] { + return this.packet.trustLevel; + } + get trustAmount(): SignaturePacket["trustAmount"] { + return this.packet.trustAmount; + } + + get inner(): SignaturePacket { + return this.packet; + } +} diff --git a/src/lib/pgp/summary.ts b/src/lib/pgp/summary.ts new file mode 100644 index 0000000..5c8a81c --- /dev/null +++ b/src/lib/pgp/summary.ts @@ -0,0 +1,232 @@ +import type { Key, PublicKey, Subkey } from "openpgp"; +import type { Verification } from "./verify.ts"; +import { Level } from "../../utils/index.ts"; +import type { NonEmptyArray } from "../../utils/iterator.ts"; +import { keyTrust } from "./trust.ts"; +import { isKeyExpired, isKeyRevoked, type RevocationReason } from "./index.ts"; + +export const enum VerificationResult { + NO_SIGNATURE, + MISSING_KEY, + SIGNATURE_CORRUPTED, + SIGNATURE_COULD_NOT_BE_CHECKED, + BAD_SIGNATURE, + UNTRUSTED_KEY, + TRUSTED_KEY, + EXPIRATION_AFTER_SIGNATURE, + EXPIRATION_BEFORE_SIGNATURE, + REVOCATION_AFTER_SIGNATURE, + REVOCATION_BEFORE_SIGNATURE, + KEY_DOES_NOT_SIGN, +} + +export function logLevel(result: VerificationResult): [Level, boolean] { + switch (result) { + case VerificationResult.NO_SIGNATURE: + return [Level.ERROR, true] as const; + case VerificationResult.MISSING_KEY: + return [Level.ERROR, false] as const; + case VerificationResult.SIGNATURE_CORRUPTED: + return [Level.ERROR, true] as const; + case VerificationResult.SIGNATURE_COULD_NOT_BE_CHECKED: + return [Level.ERROR, false] as const; + case VerificationResult.BAD_SIGNATURE: + return [Level.ERROR, false] as const; + case VerificationResult.UNTRUSTED_KEY: + return [Level.OK, false] as const; + case VerificationResult.TRUSTED_KEY: + return [Level.OK, true] as const; + case VerificationResult.EXPIRATION_AFTER_SIGNATURE: + return [Level.WARN, false] as const; + case VerificationResult.EXPIRATION_BEFORE_SIGNATURE: + return [Level.ERROR, true] as const; + case VerificationResult.REVOCATION_AFTER_SIGNATURE: + return [Level.WARN, true] as const; + case VerificationResult.REVOCATION_BEFORE_SIGNATURE: + return [Level.ERROR, true] as const; + case VerificationResult.KEY_DOES_NOT_SIGN: + return [Level.ERROR, true] as const; + } + + throw new Error("unreachable"); +} + +export type Summary = { + result: VerificationResult.NO_SIGNATURE; +} | { + result: VerificationResult.MISSING_KEY; + reason: Error; + keyID: string; + created: Date; +} | { + result: + | VerificationResult.SIGNATURE_CORRUPTED + | VerificationResult.SIGNATURE_COULD_NOT_BE_CHECKED + | VerificationResult.BAD_SIGNATURE; + reason: Error; +} | { + result: VerificationResult.TRUSTED_KEY; + key: PublicKey | Subkey; + created: Date; +} | { + result: VerificationResult.UNTRUSTED_KEY; + key: PublicKey | Subkey; + created: Date; +} | { + result: VerificationResult.EXPIRATION_AFTER_SIGNATURE; + key: PublicKey | Subkey; + expired: Date; + created: Date; +} | { + result: VerificationResult.REVOCATION_AFTER_SIGNATURE; + key: PublicKey | Subkey; + revoked: Date; + revocationReason: RevocationReason; + created: Date; +} | { + result: VerificationResult.EXPIRATION_BEFORE_SIGNATURE; + key: PublicKey | Subkey; + expired: Date; + created: Date; +} | { + result: VerificationResult.REVOCATION_BEFORE_SIGNATURE; + key: PublicKey | Subkey; + revoked: Date; + revocationReason: RevocationReason; + created: Date; +} | { + result: VerificationResult.KEY_DOES_NOT_SIGN; + key: PublicKey | Subkey; +}; + +export async function createVerificationSummary( + { dataCorrupted, verifications, signature }: Verification, +): Promise<[NonEmptyArray<Summary>, Map<string, NonEmptyArray<Summary>>]> { + if (signature === undefined) { + return [[{ result: VerificationResult.NO_SIGNATURE }], new Map()]; + } + + const corrupted = await dataCorrupted; + if (corrupted?.[0]) { + return [[{ + result: VerificationResult.BAD_SIGNATURE, + reason: corrupted[1], + }], new Map()]; + } + + const summaries = await Promise.all< + Promise<[Summary[], Map<string, Summary[]>]>[] + >( + (verifications ?? []).map( + async ({ signatureCorrupted, verified, packet, key }) => { + const errors: Summary[] = []; + const keys: Map<string, Summary[]> = new Map(); + + try { + await verified; + } catch (e) { + if (e instanceof Error) { + if ( + e.message.startsWith("Could not find signing key with key ID") + ) { + const keyID = e.message.slice(e.message.lastIndexOf(" ")); + const key = keys.get(keyID) ?? []; + key.push({ + result: VerificationResult.MISSING_KEY, + keyID, + reason: e, + }); + keys.set(keyID, key); + } else { + errors.push({ + result: VerificationResult.SIGNATURE_COULD_NOT_BE_CHECKED, + reason: e, + }); + } + } else { + throw e; + } + } + + const corrupted = await signatureCorrupted; + if (corrupted[0]) { + errors.push({ + result: VerificationResult.SIGNATURE_CORRUPTED, + reason: corrupted[1], + }); + } + + const sig = await packet; + const keyID = sig.issuerKeyID; + + sig.created; + + const keyAwaited = await key; + + if (keyAwaited === undefined) { + const key = keys.get(keyID.toHex()) ?? []; + key.push({ + result: VerificationResult.MISSING_KEY, + keyID: keyID.toHex(), + reason: new Error( + `Could not find signing key with key ID ${keyID.toHex()}`, + ), + }); + keys.set(keyID.toHex(), key); + + return [errors, keys] as [Summary[], Map<string, Summary[]>]; + } + + const keySummaries = keys.get(keyAwaited.getKeyID().toHex()) ?? []; + const expired = await isKeyExpired(keyAwaited); + + if (expired !== null && sig.created !== null) { + keySummaries.push({ + result: expired <= sig.created + ? VerificationResult.EXPIRATION_BEFORE_SIGNATURE + : VerificationResult.EXPIRATION_AFTER_SIGNATURE, + key: keyAwaited, + date: expired, + }); + } + + const revoked = isKeyRevoked(keyAwaited); + if (revoked?.date !== undefined && sig.created !== null) { + keySummaries.push({ + result: revoked?.date <= sig.created + ? VerificationResult.REVOCATION_BEFORE_SIGNATURE + : VerificationResult.REVOCATION_AFTER_SIGNATURE, + key: keyAwaited, + date: revoked.date, + revocationReason: revoked.reason, + }); + } + + const trust = sig.trustAmount ?? await keyTrust(keyAwaited as Key); + + keySummaries.push({ + result: trust > 0 + ? VerificationResult.TRUSTED_KEY + : VerificationResult.UNTRUSTED_KEY, + key: keyAwaited, + }); + + keys.set(keyAwaited.getKeyID().toHex(), keySummaries); + + return [errors, keys] as [Summary[], Map<string, Summary[]>]; + }, + ), + ); + + const errors = summaries.flatMap(([x]) => x); + const keys = new Map(summaries.flatMap(([, x]) => x.entries().toArray())); + + if (errors.length > 0 || keys.size > 0) { + return [errors, keys] as [ + NonEmptyArray<Summary>, + Map<string, NonEmptyArray<Summary>>, + ]; + } + + throw new Error("unreachable"); +} diff --git a/src/lib/pgp/trust.ts b/src/lib/pgp/trust.ts new file mode 100644 index 0000000..cf022b4 --- /dev/null +++ b/src/lib/pgp/trust.ts @@ -0,0 +1,19 @@ +import type { Key } from "npm:openpgp@^6.1.1"; +import { TRUSTED_KEYS_DIR } from "../../consts.ts"; +import { createKeysFromDir } from "./create.ts"; +import type { AsyncYieldType } from "../../utils/iterator.ts"; +import { equal, getCall } from "../../utils/anonymous.ts"; + +let trusted: + | Iterable<AsyncYieldType<ReturnType<typeof createKeysFromDir>>> + | undefined = undefined; + +const fingerprints = () => + Iterator.from(trusted ?? []).map(getCall("getFingerprint")); + +export async function keyTrust(key: Key): Promise<number> { + if (trusted === undefined) { + trusted = await Array.fromAsync(createKeysFromDir(TRUSTED_KEYS_DIR)); + } + return fingerprints().some(equal(key.getFingerprint())) ? 255 : 0; +} diff --git a/src/lib/pgp/verify.test.ts b/src/lib/pgp/verify.test.ts new file mode 100644 index 0000000..9c8ae9c --- /dev/null +++ b/src/lib/pgp/verify.test.ts @@ -0,0 +1,619 @@ +/* +import { + afterEach, + beforeAll, + beforeEach, + describe, + it, +} from "@std/testing/bdd"; +import { type Stub, stub } from "@std/testing/mock"; +import { FakeTime } from "@std/testing/time"; +import { get } from "../../utils/anonymous.ts"; +import { SignatureVerifier } from "./verify.ts"; +import { assertEquals } from "@std/assert/equals"; +import { assert, assertExists, assertFalse, assertRejects } from "@std/assert"; +import { + corruptData, + corruptSignatureFormat, + createDetachedSignature, + createInMemoryFile, + generateKeyPair, + generateKeyPairWithSubkey, + startMockFs, +} from "../../../tests/fixtures/setup.ts"; +import { emptyCommandOutput } from "../../../tests/fixtures/test_data.ts"; + +startMockFs(); + +describe("SignatureVerifier", () => { + let verifier: SignatureVerifier; + let aliceKeyPair: Awaited<ReturnType<typeof generateKeyPair>>; + let bobKeyPair: Awaited<ReturnType<typeof generateKeyPair>>; + let aliceWithSubkeyKeyPair: Awaited<ReturnType<typeof generateKeyPair>>; + + beforeAll(async () => { + aliceKeyPair = await generateKeyPair("Alice"); + bobKeyPair = await generateKeyPair("Bob"); + aliceWithSubkeyKeyPair = await generateKeyPairWithSubkey("AliceWithSubkey"); + }); + + beforeEach(() => { + verifier = new SignatureVerifier(); + Deno.Command.prototype.output = stub( + Deno.Command.prototype, + "output", + () => emptyCommandOutput, + ); + }); + + afterEach(() => { + (Deno.Command.prototype.output as Stub).restore(); + }); + + describe("when verifying a file with a single signature", () => { + const originalData = new TextEncoder().encode( + "This is the original file content for single signature tests.", + ) as Uint8Array<ArrayBuffer>; + let originalDataUrl: URL; + + beforeEach(() => { + // Create the data file in memory for each single signature test + originalDataUrl = createInMemoryFile( + new URL("file:///test/single_sig_data.txt"), + originalData, + ); + }); + + it("Scenario: No signature found", async () => { + const verification = await verifier.verify([originalDataUrl]); + + assertEquals(new Uint8Array(verification.data), originalData); + assertFalse( + await verification.dataCorrupted, + "Data is not corrupted in the absence of a signature to check against", + ); + assertEquals( + verification.verifications, + undefined, + "Should not find any signatures to verify", + ); + // commit is stubbed, so it will be undefined + }); + + it("Scenario: Signature cannot be checked (missing key - 'E')", async () => { + // Create a valid signature, but don't add the signing key to the verifier + const signature = await createDetachedSignature( + originalData, + aliceKeyPair.privateKey, + ); + const signatureUrl = createInMemoryFile( + new URL("file:///test/single_sig_data.txt.sig"), + signature, + ); + + const verification = await verifier.verify([ + originalDataUrl, + signatureUrl, + ]); + + assertEquals(new Uint8Array(verification.data), originalData); + assertEquals(await verification.dataCorrupted, [false]); + + assertEquals(verification.signatureCorrupted, [false]); + + assertExists(verification.verifications, "Should find the signature"); + assertEquals(verification.verifications.length, 1); // One signature found + + const sigVerification = verification.verifications[0]; + assertExists(sigVerification.packet); + assertFalse(await sigVerification.signatureCorrupted.then(get(0))); + + assertRejects( + () => sigVerification.verified, + "Verification should fail due to missing key", + ); + // assertEquals(await sigVerification.status, "E", "Status should be 'E'"); + + // The keys promise might resolve with an empty array or throw depending on implementation + // assert(?) sigVerification.keys resolves as expected + }); + + it("Scenario: Signature cannot be checked (Signature corrupted/malformed - 'E')", async () => { + const signature = await createDetachedSignature( + originalData, + aliceKeyPair.privateKey, + ); + const corruptedSignature = corruptSignatureFormat(signature); + const corruptedSignatureUrl = createInMemoryFile( + new URL("file:///test/single_sig_data.txt.sig"), + corruptedSignature, + ); + + verifier.addKey(aliceKeyPair.publicKey); + + const verification = await verifier.verify([ + originalDataUrl, + corruptedSignatureUrl, + ]); + + assertEquals(new Uint8Array(verification.data), originalData); + assertEquals(await verification.dataCorrupted, undefined); + + assertEquals(verification.verifications, undefined); + // assertEquals(await sigVerification.status, "E", "Status should be 'E'"); + }); + + it("Scenario: Bad signature ('B')", async () => { + // Create a valid signature for the original data + const signature = await createDetachedSignature( + originalData, + aliceKeyPair.privateKey, + ); + const signatureUrl = createInMemoryFile( + new URL("file:///test/single_sig_data.txt.sig"), + signature, + ); + // Create corrupted data + const corruptedData = corruptData(originalData); + const corruptedDataUrl = createInMemoryFile( + new URL("file:///test/corrupted_single_sig_data.txt"), + corruptedData, + ); + + verifier.addKey(aliceKeyPair.publicKey); // Key is available + + // Verify the signature (of original data) against the corrupted data + const verification = await verifier.verify([ + corruptedDataUrl, + signatureUrl, + ]); + + assertEquals(new Uint8Array(verification.data), corruptedData); // The verifier processed the corrupted data + assert( + await verification.dataCorrupted, + "Data should be marked as corrupted because signature does not match", + ); // Assuming implementation detects this + + assertFalse(verification.signatureCorrupted?.[0]); + + assertExists(verification.verifications, "Should find the signature"); + assertEquals(verification.verifications.length, 1); // One signature found + + const sigVerification = verification.verifications[0]; + assertExists(sigVerification.key); // Key should be found + + assertExists(sigVerification.packet); + assertFalse(await sigVerification.signatureCorrupted.then(get(0))); // Signature data itself is not corrupted + + // Expect verification to fail and report 'B' + assertRejects( + () => sigVerification.verified, + "Verification should fail due to data mismatch", + ); + // assertEquals(await sigVerification.status, "B", "Status should be 'B'"); + }); + + it("Scenario: Good signature ('G')", async () => { + const signature = await createDetachedSignature( + originalData, + aliceKeyPair.privateKey, + ); + const signatureUrl = createInMemoryFile( + new URL("file:///test/single_sig_data.txt.sig"), + signature, + ); + + // Add the key and assume it's ultimately trusted for this scenario + // In a real test, you might explicitly set trust levels if openpgp.js supports it easily + verifier.addKey(aliceKeyPair.publicKey); + + const verification = await verifier.verify([ + originalDataUrl, + signatureUrl, + ]); + + assertEquals(new Uint8Array(verification.data), originalData); + assertFalse( + await verification.dataCorrupted?.then((x) => x[0]), + "Data should not be marked corrupted for a good signature", + ); + + assertFalse(verification.signatureCorrupted?.[0]); + + assertExists(verification.verifications, "Should find the signature"); + assertEquals(verification.verifications.length, 1); + + const sigVerification = verification.verifications[0]; + assertExists(sigVerification.key, "Should find the signing key"); + const signingKey = await sigVerification.key; // Assuming one key found + assertExists(signingKey, "Should find the signing key"); + assertEquals(signingKey.getKeyID(), aliceKeyPair.publicKey.getKeyID()); + + assertExists(sigVerification.packet); + assertFalse(await sigVerification.signatureCorrupted.then((x) => x[0])); + + // Expect verification to succeed and report 'G' + assert( + await sigVerification.verified, + "Verification should succeed for a good signature", + ); + // assertEquals(await sigVerification.status, "G", "Status should be 'G'"); + }); + + it("Scenario: Good signature, unknown validity ('U')", async () => { + const signature = await createDetachedSignature( + originalData, + aliceKeyPair.privateKey, + ); + const signatureUrl = createInMemoryFile( + new URL("file:///test/single_sig_data.txt.sig"), + signature, + ); + + // Add the key but do *not* establish ultimate trust for this key in the verifier's context + // This scenario relies on your verifier or OpenPGP.js handling the 'unknown trust' case. + verifier.addKey(aliceKeyPair.publicKey); // Key is available, but trust level is not set + + const verification = await verifier.verify([ + originalDataUrl, + signatureUrl, + ]); + + assertEquals(new Uint8Array(verification.data), originalData); + assertFalse(await verification.dataCorrupted?.then((x) => x[0])); + + assertFalse(verification.signatureCorrupted?.[0]); + + assertExists(verification.verifications, "Should find the signature"); + assertEquals(verification.verifications.length, 1); + + const sigVerification = verification.verifications[0]; + assertExists(sigVerification.key); + + assertExists(sigVerification.packet); + assertFalse(await sigVerification.signatureCorrupted.then((x) => x[0])); + + // Expect cryptographic verification to succeed, but status to be 'U' + assert( + await sigVerification.verified, + "Cryptographic verification should succeed", + ); + // assertEquals( + // await sigVerification.status, + // "U", + // "Status should be 'U' due to unknown validity", + // ); + }); + + // TODO(#): Add tests for Scenarios involving Key Expiration ('X', 'Y') + // This requires creating keys with specific expiration dates and mocking the system clock + it("Scenario: Good signature, key expired *after* signature time ('X')", async () => { + // Use fake time to control the 'now' + const time = new FakeTime(); + + const keyExpirationTime = time.now + 30 * 1000; + const keyPairWithExpiry = await generateKeyPair("AliceWithExpiry", { + keyExpirationTime, + }); + + const signature = await createDetachedSignature( + originalData, + keyPairWithExpiry.privateKey, + ); + const signatureUrl = createInMemoryFile( + new URL("file:///test/sig_expired_after.sig"), + signature, + ); + + time.tick(60 * 1000); + + verifier.addKey(keyPairWithExpiry.publicKey); + + const verification = await verifier.verify([ + originalDataUrl, + signatureUrl, + ]); + + time.restore(); + + assertFalse(await verification.dataCorrupted?.then((x) => x[0])); + + assertFalse(verification.signatureCorrupted?.[0]); + + assertExists(verification.verifications); + // const expirationDate = await verification.verifications[0].keys[0].then(( + // x, + // ) => x.getExpirationTime()); + // assertEquals( + // expirationDate?.valueOf(), + // new Date(keyExpirationTime).valueOf(), + // ); + assertExists(await verification.verifications[0].packet); + assertFalse( + await verification.verifications[0].signatureCorrupted.then((x) => + x[0] + ), + ); + assert(await verification.verifications[0].verified); + + // assertEquals( + // await verification.verifications![0].status, + // "X", + // "Status should be 'X' due to key expired after signature", + // ); + }); + + it("Scenario: Good signature, key expired *before* signature time ('Y')", async () => { + // Use fake time to control the 'now' when creating the key (for expiration) + const time = new FakeTime(); + + const keyExpirationTime = time.now + 30 * 1000; + const keyPairExpiredBefore = await generateKeyPair("AliceExpiredBefore", { + keyExpirationTime, + }); + + time.tick(60 * 1000); + + const signature = await createDetachedSignature( + originalData, + keyPairExpiredBefore.privateKey, + ); + const signatureUrl = createInMemoryFile( + new URL("file:///test/sig_expired_before.sig"), + signature, + ); + + verifier.addKey(keyPairExpiredBefore.publicKey); + + time.tick(60 * 1000); + + const verification = await verifier.verify([ + originalDataUrl, + signatureUrl, + ]); + + time.restore(); + + assertFalse(await verification.dataCorrupted?.then((x) => x[0])); + + assertFalse(verification.signatureCorrupted?.[0]); + + assertExists(verification.verifications); + // const expirationDate = await verification.verifications[0].keys[0].then(( + // x, + // ) => x.getExpirationTime()); + // assertEquals( + // expirationDate?.valueOf(), + // new Date(keyExpirationTime).valueOf(), + // ); + assertExists(await verification.verifications[0].packet); + assertFalse( + await verification.verifications[0].signatureCorrupted.then((x) => + x[0] + ), + ); + assert(await verification.verifications[0].verified); + + //assertEquals( + // await verification.verifications![0].status, + // "Y", + // "Status should be 'Y' due to key expired before signature", + //); + }); + + // // TODO: Add tests for Scenarios involving Key Revocation ('R', 'Y') + // // This requires creating and distributing key revocation certificates. Simulating this is complex and might need mocking OpenPGP.js internal behavior or relying on its revocation handling. + + // it("Scenario: Good signature, key revoked *after* signature time ('R')", async () => { + // // This requires creating a revocation certificate for the key *after* signing. + // assert( + // false, + // "Test not implemented: Simulating key revocation requires revocation certs.", + // ); + // }); + + // it("Scenario: Good signature, key revoked *before* signature time ('Y')", async () => { + // // This requires creating a revocation certificate for the key *before* signing. + // assert( + // false, + // "Test not implemented: Simulating key revocation requires revocation certs.", + // ); + // }); + + // it("Scenario: Signature cannot be checked (Public key available but not signing)", async () => { + // // Generate a key with only encryption or certification usage flags + // const nonSigningKeyPair = await generateKeyPair("AliceNonSigning", { + // usage: ["encrypt"], + // }); // Or ["certify"] + + // const signature = await createDetachedSignature( + // originalData, + // aliceKeyPair.privateKey, + // ); // Signed with a signing key + // const signatureUrl = createInMemoryFile( + // new URL("file:///test/sig_non_signing_key.sig"), + // signature, + // ); + + // // Add the non-signing key to the verifier instead of the actual signing key + // await verifier.addKey(nonSigningKeyPair.publicKey); + + // const verification: Verification = await verifier.verify([ + // originalDataUrl, + // signatureUrl, + // ]); + + // assertExists(verification.verifications, "Should find the signature"); + // assertEquals(verification.verifications.length, 1); + + // const sigVerification = verification.verifications[0]; + // // Key is found, but it's the wrong type of key for verification + // assertExists(sigVerification.keys, "Should find a key"); + + // // Expect verification to fail and report 'E' or potentially 'B' depending on how openpgp.js handles this + // // OpenPGP.js often reports 'E' if the key's capabilities don't match the packet type. + // assertEquals( + // await sigVerification.verified, + // false, + // "Verification should fail with a non-signing key", + // ); + // // We expect 'E' as the most likely status + // assertEquals( + // await sigVerification.status, + // "E", + // "Status should be 'E' with a non-signing key", + // ); + // }); + + // TODO: Add scenarios involving signing subkeys if your verifier needs to distinguish them + // These would require more complex key generation and potentially inspecting the packet details. + }); + + // // --- Scenarios for multiple signatures --- + // describe("when verifying a file with multiple signatures", () => { + // const originalData = new TextEncoder().encode("This file has multiple signatures."); + // let originalDataUrl: URL; + // + // beforeEach(() => { + // originalDataUrl = createInMemoryFile(new URL("file:///test/multi_sig_data.txt"), originalData); + // }); + // + // + // it("Scenario: All signatures are Good ('G')", async () => { + // // Create signatures by Alice and Bob + // const aliceSignature = await createDetachedSignature(originalData, aliceKeyPair.privateKey); + // const bobSignature = await createDetachedSignature(originalData, bobKeyPair.privateKey); + // + // const aliceSignatureUrl = createInMemoryFile(new URL("file:///test/multi_sig_data.txt.alice.sig"), aliceSignature); + // const bobSignatureUrl = createInMemoryFile(new URL("file:///test/multi_sig_data.txt.bob.sig"), bobSignature); + // + // // Add both signing keys (assume trusted for this scenario) + // await verifier.addKey(aliceKeyPair.publicKey); + // await verifier.addKey(bobKeyPair.publicKey); + // + // // Verify with multiple signature files + // const verification: Verification = await verifier.verify([originalDataUrl, aliceSignatureUrl, bobSignatureUrl]); + // + // assertEquals(new Uint8Array(verification.data), originalData); + // assertEquals(verification.dataCorrupted, false); + // assertExists(verification.verifications); + // assertEquals(verification.verifications.length, 2); // Two signatures found + // + // // Check the status of each verification result + // const statuses = await Promise.all(verification.verifications.map(v => v.status)); + // assertArrayIncludes(statuses, ['G', 'G'], "Both signatures should have 'G' status"); + // + // // Check the key IDs found for each verification + // const keyIDs = await Promise.all(verification.verifications.map(async v => (await v.keys)[0]?.getKeyID())); + // assertArrayIncludes(keyIDs.filter(defined), [aliceKeyPair.publicKey.getKeyID(), bobKeyPair.publicKey.getKeyID()]); + // }); + // + // it("Scenario: Some signatures are Good ('G'), others are Bad ('B')", async () => { + // // Create a good signature by Alice + // const aliceSignature = await createDetachedSignature(originalData, aliceKeyPair.privateKey); + // const aliceSignatureUrl = createInMemoryFile(new URL("file:///test/multi_sig_data.txt.alice.sig"), aliceSignature); + // + // // Create a bad signature by attempting to sign corrupted data with Bob's key + // const corruptedDataForBadSig = corruptData(originalData); + // const bobBadSignature = await createDetachedSignature(corruptedDataForBadSig, bobKeyPair.privateKey); + // const bobBadSignatureUrl = createInMemoryFile(new URL("file:///test/multi_sig_data.txt.bob.sig"), bobBadSignature); + // + // + // // Add both signing keys + // await verifier.addKey(aliceKeyPair.publicKey); + // await verifier.addKey(bobKeyPair.publicKey); + // + // // Verify against the original data, but provide one good and one bad signature file + // const verification: Verification = await verifier.verify([originalDataUrl, aliceSignatureUrl, bobBadSignatureUrl]); + // + // assertEquals(new Uint8Array(verification.data), originalData); // Verifier should use the original data if found and matching a good sig + // assertEquals(verification.dataCorrupted, false, "Data should not be marked corrupted if at least one good signature matches"); + // + // assertExists(verification.verifications); + // assertEquals(verification.verifications.length, 2); + // + // // Check the status of each verification result + // const statuses = await Promise.all(verification.verifications.map(v => v.status)); + // // Expect one 'G' and one 'B' status + // assertEquals(statuses.filter(s => s === 'G').length, 1); + // assertEquals(statuses.filter(s => s === 'B').length, 1); + // + // // You would also need to check which key corresponded to the 'G' and 'B' status + // // This requires correlating the verification result with the key ID/fingerprint. + // const verifications = await Promise.all(verification.verifications.map(async v => ({ status: await v.status, keyID: (await Promise.all(v.keys))[0]?.getKeyID() }))); + // + // assert(verifications.some(v => v.status === 'G' && v.keyID === aliceKeyPair.publicKey.getKeyID()), "Alice's signature should be Good"); + // assert(verifications.some(v => v.status === 'B' && v.keyID === bobKeyPair.publicKey.getKeyID()), "Bob's signature should be Bad"); + // }); + // + // it("Scenario: Some signatures cannot be checked ('E'), others are Good ('G')", async () => { + // // Create a good signature by Alice + // const aliceSignature = await createDetachedSignature(originalData, aliceKeyPair.privateKey); + // const aliceSignatureUrl = createInMemoryFile(new URL("file:///test/multi_sig_data.txt.alice.sig"), aliceSignature); + // + // // Create a signature by Bob but don't add Bob's key to the verifier (will result in 'E') + // const bobSignature = await createDetachedSignature(originalData, bobKeyPair.privateKey); + // const bobSignatureUrl = createInMemoryFile(new URL("file:///test/multi_sig_data.txt.bob.sig"), bobSignature); + // + // + // // Add only Alice's key + // await verifier.addKey(aliceKeyPair.publicKey); + // + // const verification: Verification = await verifier.verify([originalDataUrl, aliceSignatureUrl, bobSignatureUrl]); + // + // assertEquals(new Uint8Array(verification.data), originalData); + // assertEquals(verification.dataCorrupted, false); + // assertExists(verification.verifications); + // assertEquals(verification.verifications.length, 2); + // + // const statuses = await Promise.all(verification.verifications.map(v => v.status)); + // + // assertEquals(statuses.filter(s => s === 'G').length, 1, "One signature should be Good (Alice)"); + // assertEquals(statuses.filter(s => s === 'E').length, 1, "One signature should be 'E' (Bob - missing key)"); + // + // const verifications = await Promise.all(verification.verifications.map(async v => ({ status: await v.status, keyID: (await Promise.all(v.keys))[0]?.getKeyID() }))); + // + // assert(verifications.some(v => v.status === 'G' && v.keyID === aliceKeyPair.publicKey.getKeyID()), "Alice's signature should be Good"); + // // For the 'E' status (missing key), the keyID might be undefined or the partial KeyID from the packet. + // // We'll just check that one status is 'E'. + // assert(verifications.some(v => v.status === 'E'), "One signature should be 'E'"); + // }); + // + // + // // TODO: Continue adding tests for all combinations from the multiple signatures table + // // This requires combining different key states (expired, revoked, untrusted) for different signers + // // within the same verification process. This is the most complex part. + // + // it("Scenario: All signatures Unknown Validity ('U')", async () => { + // // Requires generating signatures with keys that are valid but not ultimately trusted for all signers. + // // Then verifying without establishing a trust path for any key. + // assert(false, "Test not implemented: Simulating unknown trust for all signatures."); + // }); + // + // it("Scenario: At least one Good signature, with others having Key Status issues (e.g., 'X', 'Y', 'R')", async () => { + // // Requires creating signatures with a mix of good keys and expired/revoked keys for different signers. + // assert(false, "Test not implemented: Combining different key states for multiple signers."); + // }); + // + // it("Scenario: All signatures have Key Status issues ('X', 'Y', 'R')", async () => { + // // Requires creating signatures with only expired or revoked keys for all signers. + // assert(false, "Test not implemented: Simulating all signatures with key status issues."); + // }); + // + // it("Scenario: Combination of Bad, Unknown, and Key Status issues", async () => { + // // This is a very complex scenario combining multiple failure types across different signatures. + // assert(false, "Test not implemented: Simulating a complex mix of failure types."); + // }); + // + // it("Scenario: At least one signature is valid, but some Public Keys not available", async () => { + // // Requires providing multiple signature files, but only providing some of the signing keys to the verifier. + // assert(false, "Test not implemented: Simulating missing keys for some signatures in a multi-signature scenario."); + // }); + // + // it("Scenario: At least one signature is valid, but some Public Keys available but not Signing Keys", async () => { + // // Requires providing multiple signature files, and providing a key that is NOT a signing key for one of them. + // assert(false, "Test not implemented: Simulating non-signing keys for some signatures in a multi-signature scenario."); + // }); + // }); +}); +*/ diff --git a/src/lib/pgp/verify.ts b/src/lib/pgp/verify.ts new file mode 100644 index 0000000..da2de7f --- /dev/null +++ b/src/lib/pgp/verify.ts @@ -0,0 +1,349 @@ +import { + createMessage, + PublicKey, + readSignature, + type Subkey, + UserIDPacket, + verify, +} from "openpgp"; +import { + armored, + binary, + createKeyFromArmor, + createKeyFromBinary, + createKeyFromFile, + createKeysFromDir, + DEFAULT_KEY_DISCOVERY_RULES, + type KeyDiscoveryRules, + type KeyFileFormat, +} from "./create.ts"; +import { getLastCommitForOneOfFiles } from "../git/log.ts"; +import { defined, get, instanciate } from "../../utils/anonymous.ts"; +import { Packet, Signature } from "./sign.ts"; +import type { Commit } from "../git/types.ts"; +import { TRUSTED_KEYS_DIR } from "../../consts.ts"; +import { findMapAsync, type MaybeIterable } from "../../utils/iterator.ts"; + +type DataURL = [URL, URL?]; +type Corrupted = [false] | [true, Error]; + +export interface Verification { + data: Uint8Array<ArrayBufferLike>; + dataCorrupted?: Promise<Corrupted>; + signatureCorrupted?: Corrupted; + signature?: Signature; + verifications?: { + key: Promise<PublicKey | Subkey | undefined>; + keyID: Awaited<ReturnType<typeof verify>>["signatures"][number]["keyID"]; + userID: Promise<UserIDPacket[] | undefined>; + packet: Promise<Packet>; + signatureCorrupted: Promise<Corrupted>; + verified: Promise<boolean>; + }[]; + commit: Promise<Commit | undefined>; +} + +export class SignatureVerifier { + static #instance: SignatureVerifier; + private keys!: PublicKey[]; + #encoder!: TextEncoder; + #decoder!: TextDecoder; + + constructor() { + this.keys = []; + this.#encoder = new TextEncoder(); + this.#decoder = new TextDecoder(); + } + + /** + * Let's test all the possible outcome situations that can happened when + * verifying a signature of a file. A signature verification needs the message, + * the signature (detached) and the public keys. + * + * **Possible verification outcomes** + * + * Legend: + * + * - "X" → This condition is definitely true for the outcome. + * - "-" → This condition is not applicable or irrelevant. + * - "?" → This condition may or may not be true; the outcome doesn't guarantee it. + * + * | Outcome Description | Data Exists | Data Corrupted | Signature Exists | Signature Corrupted/Malformed | Public Key Available | Public Key is Signing Key | Public Key Expired Before Signature | Public Key Expired After Signature | Public Key Revoked Before Signature | Public Key Revoked After Signature | Public Key Ultimately Trusted | GPG/OpenPGP Status Output | Notes | + * | ------------------------------------------------------------------------------- | :---------: | :------------: | :--------------: | :---------------------------: | :------------------: | :-----------------------: | :---------------------------------: | :--------------------------------: | :---------------------------------: | :--------------------------------: | :---------------------------: | :------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | + * | **No signature found** | X | ? | | | | | - | - | - | - | | (No status) | No signature file provided or found. Data state is independent of this. | + * | **Signature cannot be checked (e.g., missing key, GPG error)** | X | ? | X | ? | | | - | - | - | - | ? | `E` | Verification failed before key or validity checks could be performed. Can be missing key, corrupted signature *format*, or GPG issue. | + * | **Bad signature** | X | X | X | | X | X | ? | ? | ? | ? | ? | `B` | The signature does not match the data, usually due to data corruption or a manipulated signature. Key status is irrelevant to the mismatch itself. | + * | **Good signature, unknown validity** | X | | X | | X | X | | | | | | `U` | Signature is cryptographically valid, key is available and is a signing key, but OpenPGP.js/GPG cannot determine the trust or validity of the key or signature attributes. | + * | **Good signature** | X | | X | | X | X | | | | | X | `G` | The signature is cryptographically valid, the key is available, is a signing key, and is ultimately trusted in the local keyring. | + * | **Good signature by an untrusted key** | X | | X | | X | X | | | | | | `G` (often with trust warning) | The signature is cryptographically valid, key is available and signing key, but not ultimately trusted. GPG might still report `G`. | + * | **Good signature, key expired *after* signature time** | X | | X | | X | X | | X | | | ? | `X` | The signature was valid at the time of signing, but the key's validity period has since passed. | + * | **Good signature, key expired *before* signature time** | X | | X | | X | X | X | | | | ? | `Y` | The signature was created *after* the key's validity period had passed. This signature is typically considered invalid. | + * | **Good signature, key revoked *after* signature time** | X | | X | | X | X | ? | ? | | X | ? | `R` | The signature was valid at the time of signing, but the key has since been revoked. | + * | **Good signature, key revoked *before* signature time** | X | | X | | X | X | ? | ? | X | | ? | `Y` (often, similar to expired before) | The signature was created *after* the key had been revoked. This signature is typically considered invalid. | + * | **Signature cannot be checked (Public key available but not signing)** | X | ? | X | | X | | ? | ? | ? | ? | ? | `E` (or possibly `B`) | The key required for verification is found, but it does not have the 'sign' usage flag, making verification impossible with this key. | + * | **Good signature, made by an expired signing subkey (primary key not expired)** | X | | X | | X | X | | X | | | ? | `X` | The signature was made by a subkey that expired *after* the signature time. The primary key might still be valid. | + * | **Good signature, made by a revoked signing subkey (primary key not revoked)** | X | | X | | X | X | ? | ? | | X | ? | `R` | The signature was made by a subkey that was revoked *after* the signature time. The primary key might still be valid. | + * | **Good signature, made by a signing subkey expired *before* signature** | X | | X | | X | X | X | | | | ? | `Y` | The signature was made by a subkey that was expired *before* the signature time. | + * | **Good signature, made by a signing subkey revoked *before* signature** | X | | X | | X | X | ? | ? | X | | ? | `Y` | The signature was made by a subkey that was revoked *before* the signature time. | + * + * | Outcome Description (Combined Statuses) | Data Exists | Data Corrupted | Signature(s) Exist | At least one Signature Corrupted/Malformed | At least one Public Key Available | At least one Public Key is Signing Key | All Keys Good/Trusted? | Notes | + * |---------------------------------------------------------------------------|-------------|----------------|--------------------|--------------------------------------------|-----------------------------------|----------------------------------------|------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------| + * | **No signature found** | X | ? | | | | | - | No signature file(s) provided or found. | + * | **At least one signature cannot be checked (`E`), others unknown/not checked** | X | ? | X | ? | ? | ? | ? | One or more signatures failed verification before a status could be determined (missing key, GPG issue, etc.). Other signatures' statuses might be pending or unknown. | + * | **All signatures are Bad (`B`)** | X | X | X | | X | X | ? | All provided signatures failed to match the data. Often due to data corruption or tampered signatures. | + * | **Some signatures are Good (`G`), others are Bad (`B`)** | X | ? | X | | X | X | ? | At least one valid signature found, but also invalid ones. Indicates the file was signed correctly by some, but perhaps tampered with later or signed incorrectly. | + * | **All signatures are Good (`G`)** | X | | X | | X | X | X | All provided signatures are cryptographically valid and from ultimately trusted keys. This is a strong indicator of data integrity and origin. | + * | **Some signatures are Good (`G`), others Unknown Validity (`U`)** | X | ? | X | | X | X | ? | Some valid signatures, others valid but trust/validity could not be fully determined. | + * | **All signatures Unknown Validity (`U`)** | X | ? | X | | X | X | | All provided signatures are cryptographically valid, but the validity or trust of the signing keys could not be determined for any of them. | + * | **At least one Good signature (`G`), with others having Key Status issues (`X`, `Y`, `R`)** | X | ? | X | | X | X | ? | At least one valid and potentially trusted signature exists, but others are from expired or revoked keys. Indicates multiple signers, some with key lifecycle issues. | + * | **All signatures have Key Status issues (`X`, `Y`, `R`)** | X | ? | X | | X | X | | All provided signatures are from keys that are expired or revoked. The data integrity might be verifiable for the time of signing, but the signers' keys are compromised or outdated. | + * | **Combination of Bad (`B`), Unknown (`U`), and Key Status issues (`X`, `Y`, `R`)** | X | ? | X | ? | X | X | ? | A complex mix of verification outcomes for multiple signatures. Requires examining each individual signature's status to understand the situation fully. | + * | **At least one signature is valid (`G`, `U`, `X`, `R`), but some Public Keys not available** | X | ? | X | | ? | ? | ? | Some signatures could be verified because their keys were available, but others could not be fully checked because their corresponding keys were missing. | + * | **At least one signature is valid (`G`, `U`, `X`, `R`), but some Public Keys available but not Signing Keys** | X | ? | X | | X | ? | ? | Keys were found for some signatures, allowing some level of verification, but for others, the found key did not have the signing capability. | + */ + async verify( + data: DataURL, + type: KeyFileFormat = binary, + ): Promise<Verification> { + // will throw if the file doesn't exist, not a file, ... + // we need data. + const dataBinary = await Deno.readFile(data[0], {}); + + const signatureURL = new URL( + data[1] ?? `${data[0].href}.${type === binary ? "sig" : "asc"}`, + ); + const signatureData = + await (type === binary + ? Deno.readFile(signatureURL) + : Deno.readTextFile(signatureURL)).catch(() => undefined); + + let signature: Signature | undefined; + let signatureCorrupted: Corrupted | undefined = undefined; + if (signatureData !== undefined) { + try { + signature = new Signature( + await (typeof signatureData === "string" + ? readSignature({ armoredSignature: signatureData }) + : readSignature({ binarySignature: signatureData })), + ); + signatureCorrupted = [false]; + } catch (e) { + if ( + !(e instanceof Error && + [ + "Error during parsing", + "Packet not allowed in this context", + "Unexpected end of packet", + ].some( + (x) => e.message.startsWith(x), + )) + ) { + throw e; + } + signatureCorrupted = [true, e]; + } + } + + const commit = signature !== undefined + ? getLastCommitForOneOfFiles([data[0], signatureURL]) + : Promise.resolve(undefined); + + const verification: Verification = { + data: dataBinary, + signature, + signatureCorrupted, + commit, + }; + + if (dataBinary === undefined || signature === undefined) { + return verification; + } + + const message = await createMessage({ binary: dataBinary }); + + const verificationResult = await verify({ + message, + signature: signature?.inner, + verificationKeys: this.keys, + format: "binary", + }); + + verification.verifications = verificationResult.signatures.map( + ({ verified, keyID, signature: sig }) => { + const key = findMapAsync(this.keys, (x) => x.getSigningKey(keyID)); + const packet = sig.then((x) => x.packets[0]).then(instanciate(Packet)); + const userID = key.then((key) => + key ? getUserIDsFromKey(signature, key) : undefined + ); + const signatureCorrupted = isSignatureCorrupted(verified); + return { key, keyID, userID, packet, signatureCorrupted, verified }; + }, + ); + + verification.dataCorrupted = isDataCorrupted(verification.verifications); + + return verification; + } + + async *verifyMultiple( + data: Iterable<DataURL>, + type: KeyFileFormat = binary, + ): AsyncGenerator<Verification, void, void> { + for (const i of data) { + yield this.verify(i, type); + } + } + + addKey(key: MaybeIterable<PublicKey>): void { + if (key instanceof PublicKey) { + this.keys.push(key); + } else { + this.keys.push(...key); + } + } + + async addKeysFromDir( + key: string | URL, + rules: KeyDiscoveryRules = DEFAULT_KEY_DISCOVERY_RULES, + ): Promise<void> { + for await ( + const i of createKeysFromDir(key, rules, { + encoder: this.#encoder, + decoder: this.#decoder, + }) + ) { + this.keys.push(i); + } + } + + async addKeyFromFile( + key: string | URL, + type: KeyFileFormat, + ): Promise<void> { + switch (type) { + case armored: { + this.keys.push(await createKeyFromFile(key, type, this.#decoder)); + break; + } + case binary: { + this.keys.push(await createKeyFromFile(key, type, this.#encoder)); + break; + } + } + } + + async addKeyFromArmor( + key: string | Uint8Array, + ): Promise<void> { + this.keys.push( + await createKeyFromArmor(key, this.#decoder).then((x) => x.toPublic()), + ); + } + + async addKeyFromBinary( + key: string | Uint8Array, + ): Promise<void> { + this.keys.push( + await createKeyFromBinary(key, this.#encoder).then((x) => x.toPublic()), + ); + } + + public static async instance(): Promise<SignatureVerifier> { + if (!SignatureVerifier.#instance) { + SignatureVerifier.#instance = new SignatureVerifier(); + await SignatureVerifier.#instance.addKeysFromDir(TRUSTED_KEYS_DIR); + } + + return SignatureVerifier.#instance; + } + + public clone(): this { + const clone = new SignatureVerifier(); + + clone.keys = Object.create(this.keys); + // clone.#decoder = Object.create(this.#decoder); + // clone.#encoder = Object.create(this.#encoder); + + return clone as this; + } +} + +export const verifier = SignatureVerifier.instance(); + +function getUserIDsFromKey( + signature: Signature, + key: PublicKey | Subkey, +): UserIDPacket[] { + const packet = signature.getPackets()[0]; + const userID = packet.signersUserID; + + if (userID) { + return [UserIDPacket.fromObject(parseUserID(userID))]; + } + + key = key instanceof PublicKey ? key : key.mainKey; + return key.users.map(get("userID")).filter(defined); +} + +function parseUserID(input: string) { + const regex = /^(.*?)\s*(?:\((.*?)\))?\s*(?:<(.+?)>)?$/; + const match = input.match(regex); + + if (!match) return {}; + + const [, name, comment, email] = match; + + return { + name: name?.trim() || undefined, + comment: comment?.trim() || undefined, + email: email?.trim() || undefined, + }; +} + +async function isSignatureCorrupted( + verified: Awaited< + ReturnType<typeof verify> + >["signatures"][number]["verified"], +): Promise<Corrupted> { + return await verified.then(() => [false] as Corrupted).catch( + (e) => { + if (e instanceof Error) { + if ( + [ + "Could not find signing key with key ID", + "Signed digest did not match", + ].some((x) => e.message.startsWith(x)) + ) { + return [false]; + } + + return [true, e]; + } + throw e; + }, + ); +} + +function isDataCorrupted( + verifications: Verification["verifications"], +): Promise<Corrupted> { + return new Promise<Corrupted>((resolve) => { + if (verifications === undefined) { + resolve([false]); + } else { + Promise.all(verifications.map(get("verified"))).then( + () => resolve([false]), + ).catch((e) => { + if (e instanceof Error) { + if ( + e.message.startsWith("Signed digest did not match") + ) { + resolve([true, e]); + } + } + + resolve([false]); + }); + } + }); +} diff --git a/src/pages/blog/[...year].astro b/src/pages/blog/[...year].astro new file mode 100644 index 0000000..f148a76 --- /dev/null +++ b/src/pages/blog/[...year].astro @@ -0,0 +1,165 @@ +--- +import { getCollection } from "astro:content"; +import type { CollectionEntry } from "astro:content"; +import Base from "@layouts/Base.astro"; +import DateSelector from "@components/DateSelector.astro"; +import BlogCard from "@components/BlogCard.astro"; + +type Props = { + posts: CollectionEntry<"blog">[]; + next: string; + previous: string; + years: number[]; + months: number[]; + days?: number[]; +}; + +export async function getStaticPaths() { + const posts = await getCollection("blog"); + + const archive = { + years: new Set<number>(), + monthsByYear: new Map<string, Set<number>>(), + daysByMonth: new Map<string, Set<number>>(), + postsByDate: new Map<string, typeof posts>(), + sortedDates: [] as string[], + }; + + const getYMD = (date: Date) => { + const y = date.getFullYear(); + const m = date.getMonth() + 1; + const d = date.getDate(); + return { y, m, d }; + }; + + for (const post of posts) { + const { y, m, d } = getYMD(post.data.dateCreated); + + archive.years.add(y); + + if (!archive.monthsByYear.has(y.toString())) { + archive.monthsByYear.set(y.toString(), new Set()); + } + archive.monthsByYear.get(y.toString())!.add(m); + + const ym = `${y}/${String(m).padStart(2, "0")}`; + if (!archive.daysByMonth.has(ym)) archive.daysByMonth.set(ym, new Set()); + archive.daysByMonth.get(ym)!.add(d); + + const ymd = `${ym}/${String(d).padStart(2, "0")}`; + if (!archive.postsByDate.has(ymd)) archive.postsByDate.set(ymd, []); + archive.postsByDate.get(ymd)!.push(post); + } + + archive.sortedDates = Array.from(archive.postsByDate.keys()).sort(); + + const paths = []; + + const sortedYears = Array.from(archive.years).sort(); + + const lastYear = Math.max(...sortedYears.map(Number)); + paths.push({ + params: { year: undefined }, + props: { + posts: posts.filter((p) => + p.data.dateCreated.getFullYear() === lastYear + ), + next: undefined, + previous: sortedYears?.[sortedYears.length - 2], + years: sortedYears, + months: Array.from(archive.monthsByYear.get(lastYear.toString()) ?? []), + }, + }); + + for (const y of sortedYears) { + const yearPosts = posts.filter((p) => + p.data.dateCreated.getFullYear() === Number(y) + ); + const idx = sortedYears.indexOf(y); + paths.push({ + params: { year: y }, + props: { + posts: yearPosts, + next: sortedYears?.[idx + 1], + previous: sortedYears?.[idx - 1], + years: sortedYears, + months: Array.from(archive.monthsByYear.get(y.toString()) ?? []), + }, + }); + } + + const allMonths = Array.from(archive.monthsByYear.entries()) + .flatMap(([year, mset]) => + Array.from(mset).map((m) => `${year}/${String(m).padStart(2, "0")}`) + ) + .sort(); + + for (const [y, months] of archive.monthsByYear) { + const sortedMonths = Array.from(months).sort(); + for (const m of sortedMonths) { + const monthPosts = posts.filter((p) => { + const d = p.data.dateCreated; + return ( + d.getFullYear() === Number(y) && + d.getMonth() + 1 === m + ); + }); + + const ym = `${y}/${String(m).padStart(2, "0")}`; + const idx = allMonths.indexOf(ym); + + paths.push({ + params: { year: ym }, + props: { + posts: monthPosts, + next: allMonths?.[idx + 1], + previous: allMonths?.[idx - 1], + years: sortedYears, + months: Array.from(months).sort(), + days: Array.from(archive.daysByMonth.get(ym) ?? []).sort(), + }, + }); + } + } + + for (let i = 0; i < archive.sortedDates.length; i++) { + const ymd = archive.sortedDates[i]; + const [y, m] = ymd.split("/"); + paths.push({ + params: { year: ymd }, + props: { + posts: archive.postsByDate.get(ymd), + next: archive.sortedDates?.[i + 1], + previous: archive.sortedDates?.[i - 1], + years: sortedYears, + months: Array.from(archive.monthsByYear.get(y) ?? []).sort(), + days: Array.from(archive.daysByMonth.get(`${y}/${m}`) ?? []).sort(), + }, + }); + } + + return paths; +} + +const title = "Blog"; +const description = "Latest articles."; + +let { posts, previous, next, years, months, days } = Astro.props; +posts = posts.sort((a, b) => + new Date(b.data.dateCreated).valueOf() - + new Date(a.data.dateCreated).valueOf() +); +const date = posts[0].data.dateCreated as Date; +--- + +<Base {title} {description}> + <main> + <h2>Blogue</h2> + {date && <DateSelector {date} {years} {months} {days} />} + {posts.map((post) => <BlogCard {...post} />)} + <div> + {previous && <a href={`/blog/${Astro.props.previous}`}>Previous</a>} + {next && <a href={`/blog/${Astro.props.next}`}>Next</a>} + </div> + </main> +</Base> diff --git a/src/pages/blog/keywords/[...slug].astro b/src/pages/blog/keywords/[...slug].astro new file mode 100644 index 0000000..724e8b7 --- /dev/null +++ b/src/pages/blog/keywords/[...slug].astro @@ -0,0 +1,40 @@ +--- +import { type CollectionEntry, getCollection } from "astro:content"; +import Base from "@layouts/Base.astro"; +import BlogCard from "@components/BlogCard.astro"; + +type Props = { posts: CollectionEntry<"blog">[] }; + +export async function getStaticPaths() { + const posts = await getCollection("blog"); + const keywords = [ + ...new Set( + await getCollection("blog").then((x) => + x.flatMap((x) => x.data.keywords) + ), + ).values(), + ]; + return keywords.map((k) => ({ + params: { slug: k }, + props: { + posts: posts.filter((post) => + post.data.keywords.some((i) => i.localeCompare(k) === 0) + ), + }, + })); +} + +const title = "Blog"; +const description = "Latest articles."; + +const posts = Astro.props.posts.sort((a, b) => + new Date(b.data.dateCreated).valueOf() - + new Date(a.data.dateCreated).valueOf() +); +--- + +<Base {title} {description}> + <main> + <h2>Blogue</h2> {posts.map((post) => <BlogCard {...post} />)} + </main> +</Base> diff --git a/src/pages/blog/keywords/index.astro b/src/pages/blog/keywords/index.astro new file mode 100644 index 0000000..255fbf4 --- /dev/null +++ b/src/pages/blog/keywords/index.astro @@ -0,0 +1,21 @@ +--- +import { getCollection } from "astro:content"; +import Base from "@layouts/Base.astro"; + +const title = "Keywords"; +const description = "Keywords"; + +const blogs = await getCollection("blog"); +let keywords = [ + ...new Set([ + ...blogs.flatMap(({ data }) => [...(data.keywords ?? [])]), + ]), +]; +--- + +<Base {title} {description} {keywords}> + <h1>Keywords</h1> + <ul> + {keywords.map((k) => <li><a href={`/blog/keywords/${k}`}>{k}</a></li>)} + </ul> +</Base> diff --git a/src/pages/blog/read/[...slug].astro b/src/pages/blog/read/[...slug].astro new file mode 100644 index 0000000..05d68e8 --- /dev/null +++ b/src/pages/blog/read/[...slug].astro @@ -0,0 +1,333 @@ +--- +import { type CollectionEntry, getCollection } from "astro:content"; +import { render } from "astro:content"; +import BaseHead from "@components/BaseHead.astro"; +import Translations from "@components/Translations.astro"; +import { toIso8601Full } from "@utils/datetime"; +import ReadingTime from "@components/ReadingTime.astro"; +import Keywords from "@components/Keywords.astro"; +import Citations from "@components/Citations.astro"; +import Signature from "@components/signature/Signature.astro"; +import CopyrightNotice from "@components/CopyrightNotice.astro"; +import { getEntries } from "astro:content"; +import { verifier as verifierPrototype } from "@lib/pgp/verify"; +import { defined, get } from "@utils/anonymous"; +import Authors from "@components/signature/Authors.astro"; +import { getEntry } from "astro:content"; + +export async function getStaticPaths() { + const posts = await getCollection("blog"); + return posts.map((post) => ({ + params: { slug: post.id }, + props: post, + })); +} + +type Props = CollectionEntry<"blog">; + +const post = Astro.props; + +if (defined(post.data.translationOf)) { + const original = await getEntry( + post.data.translationOf as CollectionEntry<"blog">, + ); + + if (!original) { + throw new Error(`Original post not found for ${post.id}`); + } + + const originalAuthor = (original.data.signers ?? []).filter( + (s) => s.role === "author", + ).map((s) => s.entity.id)?.[0]; + const originalCoAuthors = new Set( + (original.data.signer ?? []).filter( + (s) => s.role === "co-author", + ).map((s) => s.entity.id), + ); + const translationAuthor = (post.data.signer ?? []).filter( + (s) => s.role === "author", + ).map((s) => s.entity.id)?.[0]; + const translationCoAuthors = new Set( + (post.data.signer ?? []).filter( + (s) => s.role === "co-author", + ).map((s) => s.entity.id), + ); + + if ( + (translationAuthor !== undefined && + translationAuthor !== originalAuthor) || + !translationCoAuthors.isSubsetOf(originalCoAuthors) + ) { + throw new Error( + `Post ${post.id} has mismatched (co-)authors from original post ${original.id}`, + ); + } + + const translators = (post.data.signer ?? []).filter( + (s) => s.role === "translator", + ).map((s) => s.entity.id); + + for (const translator of translators) { + if ( + originalAuthor === translator || originalCoAuthors.has(translator) + ) { + throw new Error( + `Translator ${translator} in ${post.id} is already a (co-)author in original post`, + ); + } + } +} else { + if (post.data.signer?.some((x) => x.role === "translator")) { + throw new Error( + `Post ${post.id} is not a translation but has translators defined`, + ); + } +} + +// Add own post as a translation +const translationsSet = new Set( + (await getCollection( + "blog", + (x) => + x.data.translationOf?.id === + (post.data.translationOf !== undefined + ? post.data.translationOf.id + : post.id), + ) ?? []).map(({ id }) => id), +); + +translationsSet.add(post.id); +const translations = [...translationsSet.values()].map((id) => ({ + collection: post.collection, + id, +})); + +const signers = await getEntries( + post.data.signer?.map(get("entity")) ?? [], +).then((x) => x.filter(defined)) + .then((x) => + x.map((x) => ({ + entity: x, + role: post.data.signer?.find((y) => y.entity.id === x.id)?.role, + })) + ) + .then((x) => x.filter((x) => x.role !== undefined)); + +const verifier = await verifierPrototype.then((x) => x.clone()); + +// Add signers public keys to keyring +for (const { data } of signers.map(get("entity"))) { + if (data.publickey.armor !== undefined) { + verifier.addKeyFromArmor(data.publickey.armor); + } +} + +const verification = post.filePath !== undefined + ? await verifier.verify([ + new URL(`file://${Deno.cwd()}/${post.filePath}`), + ]) + : undefined; + +const { Content } = await render(post); + +const { lang } = post.data; + +const commit = await verification?.commit; +--- + +<html lang="pt-PT"> + <head> + <BaseHead title={post.data.title} description={post.data.description} /> + </head> + + <body> + <main> + <article + itemscope + itemtype="http://schema.org/BlogPosting" + itemid={Astro.url.href} + > + <Translations {translations} {lang} /> + <hgroup> + <h1 itemprop="headline">{post.data.title}</h1> + { + post.data.subtitle && ( + <p itemprop="alternativeHeadline" class="subtitle"> + {post.data.subtitle} + </p> + ) + } + </hgroup> + { + post.data.description && ( + <section itemprop="abstract"> + <h2>Resumo</h2> + { + post.data.description.split(new RegExp("\\s{2,}")) + .map(( + x, + ) => ( + <p>{x}</p> + )) + } + </section> + ) + } + {verification && <Signature {lang} {verification} />} + <footer> + { + verification?.verifications && + ( + <Authors + verifications={verification.verifications} + expectedSigners={signers} + commitSignerKey={commit?.signature?.keyFingerPrint} + /> + ) + } + <dl> + <dt>Data de criação</dt> + <dd> + <time + itemprop="dateCreated" + datetime={toIso8601Full(post.data.dateCreated)} + >{ + new Intl.DateTimeFormat([lang], {}).format( + post.data.dateCreated, + ) + }</time> + </dd> + { + post.data.dateUpdated && ( + <dt>Última atualização</dt><dd> + <time + itemprop="dateUpdated" + datetime={toIso8601Full(post.data.dateUpdated)} + >{ + new Intl.DateTimeFormat([lang], {}).format( + post.data.dateUpdated, + ) + }</time> + </dd> + ) + } + { + post.data.locationCreated && ( + <dt + itemprop="locationCreated" + itemscope + itemtype="https://schema.org/Place" + > + Local de criação + </dt><dd> + <span itemprop="name">{post.data.locationCreated}</span> + </dd> + ) + } + </dl> + <ReadingTime body={post.body} {lang} /> + </footer> + <hr /> + <div itemprop="articleBody text"><Content /></div> + <hr /> + <Keywords keywords={post.data.keywords} /> + <Citations citations={post.data.relatedPosts} /> + <CopyrightNotice + author={signers[0]?.entity.data.website?.[0] ?? "Anonymous"} + website={signers[0]?.entity.data.website?.[0]} + email={signers[0]?.entity.data.website?.[0]} + title={post.data.title} + dateCreated={post.data.dateCreated} + license={post.data.license as License} + /> + </article> + </main> + </body> +</html> + +<script type="module" is:inline> + hashchange(); + + window.addEventListener("hashchange", hashchange); + + document.addEventListener( + "click", + function (event) { + if ( + event.target && + event.target instanceof HTMLAnchorElement && + event.target.href === location.href && + location.hash.length > 1 + ) { + requestIdleCallback(function () { + if (!event.defaultPrevented) { + hashchange(); + } + }); + } + }, + false, + ); + + function hashchange() { + let hash; + + try { + hash = decodeURIComponent(location.hash.slice(1)).toLowerCase(); + } catch (e) { + return; + } + + const name = "user-content-" + hash; + const target = document.getElementById(name) || + document.getElementsByName(name)[0]; + + if (target) { + requestIdleCallback(function () { + target.scrollIntoView(); + }); + } + } +</script> + +<style is:inline> + section[data-footnotes].footnotes { + word-wrap: break-word; + } +</style> + +<style> + hgroup { + text-align: center; + } + + .subtitle { + font-weight: lighter; + } + + [itemprop~="articleBody"] { + line-height: 1.4; + font-size: 1.2em; + text-align: justify; + + & h1, + & h2, + & h3 { + line-height: 1.2; + } + } + + [itemprop="abstract"] { + margin-inline: 1em; + padding-block: 1em; + font-style: italic; + } + + @media print { + body { + font-size: 1rem; + font-family: var(--ff-serif); + line-height: 1.62; + } + } +</style> diff --git a/src/pages/index.astro b/src/pages/index.astro new file mode 100644 index 0000000..e1e97ef --- /dev/null +++ b/src/pages/index.astro @@ -0,0 +1,28 @@ +--- +import Base from "@layouts/Base.astro"; +import { SITE_TITLE } from "src/consts"; +--- + +<Base title={SITE_TITLE}> + <main> + <article> + <h2>Viva abril!</h2> + <figure> + <blockquote lang="es-VE" translate="no"> + «Los que le cierran el camino a la revolución + pacífica le abren al mismo tiempo el camino a la + revolución violenta». + </blockquote> + <figcaption> + — Hugo Chávez. + <p> + Tradução: “Aqueles que fecham o caminho para a + revolução pacífica abrem, ao mesmo tempo, o + caminho para a revolução violenta.” + </p> + </figcaption> + </figure> + <p><em>Portugal <em>fez</em> diferente!</em></p> + </article> + </main> +</Base> diff --git a/src/pages/robots.txt.ts b/src/pages/robots.txt.ts new file mode 100644 index 0000000..4edef8b --- /dev/null +++ b/src/pages/robots.txt.ts @@ -0,0 +1,13 @@ +import type { APIRoute } from "astro"; + +const getRobotsTxt = (sitemapURL: URL) => ` +User-agent: * +Allow: / + +Sitemap: ${sitemapURL.href} +`; + +export const GET: APIRoute = ({ site }) => { + const sitemapURL = new URL("sitemap-index.xml", site); + return new Response(getRobotsTxt(sitemapURL)); +}; diff --git a/src/pages/rss.xml.js b/src/pages/rss.xml.js new file mode 100644 index 0000000..de5685b --- /dev/null +++ b/src/pages/rss.xml.js @@ -0,0 +1,16 @@ +import rss from "@astrojs/rss"; +import { getCollection } from "astro:content"; +import { SITE_DESCRIPTION, SITE_TITLE } from "../consts"; + +export async function GET(context) { + const posts = await getCollection("blog"); + return rss({ + title: SITE_TITLE, + description: SITE_DESCRIPTION, + site: context.site, + items: posts.map((post) => ({ + ...post.data, + link: `/blog/${post.id}/`, + })), + }); +} diff --git a/src/styles/global.css b/src/styles/global.css new file mode 100644 index 0000000..11d9e5b --- /dev/null +++ b/src/styles/global.css @@ -0,0 +1,105 @@ +:root { + --ff-serif: ui-serif, serif; + --ff-sans: ui-sans-serif, sans-serif; + --ff-mono: ui-monospace, monospace; + --ff-icons: "glyphicons", emoji; + --color-link: #1a9850; + --color-visited: #006837; + --color-active: #a50026; +} + +body { + margin: 1rem auto; + max-width: 80ch; + font-family: var(--ff-sans); + padding: 0 0.62em 3.24em; +} + +a:link { + color: var(--color-link); +} + +a:visited { + color: var(--color-visited); +} + +a:active { + color: var(--color-active); +} + +@media (prefers-color-scheme: dark) { + :root { + --color-link: #a6d96a; + --color-visited: #d9ef8b; + --color-active: #f46d43; + } + + body { + background: #000; + color: #fff; + } +} + +body.theme-dark { + --color-link: #a6d96a; + --color-visited: #d9ef8b; + --color-active: #f46d43; + background: #000; + color: #fff; +} + +@media print { + body { + max-width: none; + } +} + +.emoji { + font-family: var(--ff-icons); +} + +[title] { + border-bottom: thin dashed; +} + +dt::after { + content: ":"; +} + +dl { + display: grid; + grid-template-columns: max-content 1fr; + grid-auto-rows: auto; + gap: 0.25rem 1rem; + align-items: start; +} + +dl dt, +dl dd { + margin: 0; + word-break: break-word; +} + +dl dt { + grid-column: 1; +} + +dl dd { + grid-column: 2; +} + +dl.divider { + gap: 0; +} +dl.divider dl { + gap: 0; +} +dl.divider dt { + padding-inline-end: 1em; +} +dl.divider dt + dd:not(:first-of-type) { + border-block-start: 1px solid #181818; +} +dl.divide dd + dt { + border-block-start: 1px solid #181818; +} diff --git a/src/utils/anonymous.test.ts b/src/utils/anonymous.test.ts new file mode 100644 index 0000000..2da613f --- /dev/null +++ b/src/utils/anonymous.test.ts @@ -0,0 +1,130 @@ +import { assert, assertEquals, assertFalse } from "@std/assert"; +import { describe, it } from "@std/testing/bdd"; +import { + defined, + equal, + extremeBy, + get, + getCall, + identity, + instanciate, + pass, +} from "./anonymous.ts"; +import { assertSpyCalls, spy } from "@std/testing/mock"; +import { FALSE, TRUE } from "../../tests/fixtures/test_data.ts"; + +describe("identity", () => { + it("returns the same value", () => { + assertEquals(identity(42), 42); + assertEquals(identity("hello"), "hello"); + const obj = { a: 1 }; + assertEquals(identity(obj), obj); + }); +}); + +describe("defined", () => { + it("returns true for non-null/undefined values", () => { + assert(defined(0)); + assert(defined("")); + const FALSE = false; + assert(defined(FALSE)); + }); + + it("returns false for null and undefined", () => { + assertFalse(defined(undefined)); + assertFalse(defined(null)); + }); +}); + +describe("instanciate", () => { + class MyClass { + constructor(public value: number) {} + } + + it("creates a new instance with the given argument", () => { + const create = instanciate(MyClass); + const instance = create(10); + assert(instance instanceof MyClass); + assertEquals(instance.value, 10); + }); +}); + +describe("get", () => { + it("returns the value at the specified key", () => { + const obj = { a: 123, b: "hello" }; + const getA = get("a"); + const getB = get("b"); + + assertEquals(getA(obj), 123); + assertEquals(getB(obj), "hello"); + }); +}); + +describe("getCall", () => { + it("returns the return value at the specified key", () => { + const obj = { a: () => "a", b: (c: unknown) => c }; + const getA = getCall("a"); + const getB = getCall("b", "d"); + + assertEquals(getA(obj), "a"); + assertEquals(getB(obj), "d"); + }); +}); + +describe("pass", () => { + it("calls the given function and returns the input", () => { + let a: number | null = null; + const f = spy((x: number) => a = x); + + const result = pass(f)(5); + assertSpyCalls(f, 1); + assertEquals(f.calls[0].args[0], 5); + assertEquals(result, 5); + assertEquals(a, 5); + }); +}); + +describe("equal", () => { + it("returns true when primitive values are strictly equal", () => { + const isFive = equal(5); + assert(isFive(5)); + assertFalse(isFive(6)); + + const isHello = equal("hello"); + assert(isHello("hello")); + assertFalse(isHello("world")); + }); + + it("returns true only for same object reference", () => { + const obj = { a: 1 }; + const isObj = equal(obj); + assert(isObj(obj)); + assertFalse(isObj({ a: 1 })); + }); + + it("handles boolean values correctly", () => { + const isTrue = equal(TRUE); + assert(isTrue(TRUE)); + assertFalse(isTrue(FALSE)); + }); +}); + +describe("extremeBy", () => { + it("returns the maximum value from projected numbers", () => { + const data = [1, 3, 2]; + const result = extremeBy(data, "max"); + assertEquals(result, 3); + }); + + it("returns the minimum value from projected numbers", () => { + const data = [10, 4, 7]; + const result = extremeBy(data, "min"); + assertEquals(result, 4); + }); + + it("returns -Infinity/Infinity for empty array", () => { + const data: number[] = []; + assertEquals(extremeBy(data, "max"), -Infinity); + assertEquals(extremeBy(data, "min"), Infinity); + }); +}); diff --git a/src/utils/anonymous.ts b/src/utils/anonymous.ts new file mode 100644 index 0000000..ddd28bd --- /dev/null +++ b/src/utils/anonymous.ts @@ -0,0 +1,25 @@ +export const identity = <T>(x: T): T => x; +export const defined = <T>(x: T | undefined | null): x is T => + x !== undefined && x !== null; +export const instanciate = <T, A>(C: new (arg: A) => T): (arg: A) => T => { + return (arg: A): T => new C(arg); +}; +export const get = <T extends Record<K, unknown>, K extends PropertyKey>( + key: K, +): (obj: T) => T[K] => +(obj: T): T[K] => obj[key]; +export const getCall = < + T extends Record<K, (...args: unknown[]) => unknown>, + K extends PropertyKey, +>( + key: K, + ...args: Parameters<T[K]> +): (obj: T) => ReturnType<T[K]> => +(obj: T): ReturnType<T[K]> => obj[key](...args) as ReturnType<T[K]>; +export const pass = <T>(fn: (x: T) => void): (x: T) => T => (x: T): T => { + fn(x); + return x; +}; +export const equal = <T>(x: T): (y: T) => boolean => (y: T): boolean => x === y; +export const extremeBy = (arr: number[], mode: "max" | "min"): number => + Math[mode](...arr); diff --git a/src/utils/bases.test.ts b/src/utils/bases.test.ts new file mode 100644 index 0000000..9341b18 --- /dev/null +++ b/src/utils/bases.test.ts @@ -0,0 +1,32 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { describe, it } from "@std/testing/bdd"; +import { bufferToBase } from "./bases.ts"; + +describe("bufferToBase", () => { + it("returns an empty string for an empty Uint8Array", () => { + assertEquals(bufferToBase(new Uint8Array([]), 16), ""); + }); + + it("converts bytes to hexadecimal (base 16)", () => { + const input = new Uint8Array([0, 1, 15, 16, 255]); + const expected = "00010f10ff"; + assertEquals(bufferToBase(input, 16), expected); + }); + + it("converts bytes to binary (base 2)", () => { + const input = new Uint8Array([255, 0, 1]); + const expected = "111111110000000000000001"; + assertEquals(bufferToBase(input, 2), expected); + }); + + it("converts bytes to octal (base 8)", () => { + const input = new Uint8Array([8, 64, 255]); + const expected = "010100377"; + assertEquals(bufferToBase(input, 8), expected); + }); + + it("throws on invalid base", () => { + assertThrows(() => bufferToBase(new Uint8Array([1, 2]), 1), RangeError); + assertThrows(() => bufferToBase(new Uint8Array([1, 2]), 37), RangeError); + }); +}); diff --git a/src/utils/bases.ts b/src/utils/bases.ts new file mode 100644 index 0000000..a610d13 --- /dev/null +++ b/src/utils/bases.ts @@ -0,0 +1,11 @@ +export const bufferToBase = (buf: Uint8Array, base = 10): string => { + if (base < 2 || base > 36) { + throw new RangeError("Base must be between 2 and 36."); + } + + const max = Math.ceil(8 / Math.log2(base)); // Math.log2(1 << 8) = 8 + + return Array.from(buf, (byte) => byte.toString(base).padStart(max, "0")).join( + "", + ); +}; diff --git a/src/utils/datetime.test.ts b/src/utils/datetime.test.ts new file mode 100644 index 0000000..dd239b2 --- /dev/null +++ b/src/utils/datetime.test.ts @@ -0,0 +1,63 @@ +import { assertEquals, assertMatch } from "@std/assert"; +import { describe, it } from "@std/testing/bdd"; +import { toIso8601Full, toIso8601FullUTC } from "./datetime.ts"; +import { FakeTime } from "@std/testing/time"; + +describe("toIso8601Full", () => { + it("formats current local time with offset", () => { + const date = new Date(); + const result = toIso8601Full(date); + + assertMatch( + result, + /^[+-]\d{6}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}(Z|[+-]\d{2}:\d{2})$/, + ); + }); + + it("handles dates before year 0 (BC)", () => { + const date = new Date(-2000, 0, 1, 0, 0, 0, 0); + const result = toIso8601Full(date); + + assertMatch(result, /^-\d{6}-01-01T00:00:00\.000(Z|[+-]\d{2}:\d{2})$/); + }); + + it("pads components correctly", () => { + const date = new Date(7, 0, 2, 3, 4, 5, 6); + const result = toIso8601Full(date); + + assertMatch(result, /^\+001907-01-02T03:04:05\.006(Z|[+-]\d{2}:\d{2})$/); + }); + + it("handles positive and negative timezone offsets", () => { + const date = new Date("2025-06-17T12:00:00Z"); + const result = toIso8601Full(date); + + assertMatch( + result, + /^[+-]\d{6}-06-17T\d{2}:\d{2}:\d{2}\.\d{3}(Z|[+-]\d{2}:\d{2})$/, + ); + }); +}); + +describe("toIso8601FullUTC", () => { + it("always formats in UTC with 'Z'", () => { + const date = new Date(Date.UTC(2025, 11, 31, 23, 59, 59, 999)); + const result = toIso8601FullUTC(date); + + assertEquals(result, "+002025-12-31T23:59:59.999Z"); + }); + + it("pads milliseconds and components correctly", () => { + const date = new Date(Date.UTC(7, 0, 2, 3, 4, 5, 6)); + const result = toIso8601FullUTC(date); + + assertEquals(result, "+001907-01-02T03:04:05.006Z"); + }); + + it("handles BC dates (negative years)", () => { + const date = new Date(Date.UTC(-44, 2, 15, 12, 0, 0, 0)); + const result = toIso8601FullUTC(date); + + assertMatch(result, /^-\d{6}-03-15T12:00:00\.000Z$/); + }); +}); diff --git a/src/utils/datetime.ts b/src/utils/datetime.ts new file mode 100644 index 0000000..3a2cd25 --- /dev/null +++ b/src/utils/datetime.ts @@ -0,0 +1,43 @@ +export function toIso8601Full(date: Date): string { + const yearN = date.getFullYear(); + const isNegativeYear = yearN <= 0; + const year = isNegativeYear ? pad(1 - yearN, 6) : pad(yearN, 6); + const signedYear = (isNegativeYear ? "-" : "+") + year; + + const month = pad(date.getMonth() + 1); + const day = pad(date.getDate()); + const hour = pad(date.getHours()); + const minute = pad(date.getMinutes()); + const second = pad(date.getSeconds()); + const ms = pad(date.getMilliseconds(), 3); + + const dateString = + `${signedYear}-${month}-${day}T${hour}:${minute}:${second}.${ms}`; + const tzOffset = -date.getTimezoneOffset(); + if (tzOffset === 0) { + return `${dateString}Z`; + } else { + const offsetSign = tzOffset > 0 ? "+" : "-"; + const offsetHours = pad(Math.floor(Math.abs(tzOffset) / 60)); + const offsetMinutes = pad(Math.abs(tzOffset) % 60); + return `${dateString}${offsetSign}${offsetHours}:${offsetMinutes}`; + } +} + +export function toIso8601FullUTC(date: Date): string { + const yearN = date.getUTCFullYear(); + const isNegativeYear = yearN <= 0; + const year = isNegativeYear ? pad(1 - yearN, 6) : pad(yearN, 6); + const signedYear = (isNegativeYear ? "-" : "+") + year; + + const month = pad(date.getUTCMonth() + 1); + const day = pad(date.getUTCDate()); + const hour = pad(date.getUTCHours()); + const minute = pad(date.getUTCMinutes()); + const second = pad(date.getUTCSeconds()); + const ms = pad(date.getUTCMilliseconds(), 3); + + return `${signedYear}-${month}-${day}T${hour}:${minute}:${second}.${ms}Z`; +} + +const pad = (num: number, len = 2) => String(Math.abs(num)).padStart(len, "0"); diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..5a083d5 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,19 @@ +import { trailingSlash } from "astro:config/client"; + +export function addForwardSlash(path: string): string { + if (trailingSlash === "always") { + return path.endsWith("/") ? path : path + "/"; + } else { + return path; + } +} + +export const enum Level { + OK, + INFO, + WARN, + DEBUG, + ERROR, +} + +export type MaybePromise<T> = Promise<T> | T; diff --git a/src/utils/iterator.test.ts b/src/utils/iterator.test.ts new file mode 100644 index 0000000..dda0e0a --- /dev/null +++ b/src/utils/iterator.test.ts @@ -0,0 +1,122 @@ +import { describe, it } from "@std/testing/bdd"; +import { + createAsyncIterator, + filterDuplicate, + findMapAsync, + surelyIterable, +} from "./iterator.ts"; +import { assertEquals } from "@std/assert"; + +describe("surelyIterable", () => { + it("returns the iterable as-is if input is already iterable", () => { + const input = [1, 2, 3]; + const result = surelyIterable(input); + assertEquals([...result], [1, 2, 3]); + }); + + it("wraps a non-iterable value in an array", () => { + const input = 42; + const result = surelyIterable(input); + assertEquals([...result], [42]); + }); + + it("wraps null in an array", () => { + const input = null; + const result = surelyIterable(input); + assertEquals([...result], [null]); + }); + + it("wraps undefined in an array", () => { + const input = undefined; + const result = surelyIterable(input); + assertEquals([...result], [undefined]); + }); + + it("wraps an object that is not iterable", () => { + const input = { a: 1 }; + const result = surelyIterable(input); + assertEquals([...result], [{ a: 1 }]); + }); + + it("handles a Set correctly", () => { + const input = new Set([1, 2, 3]); + const result = surelyIterable(input); + assertEquals([...result], [1, 2, 3]); + }); +}); + +describe("createAsyncIterator", () => { + it("yields resolved values in order", async () => { + const values = [Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)]; + const results: number[] = []; + for await (const value of createAsyncIterator(values)) { + results.push(value); + } + assertEquals(results, [1, 2, 3]); + }); + + it("handles empty array", async () => { + const results: unknown[] = []; + for await (const value of createAsyncIterator([])) { + results.push(value); + } + assertEquals(results, []); + }); +}); + +describe("filterDuplicate", () => { + it("filters duplicate objects by key", () => { + const items = [ + { id: 1, name: "a" }, + { id: 2, name: "b" }, + { id: 1, name: "c" }, + ]; + const result = filterDuplicate(items, (i) => i.id); + assertEquals(result.length, 2); + assertEquals(result[0].name, "a"); + assertEquals(result[1].name, "b"); + }); + + it("handles empty iterable", () => { + const result = filterDuplicate([], (x) => x); + assertEquals(result, []); + }); + + it("keeps first occurrence only", () => { + const input = [1, 2, 3, 1, 2, 4]; + const result = filterDuplicate(input, (x) => x); + assertEquals(result, [1, 2, 3, 4]); + }); +}); + +describe("findMapAsync", () => { + it("returns first successful result", async () => { + const arr = [1, 2, 3]; + const i = 2; + const result = await findMapAsync(arr, (x) => { + if (x === i) return Promise.resolve(x); + throw new Error("not found"); + }); + assertEquals(result, i); + }); + + it("returns undefined if all reject", async () => { + const arr = [1, 2]; + const result = await findMapAsync(arr, () => { + throw new Error("fail"); + }); + assertEquals(result, undefined); + }); + + it("short-circuits after first success", async () => { + const calls: number[] = []; + const arr = [1, 2, 3]; + const i = arr.length - 1; + await findMapAsync(arr, (x) => { + calls.push(x); + if (x === i) return Promise.resolve("ok"); + throw new Error("fail"); + }); + assertEquals(calls, arr.slice(0, i)); + }); +}); diff --git a/src/utils/iterator.ts b/src/utils/iterator.ts new file mode 100644 index 0000000..fa58fc9 --- /dev/null +++ b/src/utils/iterator.ts @@ -0,0 +1,52 @@ +export type MaybeIterable<T> = T | Iterable<T>; +export type NonEmptyArray<T> = [T, ...T[]]; +export type AsyncYieldType<T> = T extends AsyncGenerator<infer U> ? U : never; + +export function surelyIterable<T>(maybe: MaybeIterable<T>): Iterable<T> { + return typeof maybe === "object" && maybe !== null && Symbol.iterator in maybe + ? maybe + : [maybe]; +} + +export async function* createAsyncIterator<T>( + promises: Promise<T>[], +): AsyncGenerator<T, void, void> { + for (const promise of promises) { + yield promise; + } +} + +export function filterDuplicate<T, K>( + array: Iterable<T>, + key: (i: T) => K, +): T[] { + const seen = new Map<K, T>(); + for (const i of array) { + const id = key(i); + if (!seen.has(id)) { + seen.set(id, i); + } + } + return Array.from(seen.values()); +} + +export async function findMapAsync<T, R>( + iter: Iterable<T>, + predicate: (value: T) => Promise<R>, +): Promise<R | undefined> { + const arr = Array.from(iter); + + async function tryNext(index: number): Promise<R | undefined> { + if (index >= arr.length) { + return await Promise.resolve(undefined); + } + + try { + return await predicate(arr[index]); + } catch { + return tryNext(index + 1); + } + } + + return await tryNext(0); +} diff --git a/src/utils/lang.test.ts b/src/utils/lang.test.ts new file mode 100644 index 0000000..eac5948 --- /dev/null +++ b/src/utils/lang.test.ts @@ -0,0 +1,97 @@ +import { assert, assertEquals, assertFalse } from "@std/assert"; +import { describe, it } from "@std/testing/bdd"; +import { + getFlagEmojiFromLocale, + getLanguageNameFromLocale, + isValidLocale, + LANGUAGE_DEFAULTS, +} from "./lang.ts"; + +describe("getFlagEmojiFromLocale", () => { + it("returns 🇺🇸 for 'en-US'", () => { + assertEquals(getFlagEmojiFromLocale("en-US"), "🇺🇸"); + }); + + it("returns 🇧🇷 for 'pt-BR'", () => { + assertEquals(getFlagEmojiFromLocale("pt-BR"), "🇧🇷"); + }); + + it("returns 🇫🇷 for 'fr-FR'", () => { + assertEquals(getFlagEmojiFromLocale("fr-FR"), "🇫🇷"); + }); + + it("uses fallback country from LANGUAGE_DEFAULTS when no region", () => { + for (const i in LANGUAGE_DEFAULTS) { + if (i in LANGUAGE_DEFAULTS) { + assertEquals( + getFlagEmojiFromLocale(i), + getFlagEmojiFromLocale( + `${i}-${LANGUAGE_DEFAULTS[i as keyof typeof LANGUAGE_DEFAULTS]}`, + ), + ); + } + } + }); + + it("returns empty string for unsupported languages", () => { + assertEquals(getFlagEmojiFromLocale("xx"), ""); + assertEquals(getFlagEmojiFromLocale("de"), ""); + }); + + it("is case-insensitive", () => { + assertEquals(getFlagEmojiFromLocale("EN-us"), "🇺🇸"); + assertEquals(getFlagEmojiFromLocale("Pt"), "🇵🇹"); + }); +}); + +describe("getLanguageNameFromLocale", () => { + it("returns 'English' for 'en'", () => { + const result = getLanguageNameFromLocale("en"); + assertEquals(typeof result, "string"); + assert(result.length > 0); + }); + + it("returns '' for invalid locale", () => { + assertEquals(getLanguageNameFromLocale(new Date().toLocaleString()), ""); + }); + + it("returns name in the correct locale", () => { + const fr = getLanguageNameFromLocale("fr"); + const pt = getLanguageNameFromLocale("pt"); + + assertEquals(typeof fr, "string"); + assertEquals(typeof pt, "string"); + assert(fr.length > 0); + assert(pt.length > 0); + }); +}); + +describe("isValidLocale", () => { + it("returns true for valid simple language tags", () => { + assert(isValidLocale("en")); + assert(isValidLocale("fr")); + assert(isValidLocale("pt")); + }); + + it("returns true for valid language-region tags", () => { + assert(isValidLocale("en-US")); + assert(isValidLocale("pt-BR")); + assert(isValidLocale("fr-FR")); + }); + + it("returns true for valid locale with script", () => { + assert(isValidLocale("zh-Hant")); + assert(isValidLocale("sr-Cyrl")); + }); + + it("returns false for invalid formats", () => { + assertFalse(isValidLocale("EN_us")); + assertFalse(isValidLocale("xx-YY-ZZ")); + assertFalse(isValidLocale("123")); + assertFalse(isValidLocale("")); + }); + + it("is case-insensitive and accepts well-formed mixed cases", () => { + assert(isValidLocale("eN-uS")); + }); +}); diff --git a/src/utils/lang.ts b/src/utils/lang.ts new file mode 100644 index 0000000..2ce8fe4 --- /dev/null +++ b/src/utils/lang.ts @@ -0,0 +1,56 @@ +export const LANGUAGE_DEFAULTS = Object.freeze({ + pt: "PT", + en: "GB", + fr: "FR", +}); + +/** + * AI thought me this. + * + * Explanation: + * * Each letter in a 2-letter country code is converted to a Regional + * Indicator Symbol, which together form the emoji flag. + * * 'A'.charCodeAt(0) is 65, and '🇦' starts at 0x1F1E6 → offset of 127397 + * (0x1F1A5). + * * So 'A' → '🇦', 'B' → '🇧', etc. + * + * The flags are the combination of those emojis making the country code like + * Portugal -> PT. + */ +export function getFlagEmojiFromLocale(locale: string): string { + let countryCode: string | undefined; + + const parts = locale.split("-"); + const lang = parts[0].toLowerCase(); + if (parts.length === 2) { + countryCode = parts[1].toUpperCase(); + } else if (lang in LANGUAGE_DEFAULTS) { + countryCode = LANGUAGE_DEFAULTS[lang as keyof typeof LANGUAGE_DEFAULTS]; + } + + if (!countryCode) return ""; + + return [...countryCode] + .map((c) => String.fromCodePoint(c.charCodeAt(0) + 127397)) + .join(""); +} + +export function getLanguageNameFromLocale(locale: string): string { + try { + return new Intl.DisplayNames([locale], { + type: "language", + fallback: "code", + }).of(locale) ?? ""; + } catch { + return ""; + } +} + +export function isValidLocale(locale: string): boolean { + try { + Intl.getCanonicalLocales(locale); + return true; + } catch { + return false; + } +} diff --git a/tests/e2e/user_flow_test.ts b/tests/e2e/user_flow_test.ts new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/e2e/user_flow_test.ts diff --git a/tests/fixtures/setup.ts b/tests/fixtures/setup.ts new file mode 100644 index 0000000..0b23ec8 --- /dev/null +++ b/tests/fixtures/setup.ts @@ -0,0 +1,146 @@ +import { + createMessage, + decryptKey, + generateKey, + PrivateKey, + sign, +} from "npm:openpgp@^6.1.1"; +import { passphrase } from "./test_data.ts"; +import { MaybeIterable } from "../../src/utils/iterator.ts"; +import { afterEach, beforeEach } from "@std/testing/bdd"; +import { Stub, stub } from "@std/testing/mock"; + +export async function generateKeyPair( + name: string, + options?: Partial<Parameters<typeof generateKey>[0]>, +): ReturnType<typeof generateKey> { + const key = await generateKey({ + type: "ecc", + userIDs: [{ + name, + email: `${ + name.toLowerCase().replaceAll(/\s/g, "") + }@localhost.localdomain`, + }], + passphrase, + format: "object", + ...options, + }); + const privateKey = await decryptKey({ + privateKey: key.privateKey, + passphrase, + }); + return { ...key, privateKey }; +} + +export function generateKeyPairWithSubkey( + name: string, +): ReturnType<typeof generateKey> { + return generateKeyPair(name, { + curve: "nistP256", + subkeys: [{ type: "ecc", curve: "nistP256", sign: true }], + }); +} + +export async function createDetachedSignature( + data: Uint8Array, + signingKeys: MaybeIterable<PrivateKey>, +): Promise<Uint8Array<ArrayBuffer>> { + const message = await createMessage({ binary: data }); + const signature = await sign({ + message, + signingKeys: Symbol.iterator in signingKeys + ? Iterator.from(signingKeys).toArray() + : signingKeys, + detached: true, + format: "object", + }); + return signature.write() as Uint8Array<ArrayBuffer>; +} + +export function corruptData(data: Uint8Array): Uint8Array<ArrayBuffer> { + const corrupted = new Uint8Array(data); + if (corrupted.length > 0) { + corrupted[0] += 1; + corrupted[0] %= 1 << 8; + } + return corrupted; +} + +export function corruptSignatureFormat( + signature: Uint8Array, +): Uint8Array<ArrayBuffer> { + const corrupted = new Uint8Array(signature); + + if (corrupted.length > 0) { + // Strategy 1: Change the packet tag byte + // The first byte contains the tag and format information. + // Changing the lower 6 bits (new format tag) or higher bits (packet format) + // can easily break parsing. Let's try flipping a bit. + // corrupted[0] = corrupted[0] ^ 0x01; // Flip the last bit + + // Strategy 2 (Alternative - more drastic): Truncate the signature + // return corrupted.slice(0, corrupted.length / 2); // Cut off half the signature + + // Strategy 3 (Alternative - modify length field): + // This is more complex as length encoding varies, but for typical new format + // packets, length information is in the bytes immediately following the tag. + // Modifying these can cause the parser to misinterpret the packet length. + // Example (simplified, might need adjustment based on actual encoding): + if (corrupted.length > 2) { + corrupted[1] = (corrupted[1] + 10) % 256; + } + } + return corrupted; +} + +const inMemoryFiles = new Map< + string, + { text?: string; bytes?: Uint8Array<ArrayBuffer> } +>(); + +export function startMockFs(): void { + inMemoryFiles.clear(); + + beforeEach(() => { + Deno.readTextFile = stub( + Deno, + "readTextFile", + async (path: string | URL) => { + const url = new URL(path).href; + const content = inMemoryFiles.get(url)?.text; + if (content === undefined) { + throw new Deno.errors.NotFound(`File not found: ${url}`); + } + return await Promise.resolve(content); + }, + ); + + Deno.readFile = stub(Deno, "readFile", async (path: string | URL) => { + const url = new URL(path).href; + const content = inMemoryFiles.get(url)?.bytes; + if (content === undefined) { + throw new Deno.errors.NotFound(`File not found: ${url}`); + } + return await Promise.resolve(content); + }); + + inMemoryFiles.clear(); + }); + + afterEach(() => { + (Deno.readTextFile as Stub).restore(); + (Deno.readFile as Stub).restore(); + }); +} + +export function createInMemoryFile( + url: URL, + content: string | Uint8Array<ArrayBuffer>, +): URL { + inMemoryFiles.set( + url.href, + typeof content === "string" ? { text: content } : { bytes: content }, + ); + return url; +} diff --git a/tests/fixtures/test_data.ts b/tests/fixtures/test_data.ts new file mode 100644 index 0000000..d2143ed --- /dev/null +++ b/tests/fixtures/test_data.ts @@ -0,0 +1,63 @@ +export const TRUE = true; +export const FALSE = false; + +export const passphrase = "Za39PSymj5EcuzMs9kSNsAS3KbfzKHHP"; + +export const gitDir = new URL("file:///home/user/project/"); + +const gitLogPrettyOutput = new TextEncoder().encode([ + "abcdef1234567890abcdef1234567890abcdef12", // long hash + "abcdef1", // short hash + "2024-06-17T12:00:00Z", // author date + "Alice", // author name + "alice@example.com", // author email + "2024-06-17T12:01:00Z", // committer date + "Bob", // committer name + "bob@example.com", // committer email + "bob@example.com", // signer + "ABCDEF", // key (short) + "ABCDEF123456", // key fingerprint (long) + "gpg: Signature made...", // raw line 1 + "gpg: Good signature from...", // raw line 2 +].join("\n")) as Uint8Array<ArrayBuffer>; + +const gitDiffTreeOutput = new TextEncoder().encode([ + "M\tfile.ts", + "A\tnew_file.ts", +].join("\n")) as Uint8Array<ArrayBuffer>; + +const emptyOutput = new TextEncoder().encode("") as Uint8Array<ArrayBuffer>; + +export const gitLogPrettyCommandOutput: Promise<Deno.CommandOutput> = Promise + .resolve({ + stdout: gitLogPrettyOutput, + code: 0, + signal: null, + stderr: emptyOutput, + success: true, + }); +export const gitDiffTreeCommandOutput: Promise<Deno.CommandOutput> = Promise + .resolve({ + stdout: gitDiffTreeOutput, + code: 0, + signal: null, + stderr: emptyOutput, + success: true, + }); +export const gitRevParseCommandOutput: Promise<Deno.CommandOutput> = Promise + .resolve({ + stdout: new TextEncoder().encode(gitDir.pathname) as Uint8Array< + ArrayBuffer + >, + code: 0, + signal: null, + stderr: emptyOutput, + success: true, + }); +export const emptyCommandOutput: Promise<Deno.CommandOutput> = Promise.resolve({ + stdout: emptyOutput, + code: 0, + signal: null, + stderr: emptyOutput, + success: true, +}); diff --git a/tests/integration/api_test.ts b/tests/integration/api_test.ts new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/integration/api_test.ts diff --git a/tests/integration/db_test.ts b/tests/integration/db_test.ts new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/integration/db_test.ts diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6f77cf4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"], + "compilerOptions": { + "strictNullChecks": true, + "lib": ["deno.window", "dom.asynciterable"], + "baseUrl": ".", + "paths": { + "@components/*": ["src/components/*"], + "@layouts/*": ["src/layouts/*"], + "@lib/*": ["src/lib/*"], + "@utils/*": ["src/utils/*"], + "@fixtures/*": ["tests/fixtures/*"] + } + } +} |