summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJoão Augusto Costa Branco Marado Torres <torres.dev@disroot.org>2025-06-24 12:08:41 -0300
committerJoão Augusto Costa Branco Marado Torres <torres.dev@disroot.org>2025-06-24 12:50:43 -0300
commitf9a77c5c27aede4e5978eb55d9b7af781b680a1d (patch)
treed545e325ba1ae756fc2eac66fac1001b6753c40d
feat!: initial commit
Signed-off-by: João Augusto Costa Branco Marado Torres <torres.dev@disroot.org>
-rw-r--r--.gitignore35
-rw-r--r--AUTHORS1
-rw-r--r--CHANGELOG.md21
-rw-r--r--CODE_OF_CONDUCT.adoc133
-rw-r--r--CONTRIBUTORS1
-rw-r--r--COPYING661
-rw-r--r--README.md80
-rw-r--r--astro.config.ts98
-rwxr-xr-xbin/sign.sh44
-rw-r--r--deno.jsonc93
-rw-r--r--deno.lock2972
-rw-r--r--package.json30
-rw-r--r--public/blog/TEMPLATE46
-rw-r--r--public/blog/legislativas-2025.md40
-rw-r--r--public/blog/legislativas-2025.md.sigbin0 -> 2097 bytes
-rw-r--r--public/favicon.svg4
-rw-r--r--public/keys/cravodeabril.gpgbin0 -> 3076 bytes
-rw-r--r--src/components/BaseHead.astro79
-rw-r--r--src/components/BlogCard.astro38
-rw-r--r--src/components/Citations.astro39
-rw-r--r--src/components/Commit.astro49
-rw-r--r--src/components/CopyrightNotice.astro66
-rw-r--r--src/components/DateSelector.astro141
-rw-r--r--src/components/Footer.astro62
-rw-r--r--src/components/Header.astro41
-rw-r--r--src/components/HeaderLink.astro18
-rw-r--r--src/components/Keywords.astro52
-rw-r--r--src/components/ReadingTime.astro26
-rw-r--r--src/components/SignaturesTableRows.astro51
-rw-r--r--src/components/Translations.astro107
-rw-r--r--src/components/licenses/CC.astro120
-rw-r--r--src/components/licenses/WTFPL.astro53
-rw-r--r--src/components/signature/Authors.astro281
-rw-r--r--src/components/signature/Commit.astro87
-rw-r--r--src/components/signature/Downloads.astro63
-rw-r--r--src/components/signature/Signature.astro44
-rw-r--r--src/components/signature/Summary.astro279
-rw-r--r--src/consts.ts29
-rw-r--r--src/content.config.ts116
-rw-r--r--src/content/entities.toml85
-rw-r--r--src/layouts/Base.astro35
-rw-r--r--src/lib/git/index.test.ts40
-rw-r--r--src/lib/git/index.ts16
-rw-r--r--src/lib/git/log.test.ts71
-rw-r--r--src/lib/git/log.ts131
-rw-r--r--src/lib/git/types.ts27
-rw-r--r--src/lib/pgp/create.test.ts130
-rw-r--r--src/lib/pgp/create.ts183
-rw-r--r--src/lib/pgp/index.ts63
-rw-r--r--src/lib/pgp/sign.test.ts121
-rw-r--r--src/lib/pgp/sign.ts82
-rw-r--r--src/lib/pgp/summary.ts232
-rw-r--r--src/lib/pgp/trust.ts19
-rw-r--r--src/lib/pgp/verify.test.ts619
-rw-r--r--src/lib/pgp/verify.ts349
-rw-r--r--src/pages/blog/[...year].astro165
-rw-r--r--src/pages/blog/keywords/[...slug].astro40
-rw-r--r--src/pages/blog/keywords/index.astro21
-rw-r--r--src/pages/blog/read/[...slug].astro333
-rw-r--r--src/pages/index.astro28
-rw-r--r--src/pages/robots.txt.ts13
-rw-r--r--src/pages/rss.xml.js16
-rw-r--r--src/styles/global.css105
-rw-r--r--src/utils/anonymous.test.ts130
-rw-r--r--src/utils/anonymous.ts25
-rw-r--r--src/utils/bases.test.ts32
-rw-r--r--src/utils/bases.ts11
-rw-r--r--src/utils/datetime.test.ts63
-rw-r--r--src/utils/datetime.ts43
-rw-r--r--src/utils/index.ts19
-rw-r--r--src/utils/iterator.test.ts122
-rw-r--r--src/utils/iterator.ts52
-rw-r--r--src/utils/lang.test.ts97
-rw-r--r--src/utils/lang.ts56
-rw-r--r--tests/e2e/user_flow_test.ts0
-rw-r--r--tests/fixtures/setup.ts146
-rw-r--r--tests/fixtures/test_data.ts63
-rw-r--r--tests/integration/api_test.ts0
-rw-r--r--tests/integration/db_test.ts0
-rw-r--r--tsconfig.json18
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/
diff --git a/AUTHORS b/AUTHORS
new file mode 100644
index 0000000..24811a6
--- /dev/null
+++ b/AUTHORS
@@ -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)
diff --git a/COPYING b/COPYING
new file mode 100644
index 0000000..be3f7b2
--- /dev/null
+++ b/COPYING
@@ -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
new file mode 100644
index 0000000..8ad85c9
--- /dev/null
+++ b/public/blog/legislativas-2025.md.sig
Binary files differ
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
new file mode 100644
index 0000000..9921f2e
--- /dev/null
+++ b/public/keys/cravodeabril.gpg
Binary files differ
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} &lt;{author.email}&gt;</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>
+ &lt;<a href="/" hreflang="pt-PT">cravodeabril.pt</a>&gt; Copyright
+ &copy; 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>&lt;<a href="/">cravodeabril.pt</a>&gt;</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 && (
+ <>&lt;<a
+ itemprop="email"
+ rel="author external noreferrer"
+ target="_blank"
+ href={`mailto:${email}`}
+ >{email}</a>&gt;</>
+ )
+ }</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
+ ? (
+ <>&lt;<a
+ itemprop="email"
+ rel="author external noreferrer"
+ target="_blank"
+ href={primary?.email && `mailto:${primary.email}`}
+ >{primary?.email}</a>&gt;</>
+ )
+ : ""
+ }
+ {
+ 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} &lt;<a href={`mailto:${author.email}`}>{
+ author.email
+ }</a>&gt;
+ </dd>
+ <dt>
+ Commiter (<time datetime={toIso8601Full(committer.date)}>{
+ formatter.format(committer.date)
+ }</time>)
+ </dt>
+ <dd>
+ {committer.name} &lt;<a href={`mailto:${committer.email}`}>{
+ committer.email
+ }</a>&gt;
+ </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">
+ &laquo;Los que le cierran el camino a la revoluci&oacute;n
+ pac&iacute;fica le abren al mismo tiempo el camino a la
+ revoluci&oacute;n violenta&raquo;.
+ </blockquote>
+ <figcaption>
+ &mdash; Hugo Ch&aacute;vez.
+ <p>
+ Tradu&ccedil;&atilde;o: &ldquo;Aqueles que fecham o caminho para a
+ revolu&ccedil;&atilde;o pac&iacute;fica abrem, ao mesmo tempo, o
+ caminho para a revolu&ccedil;&atilde;o violenta.&rdquo;
+ </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/*"]
+ }
+ }
+}