diff --git a/.gitignore b/.gitignore index e6e95ca9..dafdcdea 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ pnpm-global TODOs.md *.timestamp-*.mjs .claude + +# base fixture builds +__tests__/base/fixture/.vitepress/dist-* diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md new file mode 100644 index 00000000..b5d9863b --- /dev/null +++ b/THIRD-PARTY-NOTICES.md @@ -0,0 +1,1736 @@ +# Third-party notices + +VitePress is published under the MIT license (see LICENSE). The published vitepress package additionally bundles code and font files from the projects listed below, used under the following licenses: BSD-2-Clause, BlueOak-1.0.0, ISC, MIT, OFL-1.1. The bundled code has been mechanically transformed from its original source form (concatenated, tree-shaken, and minified), and the font files are subsets of their original releases. + +## @antfu/install-pkg + +License: MIT +By: Anthony Fu +Repository: https://github.com/antfu/install-pkg + +> MIT License +> +> Copyright (c) 2021 Anthony Fu +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all +> copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE. + +## @clack/core, @clack/prompts + +License: MIT +By: Nate Moore (https://twitter.com/n_moore) +Repository: https://github.com/bombshell-dev/clack + +> MIT License +> +> MIT License Copyright (c) 2025-Present [Bombshell contributors](https://bomb.sh/team) +> +> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## @iconify/utils + +License: MIT +By: Vjacheslav Trushkin +Repository: https://github.com/iconify/iconify + +> MIT License +> +> Copyright (c) 2021-PRESENT Vjacheslav Trushkin +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all +> copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE. + +## @mdit-vue/plugin-component, @mdit-vue/plugin-frontmatter, @mdit-vue/plugin-headers, @mdit-vue/plugin-sfc, @mdit-vue/plugin-title, @mdit-vue/plugin-toc, @mdit-vue/shared, @mdit-vue/types + +License: MIT +By: meteorlxy +Repository: https://github.com/mdit-vue/mdit-vue + +> The MIT License (MIT) +> +> Copyright (c) 2022-present, mdit-vue Contributors +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +> THE SOFTWARE. + +## @mdit/plugin-anchor + +License: MIT +By: Mr.Hope (https://mister-hope.com) +Repository: https://github.com/mdit-plugins/mdit-plugins + +> The MIT License (MIT) +> +> Copyright (C) 2022 - PRESENT by MrHope +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +> THE SOFTWARE. + +## @mdit/plugin-attrs + +License: MIT +By: Mr.Hope (https://mister-hope.com) +Repository: https://github.com/mdit-plugins/mdit-plugins + +> The MIT License (MIT) +> +> Copyright (c) Arve Seljebu (arve0.github.io) +> Copyright (C) 2022 - PRESENT by MrHope +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +> THE SOFTWARE. + +## @mdit/plugin-container + +License: MIT +By: Mr.Hope (https://mister-hope.com) +Repository: https://github.com/mdit-plugins/mdit-plugins + +> The MIT License (MIT) +> +> Copyright (C) 2022 - PRESENT by MrHope +> Copyright (c) 2015 Vitaly Puzrin, Alex Kocharin. +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +> THE SOFTWARE. + +## @mdit/plugin-emoji + +License: MIT +Repository: https://github.com/mdit-plugins/mdit-plugins + +> The MIT License (MIT) +> +> Copyright (c) 2014 Vitaly Puzrin. +> Copyright (C) 2026 - PRESENT by MrHope +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +> THE SOFTWARE. + +## @mdit/plugin-footnote + +License: MIT +By: Mr.Hope (https://mister-hope.com) +Repository: https://github.com/mdit-plugins/mdit-plugins + +> The MIT License (MIT) +> +> Copyright (c) 2014-2015 Vitaly Puzrin, Alex Kocharin. +> Copyright (C) 2022 - PRESENT by MrHope +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +> THE SOFTWARE. + +## @mdit/plugin-tasklist + +License: MIT +By: Mr.Hope (https://mister-hope.com) +Repository: https://github.com/mdit-plugins/mdit-plugins + +> The MIT License (MIT) +> +> Copyright (c) 2016, Revin Guillen +> Modified code Copyright (c) 2016, linsir, MIT License +> Copyright (C) 2022 - PRESENT by MrHope +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +> THE SOFTWARE. + +## @polka/compression, @polka/url, polka, sirv + +License: MIT +By: Luke Edwards (https://lukeed.com) +Repository: https://github.com/lukeed/polka, https://github.com/lukeed/sirv + +> MIT License +> +> Copyright (c) Luke Edwards (https://lukeed.com) +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all +> copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE. + +## @rolldown/pluginutils + +License: MIT +Repository: https://github.com/rolldown/plugins + +> MIT License +> +> Copyright (c) 2026-present, rolldown/plugins repository contributors +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all +> copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE. + +## Inter + +License: OFL-1.1 +By: The Inter Project Authors +Repository: https://github.com/rsms/inter + +> Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) +> +> This Font Software is licensed under the SIL Open Font License, Version 1.1. +> This license is copied below, and is also available with a FAQ at: +> http://scripts.sil.org/OFL +> +> ----------------------------------------------------------- +> SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +> ----------------------------------------------------------- +> +> PREAMBLE +> The goals of the Open Font License (OFL) are to stimulate worldwide +> development of collaborative font projects, to support the font creation +> efforts of academic and linguistic communities, and to provide a free and +> open framework in which fonts may be shared and improved in partnership +> with others. +> +> The OFL allows the licensed fonts to be used, studied, modified and +> redistributed freely as long as they are not sold by themselves. The +> fonts, including any derivative works, can be bundled, embedded, +> redistributed and/or sold with any software provided that any reserved +> names are not used by derivative works. The fonts and derivatives, +> however, cannot be released under any other type of license. The +> requirement for fonts to remain under this license does not apply +> to any document created using the fonts or their derivatives. +> +> DEFINITIONS +> "Font Software" refers to the set of files released by the Copyright +> Holder(s) under this license and clearly marked as such. This may +> include source files, build scripts and documentation. +> +> "Reserved Font Name" refers to any names specified as such after the +> copyright statement(s). +> +> "Original Version" refers to the collection of Font Software components as +> distributed by the Copyright Holder(s). +> +> "Modified Version" refers to any derivative made by adding to, deleting, +> or substituting -- in part or in whole -- any of the components of the +> Original Version, by changing formats or by porting the Font Software to a +> new environment. +> +> "Author" refers to any designer, engineer, programmer, technical +> writer or other person who contributed to the Font Software. +> +> PERMISSION AND CONDITIONS +> Permission is hereby granted, free of charge, to any person obtaining +> a copy of the Font Software, to use, study, copy, merge, embed, modify, +> redistribute, and sell modified and unmodified copies of the Font +> Software, subject to the following conditions: +> +> 1) Neither the Font Software nor any of its individual components, +> in Original or Modified Versions, may be sold by itself. +> +> 2) Original or Modified Versions of the Font Software may be bundled, +> redistributed and/or sold with any software, provided that each copy +> contains the above copyright notice and this license. These can be +> included either as stand-alone text files, human-readable headers or +> in the appropriate machine-readable metadata fields within text or +> binary files as long as those fields can be easily viewed by the user. +> +> 3) No Modified Version of the Font Software may use the Reserved Font +> Name(s) unless explicit written permission is granted by the corresponding +> Copyright Holder. This restriction only applies to the primary font name as +> presented to the users. +> +> 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +> Software shall not be used to promote, endorse or advertise any +> Modified Version, except to acknowledge the contribution(s) of the +> Copyright Holder(s) and the Author(s) or with their explicit written +> permission. +> +> 5) The Font Software, modified or unmodified, in part or in whole, +> must be distributed entirely under this license, and must not be +> distributed under any other license. The requirement for fonts to +> remain under this license does not apply to any document created +> using the Font Software. +> +> TERMINATION +> This license becomes null and void if any of the above conditions are +> not met. +> +> DISCLAIMER +> THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +> EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +> MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +> OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +> COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +> INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +> DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +> FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +> OTHER DEALINGS IN THE FONT SOFTWARE. + +## ansi-regex, chalk, cli-cursor, cli-spinners, find-up-simple, get-east-asian-width, is-interactive, is-unicode-supported, log-symbols, mimic-function, onetime, ora, p-map, package-directory, restore-cursor, stdin-discarder, string-width, strip-ansi, yoctocolors + +License: MIT +By: Sindre Sorhus (https://sindresorhus.com) +Repository: https://github.com/chalk/ansi-regex, https://github.com/chalk/chalk, https://github.com/chalk/strip-ansi, https://github.com/sindresorhus/cli-cursor, https://github.com/sindresorhus/cli-spinners, https://github.com/sindresorhus/find-up-simple, https://github.com/sindresorhus/get-east-asian-width, https://github.com/sindresorhus/is-interactive, https://github.com/sindresorhus/is-unicode-supported, https://github.com/sindresorhus/log-symbols, https://github.com/sindresorhus/mimic-function, https://github.com/sindresorhus/onetime, https://github.com/sindresorhus/ora, https://github.com/sindresorhus/p-map, https://github.com/sindresorhus/package-directory, https://github.com/sindresorhus/restore-cursor, https://github.com/sindresorhus/stdin-discarder, https://github.com/sindresorhus/string-width, https://github.com/sindresorhus/yoctocolors + +> MIT License +> +> Copyright (c) Sindre Sorhus (https://sindresorhus.com) +> +> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## cross-spawn + +License: MIT +By: André Cruz +Repository: https://github.com/moxystudio/node-cross-spawn + +> The MIT License (MIT) +> +> Copyright (c) 2018 Made With MOXY Lda +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +> THE SOFTWARE. + +## cssesc, punycode.js + +License: MIT +By: Mathias Bynens (https://mathiasbynens.be/) +Repository: https://github.com/mathiasbynens/cssesc, https://github.com/mathiasbynens/punycode.js + +> Copyright Mathias Bynens +> +> Permission is hereby granted, free of charge, to any person obtaining +> a copy of this software and associated documentation files (the +> "Software"), to deal in the Software without restriction, including +> without limitation the rights to use, copy, modify, merge, publish, +> distribute, sublicense, and/or sell copies of the Software, and to +> permit persons to whom the Software is furnished to do so, subject to +> the following conditions: +> +> The above copyright notice and this permission notice shall be +> included in all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +> EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +> MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +> LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +> OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +> WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## entities + +License: BSD-2-Clause +By: Felix Boehm +Repository: https://github.com/fb55/entities + +> Copyright (c) Felix Böhm +> All rights reserved. +> +> Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: +> +> Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +> +> Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +> +> THIS IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS, +> EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +## esprima + +License: BSD-2-Clause +By: Ariya Hidayat +Repository: https://github.com/jquery/esprima + +> Copyright JS Foundation and other contributors, https://js.foundation/ +> +> Redistribution and use in source and binary forms, with or without +> modification, are permitted provided that the following conditions are met: +> +> * Redistributions of source code must retain the above copyright +> notice, this list of conditions and the following disclaimer. +> * Redistributions in binary form must reproduce the above copyright +> notice, this list of conditions and the following disclaimer in the +> documentation and/or other materials provided with the distribution. +> +> THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +> AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +> IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +> ARE DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY +> DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +> (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +> LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +> ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +> (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF +> THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +## eta + +License: MIT +By: Ben Gubler +Repository: https://github.com/bgub/eta + +> Copyright 2025 Ben Gubler +> +> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## extend-shallow + +License: MIT +By: Jon Schlinkert (https://github.com/jonschlinkert) +Repository: https://github.com/jonschlinkert/extend-shallow + +> The MIT License (MIT) +> +> Copyright (c) 2014-2015, Jon Schlinkert. +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +> THE SOFTWARE. + +## fast-string-truncated-width, fast-string-width + +License: MIT +Repository: https://github.com/fabiospampinato/fast-string-truncated-width, https://github.com/fabiospampinato/fast-string-width + +> The MIT License (MIT) +> +> Copyright (c) 2024-present Fabio Spampinato +> +> Permission is hereby granted, free of charge, to any person obtaining a +> copy of this software and associated documentation files (the "Software"), +> to deal in the Software without restriction, including without limitation +> the rights to use, copy, modify, merge, publish, distribute, sublicense, +> and/or sell copies of the Software, and to permit persons to whom the +> Software is furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +> FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +> DEALINGS IN THE SOFTWARE. + +## fast-wrap-ansi + +License: MIT +By: James Garbutt (https://github.com/43081j) +Repository: https://github.com/43081j/fast-wrap-ansi + +> MIT License +> +> Copyright (c) 2025 James Garbutt +> +> Copyright (c) Sindre Sorhus (https://sindresorhus.com) +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all +> copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE. + +## fdir + +License: MIT +By: thecodrr +Repository: https://github.com/thecodrr/fdir + +> Copyright 2023 Abdullah Atta +> +> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## gray-matter + +License: MIT +By: Jon Schlinkert (https://github.com/jonschlinkert), Andrew Meyer (https://github.com/Ajedi32), Brian Woodward (https://twitter.com/doowb), Caesar Schinas (https://caesarschinas.com), Ian Storm Taylor (http://ianstormtaylor.com), Jon Schlinkert (http://twitter.com/jonschlinkert), Osman Nuri Okumuş (http://onokumus.com), Pawel Kadluczka (http://blog.3d-logic.com), Rob Loach (http://robloach.net), (https://github.com/heymind), Zach Whaley (http://zachwhaleys.website) +Repository: https://github.com/jonschlinkert/gray-matter + +> The MIT License (MIT) +> +> Copyright (c) 2014-2018, Jon Schlinkert. +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +> THE SOFTWARE. + +## image-size + +License: MIT +By: netroy (http://netroy.in/) +Repository: https://github.com/image-size/image-size + +> The MIT License (MIT) +> +> Copyright © 2013-Present Aditya Yadav, http://netroy.in +> +> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## import-meta-resolve + +License: MIT +By: Titus Wormer (https://wooorm.com) +Repository: https://github.com/wooorm/import-meta-resolve + +> (The MIT License) +> +> Copyright (c) Titus Wormer +> +> Permission is hereby granted, free of charge, to any person obtaining +> a copy of this software and associated documentation files (the +> 'Software'), to deal in the Software without restriction, including +> without limitation the rights to use, copy, modify, merge, publish, +> distribute, sublicense, and/or sell copies of the Software, and to +> permit persons to whom the Software is furnished to do so, subject to +> the following conditions: +> +> The above copyright notice and this permission notice shall be +> included in all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +> EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +> MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +> IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +> CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +> TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +> SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +> +> --- +> +> This is a derivative work based on: +> . +> Which is licensed: +> +> """ +> Copyright Node.js contributors. All rights reserved. +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to +> deal in the Software without restriction, including without limitation the +> rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +> sell copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +> FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +> IN THE SOFTWARE. +> """ +> +> This license applies to parts of Node.js originating from the +> https://github.com/joyent/node repository: +> +> """ +> Copyright Joyent, Inc. and other Node contributors. All rights reserved. +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to +> deal in the Software without restriction, including without limitation the +> rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +> sell copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +> FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +> IN THE SOFTWARE. +> """ + +## is-extendable + +License: MIT +By: Jon Schlinkert (https://github.com/jonschlinkert) +Repository: https://github.com/jonschlinkert/is-extendable + +> The MIT License (MIT) +> +> Copyright (c) 2015, Jon Schlinkert. +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +> THE SOFTWARE. + +## isexe, which + +License: ISC +By: Isaac Z. Schlueter (http://blog.izs.me), Isaac Z. Schlueter (http://blog.izs.me/) +Repository: https://github.com/isaacs/isexe, https://github.com/isaacs/node-which + +> The ISC License +> +> Copyright (c) Isaac Z. Schlueter and Contributors +> +> Permission to use, copy, modify, and/or distribute this software for any +> purpose with or without fee is hereby granted, provided that the above +> copyright notice and this permission notice appear in all copies. +> +> THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +> WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +> MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +> ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +> WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +> ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +> IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +## js-yaml + +License: MIT +By: Vladimir Zapparov , Aleksey V Zapparov (http://www.ixti.net/), Vitaly Puzrin (https://github.com/puzrin), Martin Grenfell (http://got-ravings.blogspot.com) +Repository: https://github.com/nodeca/js-yaml + +> (The MIT License) +> +> Copyright (C) 2011-2015 by Vitaly Puzrin +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +> THE SOFTWARE. + +## kind-of + +License: MIT +By: Jon Schlinkert (https://github.com/jonschlinkert), David Fox-Powell (https://dtothefp.github.io/me), James (https://twitter.com/aretecode), Jon Schlinkert (http://twitter.com/jonschlinkert), Ken Sheedlo (kensheedlo.com), laggingreflex (https://github.com/laggingreflex), Miguel Mota (https://miguelmota.com), Peter deHaan (http://about.me/peterdehaan), tunnckoCore (https://i.am.charlike.online) +Repository: https://github.com/jonschlinkert/kind-of + +> The MIT License (MIT) +> +> Copyright (c) 2014-2017, Jon Schlinkert. +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +> THE SOFTWARE. + +## linkify-it + +License: MIT +Repository: https://github.com/markdown-it/linkify-it + +> Copyright (c) 2015 Vitaly Puzrin. +> +> Permission is hereby granted, free of charge, to any person +> obtaining a copy of this software and associated documentation +> files (the "Software"), to deal in the Software without +> restriction, including without limitation the rights to use, +> copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the +> Software is furnished to do so, subject to the following +> conditions: +> +> The above copyright notice and this permission notice shall be +> included in all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +> EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +> OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +> HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +> WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +> FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +> OTHER DEALINGS IN THE SOFTWARE. + +## lru-cache + +License: BlueOak-1.0.0 +By: Isaac Z. Schlueter +Repository: https://github.com/isaacs/node-lru-cache + +> # Blue Oak Model License +> +> Version 1.0.0 +> +> ## Purpose +> +> This license gives everyone as much permission to work with +> this software as possible, while protecting contributors +> from liability. +> +> ## Acceptance +> +> In order to receive this license, you must agree to its +> rules. The rules of this license are both obligations +> under that agreement and conditions to your license. +> You must not do anything with this software that triggers +> a rule that you cannot or will not follow. +> +> ## Copyright +> +> Each contributor licenses you to do everything with this +> software that would otherwise infringe that contributor's +> copyright in it. +> +> ## Notices +> +> You must ensure that everyone who gets a copy of +> any part of this software from you, with or without +> changes, also gets the text of this license or a link to +> . +> +> ## Excuse +> +> If anyone notifies you in writing that you have not +> complied with [Notices](#notices), you can keep your +> license by taking all practical steps to comply within 30 +> days after the notice. If you do not do so, your license +> ends immediately. +> +> ## Patent +> +> Each contributor licenses you to do everything with this +> software that would otherwise infringe any patent claims +> they can license or become able to license. +> +> ## Reliability +> +> No contributor can revoke this license. +> +> ## No Liability +> +> ***As far as the law allows, this software comes as is, +> without any warranty or condition, and no contributor +> will be liable to anyone for any damages related to this +> software or this license, under any kind of legal claim.*** + +## markdown-it + +License: MIT +Repository: https://github.com/markdown-it/markdown-it + +> Copyright (c) 2014 Vitaly Puzrin, Alex Kocharin. +> +> Permission is hereby granted, free of charge, to any person +> obtaining a copy of this software and associated documentation +> files (the "Software"), to deal in the Software without +> restriction, including without limitation the rights to use, +> copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the +> Software is furnished to do so, subject to the following +> conditions: +> +> The above copyright notice and this permission notice shall be +> included in all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +> EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +> OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +> HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +> WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +> FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +> OTHER DEALINGS IN THE SOFTWARE. + +## markdown-it-async + +License: MIT +By: Anthony Fu +Repository: https://github.com/antfu/markdown-it-async + +> MIT License +> +> Copyright (c) 2022 Anthony Fu +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all +> copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE. + +## markdown-it-cjk-friendly + +License: MIT +By: Tatsunori Uchino (https://github.com/tats-u) +Repository: https://github.com/tats-u/markdown-cjk-friendly + +> Copyright (c) 2024 Tatsunori Uchino +> +> markdown-it: +> +> Copyright (c) 2014 Vitaly Puzrin, Alex Kocharin. +> +> Permission is hereby granted, free of charge, to any person +> obtaining a copy of this software and associated documentation +> files (the "Software"), to deal in the Software without +> restriction, including without limitation the rights to use, +> copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the +> Software is furnished to do so, subject to the following +> conditions: +> +> The above copyright notice and this permission notice shall be +> included in all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +> EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +> OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +> HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +> WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +> FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +> OTHER DEALINGS IN THE SOFTWARE. + +## mdurl + +License: MIT +Repository: https://github.com/markdown-it/mdurl + +> Copyright (c) 2015 Vitaly Puzrin, Alex Kocharin. +> +> Permission is hereby granted, free of charge, to any person +> obtaining a copy of this software and associated documentation +> files (the "Software"), to deal in the Software without +> restriction, including without limitation the rights to use, +> copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the +> Software is furnished to do so, subject to the following +> conditions: +> +> The above copyright notice and this permission notice shall be +> included in all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +> EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +> OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +> HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +> WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +> FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +> OTHER DEALINGS IN THE SOFTWARE. +> +> -------------------------------------------------------------------------------- +> +> .parse() is based on Joyent's node.js `url` code: +> +> Copyright Joyent, Inc. and other Node contributors. All rights reserved. +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to +> deal in the Software without restriction, including without limitation the +> rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +> sell copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +> FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +> IN THE SOFTWARE. + +## minimist + +License: MIT +By: James Halliday (http://substack.net) +Repository: https://github.com/minimistjs/minimist + +> This software is released under the MIT license: +> +> Permission is hereby granted, free of charge, to any person obtaining a copy of +> this software and associated documentation files (the "Software"), to deal in +> the Software without restriction, including without limitation the rights to +> use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +> the Software, and to permit persons to whom the Software is furnished to do so, +> subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all +> copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +> FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +> COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +> IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +> CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## mrmime + +License: MIT +By: Luke Edwards (https://lukeed.com) +Repository: https://github.com/lukeed/mrmime + +> The MIT License (MIT) +> +> Copyright (c) Luke Edwards (https://lukeed.com) +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +> THE SOFTWARE. + +## nanoid + +License: MIT +By: Andrey Sitnik +Repository: https://github.com/ai/nanoid + +> The MIT License (MIT) +> +> Copyright 2017 Andrey Sitnik +> +> Permission is hereby granted, free of charge, to any person obtaining a copy of +> this software and associated documentation files (the "Software"), to deal in +> the Software without restriction, including without limitation the rights to +> use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +> the Software, and to permit persons to whom the Software is furnished to do so, +> subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all +> copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +> FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +> COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +> IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +> CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## obug + +License: MIT +By: Kevin Deng +Repository: https://github.com/sxzz/obug + +> The MIT License (MIT) +> +> Copyright © 2025-PRESENT Kevin Deng (https://github.com/sxzz) +> Copyright (c) 2014-2017 TJ Holowaychuk +> Copyright (c) 2018-2021 Josh Junon +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all +> copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE. + +## package-manager-detector + +License: MIT +By: Anthony Fu +Repository: https://github.com/antfu-collective/package-manager-detector + +> MIT License +> +> Copyright (c) 2020-PRESENT Anthony Fu +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all +> copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE. + +## path-key, shebang-regex + +License: MIT +By: Sindre Sorhus (sindresorhus.com) +Repository: https://github.com/sindresorhus/path-key, https://github.com/sindresorhus/shebang-regex + +> MIT License +> +> Copyright (c) Sindre Sorhus (sindresorhus.com) +> +> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## path-to-regexp + +License: MIT +Repository: https://github.com/pillarjs/path-to-regexp + +> The MIT License (MIT) +> +> Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com) +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +> THE SOFTWARE. + +## picocolors + +License: ISC +By: Alexey Raspopov +Repository: https://github.com/alexeyraspopov/picocolors + +> ISC License +> +> Copyright (c) 2021-2024 Oleksii Raspopov, Kostiantyn Denysov, Anton Verinov +> +> Permission to use, copy, modify, and/or distribute this software for any +> purpose with or without fee is hereby granted, provided that the above +> copyright notice and this permission notice appear in all copies. +> +> THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +> WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +> MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +> ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +> WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +> ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +> OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +## picomatch + +License: MIT +By: Jon Schlinkert (https://github.com/jonschlinkert) +Repository: https://github.com/micromatch/picomatch + +> The MIT License (MIT) +> +> Copyright (c) 2017-present, Jon Schlinkert. +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +> THE SOFTWARE. + +## postcss-selector-parser + +License: MIT +By: Maxime Thirouin (https://moox.io), Ben Briggs (http://beneb.info), Chris Eppstein +Repository: https://github.com/postcss/postcss-selector-parser + +> Copyright (c) Ben Briggs (http://beneb.info) +> +> Permission is hereby granted, free of charge, to any person +> obtaining a copy of this software and associated documentation +> files (the "Software"), to deal in the Software without +> restriction, including without limitation the rights to use, +> copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the +> Software is furnished to do so, subject to the following +> conditions: +> +> The above copyright notice and this permission notice shall be +> included in all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +> EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +> OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +> HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +> WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +> FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +> OTHER DEALINGS IN THE SOFTWARE. + +## regexparam, totalist, trouter + +License: MIT +By: Luke Edwards (https://lukeed.com) +Repository: https://github.com/lukeed/regexparam, https://github.com/lukeed/totalist, https://github.com/lukeed/trouter + +> The MIT License (MIT) +> +> Copyright (c) Luke Edwards (lukeed.com) +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +> THE SOFTWARE. + +## sax + +License: BlueOak-1.0.0 +By: Isaac Z. Schlueter (http://blog.izs.me/) +Repository: https://github.com/isaacs/sax-js + +> # Blue Oak Model License +> +> Version 1.0.0 +> +> ## Purpose +> +> This license gives everyone as much permission to work with +> this software as possible, while protecting contributors +> from liability. +> +> ## Acceptance +> +> In order to receive this license, you must agree to its +> rules. The rules of this license are both obligations +> under that agreement and conditions to your license. +> You must not do anything with this software that triggers +> a rule that you cannot or will not follow. +> +> ## Copyright +> +> Each contributor licenses you to do everything with this +> software that would otherwise infringe that contributor's +> copyright in it. +> +> ## Notices +> +> You must ensure that everyone who gets a copy of +> any part of this software from you, with or without +> changes, also gets the text of this license or a link to +> . +> +> ## Excuse +> +> If anyone notifies you in writing that you have not +> complied with [Notices](#notices), you can keep your +> license by taking all practical steps to comply within 30 +> days after the notice. If you do not do so, your license +> ends immediately. +> +> ## Patent +> +> Each contributor licenses you to do everything with this +> software that would otherwise infringe any patent claims +> they can license or become able to license. +> +> ## Reliability +> +> No contributor can revoke this license. +> +> ## No Liability +> +> ***As far as the law allows, this software comes as is, +> without any warranty or condition, and no contributor +> will be liable to anyone for any damages related to this +> software or this license, under any kind of legal claim.*** + +## section-matter + +License: MIT +By: Jon Schlinkert (https://github.com/jonschlinkert) +Repository: https://github.com/jonschlinkert/section-matter + +> The MIT License (MIT) +> +> Copyright (c) 2017, Jon Schlinkert. +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all +> copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE. + +## shebang-command + +License: MIT +By: Kevin Mårtensson (github.com/kevva) +Repository: https://github.com/kevva/shebang-command + +> MIT License +> +> Copyright (c) Kevin Mårtensson (github.com/kevva) +> +> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## signal-exit + +License: ISC +By: Ben Coe +Repository: https://github.com/tapjs/signal-exit + +> The ISC License +> +> Copyright (c) 2015-2023 Benjamin Coe, Isaac Z. Schlueter, and Contributors +> +> Permission to use, copy, modify, and/or distribute this software +> for any purpose with or without fee is hereby granted, provided +> that the above copyright notice and this permission notice +> appear in all copies. +> +> THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +> WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES +> OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE +> LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES +> OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +> WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +> ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +## sisteransi + +License: MIT +By: Terkel Gjervig (https://terkel.com) +Repository: https://github.com/terkelg/sisteransi + +> MIT License +> +> Copyright (c) 2018 Terkel Gjervig Nielsen +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all +> copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE. + +## sitemap + +License: MIT +By: Eugene Kalinin +Repository: https://github.com/ekalinin/sitemap.js + +> (The MIT License) +> +> Copyright (c) 2011 Eugene Kalinin +> +> Permission is hereby granted, free of charge, to any person obtaining +> a copy of this software and associated documentation files (the +> 'Software'), to deal in the Software without restriction, including +> without limitation the rights to use, copy, modify, merge, publish, +> distribute, sublicense, and/or sell copies of the Software, and to +> permit persons to whom the Software is furnished to do so, subject to +> the following conditions: +> +> The above copyright notice and this permission notice shall be +> included in all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +> EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +> MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +> IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +> CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +> TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +> SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## strip-bom-string + +License: MIT +By: Jon Schlinkert (https://github.com/jonschlinkert) +Repository: https://github.com/jonschlinkert/strip-bom-string + +> The MIT License (MIT) +> +> Copyright (c) 2015, 2017, Jon Schlinkert +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +> THE SOFTWARE. + +## tinyexec + +License: MIT +By: James Garbutt (https://github.com/43081j) +Repository: https://github.com/tinylibs/tinyexec + +> MIT License +> +> Copyright (c) 2024 Tinylibs +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all +> copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE. + +## tinyglobby + +License: MIT +By: Superchupu +Repository: https://github.com/SuperchupuDev/tinyglobby + +> MIT License +> +> Copyright (c) 2024 Madeline Gurriarán +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all +> copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE. + +## uc.micro + +License: MIT +Repository: https://github.com/markdown-it/uc.micro + +> Copyright Mathias Bynens +> +> Permission is hereby granted, free of charge, to any person obtaining +> a copy of this software and associated documentation files (the +> "Software"), to deal in the Software without restriction, including +> without limitation the rights to use, copy, modify, merge, publish, +> distribute, sublicense, and/or sell copies of the Software, and to +> permit persons to whom the Software is furnished to do so, subject to +> the following conditions: +> +> The above copyright notice and this permission notice shall be +> included in all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +> EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +> MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +> LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +> OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +> WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## util-deprecate + +License: MIT +By: Nathan Rajlich (http://n8.io/) +Repository: https://github.com/TooTallNate/util-deprecate + +> (The MIT License) +> +> Copyright (c) 2014 Nathan Rajlich +> +> Permission is hereby granted, free of charge, to any person +> obtaining a copy of this software and associated documentation +> files (the "Software"), to deal in the Software without +> restriction, including without limitation the rights to use, +> copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the +> Software is furnished to do so, subject to the following +> conditions: +> +> The above copyright notice and this permission notice shall be +> included in all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +> EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +> OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +> NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +> HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +> WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +> FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +> OTHER DEALINGS IN THE SOFTWARE. diff --git a/__tests__/base/cdn.test.ts b/__tests__/base/cdn.test.ts new file mode 100644 index 00000000..7b8ce80b --- /dev/null +++ b/__tests__/base/cdn.test.ts @@ -0,0 +1,63 @@ +import { newPage, realErrors, waitForHydration, type TestPage } from './helpers' + +const origin = () => `http://localhost:${process.env['PAGES_PORT']}` +const cdnPort = () => process.env['VP_CDN_PORT'] + +let t: TestPage + +beforeAll(async () => { + t = await newPage() +}) + +afterAll(async () => { + await t.page.close() + await t.browser.close() +}) + +describe('assetsBase with a separate cdn origin', () => { + test('pages hydrate from cross-origin assets', async () => { + await t.page.goto(`${origin()}/`) + await waitForHydration(t.page) + const cdnResources = await t.page.evaluate( + (port) => + performance + .getEntriesByType('resource') + .filter((r) => r.name.includes(`:${port}/`)).length, + cdnPort() + ) + expect(cdnResources).toBeGreaterThan(5) + }) + + test('client-side navigation loads page chunks from the cdn', async () => { + await t.page.evaluate(() => ((window as any).__spa_marker = 1)) + await t.page.click('.vp-doc a[href="/sub/page.html"]') + await t.page.waitForFunction(() => + document.querySelector('h1')?.textContent?.includes('Sub page') + ) + expect( + await t.page.evaluate(() => (window as any).__spa_marker === 1) + ).toBe(true) + const chunkFromCdn = await t.page.evaluate( + (port) => + performance + .getEntriesByType('resource') + .some((r) => r.name.includes(`:${port}/`) && r.name.includes('.md.')), + cdnPort() + ) + expect(chunkFromCdn).toBe(true) + }) + + test('search works with the index chunk on the cdn', async () => { + await t.page.click('.VPNavBarSearchButton') + const input = await t.page.waitForSelector('input#localsearch-input') + await input.type('xylophone') + await t.page.waitForSelector('#localsearch-list li[role=option] a') + expect( + await t.page.getAttribute('#localsearch-list li[role=option] a', 'href') + ).toBe('/sub/deep/page2.html#deep-heading') + }) + + test('no console or page errors across the whole flow', () => { + expect(realErrors(t.errors)).toEqual([]) + }) +}) diff --git a/__tests__/base/constants.ts b/__tests__/base/constants.ts new file mode 100644 index 00000000..a2d5bcbe --- /dev/null +++ b/__tests__/base/constants.ts @@ -0,0 +1,2 @@ +export const SUB_PREFIX = '/ipfs/QmRelocatableTest123/' +export const ALT_PREFIX = '/some/other/place/' diff --git a/__tests__/base/emit.test.ts b/__tests__/base/emit.test.ts new file mode 100644 index 00000000..aa805e6a --- /dev/null +++ b/__tests__/base/emit.test.ts @@ -0,0 +1,210 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { basename, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const dir = resolve(fileURLToPath(import.meta.url), '..') +const dist = (mode: string, ...p: string[]) => + join(dir, `fixture/.vitepress/dist-${mode}`, ...p) +const read = (mode: string, file: string) => + readFileSync(dist(mode, file), 'utf-8') + +const walk = (root: string): string[] => + readdirSync(root, { recursive: true, withFileTypes: true }) + .filter((e) => e.isFile()) + .map((e) => join(e.parentPath, e.name)) + +describe('relative base emit', () => { + test('root page references everything at ./', () => { + const html = read('relative', 'index.html') + expect(html).toContain( + 'window.__VP_SITE_ROOT__=new URL("./",location).href' + ) + expect(html).toMatch(/href="\.\/assets\/style\.[\w-]+\.css"/) + expect(html).toMatch(/src="\.\/assets\/app\.[\w-]+\.js"/) + expect(html).toMatch(/src="\.\/assets\/chunks\/metadata\.[\w-]+\.js"/) + expect(html).toMatch(/href="\.\/assets\/vp-icons\.[\w-]+\.css"/) + expect( + walk(dist('relative', 'assets')).some((f) => + /vp-icons\.[\w-]+\.css$/.test(f) + ) + ).toBe(true) + }) + + test('markdown links compile page-relative with explicit index.html', () => { + const html = read('relative', 'index.html') + expect(html).toContain('href="./sub/page.html"') + expect(html).toContain('href="./sub/index.html"') + expect(html).toContain('href="./moved/target.html"') + }) + + test('non-page links get the prefix but no .html', () => { + const html = read('relative', 'index.html') + expect(html).toContain('href="./file.zip"') + expect(html).not.toContain('file.zip.html') + }) + + test('public and hashed assets in content are page-relative', () => { + const html = read('relative', 'index.html') + expect(html).toContain('src="./logo.png"') + expect(html).toMatch(/src="\.\/assets\/photo\.[\w-]+\.png"/) + }) + + test('depth 1 pages use ../', () => { + const html = read('relative', 'sub/page.html') + expect(html).toContain( + 'window.__VP_SITE_ROOT__=new URL("../",location).href' + ) + expect(html).toMatch(/href="\.\.\/assets\/style\.[\w-]+\.css"/) + expect(html).toMatch(/href="\.\.\/assets\/vp-icons\.[\w-]+\.css"/) + expect(html).toContain('src="../logo.png"') + expect(html).toContain('href="../index.html"') + expect(html).toContain('href="../sub/deep/page2.html"') + }) + + test('hash and external links stay untouched', () => { + const html = read('relative', 'sub/page.html') + expect(html).toContain('href="#local-anchor"') + expect(html).toContain('href="https://example.com/x"') + }) + + test('depth 2 pages use ../../', () => { + const html = read('relative', 'sub/deep/page2.html') + expect(html).toContain( + 'window.__VP_SITE_ROOT__=new URL("../../",location).href' + ) + expect(html).toMatch(/href="\.\.\/\.\.\/assets\/style\.[\w-]+\.css"/) + }) + + test('rewritten page lands at its rewrite depth', () => { + const html = read('relative', 'moved/target.html') + expect(html).toContain( + 'window.__VP_SITE_ROOT__=new URL("../",location).href' + ) + expect(html).toMatch(/href="\.\.\/assets\/style\.[\w-]+\.css"/) + }) + + test('404 renders at root depth', () => { + const html = read('relative', '404.html') + expect(html).toContain( + 'window.__VP_SITE_ROOT__=new URL("./",location).href' + ) + expect(html).toMatch(/href="\.\/assets\/style\.[\w-]+\.css"/) + }) + + test('no sentinel leaks into emitted html or css', () => { + for (const file of walk(dist('relative'))) { + if (!/\.(html|css)$/.test(file)) continue + expect(readFileSync(file, 'utf-8'), file).not.toContain('__VP_BASE__') + } + }) + + test('content-loader html keeps site-absolute links', () => { + const html = read('relative', 'blog.html') + // the loader source lives at posts/deep/, the consumer at the root — + // per-source relativizing would point above the site root + expect(html).toContain('href="/sub/page.html"') + expect(html).not.toContain('../../sub/page.html') + // the consuming page's own chrome is still relative + expect(html).toMatch(/href="\.\/assets\/style\.[\w-]+\.css"/) + }) +}) + +describe('assetsBase emit', () => { + const cdn = () => `http://localhost:${process.env['VP_CDN_PORT']}/` + + test('scripts, styles and preloads move to the cdn with crossorigin', () => { + const html = read('cdn', 'index.html') + expect(html).toMatch( + new RegExp( + ` + +
diff --git a/__tests__/base/fixture/img/photo.png b/__tests__/base/fixture/img/photo.png new file mode 100644 index 00000000..f37764b1 Binary files /dev/null and b/__tests__/base/fixture/img/photo.png differ diff --git a/__tests__/base/fixture/index.md b/__tests__/base/fixture/index.md new file mode 100644 index 00000000..50f40608 --- /dev/null +++ b/__tests__/base/fixture/index.md @@ -0,0 +1,13 @@ +# Home + +![logo](/logo.png) + +![photo](/img/photo.png) + +[to sub](/sub/page) + +[to dir](/sub/) + +[zip](/file.zip) + +[moved](/moved/target) diff --git a/__tests__/base/fixture/posts.data.ts b/__tests__/base/fixture/posts.data.ts new file mode 100644 index 00000000..8a3fb96b --- /dev/null +++ b/__tests__/base/fixture/posts.data.ts @@ -0,0 +1,3 @@ +import { createContentLoader } from 'vitepress' + +export default createContentLoader('posts/**/*.md', { render: true }) diff --git a/__tests__/base/fixture/posts/deep/post1.md b/__tests__/base/fixture/posts/deep/post1.md new file mode 100644 index 00000000..abcd0e35 --- /dev/null +++ b/__tests__/base/fixture/posts/deep/post1.md @@ -0,0 +1,5 @@ +# Post one + +This is the intro of post one with a [site link](/sub/page) and ![img](/logo.png). + +More body. diff --git a/__tests__/base/fixture/public/file.zip b/__tests__/base/fixture/public/file.zip new file mode 100644 index 00000000..8c3b76fb --- /dev/null +++ b/__tests__/base/fixture/public/file.zip @@ -0,0 +1 @@ +PKtest \ No newline at end of file diff --git a/__tests__/base/fixture/public/logo.png b/__tests__/base/fixture/public/logo.png new file mode 100644 index 00000000..f37764b1 Binary files /dev/null and b/__tests__/base/fixture/public/logo.png differ diff --git a/__tests__/base/fixture/src-moved.md b/__tests__/base/fixture/src-moved.md new file mode 100644 index 00000000..92f16eaa --- /dev/null +++ b/__tests__/base/fixture/src-moved.md @@ -0,0 +1,3 @@ +# Moved page + +Rewritten target. diff --git a/__tests__/base/fixture/sub/deep/page2.md b/__tests__/base/fixture/sub/deep/page2.md new file mode 100644 index 00000000..21f56a29 --- /dev/null +++ b/__tests__/base/fixture/sub/deep/page2.md @@ -0,0 +1,7 @@ +# Deep page + +[up](/sub/page) + +## Deep heading + +The xylophone paragraph for search. diff --git a/__tests__/base/fixture/sub/index.md b/__tests__/base/fixture/sub/index.md new file mode 100644 index 00000000..2a28bbf3 --- /dev/null +++ b/__tests__/base/fixture/sub/index.md @@ -0,0 +1,3 @@ +# Sub index + +Index of sub. diff --git a/__tests__/base/fixture/sub/page.md b/__tests__/base/fixture/sub/page.md new file mode 100644 index 00000000..6a5ca71a --- /dev/null +++ b/__tests__/base/fixture/sub/page.md @@ -0,0 +1,15 @@ +# Sub page + +![logo again](/logo.png) + +[home](/) + +[deep](/sub/deep/page2) + +[hash](#local-anchor) + +[external](https://example.com/x) + +## Local anchor + +Body text here. diff --git a/__tests__/base/helpers.ts b/__tests__/base/helpers.ts new file mode 100644 index 00000000..05b9723d --- /dev/null +++ b/__tests__/base/helpers.ts @@ -0,0 +1,29 @@ +import { chromium, type Browser, type Page } from 'playwright-chromium' + +export interface TestPage { + browser: Browser + page: Page + errors: string[] +} + +export async function newPage(): Promise { + const browser = await chromium.connect(process.env['WS_ENDPOINT']!) + const page = await browser.newPage() + const errors: string[] = [] + page.on('console', (msg) => { + if (msg.type() === 'error') errors.push(msg.text()) + }) + page.on('pageerror', (err) => errors.push(String(err))) + return { browser, page, errors } +} + +export function realErrors(errors: string[]): string[] { + return errors.filter((e) => !e.includes('favicon')) +} + +export async function waitForHydration(page: Page): Promise { + await page.waitForSelector('#app .Layout') + await page.waitForFunction( + () => (document.querySelector('#app') as any)?.__vue_app__ !== undefined + ) +} diff --git a/__tests__/base/package.json b/__tests__/base/package.json new file mode 100644 index 00000000..555db084 --- /dev/null +++ b/__tests__/base/package.json @@ -0,0 +1,12 @@ +{ + "name": "tests-base", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run", + "watch": "DEBUG=1 vitest" + }, + "devDependencies": { + "vitepress": "workspace:*" + } +} diff --git a/__tests__/base/relative-file.test.ts b/__tests__/base/relative-file.test.ts new file mode 100644 index 00000000..7e839416 --- /dev/null +++ b/__tests__/base/relative-file.test.ts @@ -0,0 +1,60 @@ +import { join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +import { newPage, type TestPage } from './helpers' + +const dist = resolve( + fileURLToPath(import.meta.url), + '..', + 'fixture/.vitepress/dist-relative' +) + +const fileUrl = (...p: string[]) => pathToFileURL(join(dist, ...p)).href + +let t: TestPage + +beforeAll(async () => { + t = await newPage() +}) + +afterAll(async () => { + await t.page.close() + await t.browser.close() +}) + +// module scripts are cors-blocked from disk, so nothing hydrates here; the +// pre-rendered site must still be styled and navigable +describe('relative base opened over file://', () => { + test('pages render styled with working images', async () => { + await t.page.goto(fileUrl('sub/page.html')) + expect(await t.page.textContent('h1')).toContain('Sub page') + const fontFamily = await t.page.evaluate( + () => getComputedStyle(document.body).fontFamily + ) + expect(fontFamily).toContain('Inter') + const logoLoaded = await t.page.evaluate( + () => + document.querySelector('img[alt="logo again"]')! + .naturalWidth + ) + expect(logoLoaded).toBe(1) + }) + + test('content links navigate between files', async () => { + await t.page.click('.vp-doc a[href="../sub/deep/page2.html"]') + expect(await t.page.textContent('h1')).toContain('Deep page') + expect(t.page.url()).toBe(fileUrl('sub/deep/page2.html')) + }) + + test('theme links navigate between files', async () => { + await t.page.click('.VPSidebar a[href="../../moved/target.html"]') + expect(await t.page.textContent('h1')).toContain('Moved page') + expect(t.page.url()).toBe(fileUrl('moved/target.html')) + }) + + test('the root page reaches nested pages', async () => { + await t.page.goto(fileUrl('index.html')) + await t.page.click('.vp-doc a[href="./sub/index.html"]') + expect(await t.page.textContent('h1')).toContain('Sub index') + }) +}) diff --git a/__tests__/base/relative-spa.test.ts b/__tests__/base/relative-spa.test.ts new file mode 100644 index 00000000..04e0732e --- /dev/null +++ b/__tests__/base/relative-spa.test.ts @@ -0,0 +1,92 @@ +import { ALT_PREFIX, SUB_PREFIX } from './constants' +import { newPage, realErrors, waitForHydration, type TestPage } from './helpers' + +const origin = () => `http://localhost:${process.env['SUB_PORT']}` + +let t: TestPage + +beforeAll(async () => { + t = await newPage() +}) + +afterAll(async () => { + await t.page.close() + await t.browser.close() +}) + +// mark the window with a marker that only survives client-side navigation, +// proving no full document reload occurred +const mark = () => t.page.evaluate(() => ((window as any).__spa_marker = 1)) +const marked = () => t.page.evaluate(() => (window as any).__spa_marker === 1) + +describe('relative base served from a deep subpath', () => { + test('deep link loads and hydrates', async () => { + await t.page.goto(`${origin()}${SUB_PREFIX}sub/deep/page2.html`) + await waitForHydration(t.page) + expect(await t.page.textContent('h1')).toContain('Deep page') + expect(await t.page.evaluate(() => (window as any).__VP_SITE_ROOT__)).toBe( + `${origin()}${SUB_PREFIX}` + ) + }) + + test('sidebar navigation is client-side and lands on the right url', async () => { + await mark() + await t.page.click(`.VPSidebar a[href="${SUB_PREFIX}sub/page.html"]`) + await t.page.waitForFunction(() => + document.querySelector('h1')?.textContent?.includes('Sub page') + ) + expect(await marked()).toBe(true) + expect(new URL(t.page.url()).pathname).toBe(`${SUB_PREFIX}sub/page.html`) + }) + + test('content links navigate client-side', async () => { + await t.page.click('.vp-doc a[href="../index.html"]') + await t.page.waitForFunction(() => + document.querySelector('h1')?.textContent?.includes('Home') + ) + expect(await marked()).toBe(true) + // the router strips index.html from the address bar + expect(new URL(t.page.url()).pathname).toBe(SUB_PREFIX) + }) + + test('search finds pages and navigates to them', async () => { + await t.page.click('.VPNavBarSearchButton') + const input = await t.page.waitForSelector('input#localsearch-input') + await input.type('xylophone') + await t.page.waitForSelector('#localsearch-list li[role=option] a') + const href = await t.page.getAttribute( + '#localsearch-list li[role=option] a', + 'href' + ) + expect(href).toBe(`${SUB_PREFIX}sub/deep/page2.html#deep-heading`) + await t.page.click('#localsearch-list li[role=option] a') + await t.page.waitForFunction(() => + document.querySelector('h1')?.textContent?.includes('Deep page') + ) + expect(await marked()).toBe(true) + }) + + test('history back keeps working', async () => { + await t.page.goBack() + await t.page.waitForFunction(() => + document.querySelector('h1')?.textContent?.includes('Home') + ) + expect(new URL(t.page.url()).pathname).toBe(SUB_PREFIX) + }) + + test('the same build works mounted at a different prefix', async () => { + await t.page.goto(`${origin()}${ALT_PREFIX}index.html`) + await waitForHydration(t.page) + await mark() + await t.page.click('.vp-doc a[href="./sub/page.html"]') + await t.page.waitForFunction(() => + document.querySelector('h1')?.textContent?.includes('Sub page') + ) + expect(await marked()).toBe(true) + expect(new URL(t.page.url()).pathname).toBe(`${ALT_PREFIX}sub/page.html`) + }) + + test('no console or page errors across the whole flow', () => { + expect(realErrors(t.errors)).toEqual([]) + }) +}) diff --git a/__tests__/base/tsconfig.json b/__tests__/base/tsconfig.json new file mode 100644 index 00000000..1759c08d --- /dev/null +++ b/__tests__/base/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../tsconfig.json", + "include": ["**/*"], + "exclude": ["fixture/.vitepress/dist-*", "fixture/.vitepress/cache"] +} diff --git a/__tests__/base/vitest.config.ts b/__tests__/base/vitest.config.ts new file mode 100644 index 00000000..43f17310 --- /dev/null +++ b/__tests__/base/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config' + +const timeout = 60_000 + +export default defineConfig({ + test: { + globalSetup: ['vitestGlobalSetup.ts'], + testTimeout: timeout, + hookTimeout: timeout, + teardownTimeout: timeout, + globals: true, + fileParallelism: false + } +}) diff --git a/__tests__/base/vitestGlobalSetup.ts b/__tests__/base/vitestGlobalSetup.ts new file mode 100644 index 00000000..62953c6d --- /dev/null +++ b/__tests__/base/vitestGlobalSetup.ts @@ -0,0 +1,120 @@ +import { spawnSync } from 'node:child_process' +import { readFile, rm } from 'node:fs/promises' +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { extname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { chromium, type BrowserServer } from 'playwright-chromium' + +import { ALT_PREFIX, SUB_PREFIX } from './constants' + +const dir = resolve(fileURLToPath(import.meta.url), '..') +const bin = resolve(dir, '../../bin/vitepress.js') +const dist = (mode: string) => resolve(dir, `fixture/.vitepress/dist-${mode}`) + +const types: Record = { + '.html': 'text/html', + '.js': 'text/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.png': 'image/png', + '.woff2': 'font/woff2', + '.zip': 'application/zip' +} + +// listens on an os-assigned port (the other suites run in parallel on CI, +// so a pre-picked "free" port can be taken before we bind it) +function serveStatic( + mounts: [prefix: string, root: string][], + cors: boolean +): Promise { + const server = createServer(async (req, res) => { + const url = decodeURIComponent(new URL(req.url!, 'http://x').pathname) + for (const [prefix, root] of mounts) { + if (!url.startsWith(prefix)) continue + let file = url.slice(prefix.length) || 'index.html' + if (file.endsWith('/')) file += 'index.html' + try { + const data = await readFile(join(root, file)) + const headers: Record = { + 'content-type': types[extname(file)] ?? 'application/octet-stream' + } + if (cors) headers['access-control-allow-origin'] = '*' + res.writeHead(200, headers) + res.end(data) + return + } catch {} + } + res.writeHead(404) + res.end('not found') + }) + return new Promise((r) => server.listen(0, () => r(server))) +} + +const portOf = (server: Server) => (server.address() as AddressInfo).port + +let browserServer: BrowserServer +let servers: Server[] = [] + +export async function setup() { + // started before its dist exists so its real port can go into assetsBase + const cdnServer = await serveStatic([['/', dist('cdn')]], true) + const cdnPort = portOf(cdnServer) + + // one process per flavor: the markdown renderer is a module-level + // singleton, so in-process builds would leak the first base into the rest + for (const mode of ['plain', 'relative', 'cdn', 'mpa']) { + // mpa builds never empty outDir, so stale assets would survive reruns + await rm(dist(mode), { recursive: true, force: true }) + const res = spawnSync(process.execPath, [bin, 'build', 'fixture'], { + cwd: dir, + env: { + ...process.env, + VP_TEST_MODE: mode, + VP_CDN_PORT: String(cdnPort) + }, + encoding: 'utf-8' + }) + if (res.status !== 0) { + throw new Error(`build (${mode}) failed:\n${res.stdout}\n${res.stderr}`) + } + } + + servers = [ + // one relative-base build mounted at two unrelated prefixes + await serveStatic( + [ + [SUB_PREFIX, dist('relative')], + [ALT_PREFIX, dist('relative')] + ], + false + ), + await serveStatic([['/', dist('cdn')]], false), + cdnServer + ] + + browserServer = await chromium.launchServer({ + headless: !process.env.DEBUG, + args: process.env.CI + ? ['--no-sandbox', '--disable-setuid-sandbox'] + : undefined + }) + + process.env['WS_ENDPOINT'] = browserServer.wsEndpoint() + process.env['SUB_PORT'] = String(portOf(servers[0]!)) + process.env['PAGES_PORT'] = String(portOf(servers[1]!)) + process.env['VP_CDN_PORT'] = String(cdnPort) +} + +export async function teardown() { + await browserServer.close() + await Promise.all( + servers.map( + (server) => + new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())) + ) + ) + ) +} diff --git a/__tests__/e2e/.vitepress/config.ts b/__tests__/e2e/.vitepress/config.ts index acd74892..2525f02b 100644 --- a/__tests__/e2e/.vitepress/config.ts +++ b/__tests__/e2e/.vitepress/config.ts @@ -201,6 +201,8 @@ export default defineConfig({ markdown: { image: { lazyLoad: true } }, + // exercises force-inclusion of icons SSR never renders + icons: { include: ['lucide:egg'] }, themeConfig: { nav, sidebar, @@ -210,11 +212,22 @@ export default defineConfig({ link: '/home', ariaLabel: 'Home social link', target: '_self' + }, + { + icon: 'lucide:heart', + link: '/home', + ariaLabel: 'Heart social link' } ], search: { provider: 'local', options: { + miniSearch: { + options: { + tokenize: (text) => + text.split(/[\n\r\p{Z}\p{Terminal_Punctuation}]+/u) + } + }, async _render(src, env, md) { const html = await md.renderAsync(src, env) if (env.frontmatter?.search === false) return '' diff --git a/__tests__/e2e/icons/icons.test.ts b/__tests__/e2e/icons/icons.test.ts new file mode 100644 index 00000000..e1127b2e --- /dev/null +++ b/__tests__/e2e/icons/icons.test.ts @@ -0,0 +1,158 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const isBuild = !!process.env.VITE_TEST_BUILD + +const maskImage = (selector: string) => + page.$eval(selector, (el) => { + const styles = getComputedStyle(el) + return styles.maskImage || styles.webkitMaskImage + }) + +describe('icons', () => { + const externalRequests: string[] = [] + const devIconRequests: string[] = [] + + beforeAll(() => { + page.on('request', (request) => { + const url = request.url() + if (!url.startsWith(`http://localhost:${process.env['PORT']}`)) { + externalRequests.push(url) + } + if (url.includes('/_vpi/')) devIconRequests.push(url) + }) + }) + + test('social links render from both collections', async () => { + await goto('/') + + for (const [label, cls] of [ + ['Home social link', '.vpi-simple-icons-github'], + ['Heart social link', '.vpi-lucide-heart'] + ]) { + const selector = `a[aria-label="${label}"] span` + expect(await page.getAttribute(selector, 'class')).toBe(cls.slice(1)) + // an unresolved icon computes to mask-image: none and renders nothing + await page.waitForFunction( + (sel) => { + const el = document.querySelector(sel) + if (!el) return false + const styles = getComputedStyle(el) + return (styles.maskImage || styles.webkitMaskImage) !== 'none' + }, + selector, + { timeout: 3000 } + ) + } + }) + + test('VPIcon renders collection, default-collection and raw svg icons', async () => { + await goto('/icons/') + + expect(await page.getAttribute('[data-test-icon="lucide"]', 'class')).toBe( + 'vpi-lucide-rocket' + ) + expect(await page.getAttribute('[data-test-icon="simple"]', 'class')).toBe( + 'vpi-simple-icons-vuedotjs' + ) + expect( + await page.$eval('[data-test-icon="raw"]', (el) => el.innerHTML) + ).toContain(' { + const styles = getComputedStyle(el) + return { + background: styles.backgroundColor, + svgWidth: getComputedStyle(el.querySelector('svg')!).width + } + }) + ).toEqual({ background: 'rgba(0, 0, 0, 0)', svgWidth: '16px' }) + + await page.waitForFunction(() => { + const el = document.querySelector('[data-test-icon="lucide"]') + if (!el) return false + const styles = getComputedStyle(el) + return (styles.maskImage || styles.webkitMaskImage) !== 'none' + }) + }) + + test('no icon is ever fetched from an external origin', () => { + expect(externalRequests).toEqual([]) + }) + + test.runIf(!isBuild)( + 'dev resolves icons from the local endpoint', + async () => { + await goto('/') + await page.waitForFunction(() => { + const el = document.querySelector( + 'a[aria-label="Heart social link"] span' + ) + if (!el) return false + const styles = getComputedStyle(el) + return (styles.maskImage || styles.webkitMaskImage).includes('/_vpi/') + }) + expect( + devIconRequests.some((url) => url.includes('/_vpi/lucide/heart.svg')) + ).toBe(true) + } + ) + + test.runIf(isBuild)( + 'build inlines icons into the hashed stylesheet', + async () => { + await goto('/') + expect( + await maskImage('a[aria-label="Heart social link"] span') + ).toContain('data:image/svg+xml') + expect(devIconRequests).toEqual([]) + + const html = readFileSync( + resolve( + fileURLToPath(import.meta.url), + '../../.vitepress/dist/index.html' + ), + 'utf-8' + ) + expect(html).toMatch(/href="\/assets\/vp-icons\.[\w-]+\.css"/) + expect(html).not.toContain('__VP_ICONS_HASH__') + + // prose mentioning the placeholder is left alone — only the link tag + // gets the hash substituted + const iconsPage = readFileSync( + resolve( + fileURLToPath(import.meta.url), + '../../.vitepress/dist/icons/index.html' + ), + 'utf-8' + ) + expect(iconsPage).toContain('vp-icons.__VP_ICONS_HASH__.css') + expect(iconsPage).toMatch( + // + ) + } + ) + + test.runIf(isBuild)( + 'icons.include forces unrendered icons into the sheet', + () => { + const assetsDir = resolve( + fileURLToPath(import.meta.url), + '../../.vitepress/dist/assets' + ) + const cssFile = readdirSync(assetsDir).find((f) => + /^vp-icons\.[\w-]+\.css$/.test(f) + )! + expect(cssFile).toBeTruthy() + const css = readFileSync(join(assetsDir, cssFile), 'utf-8') + expect(css).toContain('.vpi-lucide-egg') + expect(css).toContain('.vpi-lucide-heart') + expect(css).toContain('.vpi-simple-icons-github') + // zero-specificity base rules ship with the sheet for any theme + expect(css).toContain(':where(') + } + ) +}) diff --git a/__tests__/e2e/icons/index.md b/__tests__/e2e/icons/index.md new file mode 100644 index 00000000..9bce3c59 --- /dev/null +++ b/__tests__/e2e/icons/index.md @@ -0,0 +1,12 @@ +# Icons + + + + + + + +Prose about the build internals must survive the rewrite pass: +`vp-icons.__VP_ICONS_HASH__.css` diff --git a/__tests__/e2e/local-search/frontmatter-title.md b/__tests__/e2e/local-search/frontmatter-title.md new file mode 100644 index 00000000..0000a15b --- /dev/null +++ b/__tests__/e2e/local-search/frontmatter-title.md @@ -0,0 +1,7 @@ +--- +title: Frontmatter Title Resolved +--- + +# {{ $frontmatter.title }} + +This page uses a frontmatter title expression. diff --git a/__tests__/e2e/local-search/index.md b/__tests__/e2e/local-search/index.md index 08d8b31a..04129895 100644 --- a/__tests__/e2e/local-search/index.md +++ b/__tests__/e2e/local-search/index.md @@ -1 +1,3 @@ -# Local search included \ No newline at end of file +# Local search included + +The custom tokenizer keeps #hash-probe and hyphen-linked-words whole. diff --git a/__tests__/e2e/local-search/local-search.test.ts b/__tests__/e2e/local-search/local-search.test.ts index 07ec1926..4a21622c 100644 --- a/__tests__/e2e/local-search/local-search.test.ts +++ b/__tests__/e2e/local-search/local-search.test.ts @@ -16,7 +16,7 @@ describe('local search', () => { }) try { - await page.locator('.VPNavBarSearchButton').click() + await openSearch() const loading = page.locator('.search-loading') const results = page.locator('.results') @@ -49,22 +49,10 @@ describe('local search', () => { ) test('exclude content from search results', async () => { - await page.locator('.VPNavBarSearchButton').click() - - const input = await page.waitForSelector('input#localsearch-input') - await input.type('local') + await searchFor('local') + await waitForSearchResults({ text: 'Local search included', count: 1 }) const searchResults = page.locator('#localsearch-list') - await page.waitForFunction(() => { - const options = [ - ...document.querySelectorAll('#localsearch-list li[role=option]') - ] - - return ( - options.length === 1 && - options[0].textContent?.includes('Local search included') - ) - }) expect(await searchResults.locator('li[role=option]').count()).toBe(1) @@ -83,6 +71,48 @@ describe('local search', () => { ).toBe(0) }) + test('resolves $frontmatter expressions in search results', async () => { + await searchFor('Frontmatter Title Resolved') + await waitForSearchResults({ text: 'Frontmatter Title Resolved' }) + + const searchResults = page.locator('#localsearch-list') + + expect( + await searchResults + .filter({ hasText: 'Frontmatter Title Resolved' }) + .count() + ).toBe(1) + expect( + await searchResults.filter({ hasText: '$frontmatter.title' }).count() + ).toBe(0) + }) + + test('typing replaces the persisted query', async () => { + await searchFor('lorem') + await waitForSearchResults({ minCount: 2 }) + await page.keyboard.press('Escape') + + // reopening restores the persisted query pre-selected, so keystrokes + // must replace it instead of appending to it + const input = await openSearch() + await input.type('Frontmatter Title Resolved') + await waitForSearchResults({ text: 'Frontmatter Title Resolved' }) + expect(await input.inputValue()).toBe('Frontmatter Title Resolved') + }) + + test('custom tokenize function reaches the client', async () => { + // '#hash-probe' survives as one token only under the custom tokenizer — + // MiniSearch's default one would degrade the query to 'hash'/'probe' + // and miss the index built with the custom tokenizer + const input = await searchFor('#hash-probe') + await waitForSearchResults({ text: 'Local search included', count: 1 }) + + // a fragment of a kept-whole token must not match anything + await input.fill('linked-words') + + await page.waitForSelector('.no-results') + }) + test('uses the same desktop breakpoint as the nav bar', async () => { try { for (const { width, isDesktop } of [ @@ -91,8 +121,7 @@ describe('local search', () => { ]) { await page.setViewportSize({ width, height: 600 }) await goto('/') - await page.locator('.VPNavBarSearchButton').click() - await page.waitForSelector('input#localsearch-input') + await openSearch() expect(await page.locator('.VPNavBarHamburger').isVisible()).toBe( !isDesktop @@ -108,17 +137,9 @@ describe('local search', () => { test('navigate results with macOS Ctrl shortcuts', async () => { await page.evaluate(() => document.documentElement.classList.add('mac')) - await page.locator('.VPNavBarSearchButton').click() - - const input = await page.waitForSelector('input#localsearch-input') - await input.type('lorem') - await page.waitForFunction(() => { - return ( - document.querySelectorAll('#localsearch-list li[role=option]').length > - 1 - ) - }) + const input = await searchFor('lorem') + await waitForSearchResults({ minCount: 2 }) expect(await input.getAttribute('aria-activedescendant')).toBe( 'localsearch-item-0' @@ -136,6 +157,43 @@ describe('local search', () => { }) }) +async function openSearch() { + await page.locator('.VPNavBarSearchButton').click() + return page.waitForSelector('input#localsearch-input') +} + +// fills the query in one step, so exactly one search runs and the result +// list settles into the state for this query and nothing else +async function searchFor(query: string) { + const input = await openSearch() + await input.fill(query) + return input +} + +// waits until the result list matches, so assertions never run against the +// results of an earlier query +function waitForSearchResults(condition: { + /** some result must contain this text */ + text?: string + /** exactly this many results */ + count?: number + /** at least this many results */ + minCount?: number +}) { + return page.waitForFunction(({ text, count, minCount }) => { + const options = [ + ...document.querySelectorAll('#localsearch-list li[role=option]') + ] + + return ( + (count === undefined || options.length === count) && + (minCount === undefined || options.length >= minCount) && + (text === undefined || + options.some((option) => option.textContent?.includes(text))) + ) + }, condition) +} + function pressMacCtrl(key: string) { return page.evaluate((key) => { window.dispatchEvent( diff --git a/__tests__/e2e/package.json b/__tests__/e2e/package.json index 1d558ff7..db6f261d 100644 --- a/__tests__/e2e/package.json +++ b/__tests__/e2e/package.json @@ -10,6 +10,7 @@ "site:preview": "vitepress preview" }, "devDependencies": { + "@iconify-json/lucide": "^1.2.126", "vitepress": "workspace:*" } } diff --git a/__tests__/tsconfig.json b/__tests__/tsconfig.json index 366c4ab8..83d5121c 100644 --- a/__tests__/tsconfig.json +++ b/__tests__/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../tsconfig.json", + "extends": "../tsconfig.base.json", "compilerOptions": { "noEmit": true, "isolatedModules": false, diff --git a/__tests__/unit/node/config.test.ts b/__tests__/unit/node/config.test.ts index df4af72c..de1eb2f8 100644 --- a/__tests__/unit/node/config.test.ts +++ b/__tests__/unit/node/config.test.ts @@ -1,5 +1,10 @@ import type { MarkdownItAsync } from 'markdown-it-async' -import { mergeConfig, type UserConfig } from 'node/config' +import { + mergeConfig, + normalizeAssetsBase, + normalizeSiteBase, + type UserConfig +} from 'node/config' describe('node/config', () => { test('merges markdown hooks from extended configs', async () => { @@ -71,3 +76,50 @@ describe('node/config', () => { expect(calls).toEqual(['base-pre', 'extended']) }) }) + +describe('node/config base normalization', () => { + describe('normalizeSiteBase', () => { + test('defaults to / and appends the trailing slash', () => { + expect(normalizeSiteBase(undefined)).toBe('/') + expect(normalizeSiteBase('')).toBe('/') + expect(normalizeSiteBase('/docs')).toBe('/docs/') + expect(normalizeSiteBase('/docs/')).toBe('/docs/') + }) + + test('coerces a leading slash onto path bases', () => { + expect(normalizeSiteBase('docs')).toBe('/docs/') + expect(normalizeSiteBase('docs/')).toBe('/docs/') + expect(normalizeSiteBase('https://example.com/x')).toBe( + 'https://example.com/x/' + ) + expect(normalizeSiteBase('//cdn.example.com/')).toBe('//cdn.example.com/') + }) + + test('normalizes relative forms to ./', () => { + expect(normalizeSiteBase('.')).toBe('./') + expect(normalizeSiteBase('./')).toBe('./') + }) + + test('rejects relative bases with a subpath', () => { + expect(() => normalizeSiteBase('./docs/')).toThrow(/relative base/) + expect(() => normalizeSiteBase('../x')).toThrow(/relative base/) + }) + }) + + describe('normalizeAssetsBase', () => { + test('accepts absolute urls, protocol-relative urls and paths', () => { + expect(normalizeAssetsBase('https://cdn.example.com')).toBe( + 'https://cdn.example.com/' + ) + expect(normalizeAssetsBase('//cdn.example.com/x')).toBe( + '//cdn.example.com/x/' + ) + expect(normalizeAssetsBase('/cdn/')).toBe('/cdn/') + }) + + test('rejects relative values', () => { + expect(() => normalizeAssetsBase('./cdn/')).toThrow(/assetsBase/) + expect(() => normalizeAssetsBase('cdn/')).toThrow(/assetsBase/) + }) + }) +}) diff --git a/__tests__/unit/node/contentLoader.test.ts b/__tests__/unit/node/contentLoader.test.ts index 9d0ce2ac..387a58c8 100644 --- a/__tests__/unit/node/contentLoader.test.ts +++ b/__tests__/unit/node/contentLoader.test.ts @@ -49,4 +49,19 @@ describe('node/contentLoader', () => { expect(data[0].html).toContain('href="./other"') expect(data[0].html).not.toContain('./other.html') }) + + test('excerpts resolve $frontmatter without render', async () => { + await setup(false) + const { writeFile } = await import('node:fs/promises') + await writeFile( + path.join(root!, 'post.md'), + '---\ntitle: My Post\n---\n\nIntro says {{ $frontmatter.title }}.\n\n---\n\nBody.\n' + ) + + const data = await createContentLoader('post.md', { + excerpt: true + }).load() + + expect(data[0].excerpt).toContain('Intro says My Post.') + }) }) diff --git a/__tests__/unit/node/icons.test.ts b/__tests__/unit/node/icons.test.ts new file mode 100644 index 00000000..35e3303e --- /dev/null +++ b/__tests__/unit/node/icons.test.ts @@ -0,0 +1,159 @@ +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { generateIconsCSS, resolveIconSVG } from 'node/icons' +import { parseIconName } from 'shared/shared' + +// the e2e workspace has @iconify-json/lucide installed — use it as the +// resolution root for collection-loading tests +const e2eRoot = resolve(fileURLToPath(import.meta.url), '../../../e2e') + +describe('node/icons', () => { + describe('parseIconName', () => { + test('parses qualified names', () => { + expect(parseIconName('lucide:heart')).toEqual({ + collection: 'lucide', + icon: 'heart' + }) + expect(parseIconName('simple-icons:github')).toEqual({ + collection: 'simple-icons', + icon: 'github' + }) + }) + + test('rejects bare names and anything outside iconify grammar', () => { + for (const name of [ + 'github', + 'GitHub', + 'foo bar', + 'foo:', + ':bar', + 'a { + test('emits base rules and per-icon rules, no legacy common rule', async () => { + // simple-icons is not in the e2e workspace's package.json — this also + // covers the fallback to vitepress's own dependency + const { css, warnings } = await generateIconsCSS( + e2eRoot, + new Set(['simple-icons:github']), + 'compressed' + ) + expect(warnings).toEqual([]) + expect(css).toContain( + '.vpi-simple-icons-github{--icon:url("data:image/svg+xml' + ) + expect(css).toContain(":where([class^='vpi-']") + expect(css).toContain('display:inline-block') + expect(css).not.toContain('.vpi-social') + }) + + test('suggests qualification for bare names', async () => { + const { css, warnings } = await generateIconsCSS( + e2eRoot, + new Set(['github']), + 'compressed' + ) + expect(css).toBe('') + expect(warnings).toEqual([ + expect.stringContaining('"github" has no collection prefix') + ]) + expect(warnings[0]).toContain('simple-icons:github') + }) + + test('groups collections and stays deterministic across insertion order', async () => { + const a = await generateIconsCSS( + e2eRoot, + new Set(['lucide:heart', 'simple-icons:github', 'lucide:egg']), + 'compressed' + ) + const b = await generateIconsCSS( + e2eRoot, + new Set(['simple-icons:github', 'lucide:egg', 'lucide:heart']), + 'compressed' + ) + expect(a.css).toBe(b.css) + expect(a.css).toContain('.vpi-lucide-heart') + expect(a.css).toContain('.vpi-lucide-egg') + expect(a.css).toContain('.vpi-simple-icons-github') + }) + + test('warns on icons missing from an installed collection', async () => { + const { css, warnings } = await generateIconsCSS( + e2eRoot, + new Set(['simple-icons:github', 'simple-icons:thisiconisnotreal']), + 'compressed' + ) + expect(css).toContain('.vpi-simple-icons-github') + expect(css).not.toContain('thisiconisnotreal') + expect(warnings).toEqual([ + expect.stringContaining( + '"thisiconisnotreal" was not found in the "simple-icons"' + ) + ]) + }) + + test('warns on uninstalled collections with an install hint', async () => { + const { css, warnings } = await generateIconsCSS( + e2eRoot, + new Set(['notinstalled:foo']), + 'compressed' + ) + expect(css).toBe('') + expect(warnings).toEqual([ + expect.stringContaining('@iconify-json/notinstalled') + ]) + }) + + test('warns on invalid names', async () => { + const { warnings } = await generateIconsCSS( + e2eRoot, + new Set(['Not A Name']), + 'compressed' + ) + expect(warnings).toEqual([ + expect.stringContaining('"Not A Name" is not a valid icon name') + ]) + }) + + test('returns empty css for an empty set', async () => { + const { css, warnings } = await generateIconsCSS( + e2eRoot, + new Set(), + 'compressed' + ) + expect(css).toBe('') + expect(warnings).toEqual([]) + }) + }) + + describe('resolveIconSVG', () => { + test('resolves an svg offline', async () => { + const resolved = await resolveIconSVG(e2eRoot, 'lucide', 'heart') + expect(resolved).toHaveProperty('svg') + const svg = (resolved as { svg: string }).svg + expect(svg).toContain(' { + expect(await resolveIconSVG(e2eRoot, 'lucide', 'noicon')).toEqual({ + error: expect.stringContaining('was not found in the "lucide"') + }) + expect(await resolveIconSVG(e2eRoot, 'nocollection', 'x')).toEqual({ + error: expect.stringContaining('@iconify-json/nocollection') + }) + expect(await resolveIconSVG(e2eRoot, 'Bad Name', 'x')).toEqual({ + error: expect.stringContaining('not a valid icon name') + }) + }) + }) +}) diff --git a/__tests__/unit/node/markdown/markdown.test.ts b/__tests__/unit/node/markdown/markdown.test.ts index 5db12abd..af5d2fef 100644 --- a/__tests__/unit/node/markdown/markdown.test.ts +++ b/__tests__/unit/node/markdown/markdown.test.ts @@ -42,6 +42,16 @@ describe('node/markdown/markdown', () => { expect(await render(':tada:', { emoji: false })).toContain(':tada:') }) + test('eagerFrontmatterInterpolation', async () => { + const src = '---\ntitle: Hello\n---\n\n{{ $frontmatter.title }}' + expect(await render(src)).toContain('

Hello

') + + const disabled = await render(src, { + eagerFrontmatterInterpolation: false + }) + expect(disabled).toContain('

{{ $frontmatter.title }}

') + }) + test('tasklist', async () => { const src = '- [ ] todo' expect(await render(src)).toContain(' code, ...options }) +} + +async function render(src: string, env: Record = {}) { + return (await createMd()).renderAsync(src, env) +} + +const frontmatter = `\ +--- +title: Hello World +count: 5 +flag: true +nothing: null +date: 2024-01-18 +html: 'bold' +mustache: '{{ x }}' +amp: 'a < b' +k-y: dashed +spaced: 'a b' +multiline: | + line one + line two +homepage: https://vitepress.dev/ +nested: + deep: value +list: + - a + - b +--- + +` + +async function renderBody(body: string, env: Record = {}) { + return (await render(frontmatter + body, env)).trim() +} + +describe('node/markdown/plugins/eagerFrontmatterInterpolation', () => { + test('resolves property paths and escapes the value', async () => { + const html = await render(`\ +--- +meta: + title: A & B +count: 2 +done: false +--- + +{{ $frontmatter.meta.title }} / {{$frontmatter.count}} / {{ $frontmatter.done }} +`) + expect(html).toContain('

A & B / 2 / false

') + }) + + test('resolves bracket paths and dates', async () => { + expect(await renderBody("{{ $frontmatter['k-y'] }}")).toBe('

dashed

') + expect(await renderBody('{{ $frontmatter["k-y"] }}')).toBe('

dashed

') + expect(await renderBody('{{ $frontmatter.list[1] }}')).toBe('

b

') + expect(await renderBody('{{ $frontmatter.list.length }}')).toBe('

2

') + // dates are normalized the same way the `__pageData` JSON round-trip + // normalizes them for the runtime + expect(await renderBody('{{ $frontmatter.date }}')).toBe( + '

2024-01-18T00:00:00.000Z

' + ) + }) + + test('escapes values so they render as this exact text', async () => { + // a value containing mustaches must not be interpolated again by Vue + expect(await renderBody('{{ $frontmatter.mustache }}')).toBe( + '

{{ x }}

' + ) + // entity look-alikes must survive the template compiler's decoding + expect(await renderBody('{{ $frontmatter.amp }}')).toBe( + '

a &lt; b

' + ) + expect( + await renderBody( + '© {{ $frontmatter.title }} / {{ $frontmatter.no }}' + ) + ).toBe('

© Hello World / {{ $frontmatter.no }}

') + }) + + test('leaves everything else to Vue', async () => { + const expressions = [ + '{{ $frontmatter.missing }}', // key not in frontmatter + '{{ $frontmatter.title.length }}', // path through a non-object + '{{ $frontmatter.nothing.x }}', + '{{ $frontmatter.nothing }}', // renders '' but may be transformed later + '{{ $frontmatter }}', + '{{ $frontmatter.nested }}', // objects are for Vue's display formatting + '{{ $frontmatter.list }}', + '{{ $frontmatter.html }}', // `<` could smuggle markup into titles + '{{ $frontmatter.spaced }}', // double space would be condensed + '{{ $frontmatter.multiline }}', + '{{ $frontmatter.list[01] }}', + '{{ $frontmatter.title.toUpperCase() }}', + '{{ $frontmatter[title] }}', + '{{ $frontmatterX }}', + '{{ $params.id }}', + '{{ frontmatter.title }}' + ] + const html = await renderBody(expressions.join('\n\n')) + for (const expression of expressions) { + expect(html).toContain(`

${expression}

`) + } + }) + + test('skips code and v-pre', async () => { + const html = await render(`\ +--- +title: Hi +--- + +\`{{ $frontmatter.title }}\` + +\`\`\`js +{{ $frontmatter.title }} +\`\`\` + +::: v-pre +{{ $frontmatter.title }} +::: + +{{ $frontmatter.title }} {{ $frontmatter.title }} +`) + expect(html.match(/\{\{ \$frontmatter\.title \}\}/g)).toHaveLength(4) + expect(html).toContain(' Hi

') + }) + + test('skips v-pre scopes from attrs', async () => { + const html = await renderBody( + '**{{ $frontmatter.title }}**{v-pre} {{ $frontmatter.title }}' + ) + expect(html).toContain( + '{{ $frontmatter.title }} Hello World' + ) + }) + + test('tracks raw inline v-pre elements the way Vue parses them', async () => { + // a quoted attribute value may contain `>` + expect( + await renderBody( + '{{ $frontmatter.title }}' + ) + ).toContain('{{ $frontmatter.title }}') + // tag names match case-insensitively, so the inner pair nests + expect( + await renderBody( + 'z abc {{ $frontmatter.title }} {{ $frontmatter.title }}' + ) + ).toContain('c {{ $frontmatter.title }} Hello World') + // a self-closing same-name tag does not affect the scope + expect( + await renderBody('ab {{ $frontmatter.title }}') + ).toContain(' Hello World') + }) + + test('scopes v-pre in raw html blocks instead of bailing out', async () => { + // mentions of v-pre that open no scope leave the page alone + for (const block of [ + '', + '', + '', + '
{{ literal }}
' + ]) { + const html = await renderBody(`${block}\n\n{{ $frontmatter.title }}`) + expect(html).toContain('

Hello World

') + } + + // a scope that spans markdown ends at its closing tag + const html = await renderBody( + '
\n\n{{ $frontmatter.title }}\n\n
\n\n{{ $frontmatter.title }}' + ) + expect(html).toContain('

{{ $frontmatter.title }}

') + expect(html).toContain('

Hello World

') + + // an unclosed scope spans the rest of the page + expect( + await renderBody('
\n\n{{ $frontmatter.title }}') + ).not.toContain('Hello World') + }) + + test('leaves whitespace-sensitive spots inside raw inline elements', async () => { + // the runtime drops whitespace-only text nodes at element edges; an + // inlined value would merge with that whitespace and keep it + expect( + await renderBody('a {{ $frontmatter.title }} b') + ).toContain(' {{ $frontmatter.title }} ') + expect( + await renderBody('a\n{{ $frontmatter.title }}\nb') + ).toContain('{{ $frontmatter.title }}') + // non-whitespace neighbors and closing-tag adjacency are safe + expect(await renderBody('ax {{ $frontmatter.title }}b')).toContain( + 'x Hello World' + ) + expect(await renderBody('x {{ $frontmatter.title }}')).toContain( + ' Hello World' + ) + }) + + test('keeps values safe when the text renderer rule is replaced', async () => { + const md = await createMd({ + config: (md) => { + md.renderer.rules.text = (tokens, idx) => + escapeHtml(tokens[idx].content) + } + }) + const html = await md.renderAsync( + frontmatter + '{{ $frontmatter.mustache }} and {{ $frontmatter.title }}' + ) + // the unsafe value renders through its own token, not the text rule + expect(html).toContain('{{ x }} and Hello World') + expect(html).not.toContain('{{ x }}') + }) + + test('keeps toc titles escaped like the heading', async () => { + const html = await renderBody( + '## {{ $frontmatter.mustache }} {{ $frontmatter.amp }}\n\n[[toc]]' + ) + expect(html).toContain('{{ x }} a &lt; b') + // no live interpolation may reach the toc markup, and the toc must show + // the same text as the heading + const toc = html.slice(html.indexOf(' { + const env: Record = {} + const html = await render( + `\ +--- +title: Hello World +--- + +# {{ $frontmatter.title }} +`, + env + ) + expect(html).toContain('id="hello-world"') + expect(env.title).toBe('Hello World') + }) + + test('records what was inlined on the env', async () => { + const env: Record = {} + await renderBody( + '{{ $frontmatter.title }} {{ $frontmatter.missing }} [x]({{$frontmatter.homepage}})', + env + ) + expect(env.eagerInterpolations).toEqual([ + { expression: '$frontmatter.title', value: 'Hello World' }, + { expression: '$frontmatter.homepage', value: 'https://vitepress.dev/' } + ]) + }) + + test('resolves link and image destinations', async () => { + const html = await renderBody( + [ + '[home]({{$frontmatter.homepage}})', + '[docs](<{{ $frontmatter.homepage }}>)', + '[nope]({{$frontmatter.nope}})' + ].join('\n\n') + ) + expect(html).toContain('href="https://vitepress.dev/"') + // external link handling applies to the resolved destination + expect(html).toContain('target="_blank"') + // unresolvable destinations keep their expression + expect(html).toContain('$frontmatter.nope') + }) + + test('resolves destinations even when only encoded delimiters exist', async () => { + const html = await render(`\ +--- +count: 5 +--- + +[v](https://vitepress.dev/%7B%7B$frontmatter.count%7D%7D) +`) + expect(html).toContain('href="https://vitepress.dev/5"') + }) + + test('resolves image sources', async () => { + const html = await render(`\ +--- +logo: /logo.png +--- + +![logo]({{$frontmatter.logo}}) +`) + expect(html).toContain('src="/logo.png"') + }) + + test('resolves custom container titles', async () => { + const html = await renderBody( + '::: tip {{ $frontmatter.title }}\nbody {{ $frontmatter.count }}\n:::' + ) + expect(html).toContain('

Hello World

') + expect(html).toContain('

body 5

') + }) + + test('leaves everything alone without frontmatter data', async () => { + expect((await render('{{ $frontmatter.title }}')).trim()).toBe( + '

{{ $frontmatter.title }}

' + ) + }) + + // entries passed via `env.frontmatter` must keep merging and inlining - + // a future `renderMd(src, env)` (#2410) relies on this + test('merges and inlines frontmatter provided via env', async () => { + // env entries only, no frontmatter block in the source + const env: Record = { + frontmatter: { intro: 'From Env', n: 42 } + } + expect( + ( + await render('{{ $frontmatter.intro }} ({{ $frontmatter.n }})', env) + ).trim() + ).toBe('

From Env (42)

') + + // the page's own frontmatter wins on conflicts + const merged: Record = { + frontmatter: { title: 'From Env', extra: 'Extra' } + } + expect( + ( + await render( + '---\ntitle: From Page\n---\n\n{{ $frontmatter.title }} / {{ $frontmatter.extra }}', + merged + ) + ).trim() + ).toBe('

From Page / Extra

') + expect(merged.frontmatter).toEqual({ title: 'From Page', extra: 'Extra' }) + }) + + describe('equivalence with runtime interpolation', () => { + async function ssr(html: string, $frontmatter: unknown) { + const app = createSSRApp({ template: `
${html}
` }) + app.config.globalProperties.$frontmatter = $frontmatter + app.config.warnHandler = () => {} + return renderToString(app) + } + + async function compare(body: string) { + const runtimeEnv: any = {} + const runtimeMd = await createMd({ eagerFrontmatterInterpolation: false }) + const runtimeHtml = await runtimeMd.renderAsync( + frontmatter + body, + runtimeEnv + ) + const resolvedHtml = await ( + await createMd() + ).renderAsync(frontmatter + body, {}) + + // the runtime sees the frontmatter after the `__pageData` JSON + // round-trip + const runtimeData = JSON.parse(JSON.stringify(runtimeEnv.frontmatter)) + expect(await ssr(resolvedHtml, runtimeData)).toBe( + await ssr(runtimeHtml, runtimeData) + ) + return resolvedHtml + } + + test('inlined values render exactly what the runtime would', async () => { + const resolvedHtml = await compare( + [ + 'Welcome to {{ $frontmatter.title }}!', + '{{ $frontmatter.mustache }}', + '{{ $frontmatter.amp }}', + '{{ $frontmatter.count }} / {{ $frontmatter.flag }}', + '{{ $frontmatter.date }}', + 'a {{$frontmatter.title}} b' // whitespace condensing parity + ].join('\n\n') + ) + // and nothing was left for the runtime to do + expect(resolvedHtml).not.toContain('$frontmatter') + }) + + test('spots left to the runtime render identically too', async () => { + await compare( + [ + 'a {{ $frontmatter.title }} b', + 'a\n{{ $frontmatter.title }}\nb', + '{{ $frontmatter.title }}', + '{{ $frontmatter.html }}', + '{{ $frontmatter.spaced }}' + ].join('\n\n') + ) + }) + }) +}) diff --git a/__tests__/unit/node/markdown/plugins/include.test.ts b/__tests__/unit/node/markdown/plugins/include.test.ts index 3053459b..0a8aa757 100644 --- a/__tests__/unit/node/markdown/plugins/include.test.ts +++ b/__tests__/unit/node/markdown/plugins/include.test.ts @@ -532,4 +532,26 @@ describe('node/markdown/plugins/include', () => { expect(html).toContain('href="https://example.com/x"') expect(html).toContain('href="/abs/target.html"') }) + + test('does not rebase destinations resolved from frontmatter', async () => { + await write( + 'guide/shared/note.md', + '![logo]({{$frontmatter.logo}}) [x]({{$frontmatter.doc}}) ![lit](./local.png)' + ) + const { html, env } = await render( + '---\nlogo: ./assets/a.png\ndoc: ./other.md\n---\n\n', + {}, + { + path: path.join(root, 'guide/index.md'), + relativePath: 'guide/index.md' + } + ) + // values from the including page's frontmatter keep meaning what they + // meant there + expect(html).toContain('src="./assets/a.png"') + expect(html).toContain('href="./other.html"') + expect(env.links).toContain('./other') + // urls authored in the included file still rebase + expect(html).toContain('src="./shared/local.png"') + }) }) diff --git a/__tests__/unit/node/markdown/plugins/link.test.ts b/__tests__/unit/node/markdown/plugins/link.test.ts index 5e4bc7f1..119132c3 100644 --- a/__tests__/unit/node/markdown/plugins/link.test.ts +++ b/__tests__/unit/node/markdown/plugins/link.test.ts @@ -62,3 +62,79 @@ describe('node/markdown/plugins/link', () => { expect(env.linkLines).toEqual([3]) }) }) + +describe('node/markdown/plugins/link with a relative base', () => { + const md = new MarkdownItAsync() + linkPlugin(md, {}, './', slugify) + const render = (src: string, env: object = {}) => + md.renderAsync(src, { + cleanUrls: false, + relativePath: 'guide/page.md', + relativizeUrls: true, + ...env + }) + + test('site-absolute links become page-relative', async () => { + expect(await render('[x](/other/thing)')).toContain( + 'href="../other/thing.html"' + ) + expect( + await render('[x](/other/thing)', { relativePath: 'index.md' }) + ).toContain('href="./other/thing.html"') + expect( + await render('[x](/other/thing)', { relativePath: 'a/b/c.md' }) + ).toContain('href="../../other/thing.html"') + }) + + test('directory links point at index.html', async () => { + expect(await render('[home](/)')).toContain('href="../index.html"') + expect(await render('[dir](/guide/)')).toContain( + 'href="../guide/index.html"' + ) + }) + + test('non-page files get the prefix but no .html', async () => { + expect(await render('[zip](/file.zip)')).toContain('href="../file.zip"') + }) + + test('hash, external and relative links stay untouched', async () => { + expect(await render('[a](#section)')).toContain('href="#section"') + expect(await render('[a](https://example.com/x)')).toContain( + 'href="https://example.com/x"' + ) + expect(await render('[a](./sibling)')).toContain('href="./sibling.html"') + }) + + test('cleanUrls drops .html and the index suffix', async () => { + expect(await render('[x](/other/thing)', { cleanUrls: true })).toContain( + 'href="../other/thing"' + ) + expect(await render('[dir](/guide/)', { cleanUrls: true })).toContain( + 'href="../guide/"' + ) + }) + + test('content-loader renders keep absolute links site-absolute', async () => { + // content loaders set relativePath but not relativizeUrls — their html + // is embedded in other pages, so the source's depth must not apply + expect( + await render('[x](/other/thing)', { relativizeUrls: undefined }) + ).toContain('href="/other/thing.html"') + expect( + await render('[x](/other/thing)', { relativePath: undefined }) + ).toContain('href="/other/thing.html"') + }) +}) + +describe('node/markdown/plugins/link with an absolute base', () => { + const md = new MarkdownItAsync() + linkPlugin(md, {}, '/docs/', slugify) + + test('site-absolute links get the base and keep one slash', async () => { + const html = await md.renderAsync('[x](/guide/what)', { + cleanUrls: false, + relativePath: 'index.md' + }) + expect(html).toContain('href="/docs/guide/what.html"') + }) +}) diff --git a/__tests__/unit/node/markdownToVue.test.ts b/__tests__/unit/node/markdownToVue.test.ts index dd0d23e8..f0f12bb1 100644 --- a/__tests__/unit/node/markdownToVue.test.ts +++ b/__tests__/unit/node/markdownToVue.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import path from 'node:path' import { resolveConfig } from 'node/config' +import { disposeMdItInstance } from 'node/markdown/markdown' import { createMarkdownToVueRenderFn } from 'node/markdownToVue' describe('node/markdownToVue', () => { @@ -153,4 +154,47 @@ describe('node/markdownToVue', () => { expect(result.pageData.relativePath).toBe('index.md') }) + + test('warns when transformPageData rewrites an interpolated value', async () => { + disposeMdItInstance() + root = await mkdtemp(path.join(tmpdir(), 'vitepress-eager-')) + + const file = path.join(root, 'index.md') + const src = '---\ntitle: Old\n---\n\n# {{ $frontmatter.title }}\n' + await writeFile(file, src) + + const siteConfig = await resolveConfig(root, 'build', 'production') + const warnings: string[] = [] + siteConfig.logger = { + ...siteConfig.logger, + warn: (msg: string) => warnings.push(msg) + } + siteConfig.transformPageData = (pageData) => { + pageData.frontmatter.title = 'New' + } + + const render = await createMarkdownToVueRenderFn( + siteConfig.srcDir, + { cache: false }, + '/', + false, + false, + siteConfig + ) + + const result = await render(src, file) + expect(result.vueSrc).toContain('Old') + expect(warnings.join('\n')).toContain('{{ $frontmatter.title }}') + + // keys only added by the transform are left to the runtime - no warning + warnings.length = 0 + siteConfig.transformPageData = (pageData) => { + pageData.frontmatter.added = 'later' + } + const src2 = + '---\ntitle: Old\n---\n\n{{ $frontmatter.title }} {{ $frontmatter.added }}\n' + await writeFile(file, src2) + await render(src2, file) + expect(warnings).toHaveLength(0) + }) }) diff --git a/__tests__/unit/node/utils/fnSerialize.test.ts b/__tests__/unit/node/utils/fnSerialize.test.ts new file mode 100644 index 00000000..04db1bca --- /dev/null +++ b/__tests__/unit/node/utils/fnSerialize.test.ts @@ -0,0 +1,98 @@ +import { + deserializeFunctions, + serializeFunctions +} from 'node/utils/fnSerialize' + +// runs the exact code shape that plugin.ts / build.ts emit into the site-data +// module and the metadata script — the revived value must come back without +// the deserializer ever compiling a string (new Function is used here only to +// stand in for the browser executing the emitted file) +function emitAndRevive(data: any): any { + const fns: string[] = [] + const serialized = serializeFunctions(data, fns) + const script = `${deserializeFunctions};return deserializeFunctions(JSON.parse(${JSON.stringify( + JSON.stringify(serialized) + )}),[${fns.join(',')}])` + return new Function(script)() +} + +describe('node/utils/fnSerialize', () => { + test('emitted deserializer does not rely on unsafe-eval', () => { + expect(deserializeFunctions).not.toContain('new Function') + expect(deserializeFunctions).not.toContain('eval') + }) + + test('serializes functions as indexed markers', () => { + const fns: string[] = [] + const serialized = serializeFunctions( + { a: (x: number) => x, b: { c: (x: number) => x * 2 } }, + fns + ) + expect(serialized).toEqual({ a: '_vp-fn_0', b: { c: '_vp-fn_1' } }) + expect(fns).toHaveLength(2) + }) + + test('revives functions nested in objects and arrays', () => { + const data = { + search: { + options: { + miniSearch: { + options: { + tokenize: (text: string) => text.split(/\s+/) + }, + searchOptions: { + boostDocument: (id: string) => (id === 'index.md' ? 2 : 1) + } + } + } + }, + list: [(n: number) => n + 1, 'plain', 42] + } + + const revived = emitAndRevive(data) + + expect(revived.search.options.miniSearch.options.tokenize('a b')).toEqual([ + 'a', + 'b' + ]) + expect( + revived.search.options.miniSearch.searchOptions.boostDocument('index.md') + ).toBe(2) + expect(revived.list[0](1)).toBe(2) + expect(revived.list[1]).toBe('plain') + expect(revived.list[2]).toBe(42) + }) + + test('revives method shorthand and async functions', () => { + const data = { + tokenize(text: string) { + return text.toUpperCase() + }, + async extractField(doc: { id: string }) { + return doc.id + } + } + + const revived = emitAndRevive(data) + + expect(revived.tokenize('abc')).toBe('ABC') + return expect(revived.extractField({ id: 'x' })).resolves.toBe('x') + }) + + test('drops underscore-prefixed keys', () => { + const revived = emitAndRevive({ _render: () => '', keep: 1 }) + expect(revived).toEqual({ keep: 1 }) + }) + + test('leaves data strings resembling markers untouched', () => { + const data = { + fn: (x: number) => x, + note: '_vp-fn_alert(1)' + } + + const revived = emitAndRevive(data) + + expect(revived.fn(1)).toBe(1) + expect(revived.note).toBe('_vp-fn_alert(1)') + }) +}) diff --git a/__tests__/unit/shared/shared.test.ts b/__tests__/unit/shared/shared.test.ts index 77826db3..16b971ae 100644 --- a/__tests__/unit/shared/shared.test.ts +++ b/__tests__/unit/shared/shared.test.ts @@ -1,4 +1,10 @@ -import { mergeHead, type HeadConfig } from 'shared/shared' +import { + isRelativeBase, + joinPath, + mergeHead, + relativePathToRoot, + type HeadConfig +} from 'shared/shared' describe('shared/shared', () => { describe('mergeHead', () => { @@ -54,3 +60,41 @@ describe('shared/shared', () => { }) }) }) + +describe('shared/shared url helpers', () => { + describe('joinPath', () => { + test('joins and collapses slash collisions', () => { + expect(joinPath('/', '/guide/')).toBe('/guide/') + expect(joinPath('/docs/', '/guide/page')).toBe('/docs/guide/page') + expect(joinPath('/docs', 'guide')).toBe('/docsguide') + }) + + test('preserves the protocol of absolute url bases', () => { + expect(joinPath('https://cdn.example.com/', '/guide/')).toBe( + 'https://cdn.example.com/guide/' + ) + expect(joinPath('https://cdn.example.com/sub//x/', '/a')).toBe( + 'https://cdn.example.com/sub/x/a' + ) + expect(joinPath('//cdn.example.com/', '/a')).toBe('//cdn.example.com/a') + }) + }) + + describe('isRelativeBase', () => { + test('only ./ is relative', () => { + expect(isRelativeBase('./')).toBe(true) + expect(isRelativeBase('/')).toBe(false) + expect(isRelativeBase('/docs/')).toBe(false) + expect(isRelativeBase('https://example.com/')).toBe(false) + }) + }) + + describe('relativePathToRoot', () => { + test('maps a page path to its ../-prefix', () => { + expect(relativePathToRoot('index.md')).toBe('./') + expect(relativePathToRoot('foo.md')).toBe('./') + expect(relativePathToRoot('guide/index.md')).toBe('../') + expect(relativePathToRoot('guide/nested/page.md')).toBe('../../') + }) + }) +}) diff --git a/docs/components/AppearanceToggleTransition.vue b/docs/components/AppearanceToggleTransition.vue new file mode 100644 index 00000000..7ad13277 --- /dev/null +++ b/docs/components/AppearanceToggleTransition.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/docs/en/guide/asset-handling.md b/docs/en/guide/asset-handling.md index 63394fd0..76b0c1c4 100644 --- a/docs/en/guide/asset-handling.md +++ b/docs/en/guide/asset-handling.md @@ -36,23 +36,15 @@ Note that you should reference files placed in `public` using root absolute path ## Base URL -If your site is deployed to a non-root URL, you will need to set the `base` option in `.vitepress/config.js`. For example, if you plan to deploy your site to `https://foo.github.io/bar/`, then `base` should be set to `'/bar/'` (it should always start and end with a slash). +If your site is deployed to a non-root URL, set the [`base`](../reference/site-config#base) option. For example, if you plan to deploy your site to `https://foo.github.io/bar/`, then `base` should be set to `'/bar/'` -All your static asset paths are automatically processed to adjust for different `base` config values. For example, if you have an absolute reference to an asset under `public` in your markdown: +Static asset references are automatically adjusted for the base, so an absolute reference to a file in `public` works with any `base` and never needs updating: ```md ![An image](/image-inside-public.png) ``` -You do **not** need to update it when you change the `base` config value in this case. - -However, if you are authoring a theme component that links to assets dynamically, e.g. an image whose `src` is based on a theme config value: - -```vue - -``` - -In this case it is recommended to wrap the path with the [`withBase` helper](../reference/runtime-api#withbase) provided by VitePress: +Only dynamically constructed paths need care — for example, an image whose `src` is based on a theme config value. Wrap those with the [`withBase` helper](../reference/runtime-api#withbase) so the base is prepended at runtime: ```vue - - - - -``` +<<< @/components/AppearanceToggleTransition.vue [.vitepress/theme/Layout.vue] Result (**warning!**: flashing colors, sudden movements, bright lights): diff --git a/docs/en/guide/frontmatter.md b/docs/en/guide/frontmatter.md index dc205816..e8dd329a 100644 --- a/docs/en/guide/frontmatter.md +++ b/docs/en/guide/frontmatter.md @@ -36,6 +36,8 @@ editLink: true Guide content ``` +Property accesses like `{{ $frontmatter.title }}` are resolved while the Markdown is rendered, so the value also ends up in the local search index, in [content loader](./data-loading#createcontentloader) output, in heading anchors - the heading above gets `id="docs-with-vitepress"` - and in link targets written without spaces around the expression, like `[text]({{$frontmatter.link}})`. Other expressions are evaluated by Vue at runtime as usual, and wrapping an expression in [`v-pre`](./using-vue#escaping) shows it literally. + You can also access current page's frontmatter data in ` + + +``` + +Pass the template ref of the element carrying the class so dev mode can resolve the icon on it. The element needs the mask rules the default theme ships; in a custom theme without them, dev applies an inline equivalent and the generated stylesheet includes zero-specificity base rules for production. + +When using the default theme, the `VPIcon` component from `vitepress/theme` wraps this composable (and also accepts a raw `{ svg }` string): + +```vue-html + +``` + +Icons rendered only on the client (e.g. inside ``) can't be collected during the build — list them in [`icons.include`](./site-config#icons) instead. + ## `withBase` - **Type**: `(path: string) => string` diff --git a/docs/en/reference/site-config.md b/docs/en/reference/site-config.md index d79eb637..7f214127 100644 --- a/docs/en/reference/site-config.md +++ b/docs/en/reference/site-config.md @@ -372,7 +372,9 @@ export default { - Type: `string` - Default: `/` -The base URL the site will be deployed at. You will need to set this if you plan to deploy your site under a sub path, for example, GitHub pages. If you plan to deploy your site to `https://foo.github.io/bar/`, then you should set base to `'/bar/'`. It should always start and end with a slash. Relative bases are not supported. +The base URL the site will be deployed at. You will need to set this if you plan to deploy your site under a sub path, for example, GitHub pages. If you plan to deploy your site to `https://foo.github.io/bar/`, then you should set base to `'/bar/'`. It should always start and end with a slash. + +The one exception is `'./'`, which produces a [relocatable build](../guide/deploy#relocatable-builds-relative-base): pages reference everything relative to their own location, so the same output works from any sub path (IPFS gateways, archives) without rebuilding and stays browsable when opened directly from the file system. The base is automatically prepended to all the URLs that start with / in other options, so you only need to specify it once. @@ -382,6 +384,8 @@ export default { } ``` +Can also be set per build with `vitepress build --base /base/`. + ## Routing ### cleanUrls @@ -463,6 +467,44 @@ export default { } ``` +### assetsBase + +- Type: `string` +- Default: `undefined` + +URL prefix the generated assets (everything under [`assetsDir`](#assetsdir)) are served from — typically a CDN. Must be an absolute URL, a protocol-relative URL, or a root-absolute path; a trailing slash is appended if missing. + +```ts +export default { + base: '/', + assetsBase: 'https://cdn.example.com/' + // scripts, styles, fonts and imported images resolve to + // https://cdn.example.com/assets/* +} +``` + +The emitted asset URL is `assetsBase` joined with the output-relative file path, so the CDN should mirror the layout of `outDir` (upload `outDir/assets` so it is reachable at `/assets/*`). HTML pages, Markdown links, [`public`](../guide/asset-handling#the-public-directory) files and `hashmap.json` stay on [`base`](#base). + +When `assetsBase` points at another origin, VitePress adds `crossorigin` to the emitted script and preload tags — the CDN must send `Access-Control-Allow-Origin` for your site's origin (module scripts are always fetched in CORS mode). + +Only production builds are affected. `vitepress preview` serves a root-absolute `assetsBase` (like `/cdn/`) from the local dist; an external one is requested from the real URL. Can also be set per build with `vitepress build --assetsBase https://cdn.example.com/`. + +### icons + +- Type: `{ include?: string[] }` + +Options for the generated icon styles. The build collects every iconify icon rendered during SSR. Names are fully qualified as `collection:name`, resolved against the `@iconify-json/*` packages declared in your project's dependencies. + +Icons rendered only on the client — inside ``, or after hydration — are invisible to SSR collection. List them in `include` to force them into the stylesheet: + +```ts +export default { + icons: { + include: ['mdi:home', 'simple-icons:discord'] + } +} +``` + ### cacheDir - Type: `string` @@ -632,6 +674,7 @@ export default { interface SSGContext { content: string teleports?: Record + vpIcons: Set [key: string]: any } ``` @@ -710,6 +753,10 @@ For simpler cases, it may be possible to use the [`head`](./frontmatter-config#h Don't mutate anything inside the `context`. Also, modifying the html content may cause hydration problems in runtime. ::: +::: note +The icon stylesheet link still carries its `vp-icons.__VP_ICONS_HASH__.css` placeholder at this point — the content hash only exists once every page has rendered, and it is substituted right after. Hooks that inline or fingerprint head assets should skip that tag. +::: + ```ts export default { async transformHtml(code, id, context) { diff --git a/docs/es/guide/extending-default-theme.md b/docs/es/guide/extending-default-theme.md index 1e4a7885..524b6dcf 100644 --- a/docs/es/guide/extending-default-theme.md +++ b/docs/es/guide/extending-default-theme.md @@ -218,79 +218,7 @@ Lista completa de _slots_ disponibles en el layout del tema por defecto: Puede extender el tema por defecto para proporcionar una transición personalizada cuando el modo de color es alternado. Un ejemplo: -```vue [.vitepress/theme/Layout.vue] - - - - - -``` +<<< @/components/AppearanceToggleTransition.vue [.vitepress/theme/Layout.vue] Resultado (**atención!**: colores destellantes, movimientos súbitos, luces brillantes): diff --git a/docs/fa/guide/extending-default-theme.md b/docs/fa/guide/extending-default-theme.md index ccea29a6..017ed979 100644 --- a/docs/fa/guide/extending-default-theme.md +++ b/docs/fa/guide/extending-default-theme.md @@ -220,79 +220,7 @@ export default { شما می‌توانید تم پیش‌فرض را گسترش دهید تا هنگام تغییر حالت رنگ، یک انتقال سفارشی را فراهم کند. به عنوان مثال: -```vue [.vitepress/theme/Layout.vue] - - - - - -``` +<<< @/components/AppearanceToggleTransition.vue [.vitepress/theme/Layout.vue] نتیجه (**هشدار!**: رنگ‌های فلاشینگ، حرکات ناگهانی، نورهای شدید): diff --git a/docs/ja/guide/extending-default-theme.md b/docs/ja/guide/extending-default-theme.md index 2abe4d84..e03aa846 100644 --- a/docs/ja/guide/extending-default-theme.md +++ b/docs/ja/guide/extending-default-theme.md @@ -220,79 +220,7 @@ export default { カラーモード切り替え時にカスタムトランジションを提供するよう、デフォルトテーマを拡張できます。例: -```vue [.vitepress/theme/Layout.vue] - - - - - -``` +<<< @/components/AppearanceToggleTransition.vue [.vitepress/theme/Layout.vue] 結果(**注意!**:点滅や急な動き、明るい光を含みます): diff --git a/docs/ko/guide/extending-default-theme.md b/docs/ko/guide/extending-default-theme.md index 2c450b8b..acd445f6 100644 --- a/docs/ko/guide/extending-default-theme.md +++ b/docs/ko/guide/extending-default-theme.md @@ -218,79 +218,7 @@ export default { 기본 테마를 확장하여 컬러 모드가 전환될 때 커스텀 트랜지션 효과를 제공할 수 있습니다. 예제: -```vue [.vitepress/theme/Layout.vue] - - - - - -``` +<<< @/components/AppearanceToggleTransition.vue [.vitepress/theme/Layout.vue] 결과 (**광과민성 주의!**: 색상 깜빡임, 갑작스러운 움직임, 밝은 빛): diff --git a/docs/pt/guide/extending-default-theme.md b/docs/pt/guide/extending-default-theme.md index 99db5b75..e3c39354 100644 --- a/docs/pt/guide/extending-default-theme.md +++ b/docs/pt/guide/extending-default-theme.md @@ -218,79 +218,7 @@ Lista completa de _slots_ disponíveis no layout do tema padrão: Você pode estender o tema padrão para fornecer uma transição personalizada quando o modo de cor é alternado. Um exemplo: -```vue [.vitepress/theme/Layout.vue] - - - - - -``` +<<< @/components/AppearanceToggleTransition.vue [.vitepress/theme/Layout.vue] Resultado (**atenção!**: cores piscantes, movimentos súbitos, luzes brilhantes): diff --git a/docs/public/appearance-toggle-transition.webp b/docs/public/appearance-toggle-transition.webp index 7c2a8d7a..84c54972 100644 Binary files a/docs/public/appearance-toggle-transition.webp and b/docs/public/appearance-toggle-transition.webp differ diff --git a/docs/ru/guide/extending-default-theme.md b/docs/ru/guide/extending-default-theme.md index 0d1320a6..c07677a8 100644 --- a/docs/ru/guide/extending-default-theme.md +++ b/docs/ru/guide/extending-default-theme.md @@ -219,79 +219,7 @@ export default { Вы можете расширить стандартную тему, чтобы обеспечить пользовательский переход при переключении цветового режима. Пример: -```vue [.vitepress/theme/Layout.vue] - - - - - -``` +<<< @/components/AppearanceToggleTransition.vue [.vitepress/theme/Layout.vue] Результат (**предупреждение!**: мигающие цвета, резкие движения, яркий свет): diff --git a/docs/tsconfig.json b/docs/tsconfig.json index 35cd2ee8..df2be3de 100644 --- a/docs/tsconfig.json +++ b/docs/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../tsconfig.json", + "extends": "../tsconfig.base.json", "compilerOptions": { "allowImportingTsExtensions": true, "noEmit": true, diff --git a/docs/zh/guide/extending-default-theme.md b/docs/zh/guide/extending-default-theme.md index 974fca81..c9068f19 100644 --- a/docs/zh/guide/extending-default-theme.md +++ b/docs/zh/guide/extending-default-theme.md @@ -217,79 +217,7 @@ export default { 可以扩展默认主题以在切换颜色模式时提供自定义过渡动画。例如: -```vue [.vitepress/theme/Layout.vue] - - - - - -``` +<<< @/components/AppearanceToggleTransition.vue [.vitepress/theme/Layout.vue] 结果(**注意!**:画面闪烁、快速闪现、强光刺激): diff --git a/package.json b/package.json index 94e77d99..039b3f83 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "vitepress": "bin/vitepress.js" }, "files": [ + "THIRD-PARTY-NOTICES.md", "bin", "dist", "types", @@ -56,8 +57,9 @@ "build": "tsdown && pnpm typecheck && node scripts/genWebTypes.ts && pnpm build:check", "build:check": "publint && attw --pack . --profile esm-only", "typecheck": "tsc -p tsconfig.shared.json && vue-tsc -p tsconfig.client.json && tsc -p tsconfig.node.json", - "test": "pnpm --aggregate-output --reporter=append-only '/^test:(types|unit|e2e|init)$/'", - "test:types": "tsc -p __tests__/unit && vue-tsc -p __tests__/e2e && tsc -p __tests__/init && vue-tsc -p docs", + "test": "pnpm --aggregate-output --reporter=append-only '/^test:(types|unit|e2e|init|base)$/'", + "test:types": "tsc -p __tests__/unit && vue-tsc -p __tests__/e2e && tsc -p __tests__/init && tsc -p __tests__/base && vue-tsc -p docs", + "test:base": "pnpm -F=tests-base test", "test:unit": "vitest run -r __tests__/unit", "test:unit:watch": "vitest -r __tests__/unit", "test:e2e": "pnpm test:e2e-dev && pnpm test:e2e-build", @@ -80,7 +82,7 @@ "docs:lunaria:open": "pnpm -F=docs lunaria:open", "format": "prettier --experimental-cli --write .", "format:fail": "prettier --experimental-cli --check .", - "check": "pnpm format:fail && pnpm build && pnpm test", + "check": "pnpm format:fail && pnpm build && git ls-files --error-unmatch THIRD-PARTY-NOTICES.md && git diff --exit-code -- THIRD-PARTY-NOTICES.md && pnpm test", "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s", "release": "node scripts/release.ts" }, @@ -110,6 +112,7 @@ "vue": "^3.5.41" }, "devDependencies": { + "@arethetypeswrong/cli": "^0.18.5", "@clack/prompts": "^1.7.0", "@iconify/utils": "^3.1.4", "@mdit-vue/plugin-component": "^3.0.2", @@ -125,11 +128,9 @@ "@mdit/plugin-emoji": "^1.1.1", "@mdit/plugin-footnote": "^1.0.2", "@mdit/plugin-tasklist": "^1.0.2", - "@arethetypeswrong/cli": "^0.18.5", "@polka/compression": "^1.0.0-next.28", "@rolldown/pluginutils": "^1.0.1", "@types/cross-spawn": "^6.0.6", - "@types/lodash.template": "^4.5.3", "@types/mark.js": "^8.11.12", "@types/minimist": "^1.2.5", "@types/node": "^26.2.0", @@ -140,11 +141,11 @@ "conventional-changelog": "^8.1.1", "conventional-changelog-angular": "^9.2.1", "cross-spawn": "^7.0.6", + "eta": "^4.6.0", "get-port": "^7.2.0", "gray-matter": "^4.0.3", "image-size": "^2.0.2", "lint-staged": "^17.3.0", - "lodash.template": "^4.18.1", "lru-cache": "^11.5.2", "markdown-it": "^14.3.0", "markdown-it-async": "^2.2.0", @@ -190,5 +191,5 @@ "optional": true } }, - "packageManager": "pnpm@11.21.0" + "packageManager": "pnpm@11.24.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d3c2d79..0757a9ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,7 +34,7 @@ importers: version: 14.1.2 '@vitejs/plugin-vue': specifier: ^6.0.8 - version: 6.0.8(vite@8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) + version: 6.0.8(vite@8.2.2(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)) '@vue/devtools-api': specifier: ^8.2.1 version: 8.2.1 @@ -61,7 +61,7 @@ importers: version: 4.4.3 vite: specifier: ^8.2.1 - version: 8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0) + version: 8.2.2(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0) vue: specifier: ^3.5.41 version: 3.5.41(typescript@6.0.3) @@ -123,9 +123,6 @@ importers: '@types/cross-spawn': specifier: ^6.0.6 version: 6.0.6 - '@types/lodash.template': - specifier: ^4.5.3 - version: 4.5.3 '@types/mark.js': specifier: ^8.11.12 version: 8.11.12 @@ -156,6 +153,9 @@ importers: cross-spawn: specifier: ^7.0.6 version: 7.0.6 + eta: + specifier: ^4.6.0 + version: 4.6.0 get-port: specifier: ^7.2.0 version: 7.2.0 @@ -168,9 +168,6 @@ importers: lint-staged: specifier: ^17.3.0 version: 17.3.0 - lodash.template: - specifier: ^4.18.1 - version: 4.18.1 lru-cache: specifier: ^11.5.2 version: 11.5.2 @@ -257,7 +254,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0)) + version: 4.1.10(@types/node@26.2.0)(vite@8.2.2(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0)) vue-sfc-transformer: specifier: ^0.2.5 version: 0.2.5(patch_hash=06dbce7d98fac77faf5e66a94aa6e04f34c0cc7ec0910ec10848e5f2e08b8b68)(@volar/typescript@2.4.28(typescript@6.0.3))(@vue/compiler-core@3.5.41)(@vue/language-core@3.3.11)(rolldown@1.2.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)) @@ -268,8 +265,17 @@ importers: specifier: ^9.1.0 version: 9.1.0(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0) + __tests__/base: + devDependencies: + vitepress: + specifier: workspace:* + version: link:../.. + __tests__/e2e: devDependencies: + '@iconify-json/lucide': + specifier: ^1.2.126 + version: 1.2.126 vitepress: specifier: workspace:* version: link:../.. @@ -299,7 +305,7 @@ importers: version: link:.. vitepress-plugin-group-icons: specifier: ^1.7.6 - version: 1.7.6(vite@8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0)) + version: 1.7.6(vite@8.2.2(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0)) vitepress-plugin-llms: specifier: ^1.13.4 version: 1.13.4(supports-color@7.2.0) @@ -411,6 +417,9 @@ packages: '@iconify-json/logos@1.2.12': resolution: {integrity: sha512-zUi/AoezU2F3L65nPVd2smiU6Y+ZI7RjdVPlGfeAeYbPbZ9kWn7Ucxj+KshmyQRBYwLtKoqAlUyoGgMqWG1T8g==} + '@iconify-json/lucide@1.2.126': + resolution: {integrity: sha512-Fl3OfR71yeWLrlTLp6C4W5W3rJJDWH9/e70mjtq9VAldYDxvHh149JMNPz7foTeLLTE2Paynnp2aYKVL9rgF5Q==} + '@iconify-json/simple-icons@1.2.93': resolution: {integrity: sha512-/XhANjfGYOuqvSR3TmUnkQkINvQ4GVjVuukvymRbxtVFBvIq/yiXJqCDycKcQPT401OYT9H2vIY6ihAlz1QIAw==} @@ -751,12 +760,6 @@ packages: '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} - '@types/lodash.template@4.5.3': - resolution: {integrity: sha512-Mo0UYKLu1oXgkV9TVoXZLlXXjyIXlW7ZQRxi/4gQJmzJr63dmicE8gG0OkPjYTKBrBic852q0JzqrtNUWLBIyA==} - - '@types/lodash@4.17.25': - resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} - '@types/mark.js@8.11.12': resolution: {integrity: sha512-244ZnaIBpz4c6xutliAnYVZp6xJlmC569jZqnR3ElO1Y01ooYASSVQEqpd2x0A2UfrgVMs5V9/9tUAdZaDMytQ==} @@ -1457,6 +1460,10 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + eta@4.6.0: + resolution: {integrity: sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==} + engines: {node: '>=20'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -1801,16 +1808,6 @@ packages: engines: {node: '>=22.22.1'} hasBin: true - lodash._reinterpolate@3.0.0: - resolution: {integrity: sha512-xYHt68QRoYGjeeM/XOE1uJtvXQAgvszfBhjV4yvsQH0u2i9I6cI6c6/eG4Hh3UAOVn0y/xAXwmTzEay49Q//HA==} - - lodash.template@4.18.1: - resolution: {integrity: sha512-5urZrLnV/VD6zHK5KsVtZgt7H19v51mIzoS0aBNH8yp3I8tbswrEjOABOPY8m8uB7NuibubLrMX+Y0PXsU9X+w==} - deprecated: This package is deprecated. Use https://socket.dev/npm/package/eta instead. - - lodash.templatesettings@4.2.0: - resolution: {integrity: sha512-stgLz+i3Aa9mZgnjr/O+v9ruKZsPsndy7qPZOchbqk2cnTU1ZaldKK+v7m54WoKIyxiuMZTKT2H81F8BeAc3ZQ==} - lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -2594,13 +2591,13 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite@8.2.1: - resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==} + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.4.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 esbuild: '*' jiti: '>=1.21.0' less: ^4.0.0 @@ -2920,6 +2917,10 @@ snapshots: dependencies: '@iconify/types': 2.0.0 + '@iconify-json/lucide@1.2.126': + dependencies: + '@iconify/types': 2.0.0 + '@iconify-json/simple-icons@1.2.93': dependencies: '@iconify/types': 2.0.0 @@ -3228,12 +3229,6 @@ snapshots: '@types/linkify-it@5.0.0': {} - '@types/lodash.template@4.5.3': - dependencies: - '@types/lodash': 4.17.25 - - '@types/lodash@4.17.25': {} - '@types/mark.js@8.11.12': dependencies: '@types/jquery': 4.0.1 @@ -3275,10 +3270,10 @@ snapshots: '@ungap/structured-clone@1.3.3': {} - '@vitejs/plugin-vue@6.0.8(vite@8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))': + '@vitejs/plugin-vue@6.0.8(vite@8.2.2(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0) + vite: 8.2.2(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0) vue: 3.5.41(typescript@6.0.3) '@vitest/expect@4.1.10': @@ -3290,13 +3285,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.10(vite@8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0))': + '@vitest/mocker@4.1.10(vite@8.2.2(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0) + vite: 8.2.2(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0) '@vitest/pretty-format@4.1.10': dependencies: @@ -3846,6 +3841,8 @@ snapshots: dependencies: '@types/estree': 1.0.9 + eta@4.6.0: {} + expect-type@1.4.0: {} extend-shallow@2.0.1: @@ -4164,17 +4161,6 @@ snapshots: optionalDependencies: yaml: 2.9.0 - lodash._reinterpolate@3.0.0: {} - - lodash.template@4.18.1: - dependencies: - lodash._reinterpolate: 3.0.0 - lodash.templatesettings: 4.2.0 - - lodash.templatesettings@4.2.0: - dependencies: - lodash._reinterpolate: 3.0.0 - lodash@4.18.1: {} log-symbols@7.0.1: @@ -5053,7 +5039,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0): + vite@8.2.2(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 @@ -5066,13 +5052,13 @@ snapshots: jiti: 1.21.7 yaml: 2.9.0 - vitepress-plugin-group-icons@1.7.6(vite@8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0)): + vitepress-plugin-group-icons@1.7.6(vite@8.2.2(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0)): dependencies: '@iconify-json/logos': 1.2.12 '@iconify-json/vscode-icons': 1.2.70 '@iconify/utils': 3.1.4 optionalDependencies: - vite: 8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0) + vite: 8.2.2(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0) vitepress-plugin-llms@1.13.4(supports-color@7.2.0): dependencies: @@ -5093,10 +5079,10 @@ snapshots: transitivePeerDependencies: - supports-color - vitest@4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0)): + vitest@4.1.10(@types/node@26.2.0)(vite@8.2.2(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0)) + '@vitest/mocker': 4.1.10(vite@8.2.2(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -5113,7 +5099,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.1(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0) + vite: 8.2.2(@types/node@26.2.0)(jiti@1.21.7)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.2.0 diff --git a/scripts/release.ts b/scripts/release.ts index 0d950707..3d861c1b 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -100,7 +100,12 @@ async function main() { // Commit changes to the Git and create a tag. prompts.log.step('Committing changes...') - await run('git', ['add', 'CHANGELOG.md', 'package.json']) + await run('git', [ + 'add', + 'CHANGELOG.md', + 'package.json', + 'THIRD-PARTY-NOTICES.md' + ]) await run('git', ['commit', '-m', `release: v${targetVersion}`]) await run('git', ['tag', `v${targetVersion}`]) diff --git a/scripts/subsetFonts.py b/scripts/subsetFonts.py index 422b6b33..e017558d 100755 --- a/scripts/subsetFonts.py +++ b/scripts/subsetFonts.py @@ -181,6 +181,11 @@ def build_subsets(release: Path, subsets: dict[str, str]) -> None: options = subset.Options() options.flavor = "woff2" options.layout_features = [*options.layout_features, "pnum", "tnum"] + # keep the OFL license notice (13) and url (14) name records that + # upstream embeds: the subsets are Modified Versions, and OFL §2 + # requires the license to travel with every copy - including the + # ones Vite copies into users' publicly served site builds + options.name_IDs = [*options.name_IDs, 13, 14] font = subset.load_font(release / file, options) subsetter = subset.Subsetter(options) subsetter.populate(unicodes=parse_ranges(value)) diff --git a/src/client/app/composables/icon.ts b/src/client/app/composables/icon.ts new file mode 100644 index 00000000..ab2d5ebb --- /dev/null +++ b/src/client/app/composables/icon.ts @@ -0,0 +1,82 @@ +import { + computed, + onMounted, + toValue, + useSSRContext, + watchPostEffect, + type ComputedRef, + type MaybeRefOrGetter +} from 'vue' + +import { parseIconName, type SSGContext } from '../../shared' +import { withBase } from '../utils' + +/** + * Resolves an icon name (`collection:name`, e.g. `simple-icons:github`) to + * its `vpi--` class. During SSR the name is registered so + * the build emits its CSS rule; in dev the SVG is served on demand and + * applied to `el` inline. + */ +export function useIcon( + icon: MaybeRefOrGetter, + el?: MaybeRefOrGetter +): ComputedRef { + const parsed = computed(() => { + const value = toValue(icon) + return typeof value === 'string' ? parseIconName(value) : null + }) + + const iconClass = computed(() => + parsed.value + ? `vpi-${parsed.value.collection}-${parsed.value.icon}` + : undefined + ) + + if (import.meta.env.SSR) { + const ctx = useSSRContext() + const value = toValue(icon) + // unparseable names are registered too — the build warns about them + if (typeof value === 'string') ctx?.vpIcons.add(value) + } else if (import.meta.env.DEV) { + // dev has no generated stylesheet — the icon is always fetched from the + // dev server, re-resolved when the name changes + let applied: string | undefined + onMounted(() => { + watchPostEffect(() => { + const span = toValue(el) + if (!span) return + const name = parsed.value + if (!name) { + if (applied) { + span.style.removeProperty('--icon') + applied = undefined + } + return + } + const key = `${name.collection}/${name.icon}` + if (applied === key) return + applied = key + span.style.setProperty( + '--icon', + `url('${withBase(`/_vpi/${name.collection}/${name.icon}.svg`)}')` + ) + // inline the mask setup for themes without the default icon rules + const styles = getComputedStyle(span) + if ((styles.maskImage || styles.webkitMaskImage) === 'none') { + Object.assign(span.style, { + display: 'inline-block', + width: '1em', + height: '1em', + mask: 'var(--icon) no-repeat', + webkitMask: 'var(--icon) no-repeat', + maskSize: '100% 100%', + webkitMaskSize: '100% 100%', + backgroundColor: 'currentColor' + }) + } + }) + }) + } + + return iconClass +} diff --git a/src/client/app/composables/preFetch.ts b/src/client/app/composables/preFetch.ts index d251f9b3..35c23a81 100644 --- a/src/client/app/composables/preFetch.ts +++ b/src/client/app/composables/preFetch.ts @@ -3,6 +3,7 @@ import { onMounted, onUnmounted, watch } from 'vue' +import { EXTERNAL_URL_RE } from '../../shared' import { useRoute } from '../router' import { inBrowser, pathToFile } from '../utils' @@ -12,13 +13,15 @@ const createLink = () => document.createElement('link') const viaDOM = (url: string) => { const link = createLink() link.rel = `prefetch` + if (EXTERNAL_URL_RE.test(url)) link.crossOrigin = '' link.href = url document.head.appendChild(link) } const viaXHR = (url: string) => { const req = new XMLHttpRequest() - req.open('GET', url, (req.withCredentials = true)) + req.open('GET', url, true) + req.withCredentials = !EXTERNAL_URL_RE.test(url) req.send() } diff --git a/src/client/app/index.ts b/src/client/app/index.ts index 15f489e3..952e52f5 100644 --- a/src/client/app/index.ts +++ b/src/client/app/index.ts @@ -26,8 +26,12 @@ function resolveThemeExtends(theme: typeof RawTheme): typeof RawTheme { ...base, ...theme, async enhanceApp(ctx) { - if (base.enhanceApp) await base.enhanceApp(ctx) - if (theme.enhanceApp) await theme.enhanceApp(ctx) + await base.enhanceApp?.(ctx) + await theme.enhanceApp?.(ctx) + }, + setup() { + base.setup?.() + theme.setup?.() } } } diff --git a/src/client/app/router.ts b/src/client/app/router.ts index 0cb0242e..bb03338c 100644 --- a/src/client/app/router.ts +++ b/src/client/app/router.ts @@ -4,7 +4,7 @@ import { inject, markRaw, nextTick, reactive, readonly } from 'vue' import type { Awaitable, PageData, PageDataPayload, Route } from '../shared' import { notFoundPageData, treatAsHtml } from '../shared' import { siteDataRef } from './data' -import { inBrowser, withBase } from './utils' +import { inBrowser, runtimeBase, withBase } from './utils' export interface Router { /** @@ -123,7 +123,7 @@ export function createRouter( if (inBrowser) { nextTick(() => { let actualPathname = - siteDataRef.value.base + + runtimeBase() + __pageData.relativePath.replace(/(?:(^|\/)index)?\.md$/, '$1') if (!siteDataRef.value.cleanUrls && !actualPathname.endsWith('/')) { @@ -153,7 +153,7 @@ export function createRouter( // the updated pageToHash map and fetch again. if (!isRetry) { try { - const res = await fetch(siteDataRef.value.base + 'hashmap.json') + const res = await fetch(runtimeBase() + 'hashmap.json') ;(window as any).__VP_HASH_MAP__ = await res.json() await loadPage(href, { scrollPosition, isRetry: true, initialLoad }) return @@ -168,7 +168,7 @@ export function createRouter( ? route.path .replace(/(^|\/)$/, '$1index') .replace(/(\.html)?$/, '.md') - .slice(siteDataRef.value.base.length) + .slice(runtimeBase().length) : '404.md' route.data = { ...notFoundPageData, relativePath } syncRouteQueryAndHash(targetLoc) @@ -318,7 +318,7 @@ function shouldHotReload(payload: PageDataPayload): boolean { const payloadPath = payload.path.replace(/(?:(^|\/)index)?\.md$/, '$1') const locationPath = location.pathname .replace(/(?:(^|\/)index)?\.html$/, '') - .slice(siteDataRef.value.base.length - 1) + .slice(runtimeBase().length - 1) return payloadPath === locationPath } diff --git a/src/client/app/ssr.ts b/src/client/app/ssr.ts index d8a4f05b..ec55258c 100644 --- a/src/client/app/ssr.ts +++ b/src/client/app/ssr.ts @@ -7,7 +7,7 @@ import { createApp } from './index' export async function render(path: string) { const { app, router } = await createApp() await router.go(path) - const ctx: SSGContext = { content: '', vpSocialIcons: new Set() } + const ctx: SSGContext = { content: '', vpIcons: new Set() } ctx.content = await renderToString(app, ctx) return ctx } diff --git a/src/client/app/theme.ts b/src/client/app/theme.ts index 8c3fd2c1..4ebb222a 100644 --- a/src/client/app/theme.ts +++ b/src/client/app/theme.ts @@ -15,7 +15,8 @@ export interface Theme { extends?: Theme /** - * @deprecated can be replaced by wrapping layout component + * Runs inside the root component's `setup()` (during SSR too). With + * `extends`, setups run base-first, like `enhanceApp`. */ setup?: () => void diff --git a/src/client/app/utils.ts b/src/client/app/utils.ts index c80080f1..9683b915 100644 --- a/src/client/app/utils.ts +++ b/src/client/app/utils.ts @@ -3,19 +3,42 @@ import { h, onMounted, shallowRef, type AsyncComponentLoader } from 'vue' import { EXTERNAL_URL_RE, + RELATIVE_BASE_SENTINEL, inBrowser, + isRelativeBase, + joinPath, sanitizeFileName, type Awaitable } from '../shared' import { siteDataRef } from './data' export { escapeHtml as _escapeHtml, inBrowser } from '../shared' +export { joinPath } from '../shared' + +let resolvedBase: string | undefined /** - * Join two paths by resolving the slash collision. + * Runtime base path used by the app. + * + * Usually this is the configured site base. + * + * For a relative base (`'./'`), the mount point is unknown at build time, so: + * - SSR: uses `RELATIVE_BASE_SENTINEL` (for per-page URL relativization) + * - dev browser: uses `'/'` (dev server always mounts at root) + * - prod browser: resolves from the page's `__VP_SITE_ROOT__` */ -export function joinPath(base: string, path: string) { - return `${base}${path}`.replace(/\/+/g, '/') +export function runtimeBase(): string { + if (resolvedBase === undefined) { + const base = siteDataRef.value.base + if (!isRelativeBase(base)) return (resolvedBase = base) + if (!inBrowser) return (resolvedBase = RELATIVE_BASE_SENTINEL) + if (import.meta.env.DEV) return (resolvedBase = '/') + const root = (window as any).__VP_SITE_ROOT__ + resolvedBase = root + ? decodeURIComponent(new URL(root, location.href).pathname) + : '/' + } + return resolvedBase } /** @@ -24,7 +47,7 @@ export function joinPath(base: string, path: string) { export function withBase(path: string) { return EXTERNAL_URL_RE.test(path) || !path.startsWith('/') ? path - : joinPath(siteDataRef.value.base, path) + : joinPath(runtimeBase(), path) } /** @@ -42,7 +65,11 @@ export function pathToFile(path: string) { // the path conversion scheme. // /foo/bar.html -> ./foo_bar.md if (inBrowser) { - const base = import.meta.env.BASE_URL + const base = runtimeBase() + // the site root may arrive without its trailing slash; anything + // outside the base has no page chunk at all + if (pagePath + '/' === base) pagePath = base + if (!pagePath.startsWith(base)) return null pagePath = sanitizeFileName( pagePath.slice(base.length).replace(/\//g, '_') || 'index' @@ -57,7 +84,7 @@ export function pathToFile(path: string) { pageHash = __VP_HASH_MAP__[pagePath.toLowerCase()] } if (!pageHash) return null - pagePath = `${base}${__ASSETS_DIR__}/${pagePath}.${pageHash}.js` + pagePath = `${__ASSETS_BASE__ || base}${__ASSETS_DIR__}/${pagePath}.${pageHash}.js` } else { // ssr build uses much simpler name mapping pagePath = `./${sanitizeFileName( diff --git a/src/client/index.ts b/src/client/index.ts index 80322bd7..6e424ece 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -17,6 +17,7 @@ import { ClientOnly } from './app/components/ClientOnly' import { Content } from './app/components/Content' // composables +export { useIcon } from './app/composables/icon' export { dataSymbol, useData } from './app/data' export { useRoute, useRouter } from './app/router' diff --git a/src/client/shims.d.ts b/src/client/shims.d.ts index 740a932a..0c00f1dd 100644 --- a/src/client/shims.d.ts +++ b/src/client/shims.d.ts @@ -9,6 +9,7 @@ declare const __ALGOLIA__: boolean declare const __CARBON__: boolean declare const __VUE_PROD_DEVTOOLS__: boolean declare const __ASSETS_DIR__: string +declare const __ASSETS_BASE__: string declare module '@siteData' { import type { SiteData } from 'vitepress' diff --git a/src/client/theme-default/components/VPDoc.vue b/src/client/theme-default/components/VPDoc.vue index b0042bbd..869a2e04 100644 --- a/src/client/theme-default/components/VPDoc.vue +++ b/src/client/theme-default/components/VPDoc.vue @@ -2,18 +2,25 @@ import { useRoute } from 'vitepress' import { computed } from 'vue' +import { runtimeBase } from '../../app/utils' +import { isRelativeBase } from '../../shared' import { useData } from '../composables/data' import { useLayout } from '../composables/layout' import VPDocAside from './VPDocAside.vue' import VPDocFooter from './VPDocFooter.vue' -const { theme } = useData() +const { theme, site } = useData() const route = useRoute() const { hasSidebar, hasAside, leftAside } = useLayout() -const pageName = computed(() => - route.path.replace(/[./]+/g, '_').replace(/_html$/, '') -) +const pageName = computed(() => { + // the mount point is unknown at build time, so the class must come from + // the site-relative path or ssr and hydration disagree + const path = isRelativeBase(site.value.base) + ? '/' + route.path.slice(runtimeBase().length) + : route.path + return path.replace(/[./]+/g, '_').replace(/_html$/, '') +}) @@ -27,9 +26,7 @@ defineProps<{ } .text, -.keys, -:root.mac .key-ctrl, -:root:not(.mac) .key-cmd { +.keys { display: none; } @@ -38,6 +35,18 @@ kbd { font-weight: 500; } +.key-mod::before { + content: 'Ctrl'; +} + +:root.mac .key-mod::before { + content: '\2318'; +} + +.key-k::before { + content: 'K'; +} + @media (min-width: 48rem) { .VPNavBarSearchButton { height: auto; diff --git a/src/client/theme-default/components/VPSocialLink.vue b/src/client/theme-default/components/VPSocialLink.vue index cc19a5f3..420ebca8 100644 --- a/src/client/theme-default/components/VPSocialLink.vue +++ b/src/client/theme-default/components/VPSocialLink.vue @@ -1,14 +1,9 @@ diff --git a/src/client/theme-default/fonts/LICENSE.txt b/src/client/theme-default/fonts/LICENSE.txt new file mode 100644 index 00000000..9b2ca37b --- /dev/null +++ b/src/client/theme-default/fonts/LICENSE.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/client/theme-default/fonts/inter-italic-cyrillic-ext.woff2 b/src/client/theme-default/fonts/inter-italic-cyrillic-ext.woff2 index 27a82ed3..296d735c 100644 Binary files a/src/client/theme-default/fonts/inter-italic-cyrillic-ext.woff2 and b/src/client/theme-default/fonts/inter-italic-cyrillic-ext.woff2 differ diff --git a/src/client/theme-default/fonts/inter-italic-cyrillic.woff2 b/src/client/theme-default/fonts/inter-italic-cyrillic.woff2 index 116e3acd..d93e5227 100644 Binary files a/src/client/theme-default/fonts/inter-italic-cyrillic.woff2 and b/src/client/theme-default/fonts/inter-italic-cyrillic.woff2 differ diff --git a/src/client/theme-default/fonts/inter-italic-greek-ext.woff2 b/src/client/theme-default/fonts/inter-italic-greek-ext.woff2 index 9117dabf..dcb14fc1 100644 Binary files a/src/client/theme-default/fonts/inter-italic-greek-ext.woff2 and b/src/client/theme-default/fonts/inter-italic-greek-ext.woff2 differ diff --git a/src/client/theme-default/fonts/inter-italic-greek.woff2 b/src/client/theme-default/fonts/inter-italic-greek.woff2 index f098cf74..14c1212f 100644 Binary files a/src/client/theme-default/fonts/inter-italic-greek.woff2 and b/src/client/theme-default/fonts/inter-italic-greek.woff2 differ diff --git a/src/client/theme-default/fonts/inter-italic-latin-ext.woff2 b/src/client/theme-default/fonts/inter-italic-latin-ext.woff2 index 42f338f1..a57883d7 100644 Binary files a/src/client/theme-default/fonts/inter-italic-latin-ext.woff2 and b/src/client/theme-default/fonts/inter-italic-latin-ext.woff2 differ diff --git a/src/client/theme-default/fonts/inter-italic-latin.woff2 b/src/client/theme-default/fonts/inter-italic-latin.woff2 index 94a1ae94..8273dc04 100644 Binary files a/src/client/theme-default/fonts/inter-italic-latin.woff2 and b/src/client/theme-default/fonts/inter-italic-latin.woff2 differ diff --git a/src/client/theme-default/fonts/inter-italic-symbols.woff2 b/src/client/theme-default/fonts/inter-italic-symbols.woff2 index d8425105..78f75ae4 100644 Binary files a/src/client/theme-default/fonts/inter-italic-symbols.woff2 and b/src/client/theme-default/fonts/inter-italic-symbols.woff2 differ diff --git a/src/client/theme-default/fonts/inter-italic-vietnamese.woff2 b/src/client/theme-default/fonts/inter-italic-vietnamese.woff2 index cfe91834..ead7a46d 100644 Binary files a/src/client/theme-default/fonts/inter-italic-vietnamese.woff2 and b/src/client/theme-default/fonts/inter-italic-vietnamese.woff2 differ diff --git a/src/client/theme-default/fonts/inter-roman-cyrillic-ext.woff2 b/src/client/theme-default/fonts/inter-roman-cyrillic-ext.woff2 index 465b2228..ff7282eb 100644 Binary files a/src/client/theme-default/fonts/inter-roman-cyrillic-ext.woff2 and b/src/client/theme-default/fonts/inter-roman-cyrillic-ext.woff2 differ diff --git a/src/client/theme-default/fonts/inter-roman-cyrillic.woff2 b/src/client/theme-default/fonts/inter-roman-cyrillic.woff2 index 6bbced8e..918fb15e 100644 Binary files a/src/client/theme-default/fonts/inter-roman-cyrillic.woff2 and b/src/client/theme-default/fonts/inter-roman-cyrillic.woff2 differ diff --git a/src/client/theme-default/fonts/inter-roman-greek-ext.woff2 b/src/client/theme-default/fonts/inter-roman-greek-ext.woff2 index 7a4633ff..9e5aa654 100644 Binary files a/src/client/theme-default/fonts/inter-roman-greek-ext.woff2 and b/src/client/theme-default/fonts/inter-roman-greek-ext.woff2 differ diff --git a/src/client/theme-default/fonts/inter-roman-greek.woff2 b/src/client/theme-default/fonts/inter-roman-greek.woff2 index e30e308a..152f6b73 100644 Binary files a/src/client/theme-default/fonts/inter-roman-greek.woff2 and b/src/client/theme-default/fonts/inter-roman-greek.woff2 differ diff --git a/src/client/theme-default/fonts/inter-roman-latin-ext.woff2 b/src/client/theme-default/fonts/inter-roman-latin-ext.woff2 index 6f19b69a..788a467d 100644 Binary files a/src/client/theme-default/fonts/inter-roman-latin-ext.woff2 and b/src/client/theme-default/fonts/inter-roman-latin-ext.woff2 differ diff --git a/src/client/theme-default/fonts/inter-roman-latin.woff2 b/src/client/theme-default/fonts/inter-roman-latin.woff2 index c428d326..d097292f 100644 Binary files a/src/client/theme-default/fonts/inter-roman-latin.woff2 and b/src/client/theme-default/fonts/inter-roman-latin.woff2 differ diff --git a/src/client/theme-default/fonts/inter-roman-symbols.woff2 b/src/client/theme-default/fonts/inter-roman-symbols.woff2 index ba86b3dc..02122e7c 100644 Binary files a/src/client/theme-default/fonts/inter-roman-symbols.woff2 and b/src/client/theme-default/fonts/inter-roman-symbols.woff2 differ diff --git a/src/client/theme-default/fonts/inter-roman-vietnamese.woff2 b/src/client/theme-default/fonts/inter-roman-vietnamese.woff2 index 2df3edeb..11f0e98d 100644 Binary files a/src/client/theme-default/fonts/inter-roman-vietnamese.woff2 and b/src/client/theme-default/fonts/inter-roman-vietnamese.woff2 differ diff --git a/src/client/theme-default/styles/components/vp-doc.css b/src/client/theme-default/styles/components/vp-doc.css index e912c4a3..a1d85d90 100644 --- a/src/client/theme-default/styles/components/vp-doc.css +++ b/src/client/theme-default/styles/components/vp-doc.css @@ -605,8 +605,8 @@ mask-position: center; -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; - -webkit-mask-size: 0.6875rem 0.6875rem; - mask-size: 0.6875rem 0.6875rem; + -webkit-mask-size: 100% 100%; + mask-size: 100% 100%; /*rtl:raw:transform: scaleX(-1);*/ vertical-align: middle; font-size: 0.5625rem; diff --git a/src/client/theme-default/styles/icons.css b/src/client/theme-default/styles/icons.css index 9bc867e5..b57b9417 100644 --- a/src/client/theme-default/styles/icons.css +++ b/src/client/theme-default/styles/icons.css @@ -1,6 +1,9 @@ [class^='vpi-'], [class*=' vpi-'], .vp-icon { + /* an unresolved icon masks to nothing instead of a currentColor box */ + --icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E"); + display: inline-block; width: 1em; height: 1em; } diff --git a/src/client/theme-default/support/utils.ts b/src/client/theme-default/support/utils.ts index 76017be5..6815399a 100644 --- a/src/client/theme-default/support/utils.ts +++ b/src/client/theme-default/support/utils.ts @@ -1,6 +1,6 @@ import { withBase } from 'vitepress' -import { isExternal, treatAsHtml } from '../../shared' +import { isExternal, isRelativeBase, treatAsHtml } from '../../shared' import { useData } from '../composables/data' export function throttleAndDebounce(fn: () => void, delay: number): () => void { @@ -46,7 +46,7 @@ export function normalizeLink(url: string): string { const { site } = useData() - const normalizedPath = + let normalizedPath = pathname.endsWith('/') || pathname.endsWith('.html') ? url : url.replace( @@ -57,6 +57,14 @@ export function normalizeLink(url: string): string { )}${search}${hash}` ) + if (isRelativeBase(site.value.base) && !site.value.cleanUrls) { + const pathPart = normalizedPath.replace(/[?#].*$/, '') + if (pathPart.endsWith('/')) { + normalizedPath = + pathPart + 'index.html' + normalizedPath.slice(pathPart.length) + } + } + return withBase(normalizedPath) } diff --git a/src/client/theme-default/without-fonts.ts b/src/client/theme-default/without-fonts.ts index 6a551bc6..91ae2a46 100644 --- a/src/client/theme-default/without-fonts.ts +++ b/src/client/theme-default/without-fonts.ts @@ -21,6 +21,7 @@ export { default as VPHomeContent } from './components/VPHomeContent.vue' export { default as VPHomeFeatures } from './components/VPHomeFeatures.vue' export { default as VPHomeHero } from './components/VPHomeHero.vue' export { default as VPHomeSponsors } from './components/VPHomeSponsors.vue' +export { default as VPIcon } from './components/VPIcon.vue' export { default as VPImage } from './components/VPImage.vue' export { default as VPLink } from './components/VPLink.vue' export { default as VPNavBarSearch } from './components/VPNavBarSearch.vue' diff --git a/src/node/build/build.ts b/src/node/build/build.ts index f59c3854..e81df4a3 100644 --- a/src/node/build/build.ts +++ b/src/node/build/build.ts @@ -1,18 +1,42 @@ import { createHash } from 'node:crypto' import fs from 'node:fs' -import { mkdir, rm, symlink, unlink, writeFile } from 'node:fs/promises' +import { + mkdir, + readFile, + rm, + symlink, + unlink, + writeFile +} from 'node:fs/promises' import { createRequire } from 'node:module' import path from 'node:path' -import { getIconsCSS } from '@iconify/utils' import pMap from 'p-map' +import c from 'picocolors' import { packageDirectory } from 'package-directory' import type { BuildOptions, Rolldown } from 'vite' -import { resolveConfig, type SiteConfig } from '../config' +import { + normalizeAssetsBase, + normalizeSiteBase, + resolveConfig, + type SiteConfig +} from '../config' import { clearCache } from '../markdownToVue' import type { PageMeta } from '../plugin' -import { slash, type Awaitable, type HeadConfig } from '../shared' +import { + EXTERNAL_URL_RE, + RELATIVE_BASE_SENTINEL, + isRelativeBase, + slash, + type Awaitable, + type HeadConfig +} from '../shared' +import { + VP_ICONS_HASH_PLACEHOLDER, + generateIconsCSS, + vpIconsFileName +} from '../icons' import { deserializeFunctions, serializeFunctions } from '../utils/fnSerialize' import { logVersion } from '../utils/logVersion' import { nativeImport } from '../utils/nativeImport' @@ -21,12 +45,11 @@ import { bundle } from './bundle' import { generateSitemap } from './generateSitemap' import { renderPage } from './render' -const require = createRequire(import.meta.url) - export async function build( root?: string, buildOptions: BuildOptions & { base?: string + assetsBase?: string mpa?: string onAfterConfigResolve?: (siteConfig: SiteConfig) => Awaitable } = {} @@ -46,10 +69,23 @@ export async function build( const unlinkVue = await linkVue() if (buildOptions.base) { - siteConfig.site.base = buildOptions.base + if (typeof buildOptions.base !== 'string') { + throw new Error('--base requires a value (e.g. --base /docs/)') + } + siteConfig.site.base = normalizeSiteBase(buildOptions.base) delete buildOptions.base } + if (buildOptions.assetsBase) { + if (typeof buildOptions.assetsBase !== 'string') { + throw new Error( + '--assetsBase requires a value (e.g. --assetsBase https://cdn.example.com/)' + ) + } + siteConfig.assetsBase = normalizeAssetsBase(buildOptions.assetsBase) + delete buildOptions.assetsBase + } + if (buildOptions.mpa) { siteConfig.mpa = true delete buildOptions.mpa @@ -128,6 +164,9 @@ async function render( const clientOutput: (Rolldown.OutputChunk | Rolldown.OutputAsset)[] = clientResult?.output || [] + const resultOutput: (Rolldown.OutputChunk | Rolldown.OutputAsset)[] = + (siteConfig.mpa ? serverResult : clientResult)?.output || [] + const appChunk = clientOutput.find( (chunk): chunk is Rolldown.OutputChunk => chunk.type === 'chunk' && @@ -135,28 +174,28 @@ async function render( !!chunk.facadeModuleId?.endsWith('.js') ) - const isDefaultTheme = clientOutput.some( + const isDefaultTheme = resultOutput.some( (chunk): chunk is Rolldown.OutputChunk => chunk.type === 'chunk' && - chunk.name === 'theme' && chunk.moduleIds.some((id) => id.includes('client/theme-default')) ) - // ---- - - const resultOutput: (Rolldown.OutputChunk | Rolldown.OutputAsset)[] = - (siteConfig.mpa ? serverResult : clientResult)?.output || [] - const cssChunk = resultOutput.find( (chunk): chunk is Rolldown.OutputAsset => chunk.type === 'asset' && chunk.fileName.endsWith('.css') ) + const assetsUrlBase = + siteConfig.assetsBase ?? + (isRelativeBase(siteConfig.site.base) + ? RELATIVE_BASE_SENTINEL + : siteConfig.site.base) + // prettier-ignore const assets = resultOutput.filter( (chunk): chunk is Rolldown.OutputAsset => chunk.type === 'asset' && !chunk.fileName.endsWith('.css') - ).map((asset) => siteConfig.site.base + asset.fileName) + ).map((asset) => assetsUrlBase + asset.fileName) // ---- @@ -181,7 +220,9 @@ async function render( } } - const usedIcons = new Set() + // pre-seeded with icons SSR collection cannot see (client-only renders) + const include = siteConfig.icons?.include + const usedIcons = new Set(Array.isArray(include) ? include : []) await pMap( ['404.md', ...siteConfig.pages], @@ -203,16 +244,7 @@ async function render( { concurrency: siteConfig.buildConcurrency } ) - const icons = require('@iconify-json/simple-icons/icons.json') - const iconsCss = getIconsCSS(icons, Array.from(usedIcons).sort(), { - iconSelector: '.vpi-social-{name}', - commonSelector: '.vpi-social', - varName: 'icon', - format: process.env.DEBUG ? 'expanded' : 'compressed', - mode: 'mask' - }).replace(/[^]*?}\n*/, '') - - await writeFile(path.join(siteConfig.outDir, 'vp-icons.css'), iconsCss) + await emitIconsCSS(siteConfig, usedIcons) // emit page hash map for the case where a user session is open // when the site got redeployed (which invalidates current hash map) @@ -222,6 +254,55 @@ async function render( ) } +async function emitIconsCSS( + config: SiteConfig, + usedIcons: Set +): Promise { + const { css, warnings } = await generateIconsCSS( + config.root, + usedIcons, + process.env.DEBUG ? 'expanded' : 'compressed' + ) + for (const warning of warnings) { + config.logger.warn(c.yellow(`(icons) ${warning}`)) + } + + const assetsDir = path.join(config.outDir, config.assetsDir) + const placeholder = vpIconsFileName(VP_ICONS_HASH_PLACEHOLDER) + + let hashedName = '' + if (css) { + hashedName = vpIconsFileName( + createHash('sha256').update(css).digest('hex').slice(0, 8) + ) + await mkdir(assetsDir, { recursive: true }) + await writeFile(path.join(assetsDir, hashedName), css) + } + + const linkRE = new RegExp( + `[ \\t]*]*${VP_ICONS_HASH_PLACEHOLDER}[^>]*>\\n?` + ) + await pMap( + ['404.md', ...config.pages], + async (page) => { + const file = path.join( + config.outDir, + (config.rewrites.map[page] || page).replace(/\.md$/, '.html') + ) + const html = await readFile(file, 'utf-8').catch(() => null) + if (html === null || !html.includes(placeholder)) return + // scoped to the tag so prose mentioning the placeholder stays intact + await writeFile( + file, + html.replace(linkRE, (tag) => + hashedName ? tag.replaceAll(placeholder, hashedName) : '' + ) + ) + }, + { concurrency: config.buildConcurrency } + ) +} + async function generateMetadataScript( pageToHashMap: Record, config: SiteConfig @@ -235,13 +316,14 @@ async function generateMetadataScript( // It's also embedded as a string and JSON.parsed from the client because // it's faster than embedding as JS object literal. const hashMapString = JSON.stringify(JSON.stringify(pageToHashMap)) + const fns: string[] = [] const siteDataString = JSON.stringify( - JSON.stringify(serializeFunctions({ ...config.site, head: [] })) + JSON.stringify(serializeFunctions({ ...config.site, head: [] }, fns)) ) const metadataContent = `window.__VP_HASH_MAP__=JSON.parse(${hashMapString});${ - siteDataString.includes('_vp-fn_') - ? `${deserializeFunctions};window.__VP_SITE_DATA__=deserializeFunctions(JSON.parse(${siteDataString}));` + fns.length + ? `${deserializeFunctions};window.__VP_SITE_DATA__=deserializeFunctions(JSON.parse(${siteDataString}),[${fns.join(',')}]);` : `window.__VP_SITE_DATA__=JSON.parse(${siteDataString});` }` @@ -255,13 +337,22 @@ async function generateMetadataScript( ) const resolvedMetadataFile = path.join(config.outDir, metadataFile) - const metadataFileURL = slash(`${config.site.base}${metadataFile}`) + const urlBase = + config.assetsBase ?? + (isRelativeBase(config.site.base) + ? RELATIVE_BASE_SENTINEL + : config.site.base) + const metadataFileURL = urlBase + slash(metadataFile) + const crossorigin = + config.assetsBase && EXTERNAL_URL_RE.test(config.assetsBase) + ? ' crossorigin' + : '' await mkdir(path.dirname(resolvedMetadataFile), { recursive: true }) await writeFile(resolvedMetadataFile, metadataContent) return { - html: ``, + html: ``, inHead: true } } diff --git a/src/node/build/buildMPAClient.ts b/src/node/build/buildMPAClient.ts index 3eebe9dd..1f7f3c20 100644 --- a/src/node/build/buildMPAClient.ts +++ b/src/node/build/buildMPAClient.ts @@ -17,6 +17,14 @@ export async function buildMPAClient( cacheDir: config.cacheDir, base: config.site.base, logLevel: config.vite?.logLevel ?? 'warn', + ...(config.assetsBase + ? { + experimental: { + renderBuiltUrl: (filename, ctx) => + ctx.type === 'asset' ? config.assetsBase! + filename : undefined + } + } + : {}), build: { emptyOutDir: false, outDir: config.outDir, diff --git a/src/node/build/bundle.ts b/src/node/build/bundle.ts index be85540f..f2d0ad08 100644 --- a/src/node/build/bundle.ts +++ b/src/node/build/bundle.ts @@ -1,5 +1,5 @@ import fs from 'node:fs' -import { cp } from 'node:fs/promises' +import { cp, mkdir, readFile, writeFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -15,7 +15,13 @@ import { import { APP_PATH } from '../alias' import type { SiteConfig } from '../config' import { createVitePressPlugin, type PageMeta } from '../plugin' -import { escapeRegExp, sanitizeFileName, slash } from '../shared' +import { + RELATIVE_BASE_SENTINEL, + escapeRegExp, + isRelativeBase, + sanitizeFileName, + slash +} from '../shared' import { buildMPAClient } from './buildMPAClient' // https://github.com/vitejs/vite/blob/a55d0b34400e3360c4100d05e422ae9cf10fa07b/packages/vite/src/node/constants.ts#L50 @@ -75,12 +81,17 @@ export async function bundle( ...restOptions } = options + const relativeBase = isRelativeBase(config.site.base) + const resolveViteConfig = async ( ssr: boolean ): Promise => ({ root: config.srcDir, cacheDir: config.cacheDir, - base: config.site.base, + // the client build relativizes its own asset URLs natively; the SSR + // build renders into per-page HTML, so it gets the sentinel base that + // renderPage swaps for each page's ../-prefix + base: ssr && relativeBase ? RELATIVE_BASE_SENTINEL : config.site.base, logLevel: config.vite?.logLevel ?? 'warn', plugins: await createVitePressPlugin( config, @@ -144,6 +155,10 @@ export async function bundle( )) as Rolldown.RolldownOutput if (config.mpa) { + // FIXME: nothing ever empties outDir in MPA mode (no client build runs + // with emptyOutDir, and buildMPAClient sets emptyOutDir: false), so + // hashed assets of every kind accumulate across rebuilds into a dirty + // output directory // in MPA mode, we need to copy over the non-js asset files from the // server build since there is no client-side build. await pMap( @@ -152,7 +167,21 @@ export async function bundle( if (!chunk.fileName.endsWith('.js')) { const tempPath = path.resolve(config.tempDir, chunk.fileName) const outPath = path.resolve(config.outDir, chunk.fileName) - await cp(tempPath, outPath) + if (relativeBase && chunk.fileName.endsWith('.css')) { + // the server build emits sentinel-based url()s; rewrite them + // relative to the css file's own location + const css = await readFile(tempPath, 'utf-8') + const dir = path.posix.dirname(slash(chunk.fileName)) + const toRoot = + dir === '.' ? './' : '../'.repeat(dir.split('/').length) + await mkdir(path.dirname(outPath), { recursive: true }) + await writeFile( + outPath, + css.replaceAll(RELATIVE_BASE_SENTINEL, toRoot) + ) + } else { + await cp(tempPath, outPath) + } } }, { concurrency: config.buildConcurrency } diff --git a/src/node/build/render.ts b/src/node/build/render.ts index 49c9a43b..0e8864d8 100644 --- a/src/node/build/render.ts +++ b/src/node/build/render.ts @@ -6,12 +6,16 @@ import { minify, normalizePath, type Rolldown } from 'vite' import { version } from '../../../package.json' with { type: 'json' } import type { SiteConfig } from '../config' +import { VP_ICONS_HASH_PLACEHOLDER, vpIconsFileName } from '../icons' import { EXTERNAL_URL_RE, + RELATIVE_BASE_SENTINEL, createTitle, escapeHtml, + isRelativeBase, mergeHead, notFoundPageData, + relativePathToRoot, resolveSiteDataByRoute, sanitizeFileName, slash, @@ -35,22 +39,36 @@ export async function renderPage( usedIcons: Set ) { const routePath = `/${page.replace(/\.md$/, '')}` + const relativeBase = isRelativeBase(config.site.base) + const pageBase = relativeBase ? relativePathToRoot(page) : config.site.base + + // user hooks must never see the build sentinel + const desentinel = (value: string) => + relativeBase ? value.replaceAll(RELATIVE_BASE_SENTINEL, pageBase) : value - // render page const context = await render(routePath) - const { content, teleports, vpSocialIcons } = - (await config.postRender?.(context)) ?? context + if (relativeBase) { + context.content = desentinel(context.content) + if (context.teleports) { + for (const key in context.teleports) { + context.teleports[key] = desentinel(context.teleports[key]) + } + } + } - // add used social icons to the set - vpSocialIcons.forEach((icon) => usedIcons.add(icon)) + // collect the icons rendered during SSR; postRender may replace the + // context and contribute more + context.vpIcons?.forEach((icon) => usedIcons.add(icon)) + + const rendered = (await config.postRender?.(context)) ?? context + const { content, teleports } = rendered + if (rendered !== context) { + rendered.vpIcons?.forEach((icon: string) => usedIcons.add(icon)) + } const pageName = sanitizeFileName(page.replace(/\//g, '_')) // server build doesn't need hash const pageServerJsFileName = pageName + '.js' - // for any initial page load, we only need the lean version of the page js - // since the static content is already on the page! - const pageHash = pageToHashMap[pageName.toLowerCase()] - const pageClientJsFileName = `${config.assetsDir}/${pageName}.${pageHash}.lean.js` let pageData: PageData let hasCustom404 = true @@ -72,26 +90,34 @@ export async function renderPage( const siteData = resolveSiteDataByRoute(config.site, page, pageData.filePath) - const title: string = createTitle(siteData, pageData) - const description: string = pageData.description || siteData.description - const stylesheetLink = cssChunk - ? `` - : '' + const assetUrl = (file: string) => (config.assetsBase ?? pageBase) + file + const assetsCrossOrigin = + config.assetsBase && EXTERNAL_URL_RE.test(config.assetsBase) + ? ' crossorigin' + : '' + const pageAssets = relativeBase ? assets.map(desentinel) : assets + + const title = createTitle(siteData, pageData) + const description = pageData.description || siteData.description + const dir = pageData.frontmatter.dir || siteData.dir || 'ltr' + const isDefault404 = page === '404.md' && !hasCustom404 + + // the initial load only needs the lean page js — the static content is + // already in the HTML + const pageHash = pageToHashMap[pageName.toLowerCase()] + const pageClientJsFileName = `${config.assetsDir}/${pageName}.${pageHash}.lean.js` - let preloadLinks = - config.mpa || (!hasCustom404 && page === '404.md') - ? [] - : result && appChunk - ? [ - ...new Set([ - // resolve imports for index.js + page.md.js and inject script tags - // for them as well so we fetch everything as early as possible - // without having to wait for entry chunks to parse - ...(await resolvePageImports(config, page, result, appChunk)), - pageClientJsFileName - ]) - ] - : [] + let preloadLinks: string[] = [] + if (result && appChunk && !config.mpa && !isDefault404) { + preloadLinks = [ + ...new Set([ + // the imports of index.js + page.md.js as well, so everything + // fetches without waiting for the entry chunks to parse + ...(await resolvePageImports(config, page, result, appChunk)), + pageClientJsFileName + ]) + ] + } let prefetchLinks: string[] = [] @@ -107,15 +133,24 @@ export async function renderPage( { rel, // don't add base to external urls - href: (EXTERNAL_URL_RE.test(file) ? '' : siteData.base) + file + href: EXTERNAL_URL_RE.test(file) ? file : assetUrl(file), + // must match the cors mode of the later module fetch, or the + // cached response is not reused + ...(assetsCrossOrigin && !EXTERNAL_URL_RE.test(file) + ? { crossorigin: '' } + : {}) } ]) const preloadHeadTags = toHeadTags(preloadLinks, 'modulepreload') const prefetchHeadTags = toHeadTags(prefetchLinks, 'prefetch') + const pageHeadTags: HeadConfig[] = relativeBase + ? JSON.parse(desentinel(JSON.stringify(additionalHeadTags))) + : additionalHeadTags + const headBeforeTransform = [ - ...additionalHeadTags, + ...pageHeadTags, ...preloadHeadTags, ...prefetchHeadTags, ...mergeHead( @@ -124,21 +159,27 @@ export async function renderPage( ) ] + const transformContext = (head: HeadConfig[]) => ({ + page, + siteConfig: config, + siteData, + pageData, + title, + description, + head, + content, + assets: pageAssets + }) + const head = mergeHead( headBeforeTransform, - (await config.transformHead?.({ - page, - siteConfig: config, - siteData, - pageData, - title, - description, - head: headBeforeTransform, - content, - assets - })) || [] + (await config.transformHead?.(transformContext(headBeforeTransform))) || [] ) + const stylesheetLink = cssChunk + ? `` + : '' + let inlinedScript = '' if (config.mpa && result) { const matchingChunk = result.output.find( @@ -153,35 +194,40 @@ export async function renderPage( force: true }) } else { - inlinedScript = `` + inlinedScript = `` } } } - const dir = pageData.frontmatter.dir || siteData.dir || 'ltr' - const html = ` ${ - isMetaViewportOverridden(head) + hasNamedMeta(head, 'viewport') ? '' : '' } ${escapeHtml(title)} ${ - isDescriptionOverridden(head) + hasNamedMeta(head, 'description') ? '' : `` } + ${ + // recovers the absolute site root at runtime; a classic inline script + // so it runs before any module resolves URLs + relativeBase && !config.mpa + ? `` + : '' + } ${stylesheetLink} - + ${metadataScript.inHead ? metadataScript.html : ''} ${ appChunk - ? `` + ? `` : '' } ${await renderHead(head)} @@ -195,18 +241,13 @@ export async function renderPage( const htmlFileName = path.join(config.outDir, page.replace(/\.md$/, '.html')) await mkdir(path.dirname(htmlFileName), { recursive: true }) - const transformedHtml = await config.transformHtml?.(html, htmlFileName, { - page, - siteConfig: config, - siteData, - pageData, - title, - description, - head, - content, - assets - }) - await writeFile(htmlFileName, transformedHtml || html) + const finalHtml = desentinel(html) + const transformedHtml = await config.transformHtml?.( + finalHtml, + htmlFileName, + transformContext(head) + ) + await writeFile(htmlFileName, transformedHtml || finalHtml) } async function resolvePageImports( @@ -216,45 +257,35 @@ async function resolvePageImports( appChunk: Rolldown.OutputChunk ) { page = config.rewrites.inv[page] || page - // find the page's js chunk and inject script tags for its imports so that - // they start fetching as early as possible let srcPath = path.resolve(config.srcDir, page) try { if (!config.vite?.resolve?.preserveSymlinks) { srcPath = await realpath(srcPath) } - } catch (e) { - // if the page is a virtual page generated by a dynamic route this would - // fail, which is expected + } catch { + // virtual pages generated by dynamic routes have no file on disk } srcPath = normalizePath(srcPath) const pageChunk = result.output.find( (chunk): chunk is Rolldown.OutputChunk => chunk.type === 'chunk' && chunk.facadeModuleId === srcPath ) - return [ - ...appChunk.imports, - // ...appChunk.dynamicImports, - ...(pageChunk?.imports || []) - // ...pageChunk.dynamicImports - ] + // dynamic imports are intentionally not preloaded + return [...appChunk.imports, ...(pageChunk?.imports || [])] } async function renderHead(head: HeadConfig[]): Promise { const tags = await Promise.all( head.map(async ([tag, attrs = {}, innerHTML = '']) => { const openTag = `<${tag}${renderAttrs(attrs)}>` - if (tag !== 'link' && tag !== 'meta') { - if ( - tag === 'script' && - (attrs.type === undefined || attrs.type.includes('javascript')) - ) { - innerHTML = (await minify('inline-script.js', innerHTML)).code - } - return `${openTag}${innerHTML}` - } else { - return openTag + if (tag === 'link' || tag === 'meta') return openTag + if ( + tag === 'script' && + (attrs.type === undefined || attrs.type.includes('javascript')) + ) { + innerHTML = (await minify('inline-script.js', innerHTML)).code } + return `${openTag}${innerHTML}` }) ) return tags.join('\n ') @@ -262,27 +293,18 @@ async function renderHead(head: HeadConfig[]): Promise { function renderAttrs(attrs: Record): string { return Object.keys(attrs) - .map((key) => { - if (isBooleanAttr(key)) return ` ${key}` - return ` ${key}="${escapeHtml(attrs[key] as string)}"` - }) + .map((key) => + isBooleanAttr(key) ? ` ${key}` : ` ${key}="${escapeHtml(attrs[key])}"` + ) .join('') } function filterOutHeadDescription(head: HeadConfig[] = []) { - return head.filter(([type, attrs]) => { - return !(type === 'meta' && attrs?.name === 'description') - }) -} - -function isDescriptionOverridden(head: HeadConfig[] = []) { - return head.some(([type, attrs]) => { - return type === 'meta' && attrs?.name === 'description' - }) + return head.filter( + ([type, attrs]) => !(type === 'meta' && attrs?.name === 'description') + ) } -function isMetaViewportOverridden(head: HeadConfig[] = []) { - return head.some(([type, attrs]) => { - return type === 'meta' && attrs?.name === 'viewport' - }) +function hasNamedMeta(head: HeadConfig[], name: string) { + return head.some(([type, attrs]) => type === 'meta' && attrs?.name === name) } diff --git a/src/node/config.ts b/src/node/config.ts index 093b455b..baea4a86 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -18,8 +18,10 @@ import type { MarkdownOptions } from './markdown/markdown' import { resolvePages } from './plugins/dynamicRoutesPlugin' import { APPEARANCE_KEY, + EXTERNAL_URL_RE, VP_SOURCE_KEY, isObject, + isRelativeBase, slash, type AdditionalConfig, type Awaitable, @@ -42,6 +44,35 @@ const additionalConfigGlob = `**/config.{js,mjs,ts,mts}` const resolve = (root: string, file: string) => normalizePath(path.resolve(root, `.vitepress`, file)) +export function normalizeSiteBase(base?: string): string { + let normalized = base ? base.replace(/([^/])$/, '$1/') : '/' + if (normalized.startsWith('.') && !isRelativeBase(normalized)) { + throw new Error( + `a relative base must be exactly './' (got: ${base}) — pages always ` + + `reference the site root relative to their own depth` + ) + } + if ( + !isRelativeBase(normalized) && + !EXTERNAL_URL_RE.test(normalized) && + !normalized.startsWith('/') + ) { + normalized = '/' + normalized + } + return normalized +} + +export function normalizeAssetsBase(assetsBase: string): string { + const normalized = assetsBase.replace(/([^/])$/, '$1/') + if (!EXTERNAL_URL_RE.test(normalized) && !normalized.startsWith('/')) { + throw new Error( + `assetsBase must be an absolute URL, a protocol-relative URL, or a ` + + `root-absolute path (got: ${assetsBase})` + ) + } + return normalized +} + export type { ConfigEnv } export type UserConfigFn = ( env: ConfigEnv @@ -142,11 +173,26 @@ export async function resolveConfig( ? '' : normalizePath(path.resolve(srcDir, vitePublicDir || 'public')) + const assetsBase = userConfig.assetsBase + ? normalizeAssetsBase(userConfig.assetsBase) + : undefined + + if (isRelativeBase(site.base) && site.cleanUrls && command === 'build') { + logger.warn( + c.yellow( + `cleanUrls with a relative base needs server-side rewrites and breaks ` + + `file:// browsing — links won't end in .html. Consider disabling ` + + `cleanUrls for relocatable builds.` + ) + ) + } + const config: Omit = { root, srcDir, publicDir, assetsDir, + assetsBase, site, themeDir, configPath, @@ -174,6 +220,7 @@ export async function resolveConfig( transformPageData: userConfig.transformPageData, userConfig, sitemap: userConfig.sitemap, + icons: userConfig.icons, buildConcurrency: userConfig.buildConcurrency ?? 64 } @@ -366,7 +413,7 @@ export async function resolveSiteData( title: userConfig.title || 'VitePress', titleTemplate: userConfig.titleTemplate, description: userConfig.description || 'A VitePress site', - base: userConfig.base ? userConfig.base.replace(/([^/])$/, '$1/') : '/', + base: normalizeSiteBase(userConfig.base), head: resolveSiteDataHead(userConfig), router: { prefetchLinks: userConfig.router?.prefetchLinks ?? true diff --git a/src/node/contentLoader.ts b/src/node/contentLoader.ts index 937aa718..b9f9d3e6 100644 --- a/src/node/contentLoader.ts +++ b/src/node/contentLoader.ts @@ -150,7 +150,10 @@ export function createContentLoader( path: file, relativePath, cleanUrls: !!config.cleanUrls, - realPath: file + realPath: file, + // excerpts are rendered on their own, without the frontmatter + // block - provide the data so `$frontmatter` still resolves + frontmatter } const html = options.render diff --git a/src/node/icons.ts b/src/node/icons.ts new file mode 100644 index 00000000..e819f720 --- /dev/null +++ b/src/node/icons.ts @@ -0,0 +1,159 @@ +import { createRequire } from 'node:module' + +import { getIconData, iconToHTML, iconToSVG } from '@iconify/utils' +import { formatCSS } from '@iconify/utils/lib/css/format' +import { getIconsCSSData } from '@iconify/utils/lib/css/icons' +import { loadCollectionFromFS } from '@iconify/utils/lib/loader/fs' + +import { dependencies } from '../../package.json' with { type: 'json' } +import { parseIconName } from './shared' + +type IconifyJSON = Parameters[0] + +const require = createRequire(import.meta.url) + +// collections vitepress itself depends on, resolvable even when the project +// doesn't install them +const ownCollections = new Set( + Object.keys(dependencies) + .filter((dep) => dep.startsWith('@iconify-json/')) + .map((dep) => dep.slice('@iconify-json/'.length)) +) + +/** + * Placeholder for the stylesheet's content hash, replaced once all pages + * have rendered and the icon set is complete. + */ +export const VP_ICONS_HASH_PLACEHOLDER = '__VP_ICONS_HASH__' + +export function vpIconsFileName(hash: string): string { + return `vp-icons.${hash}.css` +} + +// mirrors theme-default/styles/icons.css at zero specificity, so any theme's +// rules win and duplication is inert; the `--icon` default keeps unresolved +// icons invisible instead of solid currentColor boxes +const BASE_RULES = + ":where([class^='vpi-'],[class*=' vpi-'])" + + `{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E");` + + 'display:inline-block;width:1em;height:1em}' + + ":where([class^='vpi-']:not(.bg),[class*=' vpi-']:not(.bg))" + + '{-webkit-mask:var(--icon) no-repeat;mask:var(--icon) no-repeat;' + + '-webkit-mask-size:100% 100%;mask-size:100% 100%;' + + 'background-color:currentColor;color:inherit}' + +export interface IconsCSSResult { + /** empty string when no icon resolved */ + css: string + warnings: string[] +} + +const collectionCache = new Map>() + +async function loadCollection( + name: string, + root: string +): Promise { + const key = `${root}\0${name}` + let cached = collectionCache.get(key) + if (!cached) { + // falls back to vitepress's own dependencies for the collections it ships + cached = loadCollectionFromFS(name, false, '@iconify-json', root) + .catch(() => undefined) + .then( + (data) => + data ?? + (ownCollections.has(name) + ? require(`@iconify-json/${name}/icons.json`) + : undefined) + ) + collectionCache.set(key, cached) + // don't cache misses — the collection may be installed during dev + cached.then((data) => { + if (!data) collectionCache.delete(key) + }) + } + return cached +} + +const collectionMissingMessage = (collection: string) => + `icon collection "${collection}" is not installed — ` + + `run \`npm add -D @iconify-json/${collection}\` in your project` + +const iconMissingMessage = (collection: string, icon: string) => + `icon "${icon}" was not found in the "${collection}" collection — ` + + `check https://icones.js.org/collection/${collection} for valid names.` + +export async function generateIconsCSS( + root: string, + icons: Set, + format: 'expanded' | 'compressed' +): Promise { + const warnings: string[] = [] + const byCollection = new Map>() + + for (const raw of icons) { + const parsed = parseIconName(raw) + if (!parsed) { + warnings.push( + !raw.includes(':') && parseIconName(`x:${raw}`) + ? `"${raw}" has no collection prefix — write it as ` + + `":${raw}" (e.g. "simple-icons:${raw}"). Only ` + + `\`socialLinks\` qualifies bare names automatically.` + : `"${raw}" is not a valid icon name and was skipped.` + ) + continue + } + let names = byCollection.get(parsed.collection) + if (!names) byCollection.set(parsed.collection, (names = new Set())) + names.add(parsed.icon) + } + + const chunks: string[] = [] + + for (const collection of Array.from(byCollection.keys()).sort()) { + const data = await loadCollection(collection, root) + const names = Array.from(byCollection.get(collection)!).sort() + if (!data) { + warnings.push( + `${collectionMissingMessage(collection)} (needed by: ${names.join(', ')})` + ) + continue + } + const found = names.filter((name) => { + if (getIconData(data, name)) return true + warnings.push(iconMissingMessage(collection, name)) + return false + }) + if (!found.length) continue + const cssData = getIconsCSSData(data, found, { + iconSelector: '.vpi-{prefix}-{name}', + varName: 'icon', + format, + mode: 'mask' + }) + chunks.push(formatCSS(cssData.css, format)) + } + + return { + css: chunks.length ? BASE_RULES + '\n' + chunks.join('') : '', + warnings + } +} + +/** single-icon SVG for the dev-server endpoint */ +export async function resolveIconSVG( + root: string, + collection: string, + icon: string +): Promise<{ svg: string } | { error: string }> { + if (!parseIconName(`${collection}:${icon}`)) { + return { error: `"${collection}:${icon}" is not a valid icon name.` } + } + const data = await loadCollection(collection, root) + if (!data) return { error: collectionMissingMessage(collection) } + const iconData = getIconData(data, icon) + if (!iconData) return { error: iconMissingMessage(collection, icon) } + const built = iconToSVG(iconData) + return { svg: iconToHTML(built.body, built.attributes) } +} diff --git a/src/node/init/init.ts b/src/node/init/init.ts index dadce3fe..b481a62e 100644 --- a/src/node/init/init.ts +++ b/src/node/init/init.ts @@ -12,7 +12,7 @@ import { select, text } from '@clack/prompts' -import template from 'lodash.template' +import { Eta } from 'eta' import c from 'picocolors' import { slash } from '../shared' @@ -190,6 +190,12 @@ export async function scaffold({ const useMjs = userPkg.type !== 'module' + const eta = new Eta({ + useWith: true, + autoEscape: false, + autoTrim: false + }) + const renderFile = async (file: string) => { const filePath = path.resolve(templateDir, file) let targetPath = path.resolve(resolvedRoot, file) @@ -205,7 +211,7 @@ export async function scaffold({ } const content = await readFile(filePath) - const compiled = template(content)(data) + const compiled = eta.renderString(content, data) await mkdir(path.dirname(targetPath), { recursive: true }) await writeFile(targetPath, compiled) diff --git a/src/node/markdown/markdown.ts b/src/node/markdown/markdown.ts index 84886d3d..eeb90ced 100644 --- a/src/node/markdown/markdown.ts +++ b/src/node/markdown/markdown.ts @@ -51,6 +51,7 @@ import { gitHubAlertsPlugin, type ContainerOptions } from './plugins/containers' +import { eagerFrontmatterInterpolationPlugin } from './plugins/eagerFrontmatterInterpolation' import { highlight as createHighlighter } from './plugins/highlight' import { imagePlugin, type Options as ImageOptions } from './plugins/image' import { @@ -326,6 +327,24 @@ export interface MarkdownOptions extends MarkdownItAsyncOptions { * @see https://github.com/mdit-vue/mdit-vue/tree/main/packages/plugin-frontmatter */ frontmatter?: FrontmatterPluginOptions + /** + * Resolve `{{ $frontmatter. }}` interpolations to their values while + * rendering markdown, so the value also reaches consumers that never run + * Vue - heading anchors and titles, the local search index, content loader + * output, link destinations - and the compiled Vue template gets static + * text instead of a runtime expression. Only bare property paths resolving + * to simple primitive values in the page's own frontmatter are inlined - + * anything else (complex expressions, missing keys, non-primitive values, + * `v-pre` scopes) keeps its runtime interpolation. Set to `false` to leave + * all interpolation to the Vue runtime - for example when + * `transformPageData` rewrites frontmatter values that pages interpolate, + * which would otherwise render the pre-transform value (a warning is + * logged when that happens). + * + * @experimental + * @default true + */ + eagerFrontmatterInterpolation?: boolean /** * Options for `@mdit-vue/plugin-sfc`. * @see https://github.com/mdit-vue/mdit-vue/tree/main/packages/plugin-sfc @@ -557,6 +576,12 @@ export async function createMarkdownRenderer( } }) } + // applied after anchor/title so its finalize rule runs once their rules + // have extracted the plain resolved text; its main rule is anchored right + // after `text_join` regardless of when the plugin is applied + if (options.eagerFrontmatterInterpolation !== false) { + eagerFrontmatterInterpolationPlugin(md) + } // apply user config if (options.config) { diff --git a/src/node/markdown/plugins/containers.ts b/src/node/markdown/plugins/containers.ts index 690890ba..88c7c87b 100644 --- a/src/node/markdown/plugins/containers.ts +++ b/src/node/markdown/plugins/containers.ts @@ -138,7 +138,7 @@ function createOpenRender( if (noTitle) return `
\n` const title = md.renderInline( info || titlesFor(titles, env.localeIndex)[name], - { references: env.references } + { references: env.references, frontmatter: env.frontmatter } ) if (name === 'details') return `
${title}\n` diff --git a/src/node/markdown/plugins/eagerFrontmatterInterpolation.ts b/src/node/markdown/plugins/eagerFrontmatterInterpolation.ts new file mode 100644 index 00000000..a0be946e --- /dev/null +++ b/src/node/markdown/plugins/eagerFrontmatterInterpolation.ts @@ -0,0 +1,417 @@ +import type { MarkdownItAsync } from 'markdown-it-async' +import type StateCore from 'markdown-it/lib/rules_core/state_core.mjs' +import type Token from 'markdown-it/lib/token.mjs' + +import type { MarkdownEnv } from '../../shared' + +// matches an interpolation the way Vue's template parser does: from `{{` up +// to the nearest `}}` +const interpolationRE = /\{\{([^]+?)\}\}/g + +// the same inside a link destination - markdown-it percent-encodes +// destinations while tokenizing, so the delimiters may appear encoded +const destInterpolationRE = /(?:\{\{|%7B%7B)([^]*?)(?:\}\}|%7D%7D)/gi + +// page-level gate for either spelling +const anyInterpolationRE = /\{\{|%7B%7B/i + +// a statically resolvable path after `$frontmatter`: any number of `.key`, +// `[]`, `['']` or `[""]` segments. Leading-zero indices and +// string escapes are excluded - the runtime handles those. Keep both +// expressions in sync. +const pathRE = + /^(?:\s*(?:\.\s*[A-Za-z_$][\w$]*|\[\s*(?:0|[1-9]\d*|'[^'\\]*'|"[^"\\]*")\s*\]))*$/ +const segmentRE = + /\.\s*([A-Za-z_$][\w$]*)|\[\s*(?:(0|[1-9]\d*)|'([^'\\]*)'|"([^"\\]*)")\s*\]/g + +// one raw html tag: closing slash, name, attributes (a quoted value may +// contain `>`), self-closing slash +const htmlTagRE = /<(\/?)([A-Za-z][\w-]*)((?:[^"'>]|"[^"]*"|'[^']*')*?)(\/?)>/g +const htmlCommentRE = //g +// script/style/textarea/title content is raw text, not markup +const rawTextElementRE = /<(script|style|textarea|title)\b[^]*?<\/\1\s*>/gi +const vPreAttrRE = /(?:^|\s)v-pre(?=[\s=/]|$)/ +// void elements never take a closing tag, so v-pre on them opens no scope +const voidTagRE = + /^(?:area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$/ + +type Resolve = (expr: string) => string | undefined + +interface EagerInterpolation { + expression: string + value: string +} + +interface VPreScope { + tag: string + depth: number +} + +export const eagerFrontmatterInterpolationPlugin = (md: MarkdownItAsync) => { + // the main rule is anchored right after `text_join`, before the rules + // other plugins push (anchor, title, ...), so slugs and extracted titles + // are derived from the resolved text + md.core.ruler.after( + 'text_join', + 'vp_eager_frontmatter_interpolation', + eagerFrontmatterInterpolation + ) + + // the finalize rule is pushed when this plugin is applied - which must be + // after anchor/title so it runs once their rules have read the plain text + md.core.ruler.push('vp_eager_frontmatter_finalize', finalize) + + // resolved values render with their own escaping (see `escapeValue`); + // everything else keeps the existing text rule + const textRule = md.renderer.rules.text! + md.renderer.rules.text = (tokens, idx, options, env, self) => + tokens[idx].meta?.frontmatterValue + ? escapeValue(tokens[idx].content) + : textRule(tokens, idx, options, env, self) +} + +export function findStaleEagerInterpolations( + interpolations: EagerInterpolation[], + frontmatter: Record +): string[] { + const resolve = createResolver(frontmatter) + const stale = interpolations + .filter(({ expression, value }) => resolve(expression) !== value) + .map(({ expression }) => expression) + return [...new Set(stale)] +} + +function eagerFrontmatterInterpolation(state: StateCore): void { + const { frontmatter } = state.env as MarkdownEnv + if (!frontmatter || !anyInterpolationRE.test(state.src)) return + + const resolve = createResolver(frontmatter) + + // an unclosed v-pre element opened by a raw html block scopes over the + // markdown after it until later raw html closes it + let blockScope: VPreScope | undefined + let skipLevel: number | null = null + for (const token of state.tokens) { + if (token.type === 'html_block') { + blockScope = scanRawHtml(token.content, blockScope) + continue + } + if (skipLevel !== null) { + if (token.nesting === -1 && token.level === skipLevel) skipLevel = null + continue + } + if ( + token.nesting === 1 && + (token.type === 'container_v-pre_open' || hasVPre(token)) + ) { + skipLevel = token.level + continue + } + if (token.type !== 'inline' || !token.children) continue + if (blockScope) { + // a stray closing tag inside a paragraph still ends the scope, but the + // paragraph itself stays with the runtime + for (const child of token.children) { + if (child.type === 'html_inline') + blockScope = scanRawHtml(child.content, blockScope) + } + continue + } + processInline(state, token, resolve) + } +} + +// after anchor ids and the page title have been extracted from the plain +// text, retype values the shared `text` renderer rule - which user config +// may replace - could not safely emit: braces would compile as +// interpolations and entity look-alikes would decode. `html_inline` renders +// its content verbatim, so these carry their own escaping. +const unsafeAsTextRE = /[{}]|&[\w#]+;/ +function finalize(state: StateCore): void { + if (!(state.env as MarkdownEnv).eagerInterpolations?.length) return + for (const token of state.tokens) { + if (token.type !== 'inline' || !token.children) continue + for (const child of token.children) { + if (child.meta?.frontmatterValue && unsafeAsTextRE.test(child.content)) { + child.type = 'html_inline' + child.content = escapeValue(child.content) + } + } + } +} + +function createResolver(frontmatter: Record): Resolve { + // the runtime `$frontmatter` is the frontmatter after the JSON round-trip + // into `__pageData` (see `injectPageDataCode`), so resolve against the + // same view of the data - dates become ISO strings and non-JSON values + // are dropped + let data: unknown + let failed = false + return (rawExpr) => { + const expr = rawExpr.trim() + if (!expr.startsWith('$frontmatter')) return undefined + const path = expr.slice('$frontmatter'.length) + if (!pathRE.test(path)) return undefined + + if (data === undefined && !failed) { + try { + data = JSON.parse(JSON.stringify(frontmatter)) + } catch { + failed = true + } + } + if (failed) return undefined + + let value = data + for (const m of path.matchAll(segmentRE)) { + const key = (m[1] ?? m[2] ?? m[3] ?? m[4])! + // a missing key may still be provided by `transformPageData`, and a + // path through a non-object would throw at runtime - leave both alone + if ( + value === null || + typeof value !== 'object' || + !Object.hasOwn(value, key) + ) { + return undefined + } + value = (value as Record)[key] + } + return display(value) + } +} + +// scans a chunk of raw html, entering and leaving `v-pre` element scopes the +// way Vue's parser would: quoted attribute values may contain `>`, tag names +// match case-insensitively, self-closing and void tags open no scope, and +// comments and raw-text elements (script/style/...) are not markup +function scanRawHtml( + html: string, + scope: VPreScope | undefined +): VPreScope | undefined { + const src = html.replace(htmlCommentRE, '').replace(rawTextElementRE, '') + htmlTagRE.lastIndex = 0 + let m: RegExpExecArray | null + while ((m = htmlTagRE.exec(src))) { + const [, closing, rawTag, attrs, selfClosing] = m + const tag = rawTag.toLowerCase() + if (scope) { + if (tag === scope.tag && !selfClosing) { + scope.depth += closing ? -1 : 1 + if (!scope.depth) scope = undefined + } + } else if ( + !closing && + !selfClosing && + !voidTagRE.test(tag) && + vPreAttrRE.test(attrs) + ) { + scope = { tag, depth: 1 } + } + } + return scope +} + +function processInline( + state: StateCore, + inline: Token, + resolve: Resolve +): void { + const children = inline.children! + + let out: Token[] | undefined + let skipLevel: number | null = null + // scopes opened by raw inline tags end with the paragraph - Vue closes + // unclosed inline elements at the enclosing block's end tag + let scope: VPreScope | undefined + + for (let i = 0; i < children.length; i++) { + const child = children[i] + let replacement: Token[] | undefined + if (child.type === 'html_inline') { + scope = scanRawHtml(child.content, scope) + } else if (scope) { + // inside a raw v-pre element - leave everything to the runtime + } else if (skipLevel !== null) { + if (child.nesting === -1 && child.level === skipLevel) skipLevel = null + } else if (child.nesting === 1 && hasVPre(child)) { + skipLevel = child.level + } else if (child.type === 'text' && child.content.includes('{{')) { + replacement = replaceInterpolations(state, children, i, resolve) + } else if (child.type === 'link_open' || child.type === 'image') { + resolveDest( + state, + child, + child.type === 'image' ? 'src' : 'href', + resolve + ) + } + if (replacement && !out) out = children.slice(0, i) + if (out) out.push(...(replacement ?? [child])) + } + if (out) inline.children = out +} + +// how the neighbor beyond any whitespace looks from a text token: entering a +// raw inline element, leaving one, or neither +function rawTagBoundary( + children: Token[], + i: number, + dir: -1 | 1 +): { kind: 'open' | 'close' | null; sawBreak: boolean } { + let sawBreak = false + for (let j = i + dir; j >= 0 && j < children.length; j += dir) { + const t = children[j] + if (t.type === 'softbreak') { + sawBreak = true + continue + } + if (t.type !== 'html_inline') break + htmlTagRE.lastIndex = 0 + const m = htmlTagRE.exec(t.content) + if (!m) break + const [, closing, tag, , selfClosing] = m + if (selfClosing || voidTagRE.test(tag.toLowerCase())) break + return { kind: closing ? 'close' : 'open', sawBreak } + } + return { kind: null, sawBreak } +} + +// splits a text token around its resolved interpolations; the values become +// text tokens marked as `frontmatterValue` so extraction (anchors, headers, +// toc, search) sees them as plain text while the renderer applies value +// escaping +function replaceInterpolations( + state: StateCore, + children: Token[], + index: number, + resolve: Resolve +): Token[] | undefined { + const token = children[index] + const src = token.content + const left = rawTagBoundary(children, index, -1) + const right = rawTagBoundary(children, index, 1) + let out: Token[] | undefined + let lastIndex = 0 + + for (const m of src.matchAll(interpolationRE)) { + const value = resolve(m[1]) + if (value === undefined) continue + // an inlined value merges with adjacent whitespace into one text node, + // which Vue's whitespace condensing keeps - while the runtime's + // whitespace-only text nodes at raw inline element edges are removed. + // ` {{ x }} ` must stay with the runtime to render the same. + const before = src.slice(0, m.index) + const after = src.slice(m.index + m[0].length) + if ( + (left.kind === 'open' && + !before.trim() && + (before !== '' || left.sawBreak)) || + (right.kind === 'close' && + !after.trim() && + (after !== '' || right.sawBreak)) + ) { + continue + } + out ??= [] + if (m.index > lastIndex) { + out.push(textToken(state, token, src.slice(lastIndex, m.index))) + } + const valueToken = textToken(state, token, value) + valueToken.meta = { frontmatterValue: true } + out.push(valueToken) + record(state, m[1], value) + lastIndex = m.index + m[0].length + } + + if (out && lastIndex < src.length) { + out.push(textToken(state, token, src.slice(lastIndex))) + } + return out +} + +// resolves interpolations in a link href or image src, making +// `[text]({{$frontmatter.link}})` a real link (#2240, #2099); the resolved +// destination goes through the same normalization and validation a literal +// one would +function resolveDest( + state: StateCore, + token: Token, + attr: string, + resolve: Resolve +): void { + const url = token.attrGet(attr) + if (!url) return + + const resolved: EagerInterpolation[] = [] + const next = url.replace(destInterpolationRE, (match, rawExpr) => { + let expr = rawExpr as string + try { + expr = decodeURIComponent(expr) + } catch {} + const value = resolve(expr) + if (value === undefined) return match + resolved.push({ expression: expr, value }) + return value + }) + if (!resolved.length) return + + const normalized = state.md.normalizeLink(next) + if (!state.md.validateLink(normalized)) return + token.attrSet(attr, normalized) + // the value belongs to the page whose frontmatter it came from - the + // include plugin must not rebase it against an included file's directory + token.meta = { ...token.meta, frontmatterDest: true } + for (const r of resolved) record(state, r.expression, r.value) +} + +function record(state: StateCore, expression: string, value: string): void { + const env = state.env as MarkdownEnv + ;(env.eagerInterpolations ??= []).push({ + expression: expression.trim(), + value + }) +} + +function textToken(state: StateCore, from: Token, content: string): Token { + const token = new state.Token('text', '', 0) + token.content = content + token.level = from.level + return token +} + +// whitespace the Vue template compiler's default `condense` mode would not +// read back verbatim: any [\t\n\r\f], runs of spaces, or edge spaces that +// could merge with adjacent whitespace +const unstableWhitespaceRE = /[\t\n\r\f]|^ | $| {2}/ + +// mirrors Vue's `toDisplayString`, restricted to values that inline +// losslessly: non-empty primitive text that survives the template +// compiler's whitespace condensing and cannot smuggle markup into extracted +// titles and headers. Anything else - including `null`, which renders as an +// empty string but is also the classic "filled in later by +// `transformPageData`" placeholder - stays on the runtime. +function display(value: unknown): string | undefined { + if (value == null || typeof value === 'object') return undefined + const text = typeof value === 'string' ? value : String(value) + return text === '' || text.includes('<') || unstableWhitespaceRE.test(text) + ? undefined + : text +} + +// entity-encode the value so the Vue template compiler reads back exactly +// this text: `{` must never reach the compiler as a possible interpolation +// start, and entity look-alikes must survive the compiler's decoding. `&` is +// encoded numerically so downstream passes that undo `&` +// double-encoding (the toc `format` hook) leave it alone. +function escapeValue(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\{/g, '{') + .replace(/\}/g, '}') +} + +function hasVPre(token: Token): boolean { + return token.attrGet('v-pre') !== null +} diff --git a/src/node/markdown/plugins/include.ts b/src/node/markdown/plugins/include.ts index 0e4ee896..ec258e3b 100644 --- a/src/node/markdown/plugins/include.ts +++ b/src/node/markdown/plugins/include.ts @@ -269,7 +269,9 @@ function registerRebaseRules(md: MarkdownItAsync) { const token = tokens[idx] const attr = rule === 'image' ? 'src' : 'href' const url = token.attrGet(attr) - if (url?.[0] === '.') { + // a destination resolved from `$frontmatter` belongs to the page the + // frontmatter came from, not to the included file + if (url?.[0] === '.' && !token.meta?.frontmatterDest) { const rebased = slash( path.join(path.relative(path.dirname(file), dir), url) ) diff --git a/src/node/markdown/plugins/link.ts b/src/node/markdown/plugins/link.ts index f046537e..e9c52105 100644 --- a/src/node/markdown/plugins/link.ts +++ b/src/node/markdown/plugins/link.ts @@ -9,6 +9,9 @@ import type { MarkdownItAsync } from 'markdown-it-async' import { EXTERNAL_URL_RE, isExternal, + isRelativeBase, + joinPath, + relativePathToRoot, treatAsHtml, type MarkdownEnv } from '../../shared' @@ -81,7 +84,15 @@ export const linkPlugin = ( // append base to internal (non-relative) urls if (hrefAttr[1].startsWith('/')) { - hrefAttr[1] = `${base}${hrefAttr[1]}`.replace(/\/+/g, '/') + if (isRelativeBase(base)) { + // page-relative, so the same html works at any mount point + if (env.relativizeUrls && env.relativePath != null) { + hrefAttr[1] = + relativePathToRoot(env.relativePath) + hrefAttr[1].slice(1) + } + } else { + hrefAttr[1] = joinPath(base, hrefAttr[1]) + } } } if (frag) { @@ -98,10 +109,13 @@ export const linkPlugin = ( ) { let url = hrefAttr[1] + // directory urls need a server to resolve them, and file:// has none + const explicitIndex = isRelativeBase(base) && !env.cleanUrls + const indexMatch = url.match(indexRE) if (indexMatch) { const [, path, hash] = indexMatch - url = path + normalizeHash(hash) + url = path + (explicitIndex ? 'index.html' : '') + normalizeHash(hash) } else { let cleanUrl = url.replace(/[?#].*$/, '') // transform foo.md -> foo[.html] @@ -116,6 +130,9 @@ export const linkPlugin = ( ) { cleanUrl += '.html' } + if (explicitIndex && cleanUrl.endsWith('/')) { + cleanUrl += 'index.html' + } const parsed = new URL(url, 'http://a.com') url = cleanUrl + parsed.search + normalizeHash(parsed.hash) } diff --git a/src/node/markdownToVue.ts b/src/node/markdownToVue.ts index 8586fb1b..661af795 100644 --- a/src/node/markdownToVue.ts +++ b/src/node/markdownToVue.ts @@ -13,6 +13,7 @@ import { type MarkdownOptions, type MarkdownRenderer } from './markdown/markdown' +import { findStaleEagerInterpolations } from './markdown/plugins/eagerFrontmatterInterpolation' import { getPageDataTransformer } from './plugins/dynamicRoutesPlugin' import { EXTERNAL_URL_RE, @@ -158,6 +159,7 @@ export async function createMarkdownToVueRenderFn( path: file, relativePath, cleanUrls, + relativizeUrls: true, includes: [], realPath: fileOrig, localeIndex @@ -283,6 +285,24 @@ export async function createMarkdownToVueRenderFn( } } + // interpolations were inlined from the frontmatter as rendered - values + // rewritten by `transformPageData` afterwards would silently diverge + if (transformPageData.length && env.eagerInterpolations?.length) { + const stale = findStaleEagerInterpolations( + env.eagerInterpolations, + (pageData.frontmatter ?? {}) as Record + ) + if (stale.length) { + siteConfig?.logger?.warn( + `${relativePath}: ${stale.map((e) => `{{ ${e} }}`).join(', ')} ` + + `resolved while rendering markdown, but transformPageData changed ` + + `the underlying frontmatter afterwards - the rendered content ` + + `keeps the old value. Avoid rewriting interpolated keys, or set ` + + `markdown.eagerFrontmatterInterpolation: false.` + ) + } + } + const vueSrc = [ ...injectPageDataCode( sfcBlocks?.scripts.map((item) => item.content) ?? [], diff --git a/src/node/plugin.ts b/src/node/plugin.ts index af0f3502..886bae0d 100644 --- a/src/node/plugin.ts +++ b/src/node/plugin.ts @@ -27,6 +27,8 @@ import { createMarkdownToVueRenderFn, type MarkdownCompileResult } from './markdownToVue' +import { assetsBasePlugin } from './plugins/assetsBasePlugin' +import { iconsPlugin } from './plugins/iconsPlugin' import { dynamicRoutesPlugin } from './plugins/dynamicRoutesPlugin' import { localSearchPlugin } from './plugins/localSearchPlugin' import { rewritesPlugin } from './plugins/rewritesPlugin' @@ -129,7 +131,9 @@ export async function createVitePressPlugin( markdownToVue = await createMarkdownToVueRenderFn( srcDir, markdown ?? {}, - config.base, + // the site base, not the vite base: the ssr build runs under the + // sentinel, and one md singleton serves both builds + site.base, lastUpdated ?? false, cleanUrls ?? false, siteConfig @@ -148,6 +152,7 @@ export async function createVitePressPlugin( !!site.themeConfig?.algolia, // legacy __CARBON__: !!site.themeConfig?.carbonAds, __ASSETS_DIR__: JSON.stringify(siteConfig.assetsDir), + __ASSETS_BASE__: JSON.stringify(siteConfig.assetsBase ?? ''), __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: !!process.env.DEBUG }, optimizeDeps: { @@ -202,8 +207,13 @@ export async function createVitePressPlugin( return `export default window.__VP_SITE_DATA__` } } - data = serializeFunctions(data) - return `${deserializeFunctions};export default deserializeFunctions(JSON.parse(${JSON.stringify(JSON.stringify(data))}))` + const fns: string[] = [] + const dataStr = JSON.stringify( + JSON.stringify(serializeFunctions(data, fns)) + ) + return fns.length + ? `${deserializeFunctions};export default deserializeFunctions(JSON.parse(${dataStr}),[${fns.join(',')}])` + : `export default JSON.parse(${dataStr})` } }, @@ -452,6 +462,9 @@ export async function createVitePressPlugin( hmrFix, webFontsPlugin(siteConfig.useWebFonts), ...(userViteConfig?.plugins || []), + // must stay after the user plugins; see assetsBasePlugin + ...(siteConfig.assetsBase ? [assetsBasePlugin(siteConfig)] : []), + iconsPlugin(siteConfig), await localSearchPlugin(siteConfig), staticDataPlugin, await dynamicRoutesPlugin(siteConfig) diff --git a/src/node/plugins/assetsBasePlugin.ts b/src/node/plugins/assetsBasePlugin.ts new file mode 100644 index 00000000..2489244e --- /dev/null +++ b/src/node/plugins/assetsBasePlugin.ts @@ -0,0 +1,31 @@ +import type { Plugin } from 'vite' + +import type { SiteConfig } from '../config' + +/** + * Routes built asset URLs through `assetsBase`, chaining behind any user + * renderBuiltUrl. Plain strings only: a `{ runtime }` return would execute + * at module scope in the Node SSR bundle, and is an error in CSS. + */ +export function assetsBasePlugin(config: SiteConfig): Plugin { + return { + name: 'vitepress:assets-base', + // 'post', plus a position after the user plugins in plugin.ts: the + // config hook must run after theirs to chain behind (not under) their + // renderBuiltUrl + enforce: 'post', + config(userConfig, env) { + if (env.command !== 'build') return + const userHook = userConfig.experimental?.renderBuiltUrl + return { + experimental: { + renderBuiltUrl(filename, ctx) { + const userResult = userHook?.(filename, ctx) + if (userResult !== undefined) return userResult + if (ctx.type === 'asset') return config.assetsBase! + filename + } + } + } + } + } +} diff --git a/src/node/plugins/iconsPlugin.ts b/src/node/plugins/iconsPlugin.ts new file mode 100644 index 00000000..548d5011 --- /dev/null +++ b/src/node/plugins/iconsPlugin.ts @@ -0,0 +1,39 @@ +import c from 'picocolors' +import type { Plugin } from 'vite' + +import { resolveIconSVG } from '../icons' +import type { SiteConfig } from '../siteConfig' + +const iconRequestRE = /\/_vpi\/([a-z0-9-]+)\/([a-z0-9-]+)\.svg$/ + +/** + * Serves `/_vpi//.svg` in dev from locally installed + * `@iconify-json/*` collections (requested on demand by `useIcon`). + */ +export function iconsPlugin(siteConfig: SiteConfig): Plugin { + const warned = new Set() + return { + name: 'vitepress:icons', + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + const match = req.url?.split('?')[0].match(iconRequestRE) + if (!match) return next() + const [, collection, icon] = match + const resolved = await resolveIconSVG(siteConfig.root, collection, icon) + if ('svg' in resolved) { + res.setHeader('Content-Type', 'image/svg+xml') + res.setHeader('Cache-Control', 'no-cache') + res.end(resolved.svg) + } else { + const key = `${collection}:${icon}` + if (!warned.has(key)) { + warned.add(key) + siteConfig.logger.warn(c.yellow(`(icons) ${resolved.error}`)) + } + res.statusCode = 404 + res.end() + } + }) + } + } +} diff --git a/src/node/plugins/localSearchPlugin.ts b/src/node/plugins/localSearchPlugin.ts index 3bd154ce..b91ad0a1 100644 --- a/src/node/plugins/localSearchPlugin.ts +++ b/src/node/plugins/localSearchPlugin.ts @@ -127,7 +127,9 @@ export async function localSearchPlugin( function getDocId(file: string) { let relFile = slash(path.relative(siteConfig.srcDir, file)) relFile = siteConfig.rewrites.map[relFile] || relFile - let id = slash(path.join(siteConfig.site.base, relFile)) + // site-relative — the search box applies the runtime base on use, so + // the same index works for absolute and relative bases + let id = '/' + relFile id = id.replace(/(^|\/)index\.md$/, '$1') id = id.replace(/\.md$/, siteConfig.cleanUrls ? '' : '.html') return id diff --git a/src/node/plugins/rewritesPlugin.ts b/src/node/plugins/rewritesPlugin.ts index 23153244..9d597d25 100644 --- a/src/node/plugins/rewritesPlugin.ts +++ b/src/node/plugins/rewritesPlugin.ts @@ -1,6 +1,7 @@ import { compile, match } from 'path-to-regexp' import type { Plugin } from 'vite' +import { isRelativeBase } from '../shared' import type { SiteConfig, UserConfig } from '../siteConfig' export function resolveRewrites( @@ -51,12 +52,14 @@ export const rewritesPlugin = (config: SiteConfig): Plugin => { return { name: 'vitepress:rewrites', configureServer(server) { + // dev always serves at the root when the base is relative + const base = isRelativeBase(config.site.base) ? '/' : config.site.base // dev rewrite server.middlewares.use((req, _res, next) => { if (req.url) { const page = decodeURI(req.url) .replace(/[?#].*$/, '') - .slice(config.site.base.length) + .slice(base.length) if (config.rewrites.inv[page]) { req.url = req.url.replace( diff --git a/src/node/serve/serve.ts b/src/node/serve/serve.ts index 6ed7704c..d41b9a4c 100644 --- a/src/node/serve/serve.ts +++ b/src/node/serve/serve.ts @@ -5,11 +5,13 @@ import compression from '@polka/compression' import polka, { type IOptions } from 'polka' import sirv from 'sirv' -import { resolveConfig } from '../config' +import { normalizeAssetsBase, resolveConfig } from '../config' +import { EXTERNAL_URL_RE, isRelativeBase } from '../shared' import { readFile } from '../utils/fs' export interface ServeOptions { base?: string + assetsBase?: string root?: string port?: number } @@ -17,15 +19,34 @@ export interface ServeOptions { export async function serve(options: ServeOptions = {}) { const port = options.port ?? 4173 const config = await resolveConfig(options.root, 'serve', 'production') - const base = (options?.base ?? config?.site?.base ?? '').replace( - /^\/+|\/+$/g, - '' - ) + + const assetsBase = + typeof options.assetsBase === 'string' + ? normalizeAssetsBase(options.assetsBase) + : config.assetsBase + + let rawBase = + (typeof options.base === 'string' ? options.base : undefined) ?? + config?.site?.base ?? + '/' + if (isRelativeBase(rawBase)) { + // a relative base works at any mount point; serve it at the root + rawBase = '/' + } else if (EXTERNAL_URL_RE.test(rawBase)) { + rawBase = new URL(rawBase, 'http://a.com').pathname + } + const base = rawBase.replace(/^\/+|\/+$/g, '') const notAnAsset = (pathname: string) => !pathname.includes(`/${config.assetsDir}/`) const notFound = await readFile(path.resolve(config.outDir, './404.html')) const onNoMatch: IOptions['onNoMatch'] = (req, res) => { + if (base && req.path === '/') { + res.statusCode = 302 + res.setHeader('location', `/${base}/`) + res.end() + return + } res.statusCode = 404 if (notAnAsset(req.path)) res.write(notFound) res.end() @@ -45,9 +66,31 @@ export async function serve(options: ServeOptions = {}) { } }) - const app = base - ? polka({ onNoMatch }).use(base, compress, serve) - : polka({ onNoMatch }).use(compress, serve) + const app = polka({ onNoMatch }) + + if (assetsBase) { + if (EXTERNAL_URL_RE.test(assetsBase)) { + config.logger.info( + `assetsBase is external (${assetsBase}) — assets will be ` + + `requested from that URL, not from this preview server.` + ) + } else { + // mirror the asset subtree at the configured prefix + const assetsPath = `${assetsBase}${config.assetsDir}`.replace(/\/+$/, '') + app.use( + assetsPath, + compress, + sirv(path.join(config.outDir, config.assetsDir), { + etag: true, + maxAge: 31536000, + immutable: true + }) + ) + } + } + + if (base) app.use(base, compress, serve) + else app.use(compress, serve) app.listen(port) await once(app.server, 'listening') diff --git a/src/node/server.ts b/src/node/server.ts index 6f8708ff..6ad0d1af 100644 --- a/src/node/server.ts +++ b/src/node/server.ts @@ -1,6 +1,6 @@ import { createServer as createViteServer, type ServerOptions } from 'vite' -import { resolveConfig, type SiteConfig } from './config' +import { normalizeSiteBase, resolveConfig, type SiteConfig } from './config' import { createVitePressPlugin } from './plugin' export async function createServer( @@ -12,7 +12,7 @@ export async function createServer( config ??= await resolveConfig(root) const { base, ...server } = serverOptions - config.site.base = base ?? config.site.base + if (typeof base === 'string') config.site.base = normalizeSiteBase(base) return createViteServer({ root: config.srcDir, diff --git a/src/node/siteConfig.ts b/src/node/siteConfig.ts index 6538395e..2b4d1eb4 100644 --- a/src/node/siteConfig.ts +++ b/src/node/siteConfig.ts @@ -92,7 +92,9 @@ export interface UserConfig< */ extends?: RawConfigExports /** - * The base URL the site is deployed at. Must start and end with a slash. + * The base URL the site is deployed at. Usually starts and ends with a + * slash. Use `'./'` to make page references relative to their own depth, + * so the output works at any subpath. * @default '/' */ base?: string @@ -118,6 +120,43 @@ export interface UserConfig< * @default 'assets' */ assetsDir?: string + /** + * URL prefix for built assets (everything under `assetsDir`), e.g. a CDN. + * + * Must be one of: + * - an absolute URL + * - a protocol-relative URL + * - a root-absolute path + * + * The prefix must mirror `outDir` layout: each asset URL = this prefix + + * file output-relative path. + * + * These still use `base`: + * - pages + * - `withBase` links + * - `public/` files + * - `hashmap.json` + * + * If the prefix is cross-origin, it must serve CORS headers, because + * generated tags are marked `crossorigin`. + * + * Applies to builds and preview (not dev). + * + * @example 'https://cdn.example.com/' + */ + assetsBase?: string + /** + * Options for the generated icon stylesheet (`vp-icons.*.css`). + */ + icons?: { + /** + * Fully qualified `collection:name` icons to include in addition to + * the ones collected during SSR — for icons that only render + * client-side (e.g. inside ``). + * @example ['mdi:home', 'simple-icons:discord'] + */ + include?: string[] + } /** * Directory for cache files, relative to the project root. * @default './.vitepress/cache' @@ -209,10 +248,10 @@ export interface UserConfig< */ cleanUrls?: boolean /** - * Use web fonts instead of emitting font files to dist. The active - * theme must import a file named `fonts.(s)css` for this to work. If - * you are a theme author, to support this, place your web font import - * between `webfont-marker-begin` and `webfont-marker-end` comments. + * Use web fonts instead of emitting font files to dist. Requires the + * active theme to import a file named `fonts.(s)css`, with its web font + * imports placed between `webfont-marker-begin` and `webfont-marker-end` + * comments. * @experimental * @default true in webcontainers, else false */ @@ -309,6 +348,7 @@ export interface SiteConfig extends Pick< | 'transformHtml' | 'transformPageData' | 'sitemap' + | 'icons' > { /** * Absolute path of the project root (the directory containing @@ -349,6 +389,10 @@ export interface SiteConfig extends Pick< * Directory for assets within the build output. */ assetsDir: string + /** + * URL prefix for built assets, normalized to end with a slash. + */ + assetsBase?: string /** * Absolute path of the cache directory. */ diff --git a/src/node/utils/fnSerialize.ts b/src/node/utils/fnSerialize.ts index da4f3680..74a1d5f3 100644 --- a/src/node/utils/fnSerialize.ts +++ b/src/node/utils/fnSerialize.ts @@ -1,30 +1,37 @@ /* -export function deserializeFunctions(value: any): any { +export function deserializeFunctions(value: any, fns: any[]): any { if (Array.isArray(value)) { - return value.map(deserializeFunctions) + return value.map((v) => deserializeFunctions(v, fns)) } else if (typeof value === 'object' && value !== null) { return Object.keys(value).reduce((acc, key) => { - acc[key] = deserializeFunctions(value[key]) + acc[key] = deserializeFunctions(value[key], fns) return acc }, {} as any) } else if (typeof value === 'string' && value.startsWith('_vp-fn_')) { - return new Function(`return ${value.slice(7)}`)() + return fns[+value.slice(7)] ?? value } else { return value } } */ +// functions are emitted as plain code next to this and only looked up here by +// index, so no `new Function` is needed and strict CSP (no `unsafe-eval`) +// stays intact (#3685) export const deserializeFunctions = - 'function deserializeFunctions(r){return Array.isArray(r)?r.map(deserializeFunctions):typeof r=="object"&&r!==null?Object.keys(r).reduce((t,n)=>(t[n]=deserializeFunctions(r[n]),t),{}):typeof r=="string"&&r.startsWith("_vp-fn_")?new Function(`return ${r.slice(7)}`)():r}' + 'function deserializeFunctions(r,e){return Array.isArray(r)?r.map(t=>deserializeFunctions(t,e)):typeof r=="object"&&r!==null?Object.keys(r).reduce((t,n)=>(t[n]=deserializeFunctions(r[n],e),t),{}):typeof r=="string"&&r.startsWith("_vp-fn_")?e[+r.slice(7)]??r:r}' -export function serializeFunctions(value: any, key?: string): any { +export function serializeFunctions( + value: any, + fns: string[], + key?: string +): any { if (Array.isArray(value)) { - return value.map((v) => serializeFunctions(v)) + return value.map((v) => serializeFunctions(v, fns)) } else if (typeof value === 'object' && value !== null) { return Object.keys(value).reduce((acc, key) => { if (key[0] === '_') return acc - acc[key] = serializeFunctions(value[key], key) + acc[key] = serializeFunctions(value[key], fns, key) return acc }, {} as any) } else if (typeof value === 'function') { @@ -35,7 +42,7 @@ export function serializeFunctions(value: any, key?: string): any { ) { serialized = serialized.replace(key, 'function') } - return `_vp-fn_${serialized}` + return `_vp-fn_${fns.push(`(${serialized})`) - 1}` } else { return value } diff --git a/src/shared/shared.ts b/src/shared/shared.ts index c5f36a60..1dbb3a59 100644 --- a/src/shared/shared.ts +++ b/src/shared/shared.ts @@ -30,6 +30,53 @@ export type { export const EXTERNAL_URL_RE = /^(?:[a-z]+:|\/\/)/i export const APPEARANCE_KEY = 'vitepress-theme-appearance' +// iconify's icon/collection name grammar +const iconNameRE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ + +/** + * Parses a fully qualified `collection:name` icon name, corresponding to + * the `vpi--` class. Returns null for anything else, + * keeping malformed input out of generated selectors and class attributes. + */ +export function parseIconName( + name: string +): { collection: string; icon: string } | null { + const colon = name.indexOf(':') + if (colon === -1) return null + const collection = name.slice(0, colon) + const icon = name.slice(colon + 1) + if (!iconNameRE.test(collection) || !iconNameRE.test(icon)) return null + return { collection, icon } +} + +/** + * Placeholder prepended to SSR-emitted URLs when base is relative, later + * replaced with each page's `../` prefix back to the site root. + */ +export const RELATIVE_BASE_SENTINEL = '/__VP_BASE__/' + +export function isRelativeBase(base: string): boolean { + return base === './' +} + +/** + * The ../-prefix that leads from `relativePath`'s directory back to the + * site root ('./' for root-level pages). + */ +export function relativePathToRoot(relativePath: string): string { + const depth = relativePath.split('/').length - 1 + return depth ? '../'.repeat(depth) : './' +} + +/** + * Join two paths, collapsing slash collisions but keeping the `//` that + * follows a protocol. + */ +export function joinPath(base: string, path: string): string { + const protocol = /^(?:[a-z]+:)?\/\//i.exec(base)?.[0] ?? '' + return protocol + `${base.slice(protocol.length)}${path}`.replace(/\/+/g, '/') +} + export const VP_SOURCE_KEY = '[VP_SOURCE]' const UnpackStackView = Symbol('stack-view:unpack') @@ -113,7 +160,8 @@ export function getLocaleForPath( } /** - * this merges the locales data to the main data by the route + * Resolves the site data for a route, layering the matched locale and + * additional configs over the root config. */ export function resolveSiteDataByRoute( siteData: SiteData, @@ -125,8 +173,8 @@ export function resolveSiteDataByRoute( siteData.locales[localeIndex] ?? ({} as (typeof siteData.locales)[string]) Object.assign(localeConfig, { localeIndex }) - // additional configs are colocated with sources, so resolve them by the - // source path (filePath) rather than the rewritten one + // additional configs are colocated with sources — resolve them by source + // path rather than the rewritten one const additionalConfigs = resolveAdditionalConfig( siteData, filePath || relativePath @@ -313,7 +361,7 @@ function resolveAdditionalConfig( return configs.filter((config) => config !== undefined) } -// This helps users to understand which configuration files are active +// logs the config layers active for a page (dev only) function reportConfigLayers(path: string, layers: Partial[]) { const summaryTitle = `Config Layers for ${path}:` @@ -329,9 +377,8 @@ function reportConfigLayers(path: string, layers: Partial[]) { } /** - * Creates a deep, merged view of multiple objects without mutating originals. - * Returns a readonly proxy behaving like a merged object of the input objects. - * Layers are merged in descending precedence, i.e. earlier layer is on top. + * Creates a readonly proxy behaving like a deep merge of the given layers, + * without mutating them. Earlier layers take precedence. */ export function stackView(..._layers: Partial[]): T { const layers = _layers.filter((layer) => isObject(layer)) diff --git a/theme.d.ts b/theme.d.ts index 01a486da..834bf3f0 100644 --- a/theme.d.ts +++ b/theme.d.ts @@ -24,6 +24,7 @@ export declare const VPHomeContent: typeof import('./dist/client/theme-default/c export declare const VPHomeFeatures: typeof import('./dist/client/theme-default/components/VPHomeFeatures.vue').default export declare const VPHomeHero: typeof import('./dist/client/theme-default/components/VPHomeHero.vue').default export declare const VPHomeSponsors: typeof import('./dist/client/theme-default/components/VPHomeSponsors.vue').default +export declare const VPIcon: typeof import('./dist/client/theme-default/components/VPIcon.vue').default export declare const VPImage: typeof import('./dist/client/theme-default/components/VPImage.vue').default export declare const VPLink: typeof import('./dist/client/theme-default/components/VPLink.vue').default export declare const VPNavBarSearch: typeof import('./dist/client/theme-default/components/VPNavBarSearch.vue').default diff --git a/tsconfig.client.json b/tsconfig.client.json index 0e47095d..d625950b 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -1,5 +1,5 @@ { - "extends": "./tsconfig.json", + "extends": "./tsconfig.base.json", "compilerOptions": { "lib": ["ES2023", "DOM", "DOM.Iterable"], "types": ["./client.d.ts"], diff --git a/tsconfig.json b/tsconfig.json index ffcbb947..762d0a40 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,3 +1,19 @@ { - "extends": "./tsconfig.base.json" + "extends": "./tsconfig.base.json", + "compilerOptions": { + "paths": { + "vitepress": ["./src/client/index.ts"], + "vitepress/theme": ["./types/default-theme.d.ts"], + "@siteData": ["./src/client/shims.d.ts"], + "@theme/index": ["./src/client/shims.d.ts"], + "@localSearchIndex": ["./src/client/shims.d.ts"] + } + }, + "include": [ + "src", + "types", + "scripts", + "shared-globals.d.ts", + "tsdown.config.ts" + ] } diff --git a/tsconfig.node.json b/tsconfig.node.json index 3de1cdef..36330db4 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -1,5 +1,5 @@ { - "extends": "./tsconfig.json", + "extends": "./tsconfig.base.json", "compilerOptions": { "lib": ["ES2023"], "types": ["node"] diff --git a/tsconfig.shared.json b/tsconfig.shared.json index 0a9eb997..0956fe06 100644 --- a/tsconfig.shared.json +++ b/tsconfig.shared.json @@ -1,5 +1,5 @@ { - "extends": "./tsconfig.json", + "extends": "./tsconfig.base.json", "compilerOptions": { "lib": ["ES2023"], "types": [], diff --git a/tsdown.config.ts b/tsdown.config.ts index 4494b557..7608bb71 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -170,6 +170,446 @@ function skipUnchanged(): Rolldown.Plugin { } } +// The node bundle inlines its devDependencies by design, so the published +// artifact redistributes third-party code. Every module that ends up in an +// emitted chunk is traced back to its package here, and the aggregate +// notices — license texts with their copyright lines, plus any NOTICE files +// (Apache-2.0 §4(d)) — are written to THIRD-PARTY-NOTICES.md. npm does not +// auto-include that name, so it ships only via the files list in +// package.json; `pnpm check` fails when the committed copy goes stale. The +// file is regenerated by the build — do not edit it by hand. Entries with +// byte-identical texts are grouped: the copyright notices the licenses +// require live inside the quoted texts, so grouping loses nothing. +const NOTICES_FILE = 'THIRD-PARTY-NOTICES.md' + +// licenses whose redistribution obligations this file's mechanism fully +// discharges: carrying the verbatim license text and copyright notice along +// (plus NOTICE propagation for Apache-2.0, and the modifications statement +// in the header). Anything else fails the build so its obligations get +// reviewed deliberately instead of silently shipped — copyleft (GPL), +// source-offer duties (MPL/LGPL), unparseable ids, and also e.g. Zlib and +// Python-2.0, whose mark-your-changes clauses an aggregate notices file +// cannot satisfy on its own. +const PERMITTED_LICENSES = new Set([ + '0BSD', + 'Apache-2.0', + 'BSD-2-Clause', + 'BSD-3-Clause', + 'BlueOak-1.0.0', + 'CC0-1.0', + 'ISC', + 'MIT', + 'OFL-1.1', + 'Unlicense' +]) + +// canonical license bodies for packages that declare an id but ship no text +// file. MIT/ISC require the copyright and permission notice to accompany +// copies, so a bare identifier would not be compliant; the reconstruction +// pairs the fixed license wording with the package's own author line and is +// surfaced as a warning on every full build. +const LICENSE_TEMPLATES: Record string> = { + MIT: (holder) => `MIT License + +Copyright (c) ${holder} + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.`, + ISC: (holder) => `ISC License + +Copyright (c) ${holder} + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE.` +} + +// copyright holders for packages that ship neither a license file nor an +// author field, verified against their repositories +const AUTHOR_FALLBACKS: Record = { + '@polka/compression': 'Luke Edwards (https://lukeed.com)' +} + +interface BundledPackage { + name: string + version?: string + license: string + authors?: string + repository?: string + licenseText: string + noticeText?: string + reconstructed?: boolean +} + +// third-party content vendored into src rather than resolved from +// node_modules: the Inter font files shipped under theme-default/fonts +const VENDORED_PACKAGES: BundledPackage[] = [ + { + name: 'Inter', + license: 'OFL-1.1', + authors: 'The Inter Project Authors', + repository: 'https://github.com/rsms/inter', + licenseText: readFileSync( + path.join(ROOT, 'src/client/theme-default/fonts/LICENSE.txt'), + 'utf8' + ) + .replaceAll('\r\n', '\n') + .trim() + } +] + +// localeCompare consults the host locale; the output must be byte-stable +// across machines, so sort by code units everywhere +const compareStrings = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) + +const blockquote = (text: string) => + text + .split('\n') + .map((l) => (l ? `> ${l}` : '>')) + .join('\n') + +function formatPerson(person: unknown): string | undefined { + if (typeof person === 'string') return person + const { name, email, url } = (person ?? {}) as Record + if (!name) return undefined + return name + (email ? ` <${email}>` : '') + (url ? ` (${url})` : '') +} + +// render repository fields as plain https URLs, whatever form the manifest +// uses (git+/git@/ssh, github: and owner/repo shorthands) +function normalizeRepoUrl(url: string): string { + const repo = url + .replace(/^git\+/, '') + .replace(/\.git$/, '') + .replace(/^git@([^:]+):/, 'https://$1/') + .replace(/^(?:git|ssh):\/\/(?:[^@/]+@)?/, 'https://') + .replace(/^github:/, 'https://github.com/') + .replace(/^gitlab:/, 'https://gitlab.com/') + .replace(/^bitbucket:/, 'https://bitbucket.org/') + return /^[\w.-]+\/[\w.-]+$/.test(repo) ? `https://github.com/${repo}` : repo +} + +type PackageResolution = { info: BundledPackage } | { error: string } | null + +const packageCache = new Map() + +// a module's package root is everything up to the first path segment (two +// for scoped packages) after the last node_modules/ in its id — this holds +// for pnpm's .pnpm layout too, where the real package dir always sits under +// a nested node_modules/ +function resolveBundledPackage(moduleId: string): PackageResolution { + if (moduleId.startsWith('\0')) return null + const id = moduleId.replaceAll('\\', '/').replace(/\?.*$/, '') + const base = id.lastIndexOf('/node_modules/') + if (base === -1) return null + const segments = id.slice(base + '/node_modules/'.length).split('/') + const name = + segments[0][0] === '@' ? segments.slice(0, 2).join('/') : segments[0] + if (name[0] === '.') return null + const root = id.slice(0, base) + '/node_modules/' + name + const cached = packageCache.get(root) + if (cached !== undefined) return cached + const resolution = readPackageNotices(root, name) + packageCache.set(root, resolution) + return resolution +} + +function readPackageNotices(root: string, dirName: string): PackageResolution { + let pkg: Record + try { + pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8')) + } catch { + return { error: `${dirName}: no readable package.json at ${root}` } + } + const name: string = pkg.name ?? dirName + const license: string | undefined = + typeof pkg.license === 'string' + ? pkg.license + : (pkg.license?.type ?? + (Array.isArray(pkg.licenses) + ? pkg.licenses + .map((l: any) => (typeof l === 'string' ? l : l?.type)) + .filter(Boolean) + .join(' OR ') + : undefined)) + if (!license) { + return { error: `${name}: declares no license in its package.json` } + } + // an SPDX OR expression satisfies the policy if any alternative does; AND + // and WITH combine obligations, so those always need review as a whole + const alternatives = /\s(?:AND|WITH)\s/.test(license) + ? [] + : license.replace(/[()]/g, '').split(/\s+OR\s+/) + if (!alternatives.some((l) => PERMITTED_LICENSES.has(l.trim()))) { + return { + error: + `${name} is licensed under "${license}", which is not in ` + + 'PERMITTED_LICENSES — review its obligations before extending the set' + } + } + + const people = [ + pkg.author, + ...(Array.isArray(pkg.contributors) ? pkg.contributors : []), + ...(Array.isArray(pkg.maintainers) ? pkg.maintainers : []) + ] + .map(formatPerson) + .filter((p): p is string => !!p) + const authors = [...new Set(people)].join(', ') || AUTHOR_FALLBACKS[name] + const repositoryField = + typeof pkg.repository === 'string' ? pkg.repository : pkg.repository?.url + const repository = repositoryField + ? normalizeRepoUrl(repositoryField) + : undefined + + const tryReadText = (file: string): string | undefined => { + try { + return readFileSync(path.join(root, file), 'utf8') + .replace(/^\uFEFF/, '') + .replaceAll('\r\n', '\n') + .trim() + } catch { + return undefined + } + } + + let entries: string[] + try { + entries = readdirSync(root) + } catch { + entries = [] + } + // NOTICE files carry their own propagation obligation and ride along even + // when a license file exists too + const noticeText = + entries + .filter((f) => /^notices?(?:$|[-._])/i.test(f)) + .sort(compareStrings) + .map(tryReadText) + .filter(Boolean) + .join('\n\n') || undefined + const rank = (f: string) => (/^licen[cs]e/i.test(f) ? 0 : 1) + const licenseFile = entries + .filter((f) => + /^(?:(?:un)?licen[cs]e|copying|(?:mit|bsd|apache|isc)[-_.]licen[cs]e)(?:$|[-._])/i.test( + f + ) + ) + .sort((a, b) => rank(a) - rank(b) || compareStrings(a, b))[0] + + let licenseText = licenseFile ? tryReadText(licenseFile) : undefined + let reconstructed = false + if (!licenseText) { + const template = LICENSE_TEMPLATES[license] + if (!template || !authors) { + return { + error: + `${name} (${license}) ships no license text and it cannot be ` + + 'reconstructed — vendor its license text in tsdown.config.ts' + } + } + licenseText = template(authors) + reconstructed = true + } + + return { + info: { + name, + version: pkg.version, + license, + authors, + repository, + licenseText, + noticeText, + reconstructed + } + } +} + +function renderNotices(packages: BundledPackage[]): string { + // group packages whose texts match byte-for-byte (same license wording + // and same copyright holder), so shared boilerplate appears once + interface NoticeGroup { + names: string[] + licenses: string[] + authors: string[] + repositories: string[] + licenseText: string + noticeText?: string + } + const groups = new Map() + for (const p of packages) { + // texts without a copyright notice line (BlueOak, CC0, …) cannot name + // their holders themselves, so different holders must not merge — the + // By: line is the only attribution such an entry has + const hasHolder = /copyright\s+(?:\(c\)|©|[0-9])/i.test(p.licenseText) + const key = + p.licenseText + + '\0' + + (p.noticeText ?? '') + + (hasHolder ? '' : '\0' + (p.authors ?? p.name)) + let group = groups.get(key) + if (!group) { + group = { + names: [], + licenses: [], + authors: [], + repositories: [], + licenseText: p.licenseText, + noticeText: p.noticeText + } + groups.set(key, group) + } + if (!group.names.includes(p.name)) group.names.push(p.name) + if (!group.licenses.includes(p.license)) group.licenses.push(p.license) + if (p.authors && !group.authors.includes(p.authors)) { + group.authors.push(p.authors) + } + if (p.repository && !group.repositories.includes(p.repository)) { + group.repositories.push(p.repository) + } + } + const sorted = [...groups.values()] + for (const g of sorted) { + g.names.sort(compareStrings) + g.licenses.sort(compareStrings) + g.authors.sort(compareStrings) + g.repositories.sort(compareStrings) + } + // same leading name can occur twice when two bundled versions of a package + // carry different texts; tie-break on the text to keep the order stable + sorted.sort( + (a, b) => + compareStrings(a.names[0], b.names[0]) || + compareStrings(a.licenseText, b.licenseText) || + compareStrings(a.noticeText ?? '', b.noticeText ?? '') + ) + + const ids = [...new Set(packages.map((p) => p.license))].sort(compareStrings) + let out = + '# Third-party notices\n\n' + + 'VitePress is published under the MIT license (see LICENSE). The ' + + 'published vitepress package additionally bundles code and font files ' + + 'from the projects listed below, used under the following licenses: ' + + `${ids.join(', ')}. The bundled code has been mechanically ` + + 'transformed from its original source form (concatenated, tree-shaken, ' + + 'and minified), and the font files are subsets of their original ' + + 'releases.\n' + for (const g of sorted) { + out += `\n## ${g.names.join(', ')}\n\n` + out += `License: ${g.licenses.join(', ')}\n` + if (g.authors.length) out += `By: ${g.authors.join(', ')}\n` + if (g.repositories.length) { + out += `Repository: ${g.repositories.join(', ')}\n` + } + out += '\n' + blockquote(g.licenseText) + '\n' + if (g.noticeText) out += '\nNotice:\n\n' + blockquote(g.noticeText) + '\n' + } + return out +} + +const collectedPackages = new Map>() +// assigned where the configs are assembled at the bottom of this file; the +// notices file is only written once every config has reported, so building +// a lone config can never shrink it to a subset +let expectedOutputs = 0 + +// …and a build where some output never reports (a lone config, or drift in +// how the configs are assembled) must fail loudly instead of silently +// leaving a stale file behind. Watch mode never collects, so it never trips +// this. +process.on('beforeExit', () => { + if (collectedPackages.size > 0 && collectedPackages.size < expectedOutputs) { + console.error( + `${NOTICES_FILE} was not regenerated: only ` + + `${collectedPackages.size} of ${expectedOutputs} build outputs reported` + ) + process.exitCode = 1 + } +}) + +function thirdPartyNotices(): Rolldown.Plugin { + return { + name: 'vitepress:third-party-notices', + // default-order generateBundle runs before skipUnchanged's post handler + // prunes byte-identical files, so the module lists are still complete + generateBundle(options, bundle) { + // build-only: dev rebuilds shouldn't churn a tracked file — full + // builds and the check script keep it fresh + if (this.meta.watchMode) return + const errors = new Set() + const found = new Map() + for (const file of Object.values(bundle)) { + if (file.type !== 'chunk') continue + for (const [id, mod] of Object.entries(file.modules)) { + // fully tree-shaken modules contribute nothing to the output + // (declaration chunks report real lengths too) + if (mod.renderedLength === 0) continue + const resolution = resolveBundledPackage(id) + if (!resolution) continue + if ('error' in resolution) { + errors.add(resolution.error) + continue + } + const { info } = resolution + found.set(`${info.name}@${info.version}`, info) + } + } + if (errors.size) { + this.error([...errors].sort(compareStrings).join('\n')) + } + collectedPackages.set(options.dir ?? '.', found) + if (collectedPackages.size < expectedOutputs) return + + const union = new Map() + for (const packages of collectedPackages.values()) { + for (const [key, info] of packages) union.set(key, info) + } + const reconstructed = [...union.values()] + .filter((p) => p.reconstructed) + .map((p) => p.name) + .sort(compareStrings) + if (reconstructed.length) { + console.warn( + `license text for ${reconstructed.join(', ')} is reconstructed ` + + 'from their manifests — they ship none' + ) + } + const content = renderNotices([...VENDORED_PACKAGES, ...union.values()]) + const dest = path.join(ROOT, NOTICES_FILE) + // same mtime-stability contract as skipUnchanged above + try { + if (readFileSync(dest, 'utf8') === content) return + } catch {} + writeFileSync(dest, content) + console.warn(`\n${NOTICES_FILE} updated — commit the regenerated file`) + } + } +} + function withStableOutputs(config: UserConfig): UserConfig { return { ...config, @@ -177,6 +617,7 @@ function withStableOutputs(config: UserConfig): UserConfig { plugins: [ ...(config.plugins as Rolldown.Plugin[]), fixDeclarationSpecifiers(), + thirdPartyNotices(), skipUnchanged() ] } @@ -238,4 +679,7 @@ const node: UserConfig = { plugins: [syncShared('node'), rootTypesSpecifiers()] } -export default defineConfig([client, node].map(withStableOutputs)) +const configs = [client, node].map(withStableOutputs) +expectedOutputs = configs.length + +export default defineConfig(configs) diff --git a/types/shared.d.ts b/types/shared.d.ts index c1e280b2..a35663f9 100644 --- a/types/shared.d.ts +++ b/types/shared.d.ts @@ -36,10 +36,10 @@ export interface PageData { */ relativePath: string /** - * The path of the actual source file relative to the source directory. - * Differs from `relativePath` when path rewrites are in use, points to - * the route template for dynamic routes, and is an empty string if the - * page is virtual (e.g. the 404 page). + * The path of the actual source file relative to the source directory: + * differs from `relativePath` when rewrites are in use, points to the + * route template for dynamic routes, and is empty for virtual pages + * (e.g. the 404 page). */ filePath: string /** @@ -168,7 +168,8 @@ export interface Header { */ export interface SiteData { /** - * The base URL the site is deployed at. + * The base URL the site is deployed at, or `'./'` when each page + * references the site relative to its own depth. * @default '/' */ base: string @@ -246,11 +247,10 @@ export interface SiteData { prefetchLinks: boolean } /** - * Config overrides applied to pages by source directory: either a dict - * mapping a directory (e.g. `/guide/`) to overrides, where deeper - * directories take precedence, or a function returning the overrides to - * apply for a page. Directories are resolved against the source paths of - * pages, before rewrites. + * Config overrides applied to pages by source directory (before + * rewrites): a dict mapping a directory (e.g. `/guide/`) to overrides, + * deeper directories taking precedence, or a function returning the + * overrides for a page. */ additionalConfig?: AdditionalConfigDict | AdditionalConfigLoader @@ -363,11 +363,11 @@ export interface SSGContext extends SSRContext { */ content: string /** - * The names of the social icons used on the page, collected so that only - * the styles of used icons are emitted. - * @experimental + * The icons used on the page, registered during SSR (via `useIcon`) so + * that only their styles are emitted. Names are fully qualified as + * `collection:name`. */ - vpSocialIcons: Set + vpIcons: Set } /** @@ -452,13 +452,10 @@ export interface ContainerOptions { cautionLabel?: string /** * Additional containers to register, mapping the container name to its - * default title. Registered names work both as `::: name` blocks and as - * GitHub-style alerts (`> [!NAME]`), and are styleable in the theme via - * `.custom-block.name`. Names must be lowercase and may only contain - * letters, numbers, hyphens, and underscores. - * - * In locale-specific overrides only the titles of containers registered - * at the root level can be changed - new names cannot be added there. + * default title. Names must be lowercase (letters, numbers, hyphens, + * underscores), work as both `::: name` blocks and `> [!NAME]` alerts, + * and are styleable via `.custom-block.name`. Locale overrides may only + * change the titles of root-registered names. */ customContainers?: Record } @@ -480,9 +477,8 @@ export interface CodeCopyButtonOptions { } /** - * Build-time markdown strings that can be overridden per locale. Set them - * under `locales..markdown` in the site config; values fall back to - * the root `markdown` options when a locale leaves them unset. + * Markdown strings overridable per locale via `locales..markdown`, + * falling back to the root `markdown` options when unset. */ export interface MarkdownLocaleOptions { /** @@ -541,8 +537,8 @@ export type AdditionalConfigLoader = ( filePath: string ) => AdditionalConfig[] | void -// Manually declaring all properties as rollup-plugin-dts -// is unable to merge augmented module declarations +// all properties are declared manually as rollup-plugin-dts cannot merge +// augmented module declarations /** * The environment object passed to `markdown-it` when rendering a page. */ @@ -586,6 +582,13 @@ export interface MarkdownEnv { * Whether clean URLs are enabled. */ cleanUrls: boolean + /** + * Whether the rendered HTML is emitted at `relativePath`, so site-absolute + * links may be rewritten relative to it. Content loaders must not set it: + * their HTML is embedded in other pages. + * @internal + */ + relativizeUrls?: boolean /** * The URLs of the links collected from the page for the dead link check. */ @@ -613,4 +616,11 @@ export interface MarkdownEnv { * The key of the locale the page belongs to. */ localeIndex?: string + /** + * The expressions inlined by eager frontmatter interpolation while + * rendering, with the value each resolved to - used to detect values that + * `transformPageData` changes after the fact. + * @internal + */ + eagerInterpolations?: { expression: string; value: string }[] }